JSON Text Sequences(RFC 7464):RS 分隔的串流 JSON
結論:RFC 7464 用 0x1E 記錄分隔符把多條 JSON 串成可邊寫邊讀的串流,還給了正式媒體類型。本文說明格式規則、與 NDJSON 的差別、按分隔符切分再解析的實作,以及字串裡控制字元的邊界。

結論:兩端都由你控制、要正式媒體類型、還要從截斷中自動重新同步,用 JSON-seq;資料要進日誌系統、要人直接看、要餵給現成工具,用 NDJSON——對端只認行分隔格式時不要硬推 JSON-seq。
NDJSON 靠換行分隔記錄,但換行是人類的約定;真正把記錄邊界寫進位元組流的,是 RFC 7464 裡那個看不見的 0x1E。
一、一個位元組解決的問題
RFC 7464 發表於 2015 年 2 月,標題是 JSON Text Sequences,註冊的媒體類型是 application/json-seq。它沒有新增任何資料型別,只規定「一串 JSON 文字如何排在同一份位元組流裡」。規則可以壓成一句話:每條 JSON 文字前面放一個 RS,後面放一個 LF。
- RS 是 ASCII 的記錄分隔符,碼位
0x1E,Unicode 名稱是 INFORMATION SEPARATOR TWO(U+001E); - LF 是換行
0x0A。
把兩條記錄寫進檔案,位元組長這樣:
1E 7B 22 69 64 22 3A 31 7D 0A ← 0x1E {"id":1} + LF
1E 7B 22 69 64 22 3A 32 7D 0A ← 0x1E {"id":2} + LF
RS 在記錄之前,這點和「先寫內容、再寫分隔符」的 NDJSON 正好相反。方向不同,容錯能力也不同:讀取端只要看到 0x1E,就能確認上一條已經結束;即使上一條在寫入中途被截斷,下一條的記錄邊界依然清楚。
這段設計不是紙上推演。RFC 原文明確提到,追加到日誌檔案的記錄可能因為程式崩潰或被管理員終止而被檔案系統截斷,解析器不應該因此中止整個讀取。
二、編碼端與解析端的兩套規則
RFC 7464 很少見地給了兩套 ABNF:一套約束寫入方,一套放寬讀取方。
| 角色 | 規則 | 含義 |
|---|---|---|
| 編碼端 | *(RS JSON-text LF) |
每條 JSON 前一個 RS,後一個 LF |
| 解析端 | *(1*RS possible-JSON) |
允許連續多個 RS,也允許記錄之間沒有 LF |
落到工程上有五條硬規則:
- 必須使用 UTF-8;UTF-16、UTF-32 一律不允許;
- 解析端把「除 RS 以外的任意位元組」當作疑似 JSON 文字,嘗試解析,失敗就跳到下一個 RS 繼續讀;
- 連續多個 RS 不表示空記錄,直接忽略;
- 沒有「序列結束」標記,讀到串流末尾即結束;
- 頂層值是數字、
true、false、null時,結尾必須有空白位元組,否則按「可能被截斷」丟棄。
第五條最容易被忽略。物件、陣列、字串在 JSON 裡自帶結束符號,數字沒有。<RS>123<RS> 無法判斷它是完整的 123,還是被截斷的 1234。RFC 因此要求寫入端在每條記錄後補 LF,把 LF 當作截斷偵測的「哨兵位元組」。同理,<RS>true<RS> 也可能原本是 trueish。
三、和 NDJSON 怎麼選
兩者都能串流讀寫,但設計目標不同。把差異攤開來看:
| 維度 | JSON Text Sequences | NDJSON / JSON Lines |
|---|---|---|
| 分隔符位置 | 記錄前的 RS(0x1E) |
記錄後的 LF(0x0A) |
| 媒體類型 | application/json-seq,IANA 註冊 |
無官方註冊,社群約定 |
| 位元組可讀性 | 控制字元不可見,cat 看不出來 |
純文字,直接可讀 |
| 寫入並行 | 邊寫邊讀,截斷的尾記錄可丟棄 | 同樣可追加,壞行跳過 |
| 頂層數字 | 必須帶空白結尾才可信 | 行尾 LF 即邊界 |
| 字串內分隔符 | RS 在 JSON 字串裡只能寫成 \u001e |
LF 必須寫成 \n |
| 工具生態 | jq --seq 與少數函式庫 |
幾乎所有日誌與大資料工具 |
| 檔案副檔名 | 規範未定義 | .jsonl / .ndjson |
選擇標準很短:如果兩端都由你控制、需要正式媒體類型、並且希望讀取端能從截斷中自動重新同步,用 JSON-seq;如果資料要進日誌系統、要人能直接看、要餵給各種現成工具,用 JSON Lines 與 NDJSON。
四、解析器:先按 0x1E 切分,再 JSON.parse
解析不需要串流 JSON 解析器,這是 RFC 7464 最直接的收益。按位元組切分,再對每一段呼叫 JSON.parse 即可:
/** ASCII 記錄分隔符,RFC 7464 定義為 0x1E */
const RECORD_SEPARATOR = 0x1e;
/**
* 解析一段 JSON Text Sequence 位元組。
* @param buffer 已讀入的位元組緩衝區
* @returns 成功解析的記錄陣列,以及解析失敗的記錄數
*/
function parseJsonSequence(buffer: Buffer): { records: unknown[]; badCount: number } {
const records: unknown[] = [];
let badCount = 0;
let recordStart = buffer.indexOf(RECORD_SEPARATOR);
while (recordStart !== -1) {
const nextRecordStart = buffer.indexOf(RECORD_SEPARATOR, recordStart + 1);
const recordEnd = nextRecordStart === -1 ? buffer.length : nextRecordStart;
const rawText = buffer.subarray(recordStart + 1, recordEnd).toString('utf8').trim();
if (rawText !== '') {
try {
records.push(JSON.parse(rawText));
} catch {
badCount += 1;
}
}
recordStart = nextRecordStart;
}
return { records, badCount };
}
三個實作要點:
- 按位元組切分是安全的。 UTF-8 的續位元組落在
0x80–0xBF區間,不可能等於0x1E,所以按 0x1E 切分不會把一個多位元組字元切成兩半。 - 串流讀取要快取尾巴。 分塊讀取時,最後一段可能還沒等到下一個 RS,應先留在緩衝區;等下一塊到達再解析,否則會重複解析同一個半成品。
- 失敗要計數而不是中斷。 規範要求解析器跳到下一個 RS 繼續,所以
badCount是告警指標,不應直接拋出例外。截斷的最後一條記錄也歸入這一類。
記憶體佔用只與「單條記錄 + 一個緩衝區」相關,與檔案總大小無關,這點和 大 JSON 的解析與串流處理 裡討論的分片讀取是同一套思路。
五、字串裡的控制字元:切分為何不會誤傷
JSON 規定 U+0000 到 U+001F 的控制字元在字串裡必須轉義,RS 正落在這個區間。也就是說,合法的 JSON 字串中只可能出現 \u001e 這五個字元,不會出現裸的 0x1E 位元組。這就是「按 0x1E 切分」對合法資料永遠安全的原因。
會出問題的是寫入端:
- 用
'\x1e'手工拼接 JSON 文字,把裸控制字元塞進了字串; - 用樣板字串拼 JSON,而不是
JSON.stringify,導致換行、引號、控制字元全部未轉義; - 從終端機或編輯器複製帶 RS 的文字時,控制字元被吞掉或替換。
正確寫法始終是先生成文字,再拼分隔符:
const recordText = JSON.stringify(record);
const line = `${String.fromCharCode(RECORD_SEPARATOR)}${recordText}\n`;
JSON.stringify 會把控制字元輸出成 \u00XX,所以記錄內部永遠不會出現裸 RS。這一步做對,切分邏輯就只需要處理位元組,不需要理解 JSON 語法。
順帶一提:這類「按分隔符切分」的失敗,最終大多表現成 JSON.parse 報 Unexpected token,可以對照 JSON 報錯訊息反查表 逐條排查,尤其是「第一個字元就是不可見控制符」這種看起來毫無道理的報錯。
六、HTTP 傳輸與媒體類型
application/json-seq 是正式註冊的媒體類型,沒有必填參數,也沒有可選參數,字元集固定為 UTF-8。用在 HTTP 上有三個實務要點:
- 請求標頭寫
Accept: application/json-seq,回應標頭寫Content-Type: application/json-seq; - 伺服器要真的串流傳送,必須使用分塊傳輸,並關掉中間層(反向代理、CDN、壓測工具)的回應緩衝;
- 客戶端按塊讀取時,每塊都可能在記錄中間斷開,解析器要快取到下一個 RS 再產出記錄。
如果對端只認行分隔格式,不要硬推 JSON-seq。它是標準,但生態滲透率遠不如 NDJSON,相容性判斷要做在協定協商階段,而不是等解析失敗再回退。
七、命令列、日誌與工具支援
jq 是少數原生支援該格式的工具,--seq 選項就是為 RFC 7464 準備的:
printf '\x1e{"id":1}\n\x1e{"id":2}\n' | jq --seq -c '.id'
輸出兩行 1 和 2。用於串流介面時,記得關掉 curl 的緩衝:
curl -sN https://api.example.com/events | jq --seq -c '.type'
jq 的 --seq 會按 RFC 7464 讀取:忽略壞記錄、繼續往下解析。這條特性在「追一個正在寫入的日誌檔案」時特別有用——最後一條可能是半截的,讀到的結果裡不會出現一個假的末尾物件。
7.1 各語言的現成支援
| 環境 | 現成支援 | 備註 |
|---|---|---|
| jq | --seq |
命令列裡最省事的選擇 |
| Node.js | 無內建 | Buffer.indexOf 加 JSON.parse |
| Python | 無內建 | 按 b'\x1e' 切分 |
| Go | 無內建 | bufio.Scanner 自訂 SplitFunc |
| 瀏覽器 | 無內建 | fetch 加 ReadableStream 手動按位元組切分 |
7.2 常見坑
高頻坑集中在讀取側:
- 用
readline按行讀,第一行會帶著不可見的 RS 前綴,JSON.parse必然失敗; - 在編輯器裡開啟
.json-seq檔案,控制字元不顯示,看起來像一條奇怪的 JSON; - 用正規表達式
split(/\n/)切分,會把「記錄內的轉義換行」和「記錄間的 LF」混為一談; - 把 RS 當作普通字元寫進文件,導致複製貼上後分隔符遺失。
八、什麼時候值得用它
適合用的情境:
- 生產者與消費者都由你維護,中間沒有第三方工具;
- 資料是持續追加的事件串流,程式隨時可能崩潰,需要從截斷中恢復;
- 需要給協定一個正式媒體類型,而不是「約定俗成的
.jsonl」; - 記錄本身可能是頂層數字或布林值,需要明確的截斷偵測規則。
不適合的情境:
- 日誌要進 ELK、Loki 一類只認行分隔的系統;
- 需要人直接
cat、grep、awk處理; - 對端只提供
application/json的解析能力。
九、檢查清單
- [ ] 寫入端用
JSON.stringify生成記錄,再拼0x1E與\n; - [ ] 頂層數字、布林、
null的記錄都帶 LF 結尾; - [ ] 讀取端按位元組切分,不用按行讀取;
- [ ] 串流讀取時快取未閉合的尾部,等下一條 RS 到達再解析;
- [ ] 解析失敗只計數並跳過,不中斷整個序列;
- [ ] 對外介面明確宣告
application/json-seq,並準備 NDJSON 回退方案。
JSON Text Sequences 和 NDJSON 哪個更常用?
NDJSON 更常用。它的規則更簡單、工具覆蓋更廣,日誌系統與大資料管道基本都認。JSON-seq 的優勢是 IANA 註冊的媒體類型與「分隔符前置」帶來的重新同步能力,適合兩端可控的串流協定,而不是通用資料交換。
為什麼分隔符放在記錄前面,而不是後面?
因為記錄可能被截斷。分隔符在前時,讀取端看到 RS 就知道新記錄開始,上一條無論完整與否都不會污染下一條;分隔符在後時,寫入中斷會讓「最後一段」既沒有結束符也沒有下一條的開頭,邊界只能靠猜。
記錄裡出現 0x1E 會怎樣?
合法 JSON 字串裡的 RS 必須轉義成 \u001e,所以不會出現裸的 0x1E。如果確實出現了,代表這段文字已經不是合法 JSON:按 0x1E 切分會在錯誤的位置斷開,JSON.parse 隨之失敗。修法是回到寫入端,用 JSON.stringify 而不是手工拼接。
瀏覽器能直接解析 application/json-seq 嗎?
不能。瀏覽器沒有內建的 JSON-seq 解析器,fetch 只負責拿到位元組流。需要自己用 response.body.getReader() 按塊讀取,快取到下一個 0x1E 再對每段呼叫 JSON.parse。資料量不大時,也可以先整體讀成字串再切分。