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。数据量不大时,也可以先整体读成字符串再切分。