同步一个会变的 JSON:从 NDJSON 到 CRDT 的 4 种姿势

流式 LLM 输出、实时看板、协同编辑背后共同的那个问题

April 22, 2026·5 min read·Yimin
#SSE#JSON Patch#CRDT#流式输出#协同编辑

服务端有一个不断变化的 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"}]

客户端要做的事情很清晰:

  1. 收到 create 时,记住这个完整对象作为基线
  2. 之后每条 json_patch 都是对这个对象的增量修改
  3. 逐条 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 PatchRFC 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 PatchRFC 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 这个组合。下次你看到任何"流式吐结构化结果"的场景,不妨抓包看看它的协议长什么样,大概率跑不出这篇文章讨论的这几种。