后端系统工程 13:RPC 客户端的请求生命周期
系列导航:上一篇 12 有界连接池与等待取消 | 本篇 13 | 下一篇 14 会话存储与登录撤销
本地函数调用只有两种结局:返回,或者抛出异常。远程调用多了第三种:请求可能已经发出、也可能已经在对端执行,但结果没有回来。调用方在截止时间到达时只能如实说“不知道”。RPC 客户端的大部分复杂度都来自这第三种结局:同一条连接上,迟到的响应不能交给后来的请求;超时之后,客户端要判断能不能再发一次;服务端则不应该为一个已经被放弃的请求做事。
本篇实现一个最小 RPC,客户端是单连接多路复用,服务端是独立 JVM 进程并带故障注入,用 20 个用例检验四件事:字节流被正确切成帧;响应只交给发起它的那次尝试;截止时间同时约束客户端等待和服务端执行;只有允许的操作才会被自动重试。代码在 examples/13-rpc-client/,输出在 evidence/13/output.txt。
需求契约
调用方看到一个操作:call(method, idempotency, key, payload, timeout),返回状态之一:OK、APP_ERROR、INVALID_ARGUMENT、DEADLINE_EXCEEDED、UNAVAILABLE、UNKNOWN。服务端提供两个业务方法:get 读余额,声明为幂等;debit 扣减余额,声明为非幂等,可以附带幂等键。
客户端必须维持以下不变量:
- 每个响应只交给发起它的那一次尝试;查不到发起者的响应被丢弃并计数,不交给任何其他请求。
- 每次调用在
timeout之内返回,重试也不能越过它。 - 一个没有幂等键的非幂等调用,在结果未知时不会被客户端再次发送;调用方得到
UNKNOWN,而不是一个看起来确定的失败。 - 一帧非法数据出现后,这条连接上的后续字节不再被解释。
规模假设是教学取值:一个客户端进程、8 个调用线程共享一条连接;帧上限 64 KiB;单次尝试超时 300ms,调用总截止时间 2s,最多 4 次尝试;退避从 20ms 起按 2 倍增长,上限 200ms,抖动 ±20%。这些数值只服务于实验,不是推荐配置。
图 13-1:状态归属(全部在进程内存,没有持久化)
1 | |
字节流到帧
TCP 提供的是字节流。RFC 9293 §2.2 写道:“TCP provides a reliable, in-order, byte-stream service to applications.”;§3.7 进一步说明,TCP 不保证收发两端分段边界与应用读写缓冲区边界之间存在任何对应关系(RFC 9293)。Java 一侧也一样,SocketChannel.read 的文档说一次读取“might not fill the buffer, and in fact it might not read any bytes at all”(Java SE 21 SocketChannel)。所以“一次 write 对应一次 read”只是低负载回环上的巧合,消息边界必须由应用协议自己表达。
本实现使用长度前缀帧,字段全部定长或带长度,大端序:
| 字段 | 长度 | 约束 |
|---|---|---|
| frameLen | 4 | 之后的字节数,必须在 [24, 65536] |
| magic / version / type | 2 / 1 / 1 | 0x5243 / 1 / 请求或响应 |
| requestId | 8 | 非 0 |
| budgetMs | 4 | 请求:本次尝试剩余预算(相对毫秒) |
| status | 1 | 响应状态 |
| methodLen / keyLen | 1 / 1 | 1…32 / 0…64 |
| method、key、payloadLen、payload | 变长 | frameLen 必须等于各段之和 |
解码器是增量的:收到多少字节就追加到缓冲区,循环取出所有完整帧,剩余部分留到下一次。长度前缀一到达就检查上限,不按对端声明的长度分配内存:
1 | |
遇到非法帧时只有一种处理:关闭连接。帧内的长度字段错了,后面每一个字节的位置就都不可信,无法“跳过这一帧继续读”。客户端关闭连接时,这条连接上所有在途请求一并失败,它们的结果按下文的规则归类。
解码器先在进程内用模型检验:500 个随机帧编码后,按 1 字节一段、按 1 到 97 字节随机分段两种方式喂入,解出的帧与原帧逐字段比较。随后在真实进程间检验:服务端进入 chunk 模式,把一小段时间内的多个响应拼起来,再按 1 到 13 字节随机切开写出,每段之后都 flush,并关闭 Nagle 算法。
1 | |
403 个帧被切成 4235 次写出,客户端有 1535 次读取停在帧中间,11 次读取一次带回多个帧,400 次调用没有一次拿错结果。服务端面对的是同一个问题:逐字节发来的请求被正确组装(server_split_and_invalid_request),magic 错误的帧和声明长度为 2^31−1 的帧头都让服务端关闭连接,protocolErrors=2。客户端编码超过 64 KiB 的请求时直接返回 INVALID_ARGUMENT,服务端请求计数为 0。
请求 ID 与 pending 表
一条连接上同时有多个请求在途时,响应的到达顺序由服务端的执行时间决定,与发送顺序无关。客户端为每次尝试分配一个新的请求 ID,放进 pending 表,读线程按 ID 取出:
1 | |
超时的那一方在放弃前执行 pending.remove(id)。表中查不到,正是“这个响应已经没人要”的判定条件。之后才到达的同 ID 响应只会增加 lateDropped。ID 按“尝试”而不是按“调用”分配:同一次调用的第二次尝试有新的 ID,第一次尝试的迟到响应因此不会冒充第二次的结果(late_then_retry_new_request_id 的日志里是 id=1、id=2,lateDropped=1)。
对照实现 FifoClient 在同一条连接上流水线发送,收到响应时交给队首,不看 ID;超时的请求从队列中移除。这种写法单看每一步都说得通。实验时序如下:A 读 acct-A,服务端执行后延迟到预算加 150ms 才回应,A 在 200ms 时超时;随后 B 读 acct-B,服务端为 B 休眠 400ms;最后 C 读 acct-C。
图 13-2:同一时序下按 ID 匹配与按 FIFO 匹配的差别(超时 200ms,A 的响应在约 350ms 到达)
sequenceDiagram
participant Cl as 客户端
participant S as 服务端
Cl->>S: A get acct-A (id=1, budget=200)
Note over S: 执行 A,延迟回应(late 注入)
Note over Cl: 200ms 超时:pending.remove(1) / FIFO 队列移除 A
Cl->>S: B get acct-B;sleep=400 (id=2)
S-->>Cl: A 的响应 (id=1) 约 350ms 到达
alt ID 关联
Cl-->>Cl: pending 中没有 1 → lateDropped=1,B 继续等待
S-->>Cl: B 的响应 (id=2) → 交给 B
else FIFO
Cl-->>Cl: 队首是 B → B 收到 account=acct-A(串包)
end
1 | |
FIFO 客户端里,B 拿到了 A 的余额,而且没有任何错误信号。如果 B 是一次扣减,调用方会把别人的结果当成自己的确认。FIFO 匹配成立需要两个前提:服务端严格按序回应,客户端从不提前放弃请求。HTTP/1.1 流水线依赖第一个前提,只要客户端存在超时,第二个前提就不成立。还有一种不依赖 ID 的做法是“超时就关闭整条连接”,它也能避免串包,代价是同一连接上所有其他在途请求一起变成结果未知。
截止时间:本地计时与传给对端的预算
gRPC 的截止时间指南把截止时间定义为客户端愿意等待的最后时刻,并提到它默认不设置截止时间,客户端可能因此一直等下去(gRPC Deadlines)。同一指南说明,向下游传播时,gRPC 把截止时刻换算成扣除已耗时间的超时值,以避开两台机器的时钟偏差。本实现沿用这个做法:客户端在本地用 System.nanoTime() 计算总截止时刻,每次尝试发送的是一个相对值 budgetMs = min(attemptTimeout, 剩余时间);服务端从收到完整帧的时刻开始计时,工作线程出队后先检查预算:
1 | |
Socket.setSoTimeout 不能替代这个预算。它只约束单次 read 的阻塞时间,超时后 socket 仍然有效(Java SE 21 Socket)。一个响应如果每 200ms 来一个字节,单次读取永远不会超时,整个调用却可以拖很久。多路复用客户端的读线程本来就不设读超时,截止时间由每个 Pending 上的 future.get(budget) 实现。
deadline_server_skips_expired 用一个单工作线程的服务端制造排队:先发一个休眠 500ms 的 get,再发一个总截止时间 100ms、不带键的 debit。
1 | |
服务端日志记录 EXPIRED id=2 method=debit budget=99 waited=486:扣减在队列里等了 486ms,出队时预算早已用完,没有执行,余额仍是 1000。服务端随后回的 DEADLINE_EXCEEDED 到达时,客户端早已放弃,这个响应被计为迟到丢弃。客户端在 99ms 时返回的是 UNKNOWN,事后看扣减并没有发生,但客户端在那一刻不可能知道这一点。gRPC 的状态码文档对 DEADLINE_EXCEEDED 也有同样的提醒:对改变状态的操作,即使操作已经成功完成,也可能返回这个错误(gRPC Status Codes)。服务端检查预算,减少的是无用功;它不能把调用方的“未知”变成“已知”。服务端检查发生在执行之前,已经开始执行的扣减不会被中途取消,本实现也没有取消机制。
哪些失败可以重试
RFC 9110 §9.2.2 给出了幂等的定义:多次相同请求对服务端的预期效果与一次相同。它还给出了自动重试的界线:“A client SHOULD NOT automatically retry a request with a non-idempotent method unless it has some means to know that the request semantics are actually idempotent, regardless of the method, or some means to detect that the original request was never applied.”(RFC 9110)这句话里有两个例外条件:请求语义本身幂等,或者能够确认原请求从未被执行。客户端的重试分类就按这两个条件划分:
| 尝试结果 | 分类 | 依据 |
|---|---|---|
| 连接建立失败;写出前连接已失效 | 确定未执行 | 请求没有离开客户端 |
服务端 UNAVAILABLE、DEADLINE_EXCEEDED |
确定未执行 | 服务端协议承诺:这两个状态只在执行前返回 |
| 写出后超时、断连、收到非法帧、半个响应帧 | 结果未知 | 服务端可能已执行 |
OK、APP_ERROR、INVALID_ARGUMENT |
终态 | 重试也不会改变结果 |
确定未执行的失败对所有方法都可以重试;结果未知的失败,只有幂等方法或带幂等键的调用才重试,其余立即返回 UNKNOWN。gRPC 的重试文档有一个对应概念:没有配置重试策略时,gRPC 只做“透明重试”,并且只在确定 RPC 没有被服务端处理时才做;配置了重试策略后,哪些状态码可以重试由服务配置的 retryableStatusCodes 显式列出,没有固定的默认集合(gRPC Retry)。
图 13-3:单次尝试的分类与重试判定
stateDiagram-v2
[*] --> Connect
Connect --> NotSent: 连接失败
Connect --> Writing: 已连接(pending.put,writeStarted=true)
Writing --> Waiting: 写出成功
Writing --> Unknown: 写出异常
Waiting --> Final: OK / APP_ERROR / INVALID_ARGUMENT
Waiting --> NotExecuted: UNAVAILABLE / 服务端 DEADLINE_EXCEEDED
Waiting --> Unknown: 超时 / 断连 / 非法帧
NotSent --> Retry
NotExecuted --> Retry
Unknown --> Retry: IDEMPOTENT 或带幂等键
Unknown --> ReturnUNKNOWN: 非幂等且无键
Retry --> Connect: 次数未满且退避后仍在截止时间内
Retry --> GiveUp: 次数用尽或退避会越过截止时间
写出之前先置 writeStarted=true。连接若在这一步之前失效,pending 项会被标成未发送;一旦开始写,即使 write 抛出异常,客户端也无法知道对端收到了多少字节,只能按结果未知处理。分类的保守方向始终是“未知”。
故障注入覆盖了每一行:
1 | |
服务端两次执行前拒绝后,非幂等扣减在第 3 次尝试成功,只执行了一次。服务端执行后不回应、执行后断连、只写出半个响应帧、回一个坏帧,这四种情况下扣减都已经发生(serverDebitExec=1),无键扣减都返回 UNKNOWN,幂等读都在新连接上重试成功。半帧那一行的 buffered partial bytes=34 说明,客户端在连接关闭时手里有 34 个字节的残帧,它们没有被当作响应交付。
次数上限与截止时间上限分别生效。连接被拒绝时,总截止时间 5s 的调用在第 4 次尝试后以 UNAVAILABLE 结束,用时 139ms;总截止时间 60ms 的调用尝试 2 次后,下一次退避 39ms 会越过截止时间,于是停止,用时 23ms。恢复路径也是同一套规则:服务端进程晚启动时,前 3 次尝试都是连接被拒绝,第 4 次成功,非幂等扣减执行一次(server_starts_late_recovery)。
幂等键与服务端去重
结果未知时不重试,对调用方来说并不友好:它拿到 UNKNOWN 之后仍然要决定怎么办。幂等键把“确认原请求是否执行过”的能力交给服务端:调用方为一次逻辑操作生成一个键,所有尝试都携带同一个键,服务端第一次见到这个键时执行并记录结果,之后再见到同一个键就返回记录下的结果。
1 | |
同样是“执行后不回应”,带键的扣减在第二次尝试时拿到首次结果(replayed=true),余额 990;NAIVE 策略在超时后照样重试,同一次扣减 10 被执行两次,余额 980,而调用方看到的只是一次 OK。去重表除了结果还记录请求指纹:同一个键带着不同金额再次出现时,服务端拒绝,不执行。否则调用方的键生成错误会悄悄变成“返回了别的请求的结果”。原请求仍在执行时到达的重复请求,会等待原请求的结果,最长等到自己的预算耗尽。
这张去重表的边界必须写清楚:它在服务端进程内存里,没有过期,没有容量上限,进程重启就会丢失;它只对落到同一个服务端进程的重试有效。生产中的去重记录要与业务写入在同一个事务里落库,才能在崩溃后仍然成立,并且需要规定保留期,保留期必须长于客户端可能重试的时间范围。系列计划中的第 27 篇(余额扣减)把去重记录放进 PostgreSQL;本篇没有做持久化去重实验。
三组方案的取舍
| 对比 | 方案一 | 方案二 | 本实现的选择与理由 |
|---|---|---|---|
| 连接使用 | 每请求一连接:不需要 pending 表,连接关闭天然区分请求 | 单连接多路复用:需要请求 ID、读线程和连接失效时的批量失败 | 多路复用。400 次调用只建 1 条连接;每请求一连接新建 400 条,客户端 TIME_WAIT 从 35 增至 435 |
| 响应匹配 | FIFO:实现最简单,要求服务端按序回应且客户端从不提前放弃 | ID 关联:允许乱序回应和单个请求超时 | ID 关联。FIFO 对照在一次迟到后即串包 |
| 结果未知时 | 客户端按方法声明决定是否重试 | 服务端按幂等键去重,客户端可以重试任何带键调用 | 两者并用:无键非幂等调用返回 UNKNOWN;需要自动恢复的写操作带键 |
1 | |
耗时是一次运行的观察值,开发期间重复运行时,每请求一连接的耗时在 0.7 到 3.3 秒之间波动,这里只用它说明方向,不作吞吐结论。多路复用的代价也在实验里出现过:服务端回一个坏帧时,同一连接上所有在途请求都按结果未知失败(invalid_frame_from_server 中连接数增加到 3)。单连接也意味着一个慢读者、一个大载荷会拖住其他请求,本实现没有流量控制,也没有按请求分片。
用例与证据
| 用例 | 类型 | 观察值(evidence/13/output.txt) |
|---|---|---|
normal_multiplex |
正常 | 8 线程 400 次,匹配错误 0,服务端新连接 1,debitExec=200 |
decoder_split_model |
边界(模型) | 1 字节与随机切分差异 0;64 KiB 接受,多 1 字节拒绝 |
half_packet_sticky_real |
边界 | 4235 次写出、403 帧,客户端匹配错误 0 |
server_split_and_invalid_request |
边界 / 失败 | 逐字节请求正确;坏 magic 与超长前缀关闭连接 |
client_oversize_rejected_locally |
拒绝 | INVALID_ARGUMENT,服务端请求数 0 |
late_response_dropped_by_id / FIFO 对照 |
迟到响应 | lateDropped=1 / B 收到 acct-A |
late_then_retry_new_request_id |
恢复 | 第 2 次尝试 id=2 成功 |
deadline_server_skips_expired |
截止时间 | 服务端未执行,客户端 UNKNOWN |
retry_unavailable_non_idempotent |
重试 | 3 次尝试,执行 1 次 |
unknown_not_retried_without_key |
结果未知 | UNKNOWN,执行 1 次 |
retry_with_idempotency_key / NAIVE 对照 |
去重 | 执行 1 次 / 执行 2 次 |
idempotency_key_reuse_rejected |
拒绝 | 不同载荷 INVALID_ARGUMENT |
close_after_exec、half_written_response_discarded、invalid_frame_from_server |
失败恢复 | 幂等读重试成功,无键扣减 UNKNOWN |
connect_refused_bounded_retry |
边界 | 4 次上限 139ms;60ms 截止时间下 2 次 |
server_starts_late_recovery |
恢复 | 3 次未发送后成功,执行 1 次 |
compare_per_request_vs_multiplex |
方案对比 | 400 对 1 条新连接 |
运行入口为 examples/13-rpc-client/run.sh,环境记录在 evidence/13/env.txt(OpenJDK 21.0.12.1,4 核),服务端日志为 evidence/13/server-4713*.log,证据边界见 evidence/13/LIMITS.md。
结论边界
实验支持以下结论:长度前缀帧加增量解码,在任意切分下给出相同的帧序列;按尝试分配 ID 并在放弃时删除 pending 项,能保证迟到响应不会被交给其他请求,而 FIFO 匹配在第一次迟到时就会串包;把剩余预算传给服务端,可以让排队过久的请求不被执行,但客户端在截止时刻看到的仍是“未知”;把失败分成“确定未执行”和“结果未知”两类,再按方法声明和幂等键决定是否重试,在本实验的每种注入故障下,扣减都没有被客户端重复执行。
实验都在单机回环上完成,没有覆盖丢包、对端主机掉电造成的半开连接、跨机时钟和网络传输时间对预算的消耗。去重表不持久、不共享,它在服务端崩溃或重试落到另一个实例时的行为没有经过实验。性能对比只是一次运行的观察。
第 12 篇的连接池和本篇的客户端都把状态放在单个进程里。多个实例共同认可同一份状态时,问题变成“哪份记录说了算、撤销何时在所有实例上生效”,这是 14 会话存储与登录撤销 的内容。





