一次超时重发,为什么会变成两笔订单
设想一个远程 MCP Server,它暴露一个 create_order 工具,负责在企业采购系统里创建订单并扣减预算。客户端在上午 10:00:00 发出调用,参数里包含商品、数量和采购人。10:00:30 客户端仍未收到响应,本地超时触发,于是它把同一个 JSON-RPC 请求原样重发一次。
服务端两次都成功执行了。第一次执行时写入了订单,只是响应在返回途中丢失;第二次执行时又写入一笔新订单。采购系统里出现两笔内容完全相同的订单,预算被扣了两次。
直觉上,客户端会认为“我发的是同一个请求,服务端应该能认出来”。但这个直觉依赖一个前提:服务端能跨请求识别“这是同一次业务操作”。MCP 和 JSON-RPC 本身都没有提供这个前提。
基线方案失效在哪里
JSON-RPC 的 id 只负责配对,不负责去重
JSON-RPC 2.0 规定,请求对象里的 id 由客户端建立,服务端必须在响应里返回同一个值。它的作用是让客户端把响应和请求对应起来,规范原文的措辞是“用于关联两个对象之间的上下文”。
规范没有要求服务端记住历史 id,也没有要求服务端在收到重复 id 时拒绝执行。一个合规的服务端完全可以对每个到达的请求独立执行,然后各回一个响应。也就是说,id 是关联标识,不是幂等键。
断线重连会重置 id 空间
更麻烦的是会话边界。MCP 的连接是有状态的,客户端和服务端在初始化阶段协商能力。当连接断开、客户端重新建立会话时,新的会话会重新开始分配 id。
这意味着两件事。第一,重连后客户端可能再次用 id: 1 发请求,而服务端在新会话里看到的是一个全新的 id: 1,与旧会话的 id: 1 没有任何关联。第二,如果客户端在重连后重发超时请求,服务端无法通过 id 判断这是重发还是新调用。
MCP 的 Streamable HTTP 传输规范明确写道:断连不应被解释为客户端取消请求。要取消,客户端应当显式发送取消通知。这条规定保护了长任务的执行,但也意味着断连后服务端仍在继续执行,而客户端的重发会叠加在正在执行或已完成的调用之上。
服务端没有幂等键存储
真正决定是否重复的,是服务端有没有一个跨请求的业务标识。如果 create_order 的入参只有商品、数量和采购人,服务端拿到的两次请求在结构上完全一样,但它没有任何字段能证明“这两次是同一次采购意图”。
服务端可以尝试用参数哈希去重,但参数相同的两次合法调用在业务上可能是允许的,比如同一采购人确实要下两笔相同订单。参数哈希无法区分“重发”和“真实重复”。
请求在系统里如何流动
下面用贯穿场景描述一次超时重发的完整路径。客户端持有会话 A,服务端为会话 A 维护连接状态。
flowchart TD
A[客户端发起 create_order] --> B[会话 A 发送 JSON-RPC 请求 id=7]
B --> C[服务端执行下单并扣预算]
C --> D[响应在返回途中丢失]
D --> E[客户端本地超时]
E --> F{客户端如何处理}
F -->|重发同一请求| G[会话 A 或新会话 B 再次发送]
G --> H[服务端视为新调用]
H --> I[再次下单并扣预算]
F -->|显式取消并查询| J[发送取消通知或查询订单状态]
J --> K[服务端返回已有订单]
关键转折点在 F。客户端在超时后有两个选择:重发,或者先确认状态再决定。重发路径下,服务端在 H 处没有任何依据判断这是重复,于是走到 I,产生第二笔副作用。
如果客户端在超时后重建了会话,G 处的请求会带着新会话的 id 到达。服务端连“同一个 id 出现过两次”这个弱信号都拿不到。
三种可选方案的实际差别
幂等键:把业务标识放进参数
幂等键的思路是让客户端为每次业务操作生成一个唯一标识,随请求一起发送。服务端在执行前先查这个键是否已处理过,已处理则直接返回上次的结果,未处理则执行并记录。
这个方案要求客户端在重发时复用同一个键,而不是每次重发都生成新键。键的生成时机应当在第一次发起调用之前,并保存在客户端本地,直到调用有确定结果。
服务端的存储需要覆盖“执行中”和“已完成”两种状态。只记录已完成结果不够,因为重发可能落在第一次执行尚未结束的窗口内,此时服务端需要返回“处理中”而不是再执行一次。
去重窗口:用时间或参数近似
去重窗口不要求客户端提供键,而是服务端根据参数、来源和时间窗口自行判断。常见做法是维护一个近期请求指纹表,窗口内出现相同指纹就拒绝或返回缓存结果。
它的优点是客户端无需改造,缺点是判断依据弱。参数相同但业务上合法的两次调用会被误判为重复;窗口设置过长会误伤,过短则覆盖不了慢重试。窗口大小还依赖服务端对客户端重试节奏的假设,而客户端行为往往不可控。
人工确认:把不确定性交给用户
人工确认不试图自动区分重复,而是在超时后暂停自动重发,向用户展示“这笔操作状态未知”,由用户决定是查询、重试还是放弃。
它适合低频、高金额的写操作,代价是引入人工延迟,并且需要客户端能准确表达“未知”状态,而不是简单报错。如果客户端把超时统一处理成失败并提示重试,用户很可能直接点重试,效果等同于自动重发。
三种方案的对比
| 维度 | 幂等键 | 去重窗口 | 人工确认 |
|---|---|---|---|
| 客户端改造 | 需要生成并保存键 | 无需改造 | 需要展示未知状态 |
| 服务端改造 | 需要键存储与状态机 | 需要指纹表与窗口 | 需要查询接口 |
| 区分重发与合法重复 | 能准确区分 | 依赖参数,可能误判 | 由用户判断 |
| 覆盖执行中窗口 | 需要处理中状态 | 依赖窗口长度 | 不自动处理 |
| 适用频率 | 高频写操作 | 中低频、参数区分度高 | 低频高金额 |
| 主要风险 | 键生成或保存失败 | 误伤合法重复 | 用户误点重试 |
这张表的核心信息是:幂等键把判断责任放在客户端和服务端的协作上,去重窗口把责任放在服务端的启发式上,人工确认把责任放在用户身上。三者不是互斥的,生产环境常见组合是幂等键为主、人工确认为兜底。
实现中最关键的数据结构
幂等键方案落地时,服务端需要一张以幂等键为主键的记录表。每条记录至少包含四个字段:键本身、状态、结果和过期时间。
状态机通常有三个值:处理中、已完成、已失败。收到请求时,服务端尝试以键插入一条“处理中”记录。插入成功说明这是首次调用,继续执行;插入冲突说明键已存在,读取状态后决定返回处理中、返回上次结果还是允许重试。
这里有一个容易忽略的边界:执行失败后,键记录该不该删除。如果删除,客户端重发会重新执行,可能产生部分副作用;如果不删除,客户端永远拿不到重试机会。常见做法是把失败记录保留一段时间,并允许客户端用同一个键显式请求重试,由服务端决定是否真正重新执行。
过期时间需要覆盖客户端可能的最长重试周期。如果记录在客户端重试之前过期,去重就失效了。
生产环境需要观察哪些信号
重复写操作的排查难点在于,它往往不报错。两笔订单都创建成功,日志里两条都是 200。可观测性需要专门针对这个场景设计。
服务端应当记录每次写工具调用的幂等键、会话标识、请求到达时间和执行结果。当同一个幂等键在短时间内出现多次到达时,记录一条去重命中事件。这条事件的计数直接反映客户端重试频率。
客户端侧应当区分“超时”和“失败”。超时意味着结果未知,失败意味着服务端明确拒绝。把两者混在一起上报,会让重复执行的根因被掩盖。
业务侧可以对比同一时间窗口内的调用次数和实际业务单据数量。如果调用次数明显多于单据数量,说明去重生效;如果两者相等但用户投诉重复,说明去重没有覆盖到实际路径。
还需要观察会话重建频率。频繁重连会放大 id 重置的问题,也会让基于会话的去重逻辑失效。
部署边界与失效条件
幂等键方案在以下条件下会退化。客户端在重发时重新生成键,去重完全失效。客户端把键存在内存里,进程重启后丢失,重发变成新调用。服务端存储不可用,插入冲突判断失败,可能放行重复执行。
去重窗口方案在客户端重试间隔超过窗口长度时失效。在参数区分度低、合法重复常见的业务里,它会误伤正常调用。
人工确认方案在客户端无法表达未知状态时失效,用户会把超时当成失败直接重试。
还有一个跨会话的边界:如果服务端的去重记录与会话绑定,断线重连后记录不可见,去重失效。幂等键必须独立于会话生命周期存储,才能覆盖重连场景。
MCP 规范把工具描述和注解视为不可信,要求主机在调用工具前获得用户明确同意。这个安全原则同样适用于重试:客户端不应在用户不知情的情况下自动重发写操作。读操作重试通常无害,写操作重试需要显式的策略,而不是复用读操作的默认重试逻辑。
仍未解决的问题
幂等键的生成责任放在客户端,但客户端可能由不同厂商实现,键的格式和生命周期没有协议级约束。跨客户端、跨主机的去重无法保证。
服务端返回“处理中”之后,客户端应该等待多久、以什么频率查询,目前没有统一约定。等待过短会放大查询压力,过长会拖慢用户感知。
对于执行时间本身超过客户端超时阈值的工具,超时是常态而非异常。这类工具需要的是进度通知和显式取消语义,而不是重试。MCP 提供了进度跟踪和取消通知,但客户端是否正确使用,取决于具体实现。在这些机制被普遍正确实现之前,写工具的重复执行仍会是一个需要服务端自行兜底的问题。