同步一个会变的 JSON:从 NDJSON 到 CRDT 的 4 种姿势
流式 LLM 输出、实时看板、协同编辑背后共同的那个问题
服务端有一个不断变化的 JSON,客户端怎么同步看到最新状态?本文从一次 SSE 流式接口讲起,带你理清 NDJSON / Merge Patch / JSON Patch / CRDT 四种方案的适用边界。
🎯 从一个真实场景说起
想象一个常见需求:前端页面要实时展示一条 CI 流水线的执行进度,几个 stage(checkout / build / test / deploy),每个 stage 下有若干 step,状态从 pending → running → done 一路变过去。
最朴素的做法是轮询 /api/pipeline/123,但体验差、服务端压力大。更优雅的做法是服务端用 SSE (Server-Sent Events) 推送。抓一段这种流下来大概长这样:
event: create
id: 42
data: {"kind":"pipeline","state":"running","title":"build #123",
"stages":[
{"state":"pending","name":"build"},
{"state":"pending","name":"test"}
]}
event: json_patch
id: 42
data: [{"op":"replace","path":"/stages/0/state","value":"running"},
{"op":"add","path":"/stages/0/steps/0","value":{"state":"running","name":"npm install"}}]
event: json_patch
id: 42
data: [{"op":"replace","path":"/stages/0/state","value":"done"},
{"op":"replace","path":"/stages/1/state","value":"running"}]
...
event: json_patch
id: 42
data: [{"op":"replace","path":"/title","value":"build #123 ✅"},
{"op":"replace","path":"/state","value":"done"}]
客户端要做的事情很清晰:
- 收到
create时,记住这个完整对象作为基线 - 之后每条
json_patch都是对这个对象的增量修改 - 逐条 apply,就能"看到" JSON 从初始态一点点长成最终态
仔细想想会发现:"服务端有一个正在变化的 JSON,客户端怎么同步它" 这个问题其实无处不在——ChatGPT 的流式吐字、Notion 的多人协作、Grafana 的实时面板、Kubernetes 的资源更新、CI/CD 流水线看板……大家本质上都在解同一个问题,只不过取了不同的权衡点。
这篇文章就把这 4 种主流姿势一次性梳理清楚。
🏛️ 四种方案全景图
先上一张对比表,让你对整体有个印象,后面会逐个展开:
| 方案 | 消息内容 | 带宽 | 表达力 | 冲突处理 | 实现难度 | 典型场景 |
|---|---|---|---|---|---|---|
| NDJSON | 每行一个完整对象/事件 | 最费 | 只能"换掉" | 依赖时序 | 极简 | 日志流、简单事件流 |
| JSON Merge Patch | 一个"要改成什么样"的子树 | 中 | 不能改数组结构 | 后写覆盖 | 简单 | 配置热更新、REST PATCH |
| JSON Patch | 一串显式 op (add/replace/...) | 省 | 能精确改嵌套数组 | 顺序敏感 | 中等 | LLM 流式结构化输出、K8s |
| CRDT | 带因果元数据的操作 | 最费 | 任意结构 + 自动合并 | 自动收敛 | 复杂 | 协同编辑、本地优先应用 |
核心差异点在三个维度:
- 带宽:一次传多少?
- 表达力:能不能精确表达"数组中间插入一个元素"这种操作?
- 一致性模型:单一服务端权威,还是多端平等?
📦 方案一:NDJSON —— 最朴素的流
NDJSON (Newline Delimited JSON) 就是字面意思:一行一个完整 JSON,用换行符分隔。
一个流大概长这样:
{"type":"plan","state":"running","steps":[{"state":"pending"}]}
{"type":"plan","state":"running","steps":[{"state":"running"}]}
{"type":"plan","state":"done","steps":[{"state":"done"}]}
客户端的处理逻辑无脑到没朋友:读一行,解析一行,把整个对象替换成新的。
优点
- 解析零成本:
JSON.parse(line)就完事了 - 每条消息自包含:丢一条不影响后续(因为后续也是全量)
- 天然支持断点续传:反正每条都是完整状态
缺点
- 带宽浪费惊人:对象有 100 个字段,只改了 1 个字段,也要把 100 个字段全传一遍
- 大对象灾难:想象一个包含 1MB 文档的对象,每改一个字就传 1MB
适用场景
- 日志流、指标流:每条就是独立事件,不需要"增量"概念
- 早期 LLM 接口:Ollama、OpenAI 的
stream=true早期版本就是类 NDJSON,每个 chunk 是独立的 delta,客户端自己拼字符串 - 对象很小且变化不频繁:省事比省带宽重要
一句话记忆:NDJSON 是"全量快照流",不是真正的增量。
🔀 方案二:JSON Merge Patch —— 像 deep merge 的声明
JSON Merge Patch 是 RFC 7396,核心思想是:"我发一个长得像你的子树给你,你用这个子树覆盖相应位置就行"。
举个例子
假设原文档是:
{
"title": "计划 A",
"state": "running",
"owner": {"name": "Alice", "email": "alice@x.com"}
}
你要把 state 改成 done,owner 邮箱改一下,直接发这个 patch:
{
"state": "done",
"owner": {"email": "alice@new.com"}
}
合并规则简单粗暴:
- patch 里出现的字段 → 覆盖原值(对象会递归合并)
- patch 里没出现的字段 → 保留不动
- patch 里值是
null→ 删除该字段
致命局限:数组只能整体替换
Merge Patch 最大的问题是 —— 它不知道怎么"改"数组。
原文档:
{"items": ["a", "b", "c"]}
你想"在 b 后面插入 x",怎么写 patch?对不起,只能把整个数组塞进去:
{"items": ["a", "b", "x", "c"]}
数组稍微大一点,Merge Patch 的带宽优势就没了。而且它也无法表达"删除第二个元素"这种语义。
适用场景
- REST API 的 PATCH 请求体:改用户资料、改配置项
- 配置热更新:配置文件一般都是对象结构,数组少
- patch 需要人类可读:Merge Patch 本身就是合法 JSON,一眼就能看出改了什么
一句话记忆:Merge Patch 适合"平铺对象字段的增删改",碰到数组就翻车。
✏️ 方案三:JSON Patch —— 显式操作序列
JSON Patch 是 RFC 6902,配套 RFC 6901 (JSON Pointer) 定位路径。
一个 patch 是一个操作数组,每个操作明确说"干什么、在哪里、值是什么":
[
{"op": "replace", "path": "/state", "value": "done"},
{"op": "add", "path": "/steps/0/actions/0", "value": {"title": "run"}},
{"op": "remove", "path": "/steps/1"}
]
支持 6 种操作:add / remove / replace / move / copy / test。
路径用 JSON Pointer 的斜杠语法:/steps/0/actions/0 表示 steps 数组第 0 个元素的 actions 数组第 0 个元素。
对比 Merge Patch 的优势
还是刚才那个"在 b 后面插入 x",JSON Patch 可以精确表达:
[{"op": "add", "path": "/items/2", "value": "x"}]
一条操作几十个字节搞定,不用重传整个数组。这就是为什么嵌套结构复杂的场景几乎都会选 JSON Patch。
另一个隐藏彩蛋:test op。它可以做乐观并发控制——"如果 /version 当前是 3,才执行后续操作",非常适合多写者场景。
缺点
- patch 不是"人话":看一眼
{"op":"add","path":"/steps/0/actions/0","value":{...}}得在脑子里解析半天 - 顺序敏感:同一个 patch 数组里,后面操作依赖前面操作的结果,乱序就错
- Pointer 语法有坑:路径里出现
/和~要转义成~1和~0
适用场景
- LLM 流式结构化输出 / 任务进度流:像开篇那个 CI 流水线例子,整个树型状态在边跑边改,JSON Patch 完美契合
- Kubernetes strategic merge patch:K8s 的底层就是 JSON Patch 的增强版
- ShareDB / 早期 Firepad 类协同编辑:在有中心服务端的前提下
一句话记忆:JSON Patch 是"服务端权威 + 嵌套结构增量更新"场景的甜点区。
🌐 方案四:CRDT —— 去中心化的自动收敛
前面三个方案都有一个隐含假设:有一个"权威服务端",它说对象变成啥样就是啥样,客户端只负责跟进。
但有些场景这个假设不成立:
- Notion 两个人同时在改一段话
- Figma 三个人同时拖动画布上的元素
- 手机离线编辑一小时,回到网上要和云端合并
这时候就需要 CRDT (Conflict-free Replicated Data Type)。
核心思想(不展开)
- 每个操作都带一个全局唯一 id 和因果元数据(类似 Git 的 parent commit)
- 多端各自往自己的本地副本上 apply 操作,然后互相交换
- 只要两端收到的操作集合相同(不管收到的顺序),最终状态一定收敛到同一个值
- 不需要中心服务器做仲裁
主流实现:
- Yjs:二进制格式,快、小,适合文本密集场景
- Automerge:JSON 友好,调试体验更像 Git
优点
- 天然支持离线:断网一天回来继续合并
- 天然支持乱序:消息先到后到都行
- 多端平等:没有"谁覆盖谁"的问题
缺点
- 协议复杂、payload 大:每个操作都要带因果元数据,单条消息比 JSON Patch 大一个量级
- 调试困难:出问题时不容易人肉看懂
- 不适合权威数据源在服务端的场景:用它是杀鸡用牛刀
适用场景
- Notion / Linear / Figma / Excalidraw 等协同编辑类产品
- Local-first 应用(localfirstweb.dev)
- 需要跨端强一致且支持离线的场景
一句话记忆:CRDT 是"多端协同编辑"领域的答案,其他场景用它反而累赘。
📎 顺便说一下别的方案
为了保持这篇文章的聚焦,另外两种在工业界也能看到的方案一句话带过:
- WebSocket + Protobuf / MessagePack:把 JSON 换成二进制 schema,带宽更省、延迟更低,适合游戏、金融行情、高频 IoT。缺点是 schema 演进麻烦、调试工具不如 JSON 方便。
- gRPC streaming (HTTP/2):强 schema + 双向流,跨语言生态好,但浏览器端支持差(需要 gRPC-Web gateway),前端场景用得少。
它们和前面四种方案是正交维度——前面讲的是"怎么表达增量",这俩讲的是"用什么传输层"。你可以用 WebSocket 传 JSON Patch,也可以用 SSE 传 NDJSON,随便组合。
🧭 怎么选?
一张简单的决策树:
你是协同编辑(多端同时写同一份文档)吗?
├── 是 → CRDT(Yjs 优先,文本场景尤其)
│
└── 否,服务端是权威数据源
│
├── 变更只是"整体替换" or 对象很小?
│ └── NDJSON(最省心)
│
├── 只改对象字段,几乎不动数组结构?
│ └── JSON Merge Patch(patch 本身可读)
│
└── 嵌套结构,尤其数组有增删改?
└── JSON Patch(表达力最强,工业界最常用)
按我的经验,大部分后端同学日常碰到的流式 LLM 输出、实时任务进度、结构化面板更新,九成以上场景都应该用 SSE + JSON Patch,这也是开篇那个 CI 流水线例子的选择。
⚠️ 几个常见误区
| 误区 | 真相 |
|---|---|
| "SSE 就是 JSON Patch" | 不是。SSE 是传输层,JSON Patch 是语义层,两者正交。可以用 SSE 传 NDJSON,也可以用 WebSocket 传 JSON Patch |
| "CRDT 用了就不怕冲突" | CRDT 保证状态收敛,但收敛到的那个状态可能不符合业务语义(例如两人同时删了同一条,合并后消失了) |
| "Merge Patch 比 JSON Patch 简单所以更好" | 要看业务。只要碰到数组你就会想换成 JSON Patch |
| "流式输出就得用 WebSocket" | 不必。服务端单向推客户端的场景,SSE 比 WebSocket 简单得多,还能走普通 HTTP 代理 |
📝 总结
- 这 4 种方案本质上是在**【带宽 × 表达力 × 一致性模型】**三维空间里取不同的点
- 服务端权威 + 嵌套结构 → JSON Patch(最常用的甜点区)
- 协同编辑 + 本地优先 → CRDT(几乎没别的选择)
- 其他 → NDJSON 或 Merge Patch 按场景
开篇那条流水线流用的就是 SSE + JSON Patch 这个组合。下次你看到任何"流式吐结构化结果"的场景,不妨抓包看看它的协议长什么样,大概率跑不出这篇文章讨论的这几种。