题干与适用场景
发票明细 invoice_items/{id} 包含说明和金额。Alice 与 Bob 都读到版本 7;Alice 先保存为版本 8, Bob 随后仍基于版本 7 提交。如果服务直接执行无条件 UPDATE,Bob 会覆盖 Alice 的结果,双方都收到 成功响应,这就是本题要防止的丢失更新。
本题限定一个 HTTP JSON API、一个支持条件更新的关系数据库,以及不会复用的资源 ID。每个响应只有 一个规范 JSON 表示;每次业务字段变化都会推进资源版本并生成新的强 ETag。冲突预计较少,因此采用 乐观并发控制,不在用户编辑期间持有数据库锁。范围包括 PUT、PATCH 与 DELETE,不讨论多人实时 协同编辑算法。
核心能力属于后端:把客户端读到的版本、HTTP 前置条件和数据库的原子写入串成一个可验证的契约。 下面使用 PostgreSQL 风格 SQL;其他数据库要提供等价的条件更新与受影响行数检查。只在控制器中比较 版本,再执行无条件更新,仍然会留下检查与写入之间的竞争窗口。
面试官考察点
第一项信号是能否画出丢失更新的交错顺序,并指出最后写入者获胜并不等于业务正确。强回答会要求每次 可变资源读取都返回版本凭证,写请求必须证明自己基于当前版本。
第二项信号是 HTTP 语义是否准确。服务在 GET 响应中返回强 ETag,客户端在修改请求中发送 If-Match。If-Match 使用强比较;旧标签或弱标签不能通过。缺少强制前置条件时可返回 428 Precondition Required,标签不匹配时返回 412 Precondition Failed。409 Conflict 留给未由 HTTP 前置条件表达的业务冲突。
第三项信号是数据库检查必须与写入原子完成。推荐条件是 id 与 version 同时匹配,并在同一条 语句中更新业务字段和递增版本。受影响行数为 0 才表示资源已删除或版本已过期;先查版本再无条件写入 属于 TOCTOU 竞争。
最后看冲突恢复和边界。乐观并发控制不能替代幂等键,也不能保护跨多行谓词。高竞争资源持续返回 412 时,应改变竞争策略,而不是让客户端无限立即重试。
回答前需要澄清的问题
- 冲突单位是整项资源还是单个字段? 整项版本最容易证明正确,但两个用户改不同字段也会冲突。
若这种冲突频繁,可拆小聚合、提供意图明确的操作接口,或定义字段级合并规则。
- 哪些写入方法必须带版本? 本题对
PUT、PATCH和DELETE都要求If-Match。批量导入、
后台任务和管理脚本也必须使用同一版本协议,不能保留旁路写入。
- 冲突后由谁决定合并? 人工编辑通常展示“原值、我的修改、当前值”再让用户选择;机器操作只有
在合并函数满足业务不变量时才自动重算和重试。
- 冲突率与延迟预算是多少? 偶发冲突适合乐观方案。热点库存或竞价记录可能更适合单条原子业务
操作、短事务悲观锁或单写者队列。
- 客户端会不会因超时重发同一请求? 版本检查处理不同意图的竞争;幂等键处理同一意图的传输
重试。需要同时覆盖两种风险。
- 版本是否覆盖跨行规则? 单行版本只防止同一资源被覆盖。若规则是“一个班次至少一人在岗”,
需要 Serializable、共同锁行或数据库约束,不能只给每行加版本。
30 秒回答框架
“我会让 GET 返回当前资源和强 ETag,例如版本 7 对应 \"invoice-item-42-v7\"。客户端保存时把 它放进 If-Match。服务缺少该头就返回 428,收到旧标签就返回 412。真正的防线在数据库:用一条 UPDATE ... WHERE id = ? AND version = 7 同时写业务字段并把版本加一;返回 0 行表示冲突或删除, 不能先查再无条件更新。成功响应带版本 8 的新 ETag。客户端遇到 412 后重新读取,展示差异并由用户 合并;同一请求超时重发另用幂等键处理。最后用两个并发客户端固定交错,断言恰好一个版本 7 写入 成功。”
分步骤深入解答
先把错误历史写清楚:
Alice: GET item 42 -> amount=10000, version=7
Bob: GET item 42 -> amount=10000, version=7
Alice: PUT amount=10500 -> unconditional UPDATE -> success
Bob: PUT amount=9800 -> unconditional UPDATE -> success
Final: amount=9800; Alice's accepted update is lost推荐契约让读取返回强 ETag。标签是客户端必须原样带回的不透明值,不应包含敏感信息,也不能替代 认证和授权:
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "invoice-item-42-v7"
{"id":42,"description":"Consulting","amountCents":10000,"version":7}Alice 的更新把这个标签放进 If-Match:
PUT /invoice-items/42 HTTP/1.1
Content-Type: application/json
If-Match: "invoice-item-42-v7"
{"description":"Consulting","amountCents":10500}服务先完成正常的身份、权限和请求格式检查,再评价前置条件。若 API 规定所有修改都必须是条件请求, 缺少 If-Match 返回 428,并说明客户端应重新读取后带标签重试。若标签不是当前强 ETag,则不执行 修改并返回 412。HTTP 的强比较要求两边都不是 W/ 弱标签且不透明标签逐字符相同;不能把 W/"invoice-item-42-v7" 当成成功匹配。
通过 HTTP 检查后,数据库仍必须再次原子兑现相同版本条件:
UPDATE invoice_items
SET description = $1,
amount_cents = $2,
version = version + 1
WHERE id = $3
AND version = $4
RETURNING id, description, amount_cents, version;$4 是从经过校验的 ETag 得到的预期版本 7。返回一行表示写入成功,响应包含版本 8 的表示和新 ETag。返回 0 行时再按授权边界读取当前记录:不存在则返回 404,仍存在则返回 412。ID 不复用的 假设让删除与重建不会伪装成旧资源。无论怎样分类,0 行路径都不能改写数据。
不能先执行 SELECT version,比较通过后再做不带版本的 UPDATE。另一个事务可以在两条语句之间 提交,让刚通过检查的请求覆盖新值。即使控制器已经比较过 ETag,SQL 的 WHERE version = $4 仍是最终正确性边界。ORM 的并发令牌也应生成等价的条件更新,并把 0 行转换为并发冲突。
状态码要和失败原因对齐:
428 Precondition Required:客户端没有提供 API 要求的条件。412 Precondition Failed:提供了If-Match,但当前表示不满足条件。404 Not Found:在不泄露资源存在性的授权策略下,资源已经不存在。409 Conflict:请求通过了版本前置条件,但触发另一项业务状态冲突,例如已结算发票禁止修改。
收到 412 后,交互式客户端重新 GET,保留三份值:用户最初读取的值、用户准备提交的值和服务器 当前值。只有当前值相对原值没有改动的字段,才可能安全套用用户修改;其余字段展示冲突。整个资源 共用一个版本会保守地拒绝不相交字段的并发修改。若这成为真实瓶颈,可以拆分资源边界或提供 POST /invoice-items/42/adjust-amount 一类携带业务意图的原子操作,而不是默默改成最后写入获胜。
幂等键解决另一种问题。假设 Alice 的版本 7 更新已提交为版本 8,但成功响应在网络中丢失;原请求 重发时,旧 If-Match 会失败。若请求带稳定幂等键,服务可以返回第一次已保存的结果。版本凭证检测 “两个不同修改都基于版本 7”,幂等记录识别“同一个修改被传输了两次”,两者不能互换。对于无法 可靠证明请求已经执行过的写入,严格返回 412 比根据相似请求猜测成功更安全。
乐观方案假设冲突少。若某项资源的冲突率持续很高,重读和人工合并会浪费容量并恶化体验。库存扣减 可用 UPDATE ... SET stock = stock - $1 WHERE stock >= $1 直接表达单行不变量;必须读取后决策的 短操作可用行锁;需要串行处理的聚合可分区到单写者。选择取决于冲突率、操作是否可交换、等待预算 和不变量范围。
验证要固定并发历史,而不是顺序点两次接口。两个客户端都先读取版本 7,用屏障同时提交不同金额, 断言恰好一个请求成功,另一个得到 412,最终版本为 8,且内容来自成功者。还要覆盖缺头返回 428、 弱 ETag 不匹配、成功响应携带新 ETag、旧标签持续失效、更新与删除竞争,以及响应丢失后的同一幂等 键重放。线上监控各端点的前置条件缺失率、412 比例、自动重试次数和最终放弃率;异常上升通常表示 热点或有客户端没有刷新版本。
高质量示范回答
“我先把冲突边界定为一条发票明细。两个用户都拿到版本 7,后一个保存不能无条件覆盖先一个,所以 读响应会带强 ETag,所有 PUT、PATCH、DELETE 都必须用 If-Match 带回。缺头返回 428;带回 的标签已经过期就返回 412,不执行写入。
HTTP 层比较标签还不够。我会在表里保存每资源版本,并让持久层原子执行 UPDATE invoice_items SET ..., version = version + 1 WHERE id = ? AND version = ? RETURNING ...。Alice 先以版本 7 成功后,行变为版本 8;Bob 的相同条件影响 0 行,因此不会覆盖。成功响应返回新 ETag。0 行后按权限 读取一次,用 404 区分删除,其余作为 412,但任何分支都不能补做无条件更新。
客户端收到 412 会重新读取,比较原值、我的修改和当前值,让用户解决真正冲突。若不同字段经常互相 阻塞,我会拆小资源或设计意图明确的原子操作。请求超时重发另用幂等键:它识别同一意图的重复传输, 版本号识别不同意图基于同一旧状态。
我会用两个连接和屏障让它们都读取版本 7 后同时提交,断言只有一个成功、另一个 412、最终版本恰好 是 8。再测缺少 If-Match、弱标签、删除竞争和响应丢失重放。若线上 412 比例长期很高,我会从热点 和资源边界入手,评估条件业务更新、短事务锁或单写者,而不是提高无限重试次数。”
常见错误
- 只在应用层先查版本再更新 → 检查与写入之间仍能插入另一个提交 → **把版本放进同一条
UPDATE 的 WHERE 条件并检查受影响行数。**
- 允许缺少
If-Match时继续写 → 新客户端受保护,旧客户端仍能静默覆盖数据 → **把条件写入
设为 API 契约,缺头返回 428。**
- 把弱 ETag 当成
If-Match成功 → 标准要求强比较,弱标签不会匹配 → **为可修改表示生成
符合语义的强 ETag。**
- 所有冲突都返回 409 → 客户端无法区分 HTTP 前置条件失败与业务状态冲突 → **版本不匹配返回
412,独立业务冲突再使用 409。**
- 收到 412 就自动原样重试 → 相同旧标签不会变新,无条件换标签又可能覆盖别人 → **重新读取并
重新计算或让用户合并。**
- 认为幂等键已经防止并发覆盖 → 两个不同修改使用不同键时仍基于同一旧版本 → **幂等键与版本
条件分别处理重复传输和并发意图。**
- 给每行加版本就宣称所有不变量安全 → 跨行写偏斜不会触发同一行版本冲突 → **按不变量范围
选择 Serializable、共同竞争点或约束。**
- 热点上无限立即重试 → 失败请求同步重撞并放大负载 → **测量冲突率,改用原子业务操作、短锁
或单写者。**
追问及应对
追问一:PATCH 修改不同字段也要冲突吗?
整项版本意味着任何字段变化都会推进版本,因此两个不相交补丁也会冲突。这是安全且容易解释的默认值。 若数据证明误冲突很多,可以把独立生命周期拆成子资源,或让操作携带字段基线并定义三方合并。字段级 令牌会降低误冲突,却增加令牌数量、组合不变量和审计难度,不能只为减少 412 就引入。
追问二:客户端超时后重发,旧 ETag 已经失效怎么办?
修改请求携带稳定幂等键,服务把键、请求摘要、预期版本和首次结果一起保存。相同键与相同请求重放时 返回首次结果;相同键配不同请求则拒绝。若没有可靠幂等记录,不能仅凭当前字段“看起来相同”就假定 首次请求成功,应返回 412 并让客户端读取当前状态。
追问三:能否直接使用 updated_at 作为版本?
只有它由数据库生成、每次相关修改都变化、精度足以区分连续写入且所有节点语义一致时才可能安全。 时间戳精度截断或遗漏写入路径会让两个版本拥有相同值。每资源递增整数或数据库原生行版本通常更容易 形成无歧义的相等比较;对外仍可编码成不透明 ETag。
追问四:冲突率达到很高水平时怎样改?
先按资源和操作统计 412,确认是单个热点、过大的冲突边界还是客户端长时间离线。可交换的增减操作 改成数据库原子表达式;短而不可交换的决策可用行锁;必须严格排序的一组命令可按资源键进入单写者。 这些方案以等待、吞吐或架构复杂度换取更少失败,应由实际冲突和延迟数据触发。
追问五:单行版本为什么防不住写偏斜?
写偏斜中的两个事务读取同一个跨行谓词,却更新不同记录,各自的版本条件都能成功。版本只证明“我 更新的这一行没有变化”,没有证明共享谓词仍成立。应把不变量投影到共同计数行、锁定共同 guard, 建立数据库约束,或使用 Serializable 检测无法串行化的依赖。