
两位用户同时编辑同一份资料,是后台系统里非常常见的场景。若接口只接受一个普通的 PUT 或 PATCH,后提交的请求会悄悄覆盖先提交的修改:两次都返回 200,数据却已经丢失。数据库事务能保护单条 SQL 的原子性,却不会替 HTTP 客户端判断“我编辑的还是不是刚才看到的版本”。
ETag 和 If-Match 提供了一种成本很低的乐观并发控制:读取时带走版本凭据,写入时带回凭据;版本不再匹配,服务端拒绝写入,让客户端重新读取、展示差异或由用户合并。
静默覆盖是怎样发生的
假设资料当前为版本 17。A 与 B 都在 10:00 读取到它;A 在 10:01 修改昵称,B 在 10:02 修改手机号。若服务端直接用请求体覆盖整份资料,B 的旧请求体可能把 A 的新昵称一起覆盖掉。这是典型的“丢失更新”,而不是一次请求失败。

是否需要保护,取决于资源和编辑体验:收藏状态、只增不减的计数可以采用别的合并规则;商品描述、排班、权限和配置则通常应显式拒绝过期编辑。先把这个产品决策说清楚,比事后给冲突补一个重试按钮可靠得多。
| 写入方式 | 并发编辑的结果 | 适用场景 |
|---|---|---|
| 直接覆盖 | 后到请求可能丢掉先到修改 | 不会并发或内容可覆盖 |
| 服务器合并 | 按字段或业务规则合并 | 规则明确、可解释 |
| 条件更新 | 版本变化就拒绝旧写入 | 人工编辑、重要配置 |
把版本作为 HTTP 契约
客户端读取资源时,服务端返回一个不透明的强 ETag。它可以来自递增版本号,也可以来自该表示的稳定哈希;客户端不应解析它的内部格式。
1 | GET /api/profiles/42 |
编辑提交时,把原样保存的 ETag 放入 If-Match。服务端只接受仍等于当前版本的写入,并在成功响应中给出新 ETag,供下一次编辑使用。
1 | PATCH /api/profiles/42 |
不要使用以 W/ 开头的弱 ETag 做写入前置条件。If-Match 使用强比较,弱 ETag 适合“内容大致相同”的缓存验证,不能证明两个资源版本完全一致。对于会因权限、语言或个性化而变化的表示,也要谨慎复用同一个 ETag;它必须代表这一次可编辑的资源状态。
在存储层做原子比较再更新
只在应用内先查版本、再无条件写入,中间仍可能被另一请求插队。真正的判断要落到同一条更新语句中。下例用整数版本承载 ETag,WHERE 条件和递增在数据库内原子执行:
1 | UPDATE profiles |
应用将 "profile-42-v17" 安全地解析或映射为期望版本 17,参数化执行 SQL。返回一行便表示更新成功,再以新版本构造 ETag: "profile-42-v18"。零行表示该版本已经不是当前版本;若接口必须区分资源不存在与版本过期,可在失败后按既定一致性策略查询资源是否存在。

这项设计并不要求长时间持有数据库锁。读请求之间不互相阻塞,竞争只在提交更新的一瞬间由条件语句裁决,因此很适合读多写少的编辑界面。批量修改则应定义整体版本、每项版本或部分成功语义,不能把单资源规则含糊地套过去。
用对状态码,也用对客户端动作
| 情况 | 建议响应 | 客户端下一步 |
|---|---|---|
未携带 If-Match |
428 Precondition Required |
重新读取并带上版本 |
| ETag 与当前版本不符 | 412 Precondition Failed |
获取最新版,提示合并或放弃 |
| 前置条件满足但业务规则冲突 | 409 Conflict |
展示具体业务冲突 |
| 条件更新成功 | 200 或 204,附新 ETag |
保存新版本凭据 |
412 不是网络瞬断,不能把原请求体原样自动重试;那会再次覆盖别人的更改。正确流程是读取最新版、按字段合并,再携带新 ETag 提交。它也不等同于幂等键:幂等键解决“同一操作因重传被执行两次”,ETag 解决“不同编辑基于旧快照写入”。两者可同时使用。
上线前的四个检查
- 给每个可编辑资源选定稳定版本来源,并让成功写入一定推进版本;
- 服务端将缺少
If-Match的危险写入拒绝,而非悄悄降级为无条件覆盖; - 在集成测试中让两个客户端读同一 ETag,断言第一笔成功、第二笔得到 412,且第一笔字段仍在;
- 为 412 记录资源类型、旧版本和当前版本等脱敏指标,观察冲突是否反映了糟糕的交互设计。
乐观并发控制不是让冲突消失,而是让冲突从“成功但数据不见了”变成可观察、可恢复的协议结果。只要把 ETag 当作写入契约而非缓存装饰,接口就能诚实地保护用户刚刚看到的那份数据。