JSON Patch 与 JSON Merge Patch 完全指南:精确控制 API 数据变更的终极方案

深入解析 JSON Patch (RFC 6902) 与 JSON Merge Patch (RFC 7396) 的原理、差异与实战应用,帮你实现精确的 API 增量更新,告别全量替换的性能陷阱。

API 设计 2026-05-29 15 分钟

RESTful API 设计中,超过 70% 的开发者在更新资源时仍在使用 PUT 全量替换,而非 PATCH 增量更新。这意味着更新用户邮箱地址时,客户端需要发送整个用户对象——包括姓名、地址、头像等所有字段。JSON Patch(RFC 6902)和 JSON Merge Patch(RFC 7396)正是为解决这一问题而生的两个标准协议,它们让你能精确描述 JSON 文档的变更内容,将网络传输量降低 60%-90%,同时避免并发更新时的「丢失更新」问题。

本文将从协议原理到生产实践,深入对比两种方案的适用场景、实现细节和常见陷阱。

🔐 一、JSON Patch (RFC 6902):原子级精确操作

1.1 核心概念与操作指令

JSON Patch 是一个 IETF 标准(RFC 6902),它定义了一组操作指令(Operations),用于描述从一个 JSON 文档到另一个 JSON 文档的精确转换。每个操作都是一个 JSON 对象,包含 oppath 和可选的 value 字段。

JSON Patch 支持 6 种操作指令:

操作指令 用途 是否需要 value 复杂度
add 添加新值 ✅ 是 O(n)
remove 删除值 ❌ 否 O(n)
replace 替换值 ✅ 是 O(n)
move 移动值到新位置 ❌ 否(需 from) O(n)
copy 复制值到新位置 ❌ 否(需 from) O(n)
test 断言某个位置的值 ✅ 是 O(n)

📌 记住:test 操作是并发安全的关键。它允许你在执行变更前验证当前状态,确保在你读取数据到写入数据之间,没有其他人修改了目标字段。

来看一个完整的示例。假设我们有这样一个用户对象:

// 原始文档
{
  "name": "张三",
  "email": "zhangsan@example.com",
  "address": {
    "city": "北京",
    "district": "海淀区"
  },
  "tags": ["developer", "admin"]
}

我们想要:修改邮箱、添加电话、移除 admin 标签。对应的 JSON Patch 如下:

// JSON Patch 指令集
[
  { "op": "replace", "path": "/email", "value": "zhangsan@new.com" },
  { "op": "add", "path": "/phone", "value": "13800138000" },
  { "op": "remove", "path": "/tags/1" }
]

1.2 JavaScript 完整实现

在前端和 Node.js 环境中,fast-json-patch 是最成熟的 JSON Patch 库:

npm install fast-json-patch
// json-patch-demo.js — JSON Patch 实战演示
import * as jsonpatch from 'fast-json-patch';

const user = {
  name: '张三',
  email: 'zhangsan@example.com',
  age: 28,
  address: { city: '北京', district: '海淀区' },
  tags: ['developer', 'admin', 'reviewer']
};

// 定义 Patch 指令
const patch = [
  { op: 'replace', path: '/email', value: 'new@example.com' },
  { op: 'add', path: '/phone', value: '13800138000' },
  { op: 'remove', path: '/tags/1' },           // 移除 "admin"
  { op: 'move', from: '/address/district', path: '/address/area' }  // 重命名字段
];

// 应用 Patch
const result = jsonpatch.applyPatch(user, patch, true /* validate */);
console.log(JSON.stringify(result.newDocument, null, 2));
// 输出:
// {
//   "name": "张三",
//   "email": "new@example.com",
//   "age": 28,
//   "phone": "13800138000",
//   "address": { "city": "北京", "area": "海淀区" },
//   "tags": ["developer", "reviewer"]
// }

// 反向生成 Patch:自动计算两个对象的差异
const original = { a: 1, b: [1, 2, 3] };
const modified = { a: 2, b: [1, 3, 4] };
const diff = jsonpatch.compare(original, modified);
console.log(diff);
// [
//   { "op": "replace", "path": "/a", "value": 2 },
//   { "op": "remove", "path": "/b/1" },
//   { "op": "add", "path": "/b/2", "value": 4 }
// ]

⚠️ 警告:compare() 生成的 Patch 可能不是最优的。例如把 [1,2,3] 改成 [1,3],它可能生成 remove /b/1,也可能生成 replace /b[1,3]。在性能敏感的场景中,建议对生成的 Patch 进行优化后再发送。

1.3 JSON Pointer (RFC 6901) 路径规范

JSON Patch 的 path 字段遵循 JSON Pointer 规范(RFC 6901)。理解路径规则是正确使用 JSON Patch 的前提:

路径 含义 特殊字符处理
/name 根对象的 name 字段
/address/city 嵌套对象路径
/tags/0 数组第一个元素(0-indexed)
/tags/- 数组末尾(用于 add 操作)
/data/foo~1bar 字段名为 foo/bar ~1 代表 /
/data/foo~0bar 字段名为 foo~bar ~0 代表 ~

💡 **提示:**路径中的 ~ 必须编码为 ~0/ 必须编码为 ~1,且 ~0 必须在 ~1 之前解码。这是最常见的实现 Bug 之一,顺序搞反会导致路径解析错误。

🚀 二、JSON Merge Patch (RFC 7396):简洁的替代方案

2.1 设计哲学与规则

JSON Merge Patch 的设计哲学是「最小惊讶」——它的工作方式就像你对现有对象执行了一次深度合并(deep merge),同时用 null 表示删除。相比 JSON Patch 的 6 种操作指令,JSON Merge Patch 只有 3 条规则:

  1. ✅ 如果字段值是简单类型(string/number/boolean),直接替换
  2. ✅ 如果字段值是对象,递归合并
  3. ❌ 如果字段值是 null,删除该字段
// merge-patch-demo.js — JSON Merge Patch 实战演示

// 原始文档
const original = {
  title: 'Goodbye!',
  author: { name: '张三', email: 'zhangsan@example.com' },
  tags: ['article', 'draft'],
  content: '这是一段很长的内容...'
};

// Merge Patch(注意:对象类型会被递归合并,数组类型会被整体替换)
const patch = {
  title: 'Hello World!',              // 简单类型 → 直接替换
  author: { email: 'new@example.com' }, // 对象 → 递归合并(name 保留)
  tags: ['article', 'published'],     // 数组 → 整体替换!
  content: null                        // null → 删除该字段
};

// 应用 Merge Patch
function applyMergePatch(target, patch) {
  if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
    return patch;
  }

  const result = { ...target };
  for (const [key, value] of Object.entries(patch)) {
    if (value === null) {
      delete result[key];
    } else if (typeof value === 'object' && !Array.isArray(value)
               && typeof result[key] === 'object' && !Array.isArray(result[key])) {
      result[key] = applyMergePatch(result[key], value);
    } else {
      result[key] = value;
    }
  }
  return result;
}

const result = applyMergePatch(original, patch);
console.log(JSON.stringify(result, null, 2));
// {
//   "title": "Hello World!",
//   "author": { "name": "张三", "email": "new@example.com" },  ← name 保留!
//   "tags": ["article", "published"],                           ← 数组整体替换
//   "content" 字段已删除
// }

⚠️ 警告:JSON Merge Patch 会整体替换数组,而非像 JSON Patch 那样支持精确的数组元素操作。如果你需要修改数组中的单个元素,必须使用 JSON Patch 或发送完整的数组。

2.2 两种方案的深度对比

对比维度 JSON Patch (RFC 6902) JSON Merge Patch (RFC 7396)
操作精度 原子级,精确到单个字段/数组元素 对象级递归,数组整体替换
数组操作 ✅ 支持单个元素增删改 ❌ 只能整体替换
字段重命名 move 操作 ❌ 不支持
条件断言 test 操作 ❌ 不支持
Content-Type application/json-patch+json application/merge-patch+json
学习曲线 较高(6 种操作 + JSON Pointer) 极低(直觉式合并)
补丁大小 可以很小(精确描述变更) 可能较大(需包含完整子对象)
可读性 中等 极高
服务端实现 需要专用 Patch 解析器 大多数框架原生支持
并发安全 ✅ 配合 test 实现乐观锁 ❌ 需额外机制
适用场景 复杂业务、协作编辑、审计追踪 简单 CRUD、配置更新

⚡ **关键结论:**如果你的 API 只需要更新简单对象字段,JSON Merge Patch 是更简洁的选择。但如果你涉及数组元素操作、字段重命名、并发控制或操作审计,JSON Patch 是唯一正确的方案。

💡 三、生产环境实战与避坑指南

3.1 Express/Koa 服务端实现

在 Node.js 服务端同时支持两种 Patch 协议的标准实现:

// server-patch-handler.js — 生产级 PATCH 请求处理器
import express from 'express';
import * as jsonpatch from 'fast-json-patch';

const app = express();
app.use(express.json());

// PATCH /api/users/:id — 自动识别 Content-Type
app.patch('/api/users/:id', async (req, res) => {
  const contentType = req.headers['content-type'];
  const userId = req.params.id;

  // 从数据库加载当前文档
  const currentDoc = await db.users.findById(userId);
  if (!currentDoc) return res.status(404).json({ error: '用户不存在' });

  let patchedDoc;

  try {
    if (contentType === 'application/json-patch+json') {
      // JSON Patch: 验证格式并应用
      if (!Array.isArray(req.body)) {
        return res.status(400).json({ error: 'JSON Patch 必须是数组' });
      }

      // 可选:先执行 test 操作验证乐观锁
      const testOps = req.body.filter(op => op.op === 'test');
      if (testOps.length > 0) {
        const testResult = jsonpatch.applyPatch(
          structuredClone(currentDoc), testOps, true
        );
      }

      const result = jsonpatch.applyPatch(
        currentDoc, req.body, true /* validateOps */
      );
      patchedDoc = result.newDocument;

    } else if (contentType === 'application/merge-patch+json') {
      // JSON Merge Patch
      patchedDoc = applyMergePatch(currentDoc, req.body);

    } else {
      return res.status(415).json({
        error: '不支持的 Content-Type,请使用 application/json-patch+json 或 application/merge-patch+json'
      });
    }

    // 持久化并返回
    await db.users.update(userId, patchedDoc);
    res.json(patchedDoc);

  } catch (err) {
    if (err.name === 'JsonPatchError') {
      return res.status(422).json({ error: `Patch 操作失败: ${err.message}` });
    }
    res.status(500).json({ error: '服务器内部错误' });
  }
});

3.2 三大常见陷阱与解决方案

陷阱一:数组索引偏移

连续执行 remove 操作时,后续操作的索引会因为前面的删除而偏移:

// ❌ 错误写法:连续删除导致索引错乱
const patch = [
  { op: 'remove', path: '/items/1' },  // 删除 index 1
  { op: 'remove', path: '/items/2' }   // 原来的 index 3,但删除后变成了 index 2!
];

// ✅ 正确写法:从后往前删除,或使用单个 splice 操作
const patch = [
  { op: 'remove', path: '/items/2' },  // 先删后面的
  { op: 'remove', path: '/items/1' }   // 再删前面的
];

陷阱二:Merge Patch 的 null 不等于「设置为 null」

在 JSON Merge Patch 中,null 永远表示「删除字段」,无法将字段值设置为 null

// ❌ 想要把 middleName 设置为 null(确实存在但值为空)
// 但 Merge Patch 会直接删除该字段
{ "middleName": null }

// ✅ 解决方案 1:改用 JSON Patch
[{ "op": "replace", "path": "/middleName", "value": null }]

// ✅ 解决方案 2:使用包装对象
{ "middleName": { "value": null, "exists": true } }

⚠️ **警告:**这是 JSON Merge Patch 最大的设计缺陷。如果你的业务需要区分「字段不存在」和「字段值为 null」,必须使用 JSON Patch 或设计替代方案。

陷阱三:from 路径的顺序依赖

movecopy 操作中,from 路径必须在 path 操作之前有效:

// ❌ 错误:先 add 覆盖了 from 路径
[
  { op: "add", path: "/newName", value: "临时值" },
  { op: "move", from: "/oldName", path: "/newName" }  // /newName 已被覆盖
]

// ✅ 正确:先 move 再做其他操作
[
  { op: "move", from: "/oldName", path: "/newName" },
  { op: "add", path: "/extra", value: "其他数据" }
]

3.3 Spring Boot 集成方案

Java 后端同样可以优雅地支持两种 Patch 协议:

// JsonPatchController.java — Spring Boot PATCH 控制器
import com.github.fge.jsonpatch.JsonPatch;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/users")
public class UserPatchController {

    private final ObjectMapper mapper = new ObjectMapper();

    @PatchMapping(value = "/{id}", consumes = "application/json-patch+json")
    public ResponseEntity<?> applyJsonPatch(
            @PathVariable String id,
            @RequestBody JsonPatch patch) {

        User user = userService.findById(id);
        if (user == null) return ResponseEntity.notFound().build();

        try {
            JsonNode userNode = mapper.valueToTree(user);
            JsonNode patchedNode = patch.apply(userNode);
            User patchedUser = mapper.treeToValue(patchedNode, User.class);

            return ResponseEntity.ok(userService.update(id, patchedUser));
        } catch (Exception e) {
            return ResponseEntity.unprocessableEntity()
                .body(Map.of("error", "Patch 应用失败: " + e.getMessage()));
        }
    }

    @PatchMapping(value = "/{id}", consumes = "application/merge-patch+json")
    public ResponseEntity<?> applyMergePatch(
            @PathVariable String id,
            @RequestBody JsonNode patch) {

        User user = userService.findById(id);
        if (user == null) return ResponseEntity.notFound().build();

        try {
            JsonNode userNode = mapper.valueToTree(user);
            JsonNode mergedNode = mergePatch(userNode, patch);
            User mergedUser = mapper.treeToValue(mergedNode, User.class);

            return ResponseEntity.ok(userService.update(id, mergedUser));
        } catch (Exception e) {
            return ResponseEntity.badRequest()
                .body(Map.of("error", e.getMessage()));
        }
    }

    private JsonNode mergePatch(JsonNode target, JsonNode patch) {
        if (!patch.isObject()) return patch;

        var result = target.deepCopy();
        patch.fields().forEachRemaining(entry -> {
            if (entry.getValue().isNull()) {
                ((ObjectNode) result).remove(entry.getKey());
            } else if (entry.getValue().isObject() && result.has(entry.getKey())
                       && result.get(entry.getKey()).isObject()) {
                ((ObjectNode) result).set(entry.getKey(),
                    mergePatch(result.get(entry.getKey()), entry.getValue()));
            } else {
                ((ObjectNode) result).set(entry.getKey(), entry.getValue());
            }
        });
        return result;
    }
}

3.4 并发控制:用 test 操作实现乐观锁

JSON Patch 的 test 操作是实现乐观并发控制的利器,无需额外的锁机制:

// optimistic-lock.js — 基于 JSON Patch 的乐观锁实现
async function updateUserWithLock(userId, patchOps) {
  const maxRetries = 3;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    // 1. 读取当前文档(包含版本号)
    const current = await db.users.findById(userId);
    const version = current._version;

    // 2. 在 Patch 前插入 test 操作验证版本
    const safeOps = [
      { op: 'test', path: '/_version', value: version },
      ...patchOps
    ];

    try {
      // 3. 原子性地执行 test + 更新
      const result = jsonpatch.applyPatch(current, safeOps, true);
      result.newDocument._version = version + 1;

      await db.users.update(userId, result.newDocument);
      return result.newDocument;

    } catch (err) {
      if (err.name === 'JsonPatchError' && attempt < maxRetries - 1) {
        console.log(`版本冲突,第 ${attempt + 1} 次重试...`);
        continue;  // 版本冲突,重试
      }
      throw err;
    }
  }
}

// 使用示例:更新邮箱(带乐观锁保护)
const patch = [
  { op: 'replace', path: '/email', value: 'new@example.com' }
];
await updateUserWithLock('user-123', patch);

💡 **提示:**这种模式比传统的 SELECT ... FOR UPDATE 悲观锁更适合高并发读多写少的场景,因为它不会阻塞其他请求,而是在发生冲突时重试。

3.5 前端实战:React/Vue 状态管理集成

在前端应用中,JSON Patch 非常适合实现「乐观更新」和「撤销/重做」功能:

// undo-redo.js — 基于 JSON Patch 的撤销/重做系统
class PatchHistory {
  constructor() {
    this.undoStack = [];  // 正向 Patch
    this.redoStack = [];  // 反向 Patch
  }

  // 执行变更并记录历史
  apply(doc, patch) {
    const reversePatch = jsonpatch.compare(
      jsonpatch.applyPatch(doc, patch, true).newDocument,
      doc
    );

    this.undoStack.push({ forward: patch, reverse: reversePatch });
    this.redoStack = []; // 清空 redo 栈

    return jsonpatch.applyPatch(doc, patch, true).newDocument;
  }

  // 撤销
  undo(doc) {
    const entry = this.undoStack.pop();
    if (!entry) return doc;

    this.redoStack.push(entry);
    return jsonpatch.applyPatch(doc, entry.reverse, true).newDocument;
  }

  // 重做
  redo(doc) {
    const entry = this.redoStack.pop();
    if (!entry) return doc;

    this.undoStack.push(entry);
    return jsonpatch.applyPatch(doc, entry.forward, true).newDocument;
  }

  // 批量发送变更到服务端(增量同步)
  flushToServer(baseUrl, resourceId) {
    const patches = this.undoStack.map(e => e.forward);
    if (patches.length === 0) return;

    const combinedPatch = patches.flat();
    return fetch(`${baseUrl}/${resourceId}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json-patch+json' },
      body: JSON.stringify(combinedPatch)
    });
  }
}

3.6 JSON Patch vs RESTful 替代方案

方案 传输量 精确度 复杂度 并发安全 审计追踪
PUT 全量替换 🔴 高 🟡 整体 🟢 低 🔴 易冲突 🔴 困难
JSON Merge Patch 🟡 中 🟡 对象级 🟢 低 🔴 需额外机制 🟡 中等
JSON Patch 🟢 低 🟢 原子级 🟡 中 🟢 test 操作 🟢 天然支持
GraphQL Mutation 🟢 低 🟢 字段级 🔴 高 🟡 需额外机制 🟡 中等
gRPC FieldMask 🟢 低 🟢 字段级 🟡 中 🟡 需额外机制 🟡 中等

📊 四、总结与最佳实践

选择 JSON Patch 的场景:

  • ✅ 需要精确修改数组中的单个元素
  • ✅ 需要字段重命名(move)或复制(copy
  • ✅ 需要乐观并发控制(test 操作)
  • ✅ 需要操作审计日志(每条指令都可记录)
  • ✅ 协作编辑系统(如文档协同)

选择 JSON Merge Patch 的场景:

  • ✅ 简单的 CRUD 配置更新
  • ✅ 嵌套对象的部分字段更新
  • ✅ 前后端团队经验有限,追求实现简单
  • ✅ 不涉及数组的精确操作

生产环境 Checklist:

  • ✅ 设置正确的 Content-Type 头(application/json-patch+jsonapplication/merge-patch+json
  • ✅ 对 JSON Patch 操作进行格式验证(使用库的 validate 模式)
  • ✅ 在敏感字段上使用 test 操作防止并发冲突
  • ✅ 处理数组索引偏移问题(从后往前删除或使用负索引)
  • ✅ 记录 Patch 操作日志用于审计追踪
  • ❌ 不要在 Merge Patch 中用 null 表示「值为空」
  • ❌ 不要假设 compare() 生成的 Patch 是最优的
  • ❌ 不要忽略 JSON Pointer 中 ~0~1 的转义规则

⚡ **关键结论:**JSON Patch 和 JSON Merge Patch 不是互相替代的关系,而是互补的。一个成熟的 API 应该同时支持两者——简单的配置更新用 Merge Patch 降低开发成本,复杂的状态变更用 JSON Patch 保证精确性和安全性。

相关工具与库:

📚 相关文章