JSON 工具箱

JSON Patch 的安全应用:原子性、幂等与并发冲突

结论:JSON Patch 只是变更格式,原子性、幂等和并发控制仍要自己设计。本文从 test 前置条件、路径转义、回滚实现到 ETag 协作,给出可落地的安全用法。

实战约 10 分钟发布于 2026-10-31更新于 2026-10-31
JSON Patch 的安全应用封面

结论:先校验补丁的目标路径与权限,再用 test 或 If-Match 做并发断言;补丁在副本上应用,失败不能留下半成品。

PATCH 请求最怕的不是写错字段,而是「看起来成功了,实际上把别人的修改覆盖了」。JSON Patch 提供了工具,但安全用法要自己搭。

一、RFC 6902 的操作集回顾

JSON Patch 用一组操作描述变更,媒体类型是 application/json-patch+json。六种操作按数组顺序执行:

操作 作用 关键约束
add 新增成员或插入数组元素 数组下标可以等于长度,- 表示末尾
remove 删除成员或元素 目标路径必须存在
replace 替换已有值 目标路径必须存在
move 移动节点 不能移动到自己的子路径下
copy 复制节点 源路径必须存在
test 断言值相等 失败则整个补丁不生效

很多人只记住前三项,却忽略 test 才是把「盲写」变成「条件写」的关键。完整的操作语义与数组边界,在 JSON Pointer、Patch 与 Merge Patch 里有逐条说明。

二、用 test 把补丁变成条件更新

一个典型的乐观锁补丁,第一条不是写操作,而是版本断言:

{"op":"test","path":"/version","value":1}

把它放进完整的补丁里:

[
  { "op": "test", "path": "/version", "value": 1 },
  { "op": "replace", "path": "/version", "value": 2 },
  { "op": "replace", "path": "/status", "value": "paid" }
]

执行逻辑是:先确认文档当前版本确实是 1,再把它改成 2。如果另一个请求已经把版本推进到 2,第一条 test 失败,整个补丁不生效,服务端返回冲突。这样既避免了丢失更新,也不需要分布式锁。

几个设计要点:

  • test 要放在所有写操作之前,否则前面的写已经生效,断言就失去意义;
  • 断言的字段应当是服务端生成、客户端无法伪造的版本号或摘要,不要用客户端可随意改的普通字段;
  • 版本号每次成功写入都要递增,否则两个请求都用同一个旧版本仍然会通过;
  • test 的 value 必须与文档中的 JSON 值类型完全一致,1 与 "1" 不相等。

三、路径转义与原型污染

JSON Pointer 的转义规则是:~ 写成 ~0,/ 写成 ~1,且必须先替换 ~ 再替换 /,否则会出现二次转义。

键名 "a/b"    → /a~1b
键名 "m~n"    → /m~0n
键名 "a~/b"   → /a~0~1b

外部补丁最容易出问题的地方不是转义,而是危险键名。__proto__、constructor、prototype 这类键在 JavaScript 里可能触发原型污染,即使它只是补丁里的一个普通 add:

[
  { "op": "add", "path": "/__proto__/polluted", "value": true }
]

安全做法是白名单而不是黑名单:

  1. 只允许补丁修改业务字段,例如 /status、/items/0/quantity;
  2. 用正则或路径前缀校验每个 path 与 from,拒绝数组下标以外的非预期层级;
  3. 在解析 JSON 时就用 Object.create(null) 或 Map 承载数据,降低原型链被污染的影响;
  4. 对补丁条目数、单条 value 的字节数设置上限。

四、原子应用与回滚

RFC 6902 要求补丁原子执行,但「原子」是语义要求,不是所有库的默认实现。fast-json-patch 这类库在 mutateDocument 为 true 时会原地修改,失败后可能留下部分变更。

import { applyPatch, validate } from 'fast-json-patch';

/**
 * 以不可变方式应用补丁,失败时返回原始文档。
 * @param document 原始文档,调用方不应被修改
 * @param patch 待应用的 JSON Patch 操作数组
 * @returns 应用结果,包含成功标志与新文档
 */
function applySafely(document: unknown, patch: unknown[]) {
  const errors = validate(patch, document);

  if (errors) {
    return { ok: false, document };
  }

  try {
    const result = applyPatch(document, patch, false, false);
    return { ok: true, document: result.newDocument };
  } catch {
    return { ok: false, document };
  }
}

实现层面的三条建议:

  • 优先深拷贝或结构化克隆,在副本上应用,成功后再替换引用;
  • 不要依赖库的自动回滚,不同库对异常的处理并不一致;
  • 先校验再执行:validate 只能检查结构,语义冲突仍要靠 test,两者不能互相替代。

如果补丁是跨多个资源的事务(比如同时改订单和库存),JSON Patch 的原子性只在单个文档内成立,跨文档要用数据库事务或补偿操作。

五、幂等性:PATCH 不是天然幂等

HTTP 规范里 PUT 和 DELETE 是幂等的,PATCH 不一定。同一个 JSON Patch 重复提交,结果可能完全不同:

  • add /items/- 每次都在数组末尾追加,重复执行就多出一条;
  • replace /version 把版本从 1 改成 2,第二次执行仍然成功,但语义上已经重复;
  • remove /temp 第二次执行会因为路径不存在而失败。

想做到幂等,有三种常见方案:

方案 做法 适用场景
版本前置 补丁第一条 test /version,成功后递增 资源有明确版本号
幂等键 客户端生成 Idempotency-Key,服务端缓存结果 支付、下单等写操作
目标态补丁 用 replace 而不是 add,让结果只取决于终值 配置、状态字段

最忌讳的是「补丁里既有 add 又有 remove,还指望重试安全」。只要补丁包含追加或删除,就必须配合版本断言或幂等键。

六、并发冲突:ETag 与 If-Match

除了在补丁里写 test,HTTP 层还有一套标准做法:服务端返回 ETag,客户端下次请求带 If-Match。

GET /orders/42 HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
ETag: "a3f9c1"
Content-Type: application/json
PATCH /orders/42 HTTP/1.1
Host: api.example.com
If-Match: "a3f9c1"
Content-Type: application/json-patch+json

[{"op":"replace","path":"/status","value":"paid"}]

如果资源已经变化,服务端返回 412 Precondition Failed,客户端重新拉取后再重试。If-Match: * 表示「只要资源存在就执行」,不适合需要防覆盖的场景。

test 与 If-Match 的区别:

  • If-Match 在 HTTP 层校验,早于请求体解析,成本更低;
  • test 在补丁语义层校验,可以断言任意字段,不依赖服务端是否支持 ETag;
  • 两者可以叠加,但不要只做其中一件却宣称「并发安全」。

七、库实现差异与选型

实现 语言 原子性 test 支持 备注
fast-json-patch JavaScript 可选(默认生成新文档) 支持 生态广,注意 mutateDocument
jsonpatch Python 异常时回滚 支持 与标准贴合,错误信息清晰
json-patch Go 基于操作副本 支持 常与 JSON Merge Patch 一起用
zjsonpatch Java 支持 支持 与 Jackson 集成方便

选型时不要只看「是否支持六种操作」,还要确认三件事:失败是否回滚、test 的比较是否严格、数组 add 越界是否报错。宽松实现会让同一份补丁在不同环境产生不同结果。

八、上线前的安全检查清单

  • [ ] 端点声明 application/json-patch+json;
  • [ ] 补丁在副本上应用,失败不污染原文档;
  • [ ] 外部补丁的每个 path 与 from 都经过白名单校验;
  • [ ] 拒绝 __proto__、constructor、prototype 等危险键;
  • [ ] 限制补丁条目数与单个值的体积;
  • [ ] 用 test 或 If-Match 做并发控制;
  • [ ] 补丁写入审计日志,记录操作者、时间与完整操作数组;
  • [ ] 明确补丁跨文档时的事务边界。

补丁天然是一份「改了什么」的完整描述,适合直接作为审计记录。接口层的错误码与幂等设计,可以继续参考 JSON API 设计;如果补丁来自两个分支的合并,还要处理冲突语义,见 JSON 文件的 Git 合并。

常见问题

test 操作失败应该返回什么状态码?

如果冲突来自版本断言,返回 409 Conflict 或 412 Precondition Failed 都合理;推荐与 If-Match 保持一致用 412,并在响应体里带上当前版本,方便客户端重试。

补丁里有 add 和 remove,还能安全重试吗?

不能直接重试。add 到数组末尾会重复追加,remove 第二次会因路径不存在而失败。必须加版本前置断言或幂等键,让重复提交被识别为同一次操作。

JSON Patch 能保证跨文档事务吗?

不能。RFC 6902 的原子性只作用于单个 JSON 文档。涉及订单、库存等多个资源时,要用数据库事务、Saga 补偿或消息队列来保证一致性。

为什么不能在补丁里直接写 __proto__?

因为多数 JavaScript 实现会用普通对象承载文档,写入 __proto__ 可能修改对象的原型链,影响后续所有对象。路径白名单是最可靠的防线,不要只依赖库自身的过滤。

如何让 PATCH 请求具备幂等性?

常见做法有三种:补丁第一条写 test /version 并在成功后递增版本,服务端缓存 Idempotency-Key 对应的结果,或者用 replace 而不是 add 让结果只取决于终值。只要补丁包含追加或删除,就必须配合版本断言或幂等键,不能直接重试。

是否必须同时使用 test 和 If-Match?

不必两个都做,但只做其中一个不能宣称并发安全。If-Match 在 HTTP 层校验、早于请求体解析,成本更低;test 在补丁语义层校验,可以断言任意字段,不依赖服务端是否支持 ETag。