分布式系统 01:RPC 重试、并发与请求去重
计数器最初为 0。客户端调用 Add(1),服务端把计数器改成 1,回复却在返回途中丢失。客户端等待超时,再调用一次,计数器变成 2。网络只丢了一条回复,业务却多执行了一次。
RPC 隐藏了传输,没有消除失败
一次远程调用通常经过参数编码、请求传输、服务端解码与分发、业务执行、结果编码、回复传输和客户端解码。正常路径看起来接近函数调用,失败路径却多了两个独立进程和两个消息方向。
如果本地进程中的普通函数返回,调用者通常可以直接使用返回值。远程请求还可能卡在消息队列、连接缓冲区或服务端执行队列里;客户端停止等待时,服务端未必停止工作。客户端看到的超时,不能反推出业务修改已经回滚。MIT 2026 RPC 讲义把这种“不知道请求是否到达或执行”的情况作为 RPC 故障语义的起点。
TCP 提供的连接内字节流可靠性也不足以解决这个问题。服务端可以先提交业务,再在回复到达客户端之前崩溃;客户端重连之后,即使两条连接各自没有重复交付字节,也仍可能发送两次相同业务请求。TCP 序号不等于跨连接、跨进程重启的业务请求身份。
下面的时间线采用一个非拜占庭客户端和一个服务进程。消息可以丢失或延迟,客户端可以并发重试,服务在一次实验期间不崩溃;重启造成的记录丢失另行分析。服务只有一个整数计数器,没有外部支付、邮件或文件副作用。
1 | |
不重试,可以减少重复执行,却可能永远取不到已经产生的结果。重试可以提高完成机会,却要求服务端识别重复。两者分别涉及活性与安全性,不能用一个“可靠 RPC”标签替代。
一次业务操作与多次尝试
这里把客户端希望完成的一次 Add 称为逻辑操作,把每次发送称为尝试。客户端在第一轮发送前生成操作 ID;超时、重连、切换连接或并发补发都沿用这个 ID。用户真正发起第二次加一,才生成新 ID。
at-most-once 约束的是同一逻辑操作至多产生一次指定业务效果,但允许根本没有产生效果。at-least-once 通常描述持续重试下的投递或执行倾向;要保证最终执行,仍需服务恢复、消息最终能够到达、重试没有被永久中止等条件。一个最多尝试三次的客户端显然不能在永久断网时保证“至少一次”。
“Exactly once”也必须绑定对象和边界:每个网络包到达一次、处理函数进入一次、数据库状态改变一次、外部接收者接受一次,都不是同一个命题。业务可以在请求多次到达、缓存结果多次返回的情况下,只发生一次状态改变。这里实现的是单进程存活期间的后一种保证,没有跨崩溃保证。
Birrell 与 Nelson 的 《Implementing Remote Procedure Calls》发表于 1984 年。其调用标识组合调用方身份与序号;用于判重的 activity 假设同一时刻最多有一个未完成调用。这个限制解释了为什么“每客户端只存最大序号”不能直接搬到任意并发 API。
假设客户端并发发出序号 41 和 42,网络先送到 42。如果服务端只记录最大值,并把所有小于它的序号都当作重复,第一次到达的 41 也会被丢弃。要支持多个未完成操作,应保存各个未完成或未确认操作的记录,或者明确采用能处理空洞的窗口协议。实验使用完整 ID 到结果的映射,避免把串行调用假设藏进代码。
幂等不等于只执行一次
对一个状态变换 f,幂等的简化表达是 f(f(s)) = f(s)。Set(x, 1) 连续执行两次,通常仍然得到 x=1;Add(x, 1) 连续执行两次则增加 2。
HTTP 的定义还更具体:RFC 9110 §9.2.2讨论相同请求的预期服务端效果。它不要求每次返回完全相同,也不禁止每次写访问日志。将 DELETE 的第一次成功与第二次“已不存在”视为矛盾,混淆了返回值和预期效果;反过来,只检查最终值相同,也可能漏掉每次发送通知的副作用。
幂等还不能任意改变多个操作的相对顺序。考虑下面一条有限历史:
1 | |
单独的 Set(x,1) 是幂等操作,但这条历史中,B 在写 2 前后都读到了 1。如果把 A 的两次尝试当作同一个逻辑操作,就找不到一个仅执行一次 A、又保持 B 顺序的解释:A 放在 B 写 2 之前,最后一次读应为 2;A 放在 B 写 2 之后,第一次读 1 又没有来源。这个反例需要初始值不是 1,且没有别的写入,才能排除其他解释。
RIFL 论文的 Figure 2 给出了同类反例。它说明请求去重是保留逻辑操作身份的一部分;“操作天然幂等”不能替代跨操作的历史分析。Lee 等,SOSP 2015
去重记录必须与业务修改原子关联
最小服务端保存 ID -> (参数, 原始结果)。首次遇到 ID 时执行加法,并记录结果;再次收到相同 ID 和参数时返回原结果,不执行加法。相同 ID 携带不同参数属于协议冲突,应明确拒绝,不能将第一次结果假装成第二次操作的结果。
只缓存结果仍然存在并发漏洞。两个请求可以同时发现 ID 不存在,分别执行,然后分别写缓存。即使缓存容器本身线程安全,也只能保证单次读写不损坏结构,不能使“查询、修改业务、保存结果”这个组合自动成为原子动作。
配套实现把三步放在同一把互斥锁里:
1 | |
这段代码适用于无阻塞外部操作的有界演示。互斥区内不发起其他 RPC,不写磁盘,不等待用户输入。若业务执行很慢,所有不同 ID 都会排队。换成每 ID 锁可以减少无关请求之间的阻塞,但业务数据本身仍需要并发控制;更复杂的设计还必须表达 in-progress 状态、等待者取消和执行者故障。
安全性的证明可以直接从互斥区推出。起初记录表为空。对任意固定 ID,第一个取得锁并发现记录为空的请求,恰好执行一次修改,再保存记录;只要后续没有删除该记录,其他请求取得锁后都只能回放或被拒绝。因此在这个进程生命周期内,该 ID 的业务修改次数至多为 1。
这个论证依赖三项条件:所有修改都经过同一临界区;ID 不发生意外复用;结果记录不会丢失。它没有证明无限故障下的完成性。持锁线程若永久卡住,等待者也可以永远无法完成;公平调度、有限执行时间和最终到达的重试属于活性假设。
旧请求的回放结果也不能改成“当前值”。操作 42 把计数器从 0 改成 1,操作 43 再把它改成 2。42 的重试应返回第一次得到的 1,虽然此刻计数器已经是 2。否则客户端将无法分清返回值描述的是自己那次操作,还是一个没有发起过的新查询。
本地实验:丢回复、并发重试与冲突
累计代码位于仓库的 examples/distributed-systems/rpc01/main.go,只使用 Go 标准库。传输由 deliver 函数建模:先真实调用服务端方法,再按参数丢弃返回值,向调用方返回 errLost。因此故障点严格位于业务执行之后,避免把“请求没送到”与“回复没回来”混成一个随机超时。
这是进程内故障注入,不创建 TCP 连接,也没有声称复现 TCP 丢包、操作系统网络超时或 gRPC 实现。32 个重复请求由真实 goroutine 发起,使用同一个启动信号;调度先后由运行时决定,结果应该与先后无关。
在仓库根目录运行:
1 | |
本次在 Go 1.27.0、darwin/arm64 上实际运行得到:
1 | |
第一组是对照实验:首次执行之后丢回复,再重发相同请求。关闭去重时执行两次,打开后只执行一次。第二组从新的服务实例开始,将同一个 ID 并发提交 32 次;自检逐个核对返回值,同时检查业务计数器和执行计数都为 1。它没有只看“所有请求成功”这个不足以证明去重的指标。
第三组把同一 ID 的 delta 从 1 改成 2,要求得到参数冲突,且业务值不变。随后生成新 ID,验证真正的新操作能够执行;最后回放旧 ID,验证返回原结果而非当前值。
这些断言覆盖的是指定场景。-race 在本次运行中没有报告数据竞争,也不等于穷举所有调度,更不替代互斥区不变量的证明。程序没有计时与吞吐量输出,不能从这组结果推断去重带来的性能开销。
重启、过期与外部副作用
输出中的最后一个场景刻意制造不完整恢复:保留业务计数器 2,丢掉去重表,再重发旧操作 42。计数器变成 3,说明只持久化业务状态不足以维持请求去重。这里通过创建新对象模拟重启,没有实际杀进程或执行磁盘恢复。
在真实数据库中,若先提交业务修改、后写去重记录,崩溃可以留下“效果已发生,记录不存在”;反过来先写“已完成”记录再提交业务,则可能留下“记录称成功,效果没有发生”。要跨崩溃保证它们一致,应让业务修改和完成记录进入同一原子持久化机制,并在恢复后仍能按 ID 查回结果。RIFL 将完成记录与业务对象的恢复、迁移关联,正是为了解决这类窗口;本例没有实现它的恢复、租约与垃圾回收协议。
记录也不能无限保留。若服务约定只保留 24 小时,客户端在第 25 小时重试可能被当作新请求。这不是实现小细节,而是 API 的安全边界:要么明确拒绝超过窗口的请求,要么有客户端确认、会话代次与安全回收协议,证明旧 ID 不会再次被接受。一个任意 TTL 不能自行提供这种证明。
实验用 client-A/epoch-1/42 表示身份、会话代次与序号,目的是让身份含义可见,并没有实现可靠的 ID 分配。生产 API 还应将身份范围绑定到租户、调用者权限、目标资源和操作类型;不能让两个独立调用者用同一个短字符串互相命中缓存。参数摘要可以节约存储,但其编码必须稳定,并考虑摘要碰撞;本例直接比较唯一业务参数,没有引入这些假设。
如果业务效果包含向另一个系统发送邮件,本地数据库事务最多能原子保存业务状态与待发送记录,不能仅靠本地锁证明收件系统只接受一次。接收者也需要可识别的操作身份,或接受重复并提供相应业务语义。去重保证在哪一端终止,应写进接口说明,不能把本地成功提交扩大成整条链路的 exactly-once。
超时预算与重试负载
客户端需要区分单次尝试超时和整个逻辑操作的截止期限。每次重试都重新获得完整超时时间,会让调用总时长随尝试次数增长;上游已经放弃的请求还可能继续消耗下游容量。
重试间隔通常采用有上限的退避和随机扰动,配合总尝试次数、剩余预算与服务端限流。假如每一层都各自最多尝试 3 次,三层调用链最坏可以触发 27 次最底层尝试。这是组合计数,不是本地实验测出的性能数据;真正的上限还取决于各层实际重试条件。重试责任应在调用链中明确归属,不能每层都假设自己只多发了两次。
gRPC Retry文档区分透明重试与显式重试策略。某些透明重试成立的前提是请求没有被应用逻辑处理;启用可重试状态码并不会自动建立业务去重。该文档所说收到响应头后 RPC “committed”,描述重试机制停止重放的边界,不能拿来证明数据库已经提交。
gRPC Deadlines要求服务应用配合取消已经启动的工作。取消能减少剩余计算,不能撤销已经完成的业务修改。状态码文档也明确允许操作已经完成、客户端却收到 DEADLINE_EXCEEDED。实际客户端应保留逻辑操作 ID,并用查询或同 ID 重试确认结果,而不是看到超时就换 ID 再执行。
这些资料描述 API 与设计契约。本实验没有引入 gRPC 依赖,也没有验证某个语言库版本的默认配置;部署时应继续核对所用 SDK 版本、拦截器、代理以及服务端的具体行为。
自测:改变哪个假设会破坏保证
一个客户端每次重试都生成新 UUID,服务端的去重表是否还能保护同一业务操作?不能。服务端只能看到多个新操作;UUID 的唯一性保证不会自动给多次尝试建立关联。应在首次尝试前创建身份,并将它保存在整个逻辑操作的生命周期里。
把临界区缩小为两次锁操作:第一次只查去重表,解锁后做加法,第二次再保存结果,会怎样?两个并发请求都可能通过第一次检查。需要锁住完整状态转移,或者使用有等价原子性的持久化事务与唯一约束;给 map 加锁不足以证明业务安全。
下一篇进入 MapReduce:工作者被杀掉以后,调度器可以重跑任务,但只应接纳一份有效输出。请求身份与重复执行的区分将延伸到任务身份、执行尝试和输出提交;第 04 篇再把去重记录带进持久化 KV。
参考资料与核验记录
主要资料包括 MIT 6.5840 Spring 2026 RPC 讲义、Birrell 与 Nelson,TOCS 1984、RFC 9110,2022-06,§9.2.2、RIFL,SOSP 2015,以及前述 gRPC 官方指南。
逐项论断、来源日期与反向核对保存在 论断证据表;命令、原始输出与验证边界保存在 本地验证记录。Birrell 原站 PDF 在本次抓取中失败,调用标识部分由原站索引片段与课程材料交叉核对;未宣称完成该论文全文逐页复核。RFC 勘误入口同样未能读取,正文未据此宣称“没有勘误”。
