两份 JSON 差在哪:按位置比、按 id 比、还是先归一化
结论:50 条数据插进 1 条,为什么对比工具报了 51 处差异?讲清文本 diff 在 JSON 上的三类误报、数组按位置与按 id 的取舍、jq 归一化命令,以及比出差异后怎么变成可应用的补丁。

结论:先归一化再比较:键序、缩进与数字写法用 jq -S 或稳定序列化消掉,数组确认无序时按主键排序或建索引再比,最后才考虑把差异转成补丁。
JSON 的差异不是文本的差异。把两个文件当字符串比,键序一换就满屏标红;把数组按位置比,头部插一条就能把后面 50 条全标成「改过」。
订单服务上线了一个补丁,你手上只有两份响应:改动前 50 条,改动后 51 条,中间新插了一条。打开对比工具,结果报了 51 处变更,红通通一片,你根本看不出真正动的是哪一条。
这不是工具坏了,是「比较」这件事在 JSON 上有三种完全不同的做法,而多数人默认用了最不利的那种。
先复现一次误报:插 1 条,报 51 处
用 Node 22 写 30 行脚本,造 50 条订单({id, sku, qty}),分别在尾部追加一条和头部插入一条,用「按数组下标逐项对齐」的方式统计差异:
| 操作 | 数据量 | 按位置报告的差异数 |
|---|---|---|
| 尾部追加 1 条 | 50 → 51 | 1 处 |
| 头部插入 1 条 | 50 → 51 | 51 处 |
同样是「多了一条」,噪音量差 51 倍。原因很朴素:按下标比的时候,新元素挤进第 0 位,原来的每一条都往后挪了一格,第 0 位和第 0 位、第 1 位和第 1 位——每对都不相等。
换成按 id 建 Map 再比,同一份数据只报 1 处:$[id=ORD-999] added。
这个实验说明的判断规则,比任何工具推荐都重要:数组到底是有序序列,还是无序集合,决定了你该用哪种比法。
文本 diff 在 JSON 上撒谎的三种方式
diff a.json b.json 比较的是字符,JSON 承载的是数据结构,这层错位会稳定地产出三类假差异。
1. 对象键被重排
RFC 8259 第 4 节写得很清楚:对象是「无序的名值对集合」(an unordered collection of name/value pairs)。所以下面两份文档是同一个值:
{ "id": 42, "name": "Ada", "active": true }
{ "active": true, "name": "Ada", "id": 42 }
但行级 diff 会认为三行全变了。我拿一个 10 个键的对象把键序反转,pretty-print 之后逐行比对:10 行全部标红,而结构比较判定两者相等。噪声比 10:0。
键序为什么会变?切换序列化库、给 formatter 开了排序、从字典/哈希表重建对象、换语言运行时——任何一条都足以让同一份数据在磁盘上长得不一样。
2. 空白与缩进
{"a":1} 和 {\n "a": 1\n} 解析出来是同一个值。文本 diff 看到的是换行和空格,全标。
3. 数字和转义的不同写法
1.0 与 1、1e2 与 100、"\u00e9" 与 "é",解析后都是同一个值,序列化后文本不同。这类差异比前两类轻,因为它只影响少量行,但在配置对比里足够让你以为某个数值被改过。
反过来,有一类差异文本看不出来、结构看得见:5 和 "5"。字符上多了一对引号,语义上是类型漂移——这通常是 bug,不是更新。结构比较会老老实实报 modify。
三种比法,各管一段
| 比法 | 比较对象 | 噪声来源 | 输出 | 适合场景 |
|---|---|---|---|---|
| 文本 diff | 字符行 | 键序、缩进、数字写法 | 行号 + 红绿 | 本地化文案、键序稳定的 fixture |
| 结构 diff | 解析后的树,按路径对齐 | 数组按位置对齐 | 路径 + 增删改 | API 响应、配置、webhook |
| 语义 diff | 树 + 业务主键匹配 | 主键选错才有噪声 | 路径 + 移动/插入识别 | 记录列表、订单、库存 |
结构 diff 解决的是「键序和格式」这一层,语义 diff 再往前一步,解决「数组元素身份」这一层。三者的差别不在实现复杂度,在于你愿不愿意告诉工具「这条记录的身份证是哪个字段」。
数组是分水岭:位置对齐还是按 id 匹配
先问一句:这个数组的顺序本身是不是数据?
- 顺序即数据:迁移步骤、搜索排名、图表数据点、审批链路。位置变了就是行为变了,按位置比才对。
- 顺序无意义:标签、权限 ID 集合、订单明细、库存列表。插入一条不该影响其他条目的判定。
顺序无意义的场景,做法是抽主键建索引再比,而不是按下标硬怼:
// 按 id 匹配:插入/删除只报一条,其余条目不受位置影响
function diffById(before, after, path = '$') {
const out = [];
const beforeMap = new Map(before.map((item) => [item.id, item]));
const afterMap = new Map(after.map((item) => [item.id, item]));
for (const [id, value] of afterMap) {
if (!beforeMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'added' });
}
for (const [id, value] of beforeMap) {
if (!afterMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'removed' });
else if (JSON.stringify(value) !== JSON.stringify(afterMap.get(id))) {
out.push({ path: `${path}[id=${id}]`, type: 'changed' });
}
}
return out;
}
jsondiffpatch 这类成熟库把这件事做成了可配置项:数组差分用 LCS 找最长公共子序列,但对象元素默认只按位置匹配,官方文档特意强调「要按内容匹配数组里的对象,必须提供 objectHash 函数」。很多人第一次用觉得「这库怎么这么蠢」,其实是没传主键。
选主键也有讲究。用 id 最稳;用 sku 只在 SKU 唯一时成立;用整个对象做 hash 则会把「改了一个字段」变成「删一条加一条」,比按位置还糟。
命令行里最快的一招:jq 归一化
不想装库、只想在终端看一眼,做法是先归一化再 diff。jq -S 递归排序对象键并统一缩进:
# 归一化后逐行对比,键序和缩进造成的噪声会被消掉
diff -u <(jq -S . before.json) <(jq -S . after.json)
# 数组确认无序时,先按主键排序再比
diff -u <(jq -S '.items |= sort_by(.id)' before.json) \
<(jq -S '.items |= sort_by(.id)' after.json)
我实测 printf '{"b":2,"a":1,"arr":[1,2]}' | jq -S .,输出是 {"a":1,"arr":[1,2],"b":2}——键排了,arr 没动。这一点必须记住:-S 只排对象键,不排数组元素。数组要么用 sort / sort_by 显式处理,要么接受位置比较的噪声。
两个坑:
jq没有原地编辑能力,输出到标准输出。要么用进程替换(<(...)),要么写临时文件确认后再替换;-r会去掉字符串引号,输出里可能带换行或对 shell 有特殊含义的字符。把不可信数据喂给下一条命令时记得加引号。
本站 /diff 能做什么、不能做什么
站内的 JSON 对比工具 用的是同一套结构比较思路:两边都解析成树,再按路径递归对齐,输出 path + type(add/remove/modify) + before/after,对象键序不影响结果。全程在浏览器里算,粘贴的报文不会上传。
但它的数组是按下标对齐的——这是个明确的取舍,不是缺陷:
- 尾部追加、同位置修改、整体替换,它能给你干净的结果;
- 头部或中间插入一条记录,它会把后面所有条目都标成
modify,你得自己看出「其实只多了一条」。
所以用它处理订单列表这类数据时,正确姿势是:先按 id 排序两边再粘进去。排序后插入位置固定,噪声收敛到一条。这一招不需要装任何东西,能解决八成日常场景。
如果要做的是「生成可应用的补丁」「识别元素移动」,站内工具不做这件事,往下看下一节。
比出差异之后:补丁有两种,别选错
人看差异用 diff,程序应用差异用 patch。JSON 有两种补丁格式,语义完全不同。
| 维度 | JSON Patch(RFC 6902) | JSON Merge Patch(RFC 7396) |
|---|---|---|
| 形态 | 操作数组,op + path + value |
一个 JSON 对象,结构同原文档 |
| 能改数组吗 | 能,按索引,/items/0、/- 追加 |
不能,数组整体替换 |
| 能表达「删键」吗 | 能,remove |
只能靠 null,且无法删除本身就是 null 的字段 |
| 能表达移动吗 | 能,move / copy |
不能 |
| 适用 | 含数组的资源、审计日志、需要原子性 | 纯对象的部分更新、配置下发 |
JSON Patch 里有个常被忽略的操作 test:
[
{ "op": "test", "path": "/version", "value": 3 },
{ "op": "replace", "path": "/status", "value": "shipped" }
]
test 不通过,整个补丁被拒绝。这等于把乐观并发控制塞进了协议里——不用额外的 If-Match 头,也能保证「我看到的是第 3 版,我才改」。做 PATCH 接口时,这条比任何文档约定都管用。
路径语法是 JSON Pointer(RFC 6901),~0 转义 ~、~1 转义 /。键名里带斜杠(比如 MIME 类型当键)就必须转义,漏了就会指到别处。这部分细节在 JSON Pointer 与 Patch 里展开过。
还有一个现实问题:由 diff 自动生成的补丁,数组部分往往是按索引写的。索引在生产环境里很脆弱——并发插入一次,/items/3 指的就不是原来那条了。所以自动生成的补丁要么限定在对象字段,要么在应用前用 test 校验关键字段。
把对比放进 CI:断言值,不断言文本
最后落到团队习惯。三条规则足够:
- 测试里断言解析后的值,不要断言
JSON.stringify的结果。Jest 用toEqual,Python 在json.loads之后assert a == b,键序和缩进都不参与判定; - 给动态字段留白名单。
requestId、timestamp、traceId每次都不一样,不排除就永远红。jsondiffpatch 的propertyFilter、deepdiff 的exclude_paths都是干这个的; - 对比失败时输出路径而不是全文。5 MB 的报文打满日志没人看,
$.data.items[id=ORD-042].qty: 2 → 3一眼就能定位。
配置漂移的场景同理:把 staging 和 production 的两份配置都 jq -S 之后对比,再按业务字段排序数组,剩下的差异才是真差异。这类「同一份数据在不同环境长得不一样」的问题,本质上是契约没定死,可以顺着 API 契约设计 那篇往下看。
FAQ
JSON 键的顺序不同,算不算有差异?
不算。RFC 8259 明确规定对象是无序集合,{"a":1,"b":2} 和 {"b":2,"a":1} 是同一个值。任何把键序当差异报出来的工具,做的是文本比较,不是 JSON 比较。
为什么插入一条记录后,所有条目都被标成「变更」?
因为工具按数组下标逐项对齐。新元素插在第 0 位,原来每条都后移一格,每对下标的内容都不相等。解决办法是按主键(如 id)排序或建索引后再比,而不是按位置硬比。
在线对比工具会不会把我的数据传上去?
看实现。服务端渲染的工具会把报文发到后端,可能落日志;纯前端工具在浏览器里解析,不上传。对比生产配置、用户报文这类数据前,先确认页面是本地计算,或直接断网测试一次。
对比结果能直接当补丁用吗?
不能直接用。对比输出是「哪里不一样」,JSON Patch 是「按顺序执行哪些操作」,中间还差一层转换。而且自动生成的补丁常用数组索引定位,并发场景下索引会失效,应用前建议加 test 校验。
几十 MB 的大文件怎么比?
先用流式方式(jq -c 输出每行一条)或只抽取关心的子树再比,比如 jq '.data.items'。整棵树加载进内存做递归对比,几十 MB 还能撑住,上百 MB 就要考虑分片或按主键抽取。内存与耗时的具体量级可以看 大文件 JSON 性能。
要不要先按 id 排序再比较数组?
要。站内 JSON 对比工具 的数组按下标对齐,头部或中间插入一条就会把后面所有条目标成 modify;先按 id 排序再粘进去,插入位置固定,噪声收敛到一条。但如果数组顺序本身是数据(迁移步骤、搜索排名、图表数据点),就应按位置比较,不要排序。