1. 订单写入链路里,超时重试到底在赌什么
做 ERP 和 OA 系统对接的同学,大概率都遇到过这个场景:通过 MCP 工具调用下游订单服务写单,网络抖动或者下游处理慢,客户端等不到响应就超时了。这时候摆在面前的选择很尴尬——不重试,订单可能真的没写进去,业务方追着你要数据;重试,又怕下游其实已经写成功了,只是响应回来的路上丢了,结果同一笔订单被写了两遍。
这个问题的本质,是分布式系统里"超时"这个信号本身携带的信息量太少。超时只告诉你"我没在预期时间内拿到确定结果",它既可能是请求根本没到达下游,也可能是下游处理完了但响应丢了,还可能是下游正在处理中。三种情况对应三种完全不同的处理策略,但客户端从超时这一个信号里根本区分不出来。
我在实际项目里踩过最典型的一次坑,是给一个 OA 审批流对接订单系统。审批通过后触发写单,用的是 MCP 工具封装的 HTTP 调用。当时图省事,直接在调用层加了个"超时重试三次"的通用逻辑。上线第一周就出事了:财务对账发现有几笔订单金额翻倍,查下来全是重试导致的重复单。更麻烦的是,这些重复单状态还不一样,有的已经进入发货流程,清理起来极其痛苦。
所以这篇内容想聊的不是"要不要重试",而是在 MCP 工具写订单这个具体场景下,怎么设计一套让重试安全的机制。核心关键词就一个:幂等。但幂等这两个字说起来简单,落到 MCP 工具、API 网关、ERP 订单服务这条链路上,每一层该做什么、不该做什么,坑非常多。适合正在做系统对接、订单中台、或者任何需要跨服务写数据的同学参考,不管你是用 Java、Python 还是 Go,思路是通用的。
2. 为什么"超时重试"天然会制造重复单
2.1 超时发生在响应阶段,而不是请求阶段
很多人对超时的直觉理解是"请求没发出去",但真实情况恰恰相反。绝大多数超时发生在请求已经到达下游、下游也处理完了、但响应在返回路上丢了或者慢了这个阶段。
举个具体的例子。MCP 工具发起写单请求,下游订单服务收到后开始处理:校验参数、生成订单号、写主表、写明细表、扣库存、发消息。这一套下来假设要 800ms。而 MCP 工具这边设置的读超时是 500ms。那么 500ms 一到,客户端就认为超时了,但下游其实还在老老实实跑那 800ms 的流程,最后订单是写成功了的。
这时候客户端一重试,下游又收到一笔一模一样的请求,如果没有任何去重机制,就是妥妥的两笔单。你看,问题不在于重试本身,而在于客户端无法知道下游到底处理到哪一步了。
2.2 重试放大效应:一次超时可能变成多次重复
更隐蔽的问题在于重试策略的叠加。我见过一个系统,MCP 工具层配了重试 2 次,API 网关层配了重试 3 次,HTTP 客户端库自己还有一层默认重试。三层叠加下来,一次业务请求在最坏情况下可能打到下游 2×3×2=12 次。如果下游没有幂等保护,这就是 12 笔重复单。
这种放大效应在链路长的时候特别致命。而且每一层的重试超时时间如果没协调好,会出现"外层已经放弃、内层还在重试"的诡异状态,排查起来非常费劲。
2.3 重复单为什么比"漏单"更难处理
从业务角度讲,漏单和重复单哪个更严重?很多人第一反应是漏单严重,因为数据少了。但实际运维经验告诉我,重复单的危害往往更大。
漏单是可以补的——你发现某笔订单没写进去,手动补一笔或者跑个补偿任务就行,业务上顶多延迟。但重复单一旦进入下游流程,比如已经扣了库存、已经发了货、已经开了发票,清理成本是指数级上升的。尤其是涉及资金、库存、发票这些强一致场景,重复单可能直接导致财务对不平、库存对不上。
所以设计原则应该是:宁可漏,不可重。在这个前提下,重试必须建立在幂等的基础上,否则宁可不重试,让业务层去做补偿。
3. 幂等键怎么设计,才能让 MCP 工具的重试被下游认出来
3.1 幂等键必须由调用方生成,且全局唯一
幂等机制的核心是:同一笔业务请求,无论被发送多少次,下游都只处理一次。要实现这个,下游必须能识别出"这两次请求其实是同一笔业务"。
识别靠什么?靠幂等键。而幂等键的设计有几个硬性要求:
- 由调用方(MCP 工具侧)生成,不能由下游生成。因为下游生成的话,每次请求都会得到一个新键,根本没法去重。
- 全局唯一,不能只用订单号,因为订单号可能在下游才生成。
- 同一笔业务的重试必须用同一个键,这就要求幂等键在重试链路里被稳定传递,不能每次重试重新生成。
我通常的做法是:在业务发起写单的最外层,用"业务类型 + 业务单据号 + 时间戳 + 随机数"拼一个 requestId,或者直接用 UUID。这个 requestId 一旦生成,就跟着整条调用链走,MCP 工具重试时复用它,而不是重新生成。
import uuid def build_idempotent_key(biz_type, biz_no): # biz_no 是业务侧的单据号,比如审批单号 # 同一笔业务无论重试多少次,这个 key 都不变 return f"{biz_type}:{biz_no}"注意这里我用的是业务单据号而不是 UUID。为什么?因为如果用 UUID,MCP 工具在重试时如果重新生成 UUID,幂等就失效了。用业务单据号的好处是,它天然稳定,只要业务没变,键就不变。前提是业务单据号本身能唯一标识一笔写单请求。
3.2 幂等键要透传到下游的存储层
光有幂等键还不够,关键是这个键要能一路传到下游,并且在下游落库。常见做法是在订单主表加一个idempotent_key字段,建唯一索引。
ALTER TABLE order_main ADD COLUMN idempotent_key VARCHAR(128); CREATE UNIQUE INDEX uk_order_idempotent ON order_main(idempotent_key);有了唯一索引,下游处理时先尝试插入,如果键已存在,数据库会直接抛唯一约束冲突。这时候下游捕获这个异常,返回"已处理"的结果给上游,而不是报错。这样即使 MCP 工具重试了 10 次,数据库层面也只会有一笔单。
这里有个细节要注意:唯一索引的字段长度和字符集要统一。我踩过一次坑,上游传的 key 是 UTF-8,下游表是 latin1,结果两个看起来一样的 key 在数据库里被当成不同的值,唯一索引没拦住,重复单照样进来了。排查了大半天才发现是字符集问题。
3.3 幂等键和业务唯一键的区别
很多人会把幂等键和业务唯一键搞混。业务唯一键比如"订单号",是业务语义上的唯一标识;幂等键是技术层面的去重标识。两者可能重合,但职责不同。
举个例子:一笔审批单可能触发多次写单(比如先写主单再写补单),这时候业务唯一键可能是订单号,但幂等键要区分是"主单写入"还是"补单写入",所以幂等键应该是"审批单号 + 操作类型"。
# 主单写入 key_main = f"APPROVAL:{approval_no}:MAIN" # 补单写入 key_supplement = f"APPROVAL:{approval_no}:SUPPLEMENT"这样设计的好处是,同一笔审批的不同操作互不干扰,但同一个操作的重试能被正确去重。
4. MCP 工具这一层,重试策略该怎么配
4.1 只对"可重试错误"重试,别一把梭
MCP 工具封装 API 调用时,最容易犯的错就是"只要失败就重试"。但错误分很多种:
| 错误类型 | 是否可重试 | 原因 |
|---|---|---|
| 连接超时 | 可重试 | 请求可能没到达下游 |
| 读超时 | 谨慎重试 | 下游可能已处理,需幂等保护 |
| 4xx 参数错误 | 不可重试 | 重试多少次都是错 |
| 401/403 鉴权失败 | 不可重试 | 重试无意义 |
| 429 限流 | 可重试 | 需退避 |
| 5xx 服务端错误 | 可重试 | 但需幂等保护 |
我的经验是:只有连接超时、429、部分 5xx 才值得重试,而且必须配合幂等。参数错误、鉴权失败这种,重试就是浪费资源,还会污染日志。
4.2 退避策略:别用固定间隔猛冲
重试间隔也很讲究。固定间隔重试(比如每 1 秒重试一次)在下游压力大时是灾难,会把下游彻底打垮。推荐用指数退避 + 抖动:
import random import time def retry_with_backoff(max_retries=3, base_delay=0.5): for attempt in range(max_retries): try: return call_downstream() except RetryableError: if attempt == max_retries - 1: raise # 指数退避 + 随机抖动,避免惊群 delay = base_delay * (2 ** attempt) + random.uniform(0, 0.1) time.sleep(delay)抖动(jitter)这个细节很多人忽略。如果一批请求同时超时、同时重试,没有抖动的话它们会在同一时刻再次冲击下游,形成"重试风暴"。加上随机抖动,能把重试请求打散开。
4.3 重试次数不是越多越好
重试次数要结合业务容忍度来定。写订单这种场景,我一般建议最多重试 2 次,加上首次一共 3 次。为什么不是 5 次、10 次?
因为重试次数越多,链路占用时间越长,上游的线程/连接被占着,整体吞吐会下降。而且如果下游真的挂了,重试 10 次也救不回来,不如快速失败,让业务层走补偿流程。快速失败 + 异步补偿,比死磕重试要健康得多。
4.4 超时时间要分层设置
MCP 工具、网关、下游服务,每一层的超时时间要协调好。原则是外层超时 > 内层超时,给内层留出处理时间。
比如下游订单服务正常处理要 800ms,那么:
- MCP 工具读超时设 2000ms
- 网关超时设 2500ms
- 下游服务自身超时设 1500ms
这样即使下游慢,也是下游先超时返回明确错误,而不是外层先超时导致状态不明。状态明确的失败,比状态不明的超时好处理得多。
5. 下游订单服务的幂等落地细节
5.1 先查后插 vs 直接插靠唯一索引兜底
下游收到写单请求后,判断是否重复有两种思路:
思路一:先查后插。先根据幂等键查一下订单是否存在,存在就返回已有结果,不存在就插入。这个思路直观,但有并发问题——两个请求同时查,都发现不存在,然后都插入,还是重复。
思路二:直接插,靠唯一索引兜底。不管三七二十一先插,唯一索引冲突就捕获异常,返回已存在。这个思路在并发下是安全的,因为数据库的唯一约束是原子的。
我推荐思路二为主,思路一为辅。具体做法是:先尝试插入,捕获DuplicateKeyException,然后查询已有记录返回。这样既保证了并发安全,又能在冲突时返回正确结果。
try { orderMapper.insert(order); return OrderResult.success(order.getId()); } catch (DuplicateKeyException e) { // 幂等键冲突,说明已经处理过 Order existing = orderMapper.selectByIdempotentKey(order.getIdempotentKey()); return OrderResult.success(existing.getId()); }5.2 幂等键的存储要有过期策略
幂等键不能无限期存着,否则表会越来越大。但过期时间设多长有讲究。
设太短,比如 1 小时,那么如果一笔业务的重试跨越了 1 小时(比如人工补偿),幂等就失效了。设太长,比如永久,存储成本高。
我的经验值是7 天到 30 天。因为绝大多数重试和补偿都发生在业务发起后的短时间内,超过这个窗口的重复请求,要么是异常情况,要么业务上已经允许重复了。具体可以结合业务单据的生命周期来定。
如果用的是 Redis 做幂等键存储,可以设置 TTL;如果是数据库,可以定期归档或者分区清理。
5.3 幂等和事务的配合
写订单往往涉及多张表:主表、明细表、库存表。幂等键加在主表上,但整个写入要在一个事务里。如果主表插入成功、明细表插入失败,事务回滚,幂等键也回滚了,下次重试还能正常处理。
这里有个坑:如果幂等键存在 Redis 里,而订单存在数据库里,两者不在同一个事务中。可能出现 Redis 写成功、数据库回滚的情况,导致下次重试被 Redis 拦住,但订单其实没写进去。这种"假幂等"很隐蔽。
解决办法是:幂等键的最终判定以数据库唯一索引为准,Redis 只作为前置快速过滤。或者干脆不用 Redis,直接靠数据库唯一索引,简单可靠。
6. 那些年我踩过的重复单坑
6.1 坑一:重试时重新生成了幂等键
这是最经典的坑。代码里幂等键在每次调用时生成,重试逻辑在更外层,结果每次重试都生成新键,幂等完全失效。
# 错误示范 def write_order(order_data): key = str(uuid.uuid4()) # 每次调用都新生成 return call_api(order_data, key) # 正确示范 def write_order(order_data, idempotent_key): # key 由最外层生成,一路传进来 return call_api(order_data, idempotent_key)排查这类问题的技巧是:在日志里打印幂等键。如果发现同一笔业务的重试日志里幂等键不一样,那就是这个问题。
6.2 坑二:网关层重试没带幂等键
MCP 工具层做了幂等,但网关层自己配了重试,而网关重试时没有透传幂等键(或者重新生成了),导致下游收到的是"看起来不同"的请求。
这个坑的隐蔽性在于,你在 MCP 工具层测得好好的,一上生产经过网关就出问题。解决办法是:所有中间层的重试都必须透传原始请求的幂等键,不能自己造。
6.3 坑三:异步消息导致的重复消费
写订单成功后往往要发消息通知下游。如果消息发送失败重试,或者消息中间件本身有重试机制,消费方可能收到重复消息。这时候消费方也要做幂等,用同样的幂等键思路。
我见过一个系统,订单写入做了幂等,但发消息没做,结果库存被扣了两次。所以幂等要贯穿整条链路,不能只管写单那一步。
6.4 坑四:幂等键冲突被当成业务异常
下游捕获唯一索引冲突后,如果直接抛业务异常给上游,上游会以为写单失败,可能触发更多重试或者人工介入。正确做法是把冲突当成"已成功"来处理,返回成功结果。
这个细节看似小,但影响很大。我见过因为这个问题,运维半夜被叫起来处理"订单写入失败"告警,结果一查订单其实都在。
7. 一套可复用的幂等写单方案
把上面的经验串起来,给一套可以直接参考的方案。
调用方(MCP 工具侧):
- 在业务发起的最外层生成幂等键,格式建议
业务类型:业务单据号:操作类型。 - 幂等键随请求一路透传,所有重试复用同一个键。
- 只对可重试错误重试,最多 2 次,用指数退避 + 抖动。
- 超时时间分层设置,外层大于内层。
下游(订单服务侧):
- 订单主表加
idempotent_key字段,建唯一索引。 - 写入时直接插,捕获唯一约束冲突,冲突则查询已有记录返回成功。
- 整个写入放在一个事务里,保证主表、明细表、库存表一致。
- 幂等键设置合理的过期或归档策略。
链路中间层:
- 网关、消息中间件的重试必须透传幂等键。
- 消息消费方也要做幂等,用同样的键思路。
这套方案的核心思想是:幂等键由业务最外层生成并稳定透传,下游靠数据库唯一约束做最终判定。简单、可靠、不依赖额外的分布式锁或缓存。
8. 关于幂等,还有几个容易忽略的点
幂等不等于加锁。有些人一提到幂等就想到分布式锁,但锁只能防并发,防不了"先处理后重试"这种时序上的重复。幂等键 + 唯一索引才是根本。
幂等键的生成要考虑业务语义。不是随便一个 UUID 就行,要能反映"这是同一笔业务"。否则重试时换了 UUID,幂等就废了。
测试要覆盖重试场景。很多人测幂等只测"同一请求发两次",但真实场景是"超时后重试",中间有时间间隔、有网络抖动。建议用工具模拟超时,验证重试链路。
监控要能发现重复单。即使做了幂等,也要有监控兜底。比如定期扫描订单表,看有没有相同业务单据号的多笔订单。发现异常及时告警,比事后对账发现要好得多。
最后分享一个我自己的习惯:每次做写单接口,我都会在代码注释里写清楚幂等键的生成规则和透传路径。因为这类逻辑一旦出问题,排查成本极高,注释清楚能省下大量时间。而且新同事接手时,一眼就能看懂幂等是怎么设计的,避免改坏。
写订单这件事,表面上是调个 API,背后是一整套分布式一致性的思考。超时重试只是表象,幂等才是那个真正需要花心思设计的地方。把幂等做扎实了,重试才能放心大胆地用。