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

结论:先校验补丁的目标路径与权限,再用 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 }
]
安全做法是白名单而不是黑名单:
- 只允许补丁修改业务字段,例如
/status、/items/0/quantity; - 用正则或路径前缀校验每个
path与from,拒绝数组下标以外的非预期层级; - 在解析 JSON 时就用
Object.create(null)或Map承载数据,降低原型链被污染的影响; - 对补丁条目数、单条
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。