别让 API 静默覆盖:用 ETag 与 If-Match 实现乐观并发控制

ETag 与条件更新保护 API 资源

两位用户同时编辑同一份资料,是后台系统里非常常见的场景。若接口只接受一个普通的 PUTPATCH,后提交的请求会悄悄覆盖先提交的修改:两次都返回 200,数据却已经丢失。数据库事务能保护单条 SQL 的原子性,却不会替 HTTP 客户端判断“我编辑的还是不是刚才看到的版本”。

ETagIf-Match 提供了一种成本很低的乐观并发控制:读取时带走版本凭据,写入时带回凭据;版本不再匹配,服务端拒绝写入,让客户端重新读取、展示差异或由用户合并。

静默覆盖是怎样发生的

假设资料当前为版本 17。A 与 B 都在 10:00 读取到它;A 在 10:01 修改昵称,B 在 10:02 修改手机号。若服务端直接用请求体覆盖整份资料,B 的旧请求体可能把 A 的新昵称一起覆盖掉。这是典型的“丢失更新”,而不是一次请求失败。

两个客户端竞争同一资源版本

是否需要保护,取决于资源和编辑体验:收藏状态、只增不减的计数可以采用别的合并规则;商品描述、排班、权限和配置则通常应显式拒绝过期编辑。先把这个产品决策说清楚,比事后给冲突补一个重试按钮可靠得多。

写入方式 并发编辑的结果 适用场景
直接覆盖 后到请求可能丢掉先到修改 不会并发或内容可覆盖
服务器合并 按字段或业务规则合并 规则明确、可解释
条件更新 版本变化就拒绝旧写入 人工编辑、重要配置

把版本作为 HTTP 契约

客户端读取资源时,服务端返回一个不透明的强 ETag。它可以来自递增版本号,也可以来自该表示的稳定哈希;客户端不应解析它的内部格式。

1
2
3
4
5
6
7
GET /api/profiles/42

HTTP/1.1 200 OK
ETag: "profile-42-v17"
Content-Type: application/json

{"displayName":"Lin","phone":"13800000000"}

编辑提交时,把原样保存的 ETag 放入 If-Match。服务端只接受仍等于当前版本的写入,并在成功响应中给出新 ETag,供下一次编辑使用。

1
2
3
4
5
PATCH /api/profiles/42
If-Match: "profile-42-v17"
Content-Type: application/json

{"displayName":"Lin Chen"}

不要使用以 W/ 开头的弱 ETag 做写入前置条件。If-Match 使用强比较,弱 ETag 适合“内容大致相同”的缓存验证,不能证明两个资源版本完全一致。对于会因权限、语言或个性化而变化的表示,也要谨慎复用同一个 ETag;它必须代表这一次可编辑的资源状态。

在存储层做原子比较再更新

只在应用内先查版本、再无条件写入,中间仍可能被另一请求插队。真正的判断要落到同一条更新语句中。下例用整数版本承载 ETag,WHERE 条件和递增在数据库内原子执行:

1
2
3
4
5
6
7
8
UPDATE profiles
SET display_name = $1,
phone = $2,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $3
AND version = $4
RETURNING id, version, display_name, phone;

应用将 "profile-42-v17" 安全地解析或映射为期望版本 17,参数化执行 SQL。返回一行便表示更新成功,再以新版本构造 ETag: "profile-42-v18"。零行表示该版本已经不是当前版本;若接口必须区分资源不存在与版本过期,可在失败后按既定一致性策略查询资源是否存在。

条件请求在数据库比较后决定更新

这项设计并不要求长时间持有数据库锁。读请求之间不互相阻塞,竞争只在提交更新的一瞬间由条件语句裁决,因此很适合读多写少的编辑界面。批量修改则应定义整体版本、每项版本或部分成功语义,不能把单资源规则含糊地套过去。

用对状态码,也用对客户端动作

情况 建议响应 客户端下一步
未携带 If-Match 428 Precondition Required 重新读取并带上版本
ETag 与当前版本不符 412 Precondition Failed 获取最新版,提示合并或放弃
前置条件满足但业务规则冲突 409 Conflict 展示具体业务冲突
条件更新成功 200204,附新 ETag 保存新版本凭据

412 不是网络瞬断,不能把原请求体原样自动重试;那会再次覆盖别人的更改。正确流程是读取最新版、按字段合并,再携带新 ETag 提交。它也不等同于幂等键:幂等键解决“同一操作因重传被执行两次”,ETag 解决“不同编辑基于旧快照写入”。两者可同时使用。

上线前的四个检查

  1. 给每个可编辑资源选定稳定版本来源,并让成功写入一定推进版本;
  2. 服务端将缺少 If-Match 的危险写入拒绝,而非悄悄降级为无条件覆盖;
  3. 在集成测试中让两个客户端读同一 ETag,断言第一笔成功、第二笔得到 412,且第一笔字段仍在;
  4. 为 412 记录资源类型、旧版本和当前版本等脱敏指标,观察冲突是否反映了糟糕的交互设计。

乐观并发控制不是让冲突消失,而是让冲突从“成功但数据不见了”变成可观察、可恢复的协议结果。只要把 ETag 当作写入契约而非缓存装饰,接口就能诚实地保护用户刚刚看到的那份数据。