1. 从一次线上事故说起超时重试为什么越重越乱很多做后端或者客户端对接的同学都遇到过这样的场景调用一个远程工具接口等了 3 秒没返回于是超时了。第一反应是什么换一个请求键重新发一次。结果呢第一次的请求其实已经在服务端执行成功了只是回执慢了一点第二次换了键又执行了一遍。用户看到的是重复扣款、重复发券、重复创建订单而日志里两条记录看起来还都成功。这个问题的根子不在于超时本身而在于我们把三件本该分开的事情揉成了一团请求的唯一标识callId、请求的幂等性、业务层的回执确认。这三者各管一段谁也不能替代谁。标题里说的工具超时别换键重试其实就是在提醒我们超时是传输层的事换键是标识层的事重试是策略层的事而最终业务有没有真正生效是回执层的事。把它们混在一起系统就会在边界条件下失控。这篇内容适合谁看如果你正在做工具调用网关、RPC 框架、任务调度、支付回调、消息投递这类涉及远程调用 超时 重试的系统那基本就是给你写的。哪怕你只是写业务代码时随手加了个try-catch重试看完也应该能意识到自己埋了哪些雷。我会从 callId 的设计、幂等键的落地、业务回执的分工三个角度拆开讲再补上实测中踩过的坑和一套可以直接抄的排查链路。全文不涉及任何具体厂商和敏感内容纯粹是工程经验的复盘。先说结论方便你带着框架往下读callId 负责这一次请求是谁幂等键负责这个操作只能生效一次业务回执负责到底生没生效、要不要补偿。三者是递进关系不是替代关系。超时之后正确的动作是用同一个 callId 去查状态而不是换个键再发一次。下面逐层展开。2. callId 到底该由谁生成生成规则里藏着哪些坑2.1 callId 的本质一次调用的身份证不是业务单号很多人第一次设计 callId会直接拿业务订单号或者用户 ID 当请求标识。这在单次调用里没问题但一旦涉及重试、并发、多工具串联就会出大问题。callId 的本质是一次调用行为的唯一标识它描述的是这一次网络请求而不是这一笔业务。同一笔业务可能对应多次调用首次 重试 补偿它们应该共享业务单号但必须有不同的 callId。我习惯把 callId 设计成业务前缀 时间戳 随机熵 节点标识的组合。比如ord-1710000000000-a3f9-node07。这样设计有几个好处出问题时能一眼看出属于哪类业务、大概什么时间发生、由哪个节点发出排查时不用满世界捞日志。随机熵部分建议至少 4 到 8 位十六进制避免同一毫秒内并发请求撞号。节点标识在多实例部署时尤其重要否则两台机器同一毫秒生成的 ID 可能一模一样。注意callId 一旦生成在整个重试生命周期内必须保持不变。这是别换键重试的字面含义。换键等于告诉服务端这是一个全新请求幂等机制直接失效。2.2 生成位置的选择客户端生成还是网关生成这是一个经常被忽略的决策点。我的经验是callId 应该在发起调用的最外层生成也就是客户端或调用方生成而不是在网关或服务端生成。原因很简单如果由服务端生成那么客户端在超时后根本不知道自己第一次请求的 callId 是什么重试时只能生成新的幂等就无从谈起。callId 必须由最清楚这次调用意图的一方持有。那网关能不能生成可以但前提是网关生成后要把它透传给下游并且客户端能拿到。实际工程里更稳妥的做法是客户端生成 callId放在请求头或请求体里网关只做透传和校验不重新生成。如果网关发现请求里没有 callId可以补一个但要记录这是网关补的方便后续区分。我见过一些团队在网关层强制重写 callId结果客户端重试时带的老 callId 被覆盖幂等直接崩掉这种坑排查起来非常费劲。2.3 callId 的传递链路跨工具、跨服务怎么带在稍微复杂一点的系统里一次业务操作可能串联多个工具调用。比如先调 A 工具做校验再调 B 工具做扣减最后调 C 工具发通知。这时候 callId 怎么传我的做法是分两层链路级 traceId 调用级 callId。traceId 贯穿整条业务链路用于全链路追踪callId 标识每一次具体的工具调用用于幂等和重试。两者是父子关系一个 traceId 下可以有多个 callId。传递方式上HTTP 场景放请求头如X-Call-IdRPC 场景放 attachment 或 context消息队列场景放消息属性。关键是下游要能读到、要能回传。我踩过的一个坑是某工具调用走的是异步消息callId 只放在了消息体里结果消费方处理失败重投时消息体被重新序列化callId 丢了导致重复消费无法识别。后来改成把 callId 同时写进消息属性和消息体双保险才稳。3. 幂等键不是 callId 的复制品两者分工要划清3.1 幂等键和 callId 的核心区别一个管唯一一个管只生效一次这是最容易混淆的地方。callId 保证的是这次请求能被唯一识别幂等键保证的是这个业务操作只生效一次。听起来像一回事其实差很远。举个例子用户下单客户端生成 callId 发起请求超时了客户端用同一个 callId 重试。服务端收到两次相同 callId 的请求靠 callId 能识别出这是同一次调用的重试但如果服务端没有幂等键它依然可能执行两次扣库存。所以幂等键通常是业务维度的唯一约束比如订单号 操作类型或者用户 ID 业务流水号。它落到数据库上往往是一个唯一索引。callId 是传输层的去重依据幂等键是业务层的去重依据。两者配合使用先用 callId 做快速去重比如 Redis 里查一下这个 callId 处理过没有再用幂等键做最终的业务兜底数据库唯一索引。维度callId幂等键作用范围单次调用单笔业务操作生成方调用方业务方通常由业务规则决定存储位置请求头/上下文业务表唯一索引 缓存重试时保持不变保持不变失效条件调用结束业务状态变更3.2 幂等键的落地数据库唯一索引是最后一道防线我强烈建议幂等键最终落到数据库唯一索引上。缓存如 Redis可以做前置拦截提升性能但缓存会丢、会过期、会有主从延迟不能作为唯一依据。真实场景里我见过太多Redis 判断没处理过结果数据库里已经有两条的案例。唯一索引虽然会带来写入失败但那个失败恰恰是我们想要的——它告诉我们这个操作已经生效过了。具体做法业务表上加一个idempotent_key字段建唯一索引。插入时如果冲突捕获异常然后去查已有记录返回已有结果。这里有个细节捕获异常后不要直接返回失败要返回已处理的语义因为对调用方来说重复请求和首次请求应该得到一致的结果。这就是幂等接口的结果一致性要求。3.3 幂等窗口期不是所有操作都能永久幂等有些业务操作天然不适合永久幂等比如每天签到一次。这时候幂等键要带上时间维度比如用户 ID 日期。窗口期的设计要结合业务支付类通常要求长期幂等签到类按天限流类按分钟。窗口期太短重试跨窗口就会重复窗口期太长存储成本高。我的经验是幂等窗口期至少覆盖最大重试时长 业务容忍延迟一般建议 24 小时起步重要业务 7 天。提示幂等键的过期清理要谨慎。如果业务还在重试窗口内键就过期了重复请求会穿透。清理任务最好和业务重试策略对齐或者干脆不清理靠数据归档解决。4. 业务回执超时之后真正该做的事4.1 超时不等于失败回执才是真相这是整篇内容最核心的认知转变。超时是传输层信号它只说明我没在预期时间内收到响应不说明服务端没执行。服务端可能已经执行成功只是回执在路上丢了也可能正在执行只是慢也可能真的没执行。这三种情况靠超时本身完全区分不了。唯一能区分的方式是去查业务回执。所以正确的超时处理流程是超时后不要立刻换键重试而是用原 callId 去查询执行状态。查询接口要能回答三个问题这个 callId 对应的请求服务端收到没有执行了没有执行结果是什么如果查询显示已成功直接返回成功如果显示未收到或处理中再决定是否重试如果显示已失败才走失败补偿。4.2 回执查询接口的设计要点回执查询接口不是随便写个select就完事。它要满足几个条件按 callId 可查、结果稳定、有明确的状态机。状态机至少包含RECEIVED已接收、PROCESSING处理中、SUCCESS成功、FAILED失败、UNKNOWN未知。UNKNOWN很重要当查询本身也超时或异常时不能假装知道结果要如实返回未知让上层决定是继续等还是人工介入。查询接口的性能也要考虑。如果每次超时都去查数据库高并发下会把库打挂。我的做法是callId 的处理状态先写 Redis设置合理过期时间查询优先走 Redismiss 了再查库。写入时先写库再写缓存或者用 binlog 同步保证最终一致。这里不展开具体实现核心是查询要快、要稳、要能扛住重试风暴。4.3 回执与补偿的分工谁负责重试谁负责兜底回执查询解决的是当前状态是什么补偿解决的是状态不对怎么办。这两件事要分开。重试是短周期的、自动的、有次数上限的补偿是长周期的、可能是人工的、最终一致性的。我通常这样分工调用方负责在超时后查询回执如果状态是未收到就重试保持 callId 不变重试 N 次后仍无确定结果就投递到补偿队列由专门的补偿任务去处理。补偿任务要做的事情包括再次查询回执、必要时发起冲正、通知人工介入、记录对账差异。补偿不能无限重试要有终止条件否则会变成永远在补偿的僵尸任务。我一般设置补偿任务最多执行 3 到 5 轮每轮间隔递增超过就转人工。5. 一套可复现的排查链路从超时日志到根因定位5.1 第一步确认 callId 在重试前后是否一致排查任何重复执行问题第一件事就是看日志里重试前后的 callId。如果 callId 变了那基本可以确定是换键重试导致的问题在调用方。如果 callId 没变但依然重复执行那问题在幂等键或服务端处理逻辑。这一步能快速把问题范围缩小一半。我习惯在日志里把 callId 放在最显眼的位置方便 grep。5.2 第二步检查幂等键是否真正生效确认 callId 一致后去看服务端的幂等逻辑。重点查三处幂等键的生成规则是否稳定有没有包含随机数、时间戳这种每次都变的东西、唯一索引是否真的建了、缓存和数据库的判断顺序是否正确。我遇到过一个案例幂等键里带了请求时间戳导致每次重试生成的键都不一样唯一索引形同虚设。这种问题不看代码根本发现不了。5.3 第三步核对回执状态与业务实际状态如果前两步都没问题就要看回执了。把 callId 对应的回执状态和业务表的实际状态对一遍。常见的不一致有回执显示处理中但业务已成功、回执显示成功但业务没落库、回执丢失导致查询返回未知。这些不一致往往指向回执写入和业务写入不在同一个事务里。解决办法是让回执状态和业务数据尽量在同一个本地事务中提交或者用可靠消息保证最终一致。5.4 第四步复盘重试策略本身最后回头看重试策略。重试次数、重试间隔、重试触发条件任何一个设计不当都会放大问题。比如重试间隔太短服务端还没处理完就又来一次重试次数太多把偶发问题变成雪崩。我的建议是重试间隔用指数退避重试次数控制在 3 次以内重试前必须先查回执。这三条能挡掉大部分重复执行问题。6. 实测中踩过的坑与几条硬经验6.1 坑一把超时异常当成业务失败直接返回早期我写代码catch (TimeoutException)之后直接return fail用户看到失败就重新下单结果服务端其实已经扣了库存。后来改成超时后先查回执才解决。这个坑的本质是把传输失败和业务失败划了等号。记住超时是未知态不是失败态。6.2 坑二幂等键用了自增 ID 或 UUID自增 ID 每次插入都不同UUID 每次生成都不同拿它们当幂等键等于没有幂等。幂等键必须是业务上可复现的、稳定的值。我见过用 UUID 当幂等键的系统重试一次就多一条记录查了半天才发现是键本身的问题。6.3 坑三回执查询接口自己也超时回执查询接口如果依赖下游下游慢它也会慢超时重试时查回执又超时就陷入了死循环。解决办法是回执查询要尽量轻量直接查本地状态表或缓存不要再去调下游。回执数据应该在业务处理时就落好查询只是读。6.4 坑四重试风暴打垮服务端超时后所有客户端同时重试服务端瞬间压力翻倍。缓解手段包括重试加随机抖动、限制单客户端并发重试数、服务端做请求排队和限流。我一般会在重试间隔上加 ±20% 的随机抖动避免所有重试请求同时到达。6.5 几条可以直接抄的硬经验callId 由调用方生成全程不变重试必带。幂等键用业务维度落数据库唯一索引缓存只做前置。超时后先查回执再决定重试绝不换键。回执状态机要有 UNKNOWN不要假装知道结果。重试指数退避 随机抖动次数不超过 3 次。补偿任务有终止条件超限转人工。这套组合拳打下来重复执行类问题的发生率能降一个数量级。最后分享一个我个人的习惯每次设计涉及远程调用的接口我都会先问自己三个问题——callId 谁生成、幂等键是什么、超时后查什么。这三个问题答不上来接口就不算设计完。这个习惯帮我省了无数次半夜爬起来查重复订单的时间。