题干与适用场景
一个客户端发送创建订单命令,服务端可能已经提交订单,却在返回响应前断网。客户端无法判断结果,准备使用同一个 Idempotency-Key 重试。请设计请求契约、持久化记录、并发控制和恢复流程,并说明哪些失败可以安全重试。题目核心是副作用边界、原子性和故障后的可解释状态,因此归入 backend。
面试官考察点
高质量回答会先区分“请求未到达”“已完成但响应丢失”和“仍在处理”,再决定重放结果、查询状态或返回处理中。还应覆盖相同键不同参数、并发首请求、存储故障、键过期、跨区域路由和下游副作用,而不是只说“把键放 Redis”。
回答前需要澄清的问题
- 幂等键由客户端为一次逻辑命令生成,还是由服务端从业务字段推导?
- 相同键必须绑定哪些请求字段,参数不一致时返回什么错误?
- 订单写入和幂等记录是否在同一个数据库事务中?下游支付是否另有幂等能力?
- 客户端允许等待多久,键需要保留多久,过期后重复命令是否可能造成新订单?
- 服务是单区还是多区,幂等记录如何保证所有入口看到同一结果?
30 秒回答框架
“客户端为每次逻辑命令生成稳定的幂等键,服务端以租户、端点和键组成唯一约束,并保存请求指纹、状态和最终响应。首次请求原子地创建 processing 记录并执行订单事务;同键同参数的并发请求等待或重放结果,同键不同参数立即返回冲突。服务端崩溃后通过事务结果和超时恢复状态,未知的下游副作用先查询再补偿,不能盲目重试。键的保留期要覆盖客户端重试窗口,并用指标验证重复副作用为零。”
分步骤深入解答
请求契约应要求幂等键非空、长度受限且在一次逻辑命令的所有重试中保持不变。服务端将 (tenantid, operation, idempotencykey) 建为唯一键,同时保存规范化请求哈希、状态、资源 ID、响应码、响应体和过期时间。请求哈希避免客户端错误复用同一个键执行不同命令。
首次请求应先在持久化存储中插入 processing 记录,再与订单创建写入放进同一个本地事务,或使用能明确关联两者的事务日志。插入冲突时读取已有记录:succeeded 直接返回保存的响应,failed 按契约返回同一业务错误,processing 返回处理中或短暂等待。不要只把锁放在单机内存里,否则重启和多副本会失效。
并发请求必须由唯一约束和条件更新决定胜负。只有持有创建记录的请求可以把状态从 processing 改为 succeeded;更新条件包含版本号或状态,避免两个 worker 同时提交。若订单事务成功而进程在写响应前崩溃,重试读取 succeeded 记录即可重放;若事务回滚,则可安全再次执行。
下游副作用会产生“本地未知、远端可能成功”的窗口。支付、发货或消息发送应使用同一个业务操作 ID调用支持幂等的下游接口,并记录请求与结果。若下游不支持幂等,先查询对账接口或写入 outbox,再由可重试 worker 发送;不能因为本地请求超时就再次扣款。
处理中记录需要恢复策略。可以保存租约和最后心跳,由恢复 worker 查询订单事务、下游状态或事务日志后把记录推进到终态;若证据不足,标记 unknown 并要求人工或对账流程处理,而不是把未知当失败。状态机要禁止 succeeded 回退到 processing,并记录每次转移的原因。
键过期必须与业务风险匹配。保留期至少覆盖客户端最大重试、网络重试队列和人工补偿窗口;过期后再次使用同一键应返回明确的 key_expired,不要静默创建第二个订单。可以定期压缩旧响应,但需保留审计摘要和资源唯一约束,防止清理任务破坏安全边界。
跨区域部署要让同一个逻辑键落到一致的权威存储,或使用全局唯一约束与同步复制。读到 processing 时不能因为跨区延迟就转到另一副本再次执行。指标至少包括键冲突率、参数冲突、处理中超时、重放响应、未知状态、重复资源拦截和下游对账差异。
高质量示范回答
“我把幂等键定义成一次逻辑创建命令的稳定标识,而不是每次 HTTP 重试都新生成的请求 ID。服务端用租户、操作类型和键建立持久化唯一约束,记录请求哈希、状态、资源 ID、响应和过期时间。首个请求原子地占有 processing 记录并创建订单;同键同参数的请求重放保存的结果或等待,同键不同参数返回冲突。
我会把订单写入和幂等记录放在同一事务里,并让支付等下游使用相同业务操作 ID。若响应丢失,重试读取已保存结果;若本地状态未知,先查下游和对账记录,绝不靠再次 POST 猜测。worker 用租约恢复卡住的 processing,状态只能按条件更新推进到 succeeded、failed 或 unknown。键的保留期覆盖所有重试和补偿窗口,过期键明确拒绝。压测并发首请求、进程崩溃和跨区延迟,验收重复订单和重复扣款均为零。”
常见错误
- 每次重试生成新键 → 服务端无法识别同一逻辑命令 → 客户端在重试中复用原键。
- 只用内存或单机锁 → 重启和多副本失去去重 → 使用持久化唯一约束。
- 同键不同参数仍返回旧结果 → 客户端错误被掩盖 → 保存请求指纹并返回冲突。
- 记录
processing后直接调用下游 → 崩溃时无法判断副作用 → 使用事务、outbox 或下游幂等接口。 - 超时就再次扣款 → 远端可能已经成功 → 先查询状态和对账。
- 把卡住的记录当失败重做 → 可能产生第二个资源 → 由恢复 worker 先收集证据。
- 键很快过期 → 延迟重试创建第二个订单 → 保留期按风险和重试窗口确定。
- 只测试串行请求 → 并发竞争仍会双写 → 测试同键并发、崩溃和多区路由。
追问及应对
追问一:为什么不能只用订单号做唯一键?
订单号通常在服务端创建后才产生,无法覆盖首次请求尚未返回的窗口。幂等键先于副作用存在,并通过唯一约束把重试绑定到同一次逻辑命令。
追问二:同一个键但请求体变化怎么办?
将规范化请求体和关键头字段计算指纹并持久化。指纹不同返回参数冲突,不执行新副作用;客户端应生成新键表示新的逻辑命令。
追问三:首个请求一直停在 processing 怎么办?
使用租约、心跳和超时扫描。恢复 worker 查询本地事务、下游结果和消息日志;证据足够才推进到终态,证据不足就标记 unknown 并进入对账流程。
追问四:Redis 能否单独承担幂等记录?
若副作用的权威数据在数据库,单独 Redis 可能在淘汰、故障或复制延迟时丢失安全边界。可用 Redis 做短期协调,但最终状态和唯一约束应在能与业务写入保持一致的持久化存储中。
追问五:键过期后用户再次提交同一订单怎么办?
不要静默接受旧键。返回过期错误并要求客户端查询原订单或生成新命令;资源层还应有业务唯一约束,例如同一购物车版本不能重复结算。
追问六:如何验证没有重复副作用?
并发发送相同键、在提交前后杀进程、制造响应丢失和下游超时,检查订单唯一约束、支付操作 ID、重放响应、状态转移日志和对账结果。核心断言是每个逻辑键最多一次成功副作用。
追问七:幂等键是否等于 exactly-once?
不是。它约束同一服务识别重复命令,并不能让跨服务网络天然 exactly-once。跨边界仍需事务、outbox、下游幂等、查询和对账,明确哪些结果可能是 unknown。