网络超时会让客户端重发,网关会重试,用户还会继续点。系统不能要求世界只请求一次;它必须让同一个业务意图无论到达几次,都只留下一个可解释的结果。
创业第 3 年 9 个月,「秒抢」累计用户约 700 万,公司 210 人,后端 38 人。晚间加场开票刚结束,客服在 20:14 转来一张手机截图:同一用户、同一场次、同一分钟,订单列表整齐地排着 8 笔待支付记录。
数字看起来甚至有一点荒谬:客户端记录了 6 次点击,网关因两次上游超时补发了 2 次,订单库最终出现 8 行。每一行主键都不同,所以数据库认为它们互不冲突;每一次接口也都返回了某个订单号,所以服务成功率保持漂亮。
你沿着 trace 往前追:第一次请求其实在 430ms 时已经写入订单,只是响应穿过网络时超时。网关不知道结果,带着一份新的请求再次调用;客户端也没有稳定请求标识,每次点击都让服务端生成新订单号。系统没有认出「这些是同一件事」,于是忠实地做了八遍。
更麻烦的是,其中两笔已经进入支付。现在不能简单删掉后七行:你需要找出哪一笔应保留、哪些支付要原路退、库存预扣是否释放,以及队列里还有多少重复事件在路上。
A的问题是顺序证据不足。「后创建」不代表「未付款」或「未占库存」,数据库删除还可能让支付和出票失去关联。先做差集对账,再通过状态机取消,不能把业务补偿缩成删行。
B方向可能有用,但锁不是幂等记录。30 秒内可以挡住同一键并发,却无法回答锁释放后的重复请求应该返回哪笔订单;若业务超过过期时间,锁本身还会失效。先建立稳定幂等键与结果表,再判断临界区是否需要锁。
C只关掉一个重复来源。用户连点、客户端超时、消息重投和人工补偿仍会制造重复;而完全关闭重试还会把短暂网络故障变成请求丢失。第 7 章已经展示过重试的放大,第 9 章也说明 at-least-once 会主动选择重复。
D是这一步的正解:先阻止副作用继续扩散。暂停自动支付与出票,避免 8 笔订单继续变成 8 次扣款和 8 张票;按业务意图收敛新请求,并保存订单、支付、库存、消息四本账,才能逐笔决定保留与补偿。
幂等不是「接口尽量不要重复调用」,而是一个可以验收的函数性质:
在创建订单里,x 不是某个 HTTP 包,而是「用户 U-7318 购买场次 S-204 的这一次确认」。如果 8 次请求携带同一个幂等键 K-8842,第一次创建订单 O-501;后 7 次查询幂等记录并返回同一个 O-501,而不是继续 INSERT。
「幂等键必须由请求方生成——由服务端生成的『唯一 ID』挡不住重试。」原因很直接:服务端只有收到请求后才生成 ID,而重试在服务端看来是一次新请求,于是又生成一个新 ID。请求方必须在第一次发送前固定业务键,并在超时、重连和手动重试时复用它。
请求方不一定是浏览器。网关调用订单服务时,网关是请求方;订单消费者写票权时,消费者是请求方;补偿任务重放消息时,补偿任务是请求方。责任规则不变:谁发起可能重试的业务动作,谁在动作开始前固定幂等键。
| 键状态 | 请求应该做什么 | 返回语义 | 不能做什么 |
|---|---|---|---|
| 不存在 | 原子占位为 PROCESSING,记录请求摘要 | 进入首次处理 | 先执行业务、事后才写幂等记录 |
| PROCESSING | 校验请求摘要一致,等待或返回处理中 | 不重复执行副作用 | 把同键不同参数当成同一请求 |
| SUCCEEDED | 读取持久化结果 | 返回第一次的订单号与状态 | 重新生成响应里的业务标识 |
| FAILED_RETRYABLE | 按版本抢占重试权,沿用原键 | 成功后覆盖为最终结果 | 换键绕过失败记录 |
幂等键还要绑定请求摘要。如果客户端错误地复用 K-8842,第一次买 1 张,第二次改成 3 张,服务端不能悄悄返回第一次结果;应拒绝「同键不同参数」,让调用方生成新的业务意图。否则幂等会从防重复变成吞请求。
数据库护栏常用复合 UNIQUE 约束,例如 (tenant_id, idempotency_key)。两个实例同时看见记录不存在并发 INSERT 时,只有一个能成功;另一个捕获冲突后读取已有结果。这里的约束不是完整幂等逻辑,但它是竞态穿过应用层检查后的最后闸门。
幂等记录不能刚返回响应就删除。一个超时请求可能在几十秒后由网关重试,也可能在数小时后由死信任务重放;若记录已经清理,同一个业务键会再次变成「首次请求」。反过来,持续保留每个键又会让结果表不断增长,查询索引和存储成本都要交账。
| 时间层 | 要覆盖的重复来源 | 建议保存什么 | 清理前检查 |
|---|---|---|---|
| 短时重试窗 | 客户端连点、网络超时、网关自动重试 | 完整状态与可直接返回的响应摘要 | 请求是否仍在 PROCESSING,调用方最长重试多久 |
| 异步重放窗 | 消息重投、死信恢复、批量补偿 | 业务键、请求摘要、结果引用、最终状态 | 队列保留期和补偿任务的最晚执行时间 |
| 业务争议窗 | 退款、客服申诉、财务对账 | 订单与支付引用、首次处理时间、审计字段 | 业务单据是否仍可能被追溯或撤销 |
| 归档期 | 低频审计,不再参与在线去重 | 压缩后的键与结果指纹 | 在线索引已移除,审计记录仍可按单号检索 |
例如客户端最长重试 2 分钟、消息保留 24 小时、死信允许运营在 7 天内重放,那么在线幂等证据只保留 10 分钟显然不够。更稳妥的做法是让热记录覆盖 7 天重放窗,再把最终结果压缩归档;PROCESSING 记录则单独设超时扫描,不能和已完成记录一起粗暴过期。
清理任务本身也要幂等。它只能删除已经进入终态、超过业务窗口且没有待处理补偿的记录;若清掉一条仍有死信在路上的键,后到消息就会重新执行副作用。存储账单可以通过分区、冷热分层和结果摘要降低,不能通过遗忘仍有效的业务证据来降低。
幂等记录回答「这件事做过没有、结果是什么」;分布式锁回答「此刻谁能进入临界区」。两者不是替代关系。锁若设计不完整,低频竞态会在最难复现的时刻穿过:
| 死法 | 时间线 | 结果 | 护栏 |
|---|---|---|---|
| 没设过期 | A 拿锁后进程退出,没有执行释放 | 后续请求持续拿不到锁,业务死锁 | 租约过期 + 可观测持有者 + 人工解锁手册 |
| 过期太短 | A 业务需 8s,锁在 3s 过期;B 于 3.1s 拿锁 | A、B 在 4.9s 内同时执行临界区 | 合理租约、续约、下游 fencing token |
| 误删他人锁 | A 的锁过期,B 拿到新锁;A 完成后直接 DEL | A 删除 B 的锁,C 又进入临界区 | value 存 owner,Lua 比较 owner 后释放 |
| 主从切换丢锁 | 主节点写入锁后、复制前故障;副本提升 | 新主没有锁,第二个持有者获得许可 | 理解一致性边界;高风险写由权威系统校验 fencing token |
第二与第三种死法可以用具体时间复盘:A 在 0s 获得 owner=A、租约 3s;3.1s 时 B 获得 owner=B;8s 时 A 完成,如果执行裸 DEL lock,它删掉的是 B 的锁。正确释放必须原子比较当前 value 是否仍为 A,只有匹配才删除。
但 owner 校验仍挡不住 A 在锁过期后继续写下游。A 可能经历长时间 GC 停顿,醒来时不知道租约已经失效。第 15 章会引入 fencing token:每次获锁拿到递增令牌,下游拒绝比已见令牌更旧的写入。这样即使旧持有者醒来,它也无法覆盖新持有者的结果。
记住 ▁▁█▁▁:它不像持续过载那样给你充足证据,而是在低概率时序重合时扎一针。排查要保留 owner、token、获取与释放时间、进程停顿和故障切换事件;没有这些字段,复盘只会剩一句「理论上不该发生」。
8 笔订单收敛后,团队要确定幂等能力的归属。统一网关擅长挡重复请求,业务接口才知道「什么算同一件事」。两种设计都能成立,也可以组合;关键是别让双方都以为对方会处理。每一个都是对的,每一个都有账单。
A · 网关统一:接入规则一致,能在流量入口合并并发请求,重复请求不必到达后端。账单:网关不了解订单状态机,难以判断同键不同参数、长任务和补偿重放;缓存结果丢失后仍要有业务兜底。适合无复杂副作用、响应可短期缓存的接口。
B · 接口自治:业务能把幂等记录与订单、支付状态放在同一事务或同一不变量中,语义最准确。账单:每个团队都要实现状态、清理、监控和冲突处理,容易出现十种方言。适合业务差异大、结果需要长期可查询的核心写接口。
C · 两层组合:网关吸收短时并发,业务库对跨时重试、消息重放和补偿提供最终证据。账单:两层状态可能不同步,必须明确以业务结果为准,并处理网关超时但业务已成功的查询。适合高并发且副作用昂贵的订单链路。
「秒抢」的选择:采用 C。网关要求客户端提供幂等键并做短时请求合并;订单服务把键、请求摘要和结果写入业务库,以 (tenant_id, key) 的 UNIQUE 约束兜底。网关结果不确定时查询业务状态,而不是生成新键重试。第 9 章的消息消费者与人工补偿也复用同一业务键。
回放 20:14 的正确剧本——先冻结副作用,再选定业务意图,最后让各路重试返回同一结果:
冻结自动链路。暂停该用户相关订单的自动支付确认、出票和库存释放,截住仍在队列中的重复事件;8 笔订单状态不再继续分叉。
四本账对齐。按用户、场次、客户端会话和时间窗合并订单、支付、库存与消息,保留最早已付款且有库存凭证的一笔,其余 7 笔进入显式取消与退款流程。
上线幂等护栏。客户端在确认页生成稳定键,网关透传;订单库新增幂等记录与 UNIQUE 约束。并发回放 8 次只产生 1 次 INSERT,其余返回同一订单号。
补齐锁证据。分布式锁 value 改为 owner + token,释放使用原子比较;告警新增锁持有时长超过租约 70%、owner 不匹配释放和重复业务键冲突。
锁服务平时曲线稳定,某次长 GC 后出现 ▁▁█▁▁:同一库存被重复释放一次,随后指标恢复。最符合哪张病历卡?
B。低频单针、长停顿附近重复进入临界区,正是 No.13 的形状。A 会持续爬升,C 是命中率断崖,D 是连接阶跃打满;只看「出过一次错」不够,时序与形状共同指向锁租约失效。
同一业务意图带同一个幂等键到达 8 次,第一次成功创建订单,后 7 次正确命中幂等结果。应有多少次订单 INSERT、多少次复用响应?
A。第一次建立业务结果,后续请求返回它,所以 f(f(x))=f(x)。B 是没有幂等时的事故现场;C 忘了首次请求仍需创建结果。幂等减少的是重复副作用,不是取消首次执行。
为什么创建订单后由服务端生成 order_id,不能单独解决客户端超时重试?
B。识别必须发生在副作用之前,而服务端 order_id 在请求到达后才生成。D 是最诱人的错误:超时只说明响应未知,首次请求可能已经提交。请求方在首次发送前固定幂等键,重试时才能指向同一意图。
A 的锁过期后 B 拿到新锁,随后 A 完成并执行无条件 DEL。风险是什么,正确释放是什么?
B。锁 key 相同,value 的 owner 已经从 A 变成 B;裸 DEL 会误删新租约。A 把「曾经持有」误当成「现在仍持有」。比较 owner 与删除必须在同一个原子动作里,否则比较之后仍可能发生切换。