一、先从一次让我印象深刻的排查开始

有一次,一个同事半夜给我打电话,说用户明明只是改了个昵称,结果把整个账户资料都弄丢了。我一查日志,发现他调的是PUT /users/123,请求体里只带了 { "nickname": "新昵称" }。而服务端代码用的是“先查出来,再合并,再整体存回去”的逻辑,没查出来的时候就给了一个空对象,然后就把数据库里其他字段清空了。

这就是典型的PUT使用错误。PUT是整体替换,你只给一个字段,那其他字段就会被当成“不存在”。这种事情不止新手会犯,很多老手也可能因为图省事而踩坑。所以我们今天就聊聊,为什么PUT和PATCH这两个方法总是出问题,以及怎么才能用对它们。

二、先搞清楚HTTP方法的基本“性格”

2.1 安全方法:GET、HEAD、OPTIONS

安全方法指的是:调用这个方法,不会对服务器资源做任何修改。就像你去图书馆查一本书,不管查多少遍,书的内容不会变。GET、HEAD、OPTIONS就是这种“只读”的操作。

比如说,你用GET去请求一个用户列表,服务器每次返回同样的数据,不会因为多请求几次就多删一条记录。用POST或者PUT去请求,那服务器可能就改数据了。安全方法的好处是,你可以放心地随便调,不会有副作用。

2.2 不安全的幂等方法:PUT、DELETE

幂等这个词听起来很学术,其实很好理解。幂等就是说“不管执行一次还是执行一百次,最终结果都一样”。拿DELETE举例,你删一个订单,第一次删成功了,第二次再删,返回的还是“已删除”或者404,但最终订单都是不存在了,状态没变化。这就是幂等。PUT也这样,你把一个用户的名字改成“小明”,执行一次是“小明”,执行十次还是“小明”,最后服务器上的资源就是“小明”,完全一样。

2.3 既不安全又不幂等的:POST、PATCH

POST就不多说了,你用POST创建订单,点一次生成一个订单,点十次生成十个订单,既改数据,又不幂等。PATCH呢?按定义,PATCH是局部更新,但很多人以为局部更新自然就是幂等的。其实不一定。比如PATCH里写“对某个计数器加1”,你调用一次,计数器加1,调用十次,就加10了,结果明显不一样,所以PATCH不是天生的幂等方法。只有当我们把PATCH设计成“设置某个字段为固定值”时,它才碰巧幂等。也就是说,幂不幂等,还得看你怎么实现。

三、PUT和PATCH的本质区别

3.1 PUT是“把整碗饭换掉”,PATCH是“往碗里加个菜”

我们都吃过饭。你要一份盖浇饭,整碗饭从饭到菜都是厨子给你配好的。PUT就是“我重新给你做一份完整的饭”,哪怕你只想多加个鸡蛋,你也得把整份饭的所有食材都告诉厨子,否则厨子默认你没点的就是不要。PATCH更像是“我这份饭做好了,但我想把其中一块肉换成豆腐”,你只需要告诉厨子哪块肉要换,其他不变。

用技术话讲,PUT要求请求体里携带目标资源的完整状态。比如你要更新用户ID为1的信息,那么姓名、年龄、邮箱、地址,全部都要传上来。少传一个字段,服务器就会把那个字段覆盖成空值或默认值。PATCH则允许你只传需要修改的字段,服务器只更新你传的字段,其余字段保持不变。

3.2 一个代码例子把这个区别摆清楚

我们统一用Node.js + Express来演示。假设我们有一个用户对象:

// 技术栈:Node.js + Express
// 用户资源模拟,放在内存里
const users = {
  1: {
    id: 1,
    name: '张三',
    age: 30,
    email: 'zhangsan@example.com',
    address: '北京市朝阳区'
  }
};

如果前端要改用户的姓名,正确的做法是用PATCH,只传name字段:

// 技术栈:Node.js + Express
// 局部更新:只修改name,其他字段不受影响
app.patch('/users/:id', (req, res) => {
  const id = req.params.id;
  const user = users[id];
  if (!user) {
    return res.status(404).json({ message: '用户不存在' });
  }

  // 只更新请求体里出现的字段
  const allowedFields = ['name', 'age', 'email', 'address'];
  for (const key of allowedFields) {
    if (req.body[key] !== undefined) {
      user[key] = req.body[key];
    }
  }

  res.json(user);
});

如果非要用PUT做局部更新,你得先把原有数据查出来,再合并,再整体覆盖。稍微不注意,像文章开头那个同事一样,先查的时候没查到,或者查询逻辑有bug,最后存进去的就是一个残缺对象。代码可能长这样:

// 技术栈:Node.js + Express
// 错误的PUT用法:请求体里只有一个字段,其他字段会被清空
app.put('/users/:id', (req, res) => {
  const id = req.params.id;
  // 注意:这里没有先读取旧数据,直接拿请求体覆盖
  // 如果请求体里没有name/age/email/address,这些字段就会变成undefined
  const user = {
    id: id,
    name: req.body.name,       // 如果没传,就是undefined
    age: req.body.age,         // undefined
    email: req.body.email,     // undefined
    address: req.body.address  // undefined
  };
  users[id] = user;
  res.json(user);
});

你看,如果只传name,其他三个字段全变成了undefined。一旦存进数据库,或者序列化返回给前端,那些字段就丢了。这就是为什么PUT出问题的频率最高。

四、幂等性到底怎么理解

4.1 幂等不是“安全”,而是“结果可预测”

很多同学把幂等和安全混为一谈。安全是“不修改数据”,幂等是“修改了多次但最终结果一样”。怎么说呢,就像你睡觉前定了闹钟,第一次定7点,第二次还是定7点,不管定多少次,最终闹钟都是7点响,这就是幂等。但你确实修改了闹钟的状态,所以这个操作不安全(非安全方法),可它是幂等的。

对于API来说,幂等最大的价值是:可以让客户端放心重试。网络超时了,客户端重新发一次同样的请求,不会产生额外的副作用。比如用PUT把一个订单的状态改成“已支付”,发一次和发十次,最后订单状态还是“已支付”,所以重试没问题。但用POST创建订单,发十次就创建了十个订单,这就不行。

4.2 怎么让PATCH具备幂等性

上面说了,PATCH本身不保证幂等,但你可以通过设计让它幂等。最简单的办法是:PATCH只做“设置值”的操作,不做“累加”之类的操作。比如“把这个字段的值改成5”就是幂等的,因为改多少次都是5。而“给这个字段加1”就不幂等。

更严谨的做法是引入版本号或者请求ID。我们来看一个用请求ID实现幂等的例子。

4.3 用请求ID实现幂等创建或更新

现在很多支付平台都要求客户端传一个Idempotency-Key,这个Key就是请求的唯一标识。服务器第一次收到这个Key,会正常处理并缓存结果;第二次收到相同的Key,就直接返回缓存的结果,不再重复修改数据。下面是一个简单的实现思路:

// 技术栈:Node.js + Express
// 简单的幂等中间件:用Map存储请求结果
const idempotencyStore = new Map();

app.use('/users', (req, res, next) => {
  const key = req.headers['idempotency-key'];
  if (!key) return next(); // 没传Key就正常处理

  // 如果这个Key已经处理过,直接返回之前的结果
  if (idempotencyStore.has(key)) {
    return res.json(idempotencyStore.get(key));
  }

  // 让请求继续,同时拿到原始的res.json以便缓存结果
  const originalJson = res.json.bind(res);
  res.json = (body) => {
    idempotencyStore.set(key, body); // 缓存返回结果
    return originalJson(body);
  };

  next();
});

这个例子虽然简单,但是思路很清晰。真实项目中,一般会把请求ID和响应结果存到Redis里,并设置过期时间。这样就算客户端重复提交,服务器也能识别出来,不会重复干活。

五、安全性:不是HTTPS那种“安全”

5.1 HTTP方法的安全性是“修改”的另一种说法

HTTP规范里定义的安全方法,是指不会改变服务器状态的请求。说白了,就是“只读”。GET是安全的,因为你请求多少次,资源都不会变。PUT、PATCH、DELETE、POST都是不安全的,因为它们都会对资源做修改。

注意,这里的安全和“密码安全性”“传输加密”没关系。就算你用HTTPS把数据加密了,GET依然是安全方法,PUT依然是不安全方法。这两个“安全”是不同维度。

5.2 为什么不安全的方法依然很常用

因为我们需要修改数据啊。安全方法只有GET、HEAD、OPTIONS,它们没法完成更新和删除。所以“不安全”不是贬义,只是说明这个请求有副作用。设计API的时候,你要清楚每个方法是不是会修改数据,这样才能决定要不要幂等设计、要不要防重复提交。

六、实际场景中的问题模式

6.1 用PUT做局部更新

这是最常见的坑。就像开头说的,前端只传了一个字段,后端直接整体覆盖,导致其他字段丢光。解决办法:要么改用PATCH,要么在PUT接口里先把旧数据查出来合并完整再存。但是既然有了PATCH,就别用PUT干这种事了。

6.2 用PATCH做完整替换

也有一些人反过来,用PATCH传了所有字段,虽然能工作,但语义不对。PATCH的本意就是“增量更新”,你传完整资源会让维护者困惑,而且不利于后续优化。正确做法是:完整替换用PUT,局部更新用PATCH。

6.3 不做并发控制,数据互相覆盖

两个人同时编辑同一个用户,A把年龄改成25,B把年龄改成30,最后谁后保存谁赢。这种“最后写入者赢”的问题,在并发场景下会丢数据。解决方法是使用版本号或乐观锁。我们可以在资源上放一个version字段,更新时检查版本号是否匹配。

下面是一个带乐观锁的PATCH接口示例:

// 技术栈:Node.js + Express
// 用version字段实现乐观锁
app.patch('/users/:id', (req, res) => {
  const id = req.params.id;
  const user = users[id];
  if (!user) {
    return res.status(404).json({ message: '用户不存在' });
  }

  // 前端必须传来当前版本号,且与服务器版本一致才能修改
  const clientVersion = req.headers['if-match']; // 或者用请求体中的version
  if (!clientVersion || clientVersion !== String(user.version)) {
    return res.status(409).json({ message: '版本冲突,请刷新后重试' });
  }

  // 更新字段...
  if (req.body.name !== undefined) user.name = req.body.name;
  if (req.body.age !== undefined) user.age = req.body.age;

  // 更新版本号
  user.version += 1;

  res.json(user);
});

这里用了If-Match请求头,HTTP里有个更标准的方式叫条件请求。浏览器或客户端只要带上这个头,服务器就检查资源的ETag是否一致,如果不一致就返回412 Precondition Failed。这样就能避免并发覆盖。

6.4 不处理部分失败

PATCH的局部更新可能导致“更新一半”的情况。比如一个请求里同时改五个字段,在更新第三个时数据库出错了,那前两个已经改了,后两个没改。这就变成了一个不完整的资源。解决办法是使用事务,要么全部成功,要么全部回滚。在关系型数据库里,用事务可以很好解决;在NoSQL里,可能需要设计成整体更新或使用更复杂的补偿机制。

七、正确设计指南

7.1 统一技术栈:Node.js + Express 的完整示例

我们把这个用户API设计得规范一点。先定义好路由,再体现PUT和PATCH的正确用法。

// 技术栈:Node.js + Express
// 完整示例:用户资源API
const express = require('express');
const app = express();
app.use(express.json());

// 模拟数据库
let users = {
  1: {
    id: 1,
    name: '张三',
    age: 30,
    email: 'zhangsan@example.com',
    address: '北京市朝阳区',
    version: 1
  }
};

// PUT:整体替换。请求体必须包含所有字段
app.put('/users/:id', (req, res) => {
  const id = req.params.id;
  if (!users[id]) {
    return res.status(404).json({ message: '用户不存在' });
  }

  // 从请求体中取出所有必要字段
  const { name, age, email, address } = req.body;
  if (name === undefined || age === undefined || email === undefined || address === undefined) {
    return res.status(400).json({ message: '缺少必填字段,请提交完整资源' });
  }

  // 整体替换:直接创建一个新对象,version加1
  users[id] = {
    id: id,
    name: name,
    age: age,
    email: email,
    address: address,
    version: users[id].version + 1
  };

  res.json(users[id]);
});

// PATCH:局部更新。请求体只需要包含要修改的字段
app.patch('/users/:id', (req, res) => {
  const id = req.params.id;
  if (!users[id]) {
    return res.status(404).json({ message: '用户不存在' });
  }

  // 只更新请求体中出现的字段
  const updatable = ['name', 'age', 'email', 'address'];
  for (const field of updatable) {
    if (req.body[field] !== undefined) {
      users[id][field] = req.body[field];
    }
  }

  // 版本号每次更新都递增
  users[id].version += 1;

  res.json(users[id]);
});

// DELETE:删除资源,幂等。重复删除也返回成功或404均可
app.delete('/users/:id', (req, res) => {
  const id = req.params.id;
  if (users[id]) {
    delete users[id];
  }
  res.status(204).send(); // 无论之前是否存在,最终都不存在
});

app.listen(3000, () => {
  console.log('API server running on http://localhost:3000');
});

这个示例很直观。PUT要求四个字段全在,不然就400。PATCH则可以只改其中一个字段。注意DELETE我们返回的是204,不管用户之前是否存在,删除后都不存在了,这样就是幂等的。

7.2 如何设计POST、PUT、PATCH、DELETE的语义

  • POST:创建新资源。每次调用都会新增一个资源,所以不幂等。常用于“下单”“注册”。
  • PUT:整体替换指定资源。幂等,因为资源最终状态就是你提交的那个完整状态。
  • PATCH:局部更新指定资源。不保证幂等,但可以通过设计让它幂等。
  • DELETE:删除指定资源。幂等,因为删一次和删多次结果都是“没有了”。

7.3 状态码怎么选

用对状态码,能少很多沟通成本。常见的有:

  • 200 OK:更新成功,返回新资源
  • 201 Created:创建成功,返回新资源
  • 204 No Content:删除成功或无内容返回
  • 400 Bad Request:参数错误,比如PUT缺少字段
  • 404 Not Found:资源不存在
  • 409 Conflict:版本冲突,比如并发更新
  • 412 Precondition Failed:条件请求失败,比如If-Match不匹配

在PATCH返回时,很多人喜欢返回200加更新后的资源,这样前端方便拿到最新数据。如果不想返回内容,那就用204。

八、应用场景与优缺点对比

8.1 PUT的典型场景

  • 同步资源快照:比如配置文件整体覆盖、用户画像全量更新。
  • 批量更新整个对象:前端有完整资源对象,且不允许部分缺失。
  • 优点:幂等,天然支持重试;语义清晰,请求体就是资源的完整表达。
  • 缺点:要求客户端每次都要传完整数据,网络开销大;如果客户端没有全量数据,就很容易出错。

8.2 PATCH的典型场景

  • 用户只改了昵称或者只改了头像:比如用户设置页面,往往只提交一两个字段。
  • 增量更新大对象:比如一个大型文档,只改其中一段文字,传输全量文档代价太大。
  • 优点:流量更小,灵活;能精确表达“改哪个字段”,不干扰其他字段。
  • 缺点:不保证幂等,需要额外设计;多个字段同时更新时,可能只成功一半,需要事务或补偿。

8.3 怎么选

如果客户端能拿到最新全量数据,而且是覆盖式更新,就选PUT。如果客户端只改了部分字段,或者资源很大,就选PATCH。不要搞混。

九、注意事项

  1. 永远不要用PUT做局部更新。要么用PATCH,要么强制客户端提交完整数据。
  2. 使用PATCH时,最好只做“把字段设置为指定值”的操作,避免“自增/自减”这类非幂等逻辑,除非你专门实现了幂等机制。
  3. 对于需要重试的支付、订单等场景,一定要加幂等键(Idempotency-Key)。这个方法对POST和PATCH都适用。
  4. 处理并发更新,请使用版本号、If-Match、ETag等条件请求机制,而不是靠心情覆盖。
  5. 在前后端联调时,明确接口契约:哪个接口是PUT,传什么,哪个是PATCH,传什么。可以发布OpenAPI文档,比口头沟通靠谱。
  6. 注意大对象场景下的性能。如果资源很大,用PUT传全量会很慢,此时PATCH更友好,但要注意部分更新的原子性。
  7. 日志和监控里,要区分不同的方法。如果你想统计谁在滥用API,发现PUT的请求体字节数很小,那很可能是错误用法,可以暴露出来治理。

十、总结

PUT和PATCH本身不难,难的是我们常常凭直觉去用。记住一句话:PUT是全量替换,PATCH是局部更新。PUT天然幂等,PATCH需要你费心去保证幂等。设计API时,先想清楚这个操作是“整体覆盖”还是“增量修改”,然后再选方法。再配合版本号、条件请求、幂等键,你的RESTful API就能少掉很多头发。接口设计是一门沟通的艺术,把语义定准了,客户端和服务端都会很舒服。