RPC:一次远程调用可能处于哪些状态
从客户端超时看清一次 RPC 的发送、执行与响应状态,避免把网络错误误当成业务失败。
一次 RPC 并不只有成功和失败。客户端收到响应时可以确认成功或明确错误;连接失败且请求尚未写出时可以确认未执行;其余大量超时、断连和进程崩溃情况都属于结果未知。调用方不知道远端是否已执行,也不知道执行到哪一步。
这篇把一次 reserveStock 调用拆成状态,说明哪些错误能重试,哪些必须按业务键查询。HTTP 的请求与响应语义可参照 RFC 9110,同样的边界适用于 gRPC、消息消费和第三方 SDK。
先分清三层状态
一次 RPC 同时存在传输状态、服务端执行状态与业务状态。传输层知道连接是否建立、字节是否写出、是否收到 header;服务端知道 handler 是否开始、数据库是否提交;业务层知道库存预占是否存在、订单能否继续。任何一层的“成功”都不能直接替代另外两层。
| 层 | 典型状态 | 谁能确认 | 影响 |
|---|---|---|---|
| 传输 | 未连接、已发送、收到响应、连接中断 | RPC 库与代理 | 是否可能透明重试 |
| 执行 | 未进入 handler、执行中、已提交、已回滚 | 服务端应用 | 副作用是否可能发生 |
| 业务 | 已预占、已拒绝、确认中、已释放 | 领域服务与持久化记录 | 调用方下一步动作 |
客户端通常只能直接观察第一层的局部信息和最终响应。只要响应缺失,后两层就可能无法从异常类型推断。可靠接口因此要把业务状态持久化,并提供按业务键查询的路径。
一、调用至少跨过四个边界
客户端序列化 → 写入网络 → 服务端接收 → 执行业务 → 写入响应 → 客户端读取
每跨过一个边界,调用方对事实的把握都会下降。连接建立前失败,通常可以确认服务端没收到;字节写入一半时,服务端可能收到完整请求,也可能没有;服务端已提交数据库但响应丢失时,客户端仍只会看到超时。
| 客户端观察 | 远端可能事实 | 安全动作 |
|---|---|---|
| 参数校验失败 | 未执行 | 修正参数,不重试 |
| 连接未建立 | 大概率未执行 | 可在 deadline 内重试 |
| 收到明确业务错误 | 已执行并拒绝 | 按错误码处理 |
| 收到成功响应 | 已完成 | 保存结果 |
| 读超时、连接中断 | 未到达 / 执行中 / 已完成 | 用业务键查询 |
“大概率未执行”不应升级为业务保证。客户端库有时无法精确知道一个请求是否已被内核或代理转发;高风险写操作最好把每次尝试都当作可能已经送达。
从名字解析到服务端 handler
RPC 在真正执行方法前已经经过多步:服务发现返回地址,客户端从连接池取连接,完成 DNS、TCP 或 QUIC 与 TLS,代理或负载均衡器选择后端,协议库发送 header 和消息,服务端再排队进入 handler。任一步都能失败,错误对业务的含义不同。
DNS 失败或没有可用地址时,请求通常尚未到达服务端;连接复用时,客户端可能拿到已被对端关闭的空闲连接,第一次写才发现失效;代理把请求转给后端后自身崩溃,客户端不知道后端是否已处理。负载均衡器返回 502 也不能笼统解释为“业务未执行”。
HTTP/2 多路复用又增加了一层:一条连接承载多个 stream,连接级故障会同时影响多个 RPC;某个 stream reset 不一定表示其他调用失败。客户端库可能对确认未进入应用的请求做透明重试,但应用仍需为不能证明的情况准备幂等语义。
序列化成功不表示对端理解相同
请求在客户端通过类型检查,只证明本地对象可编码。字段默认值、未知枚举、数值溢出、时区和版本兼容仍可能让两端理解不同。Protobuf 通常能保留未知字段,但删除后复用 field number、改变字段语义或把 optional 当 required,仍会破坏兼容。
服务端应区分协议解析失败、参数格式错误与业务前置条件不满足。前两者用相同请求重试不会改善;后者可能在系统状态改变后成功。错误模型越明确,调用方越少依赖字符串匹配。
排队也是调用的一部分
请求到达进程后,可能先等待线程池、协程信号量、数据库连接池或下游配额。handler 尚未开始业务代码,客户端 deadline 已经消耗大半。若服务端不检查剩余预算,它会完成一个调用方已经放弃的动作,并挤占仍有价值的请求。
队列等待应单独记录。端到端耗时 800ms,其中排队 700ms 与数据库执行 700ms 的解决办法不同。前者需要过载保护、并发控制或容量调整,后者需要查询和数据路径优化。
二、超时不是远端取消
客户端 deadline 到期后停止等待,服务端可能仍在排队、调用下游或提交事务。取消信号若能传到服务端,也只能要求协作取消;已经发生的扣款、写库或消息发布不能自动撤销。
因此服务端应检查 deadline,避免继续做没有价值的长任务;但对于有副作用的动作,更重要的是保存业务键和当前状态。reserveStock(orderId, sku, count) 超时后,订单服务应调用 getReservation(orderId),而不是把同一订单换一个请求 ID 再预占一次。
timeout 与 deadline 的传播方式不同
Timeout 表示从当前时刻最多等待多久,deadline 表示一个绝对截止点。调用链中传播原始 timeout 会让每一跳重新获得完整预算;传播 deadline 或扣除已消耗时间后的 timeout,才能守住入口总预算。gRPC deadline 文档说明了这种传播,并在跨机器时通过剩余 timeout 减少时钟偏差影响。
例如入口预算 1500ms,用户服务已消耗 400ms,调用库存时最多只剩 1100ms,还要预留组装响应时间。库存继续调用数据库时再次扣减。服务若发现剩余 20ms 而正常执行至少需要 100ms,应尽早返回 deadline exceeded,避免无效占用。
deadline 到期后,客户端可停止等待;服务端 handler 是否停止取决于实现是否感知取消并主动退出。CPU 循环、阻塞数据库驱动或已经发出的第三方请求可能继续运行。取消信号适合节省未完成计算,不能证明业务回滚。
长任务应从同步 RPC 转成资源状态
模型推理、视频转码、大批量导入等任务无法稳定塞进一次短 RPC。更好的接口先创建任务资源,返回 taskId,客户端查询或订阅状态。创建接口用 idempotency key 去重,任务状态持久化,worker 通过 lease 推进。
客户端取消订阅与取消任务是两个不同动作。断开页面连接只表示不再接收进度;真正取消任务需要显式命令、权限检查和状态迁移。任务若已经进入不可撤销阶段,可以返回 CANCEL_REQUESTED,完成清理后再进入 CANCELLED。
三、协议调用 ID 不是业务幂等键
RPC 框架常为一次请求生成 trace ID、stream ID 或 call ID,用来关联日志和响应。客户端重试会产生新的调用 ID;同一个业务动作经过网关、队列和 worker 还会出现更多 ID。它们不能识别“这是不是同一次下单”。
业务幂等键应由稳定意图组成,例如 orderId、paymentId 或客户端生成的 UUID,并且服务端要校验同键参数一致。调用 ID 用于观测,业务键用于去重和查询,两者都应进入日志。
一次逻辑调用可能对应多个 attempt
SDK 重试、代理故障转移和客户端手动重放都会产生新的 attempt。Trace 可以把它们挂在同一逻辑 operation 下,每个 attempt 有独立 span、目标地址、连接与状态码。若只记录最终成功,重试带来的额外负载和前几次副作用会被隐藏。
建议区分 operation_id、attempt_id 和 idempotency_key。operation_id 关联调用方眼中的一次工作,attempt_id 定位具体网络尝试,idempotency_key 标识业务意图。它们有时数值相同,但责任不同,不应靠一个 requestId 字段包办。
服务间继续调用时,trace context 可以传播,业务键是否传播要按领域决定。订单调用库存应传 orderId 作为预占键;库存调用日志服务不应把它当作日志写入的全局幂等键。每个副作用边界需要自己的稳定语义。
四、把结果未知设计成正常分支
一个可靠的写接口可以返回:
CONFIRMED 已完成,附带资源版本
REJECTED 明确业务拒绝
PROCESSING 已受理,稍后查询
UNKNOWN 连接中断或超时,按业务键查询
客户端不必把 UNKNOWN 原样展示给用户。下单页可以显示“正在确认”,后台轮询状态;超过 SLA 后进入对账。关键是状态查询必须能抵达权威记录,且查询本身没有再次触发副作用。
明确失败也分传输失败与业务拒绝
INVALID_ARGUMENT、NOT_FOUND、ALREADY_EXISTS、FAILED_PRECONDITION 等状态由应用返回时,客户端拿到了一个明确结论;连接中断产生的 UNAVAILABLE 或 DEADLINE_EXCEEDED 通常只描述通信结果。gRPC 的错误处理说明也区分库生成与应用返回的错误。
错误码要告诉调用方下一步,而不只是给日志分类。参数错误要求修改请求;并发 ABORTED 需要重读后重做完整事务;服务 UNAVAILABLE 可以在幂等前提下退避;前置条件失败要先改变业务状态。把所有异常包装为 INTERNAL,会让上游不是盲重试,就是全部放弃。
业务拒绝的响应也应稳定。库存不足返回 REJECTED 后,重试同一预占不应突然创建另一条记录,除非接口明确定义状态变化后可重新尝试。若调用方需要“库存恢复时再试”,应创建订阅或新的业务意图,而不是无限重放旧请求。
查询接口需要权威且单调
写请求 UNKNOWN 后,查询若走落后副本,可能先返回不存在,稍后又出现,调用方会误判为未执行并重放。确认接口应读取权威状态、接受 minVersion,或返回“尚无法确认”而不是虚假的 NOT_FOUND。
状态最好单调推进:PROCESSING → CONFIRMED / REJECTED / NEEDS_REVIEW,终态不因缓存或副本切换退回处理中。若补偿会把已确认预占释放,状态应变成新的 RELEASED 终态,保留历史,而不是删除记录让它看起来从未发生。
五、重试要由操作语义决定
查询商品详情、获取配置等只读调用可在退避和总 deadline 内重试。创建订单、支付、发送通知等操作只有在服务端保证幂等,或调用方能证明请求从未写出时才适合自动重试。重试前应保留同一业务键;重试后仍未知则查询,而不是无限循环。
服务端还要应对重复、乱序和并发到达:同一个 orderId 的两次请求可能同时落到不同实例。唯一约束、条件更新或幂等状态表要在共享持久层决定胜者,不能依赖单机内存锁。
transparent retry 的安全范围很窄
RPC 库有时能证明请求从未离开客户端,或到达服务端协议栈但未交给应用 handler,于是自动创建新 attempt。gRPC 的重试指南称之为 transparent retry。它改善连接竞态,不意味着所有 UNAVAILABLE 都可无条件重放。
一旦应用可能看见请求,框架就不知道 handler 是否写库。显式重试策略应按方法配置最大尝试数、退避、可重试状态码和总 deadline;创建、扣款等方法还要有幂等契约。收到 response header 后,框架通常认为调用已 committed,不再透明切换 attempt。
retry、hedging 和 failover 不同
Retry 在一次 attempt 明确结束后再发起下一次;hedging 在第一份仍未完成时并行发第二份;failover 把请求切到另一个地址或区域。三者都可能让多个服务端实例看到同一个逻辑调用。
Hedging 适合尾延迟敏感的只读请求,并应在收到第一份有效响应后取消其余 attempt。写操作即使有幂等键,也会增加锁竞争和无效工作。跨区域 failover 还要确认目标区域的业务记录与幂等表已同步,否则新区域无法识别旧 attempt。
重试的总成本需要可见
监控只统计最终 RPC 成功率,会把“平均每次成功前重试四次”隐藏起来。应同时记录 logical call 数、attempt 数、每个 attempt 的状态、retry reason、退避时间和放大系数。下游过载时,成功率可能暂时维持,资源却已被重试耗尽。
重试预算可以限制额外 attempt 占比。超过预算后,读取走缓存或降级,写操作转确认中或快速失败。让下游恢复比让每个上游都坚持最后一次尝试更重要。
六、RPC 负载均衡与故障摘除会改变失败窗口
客户端负载均衡可能在本地维护地址列表,也可能经由代理转发。健康检查只说明探针成功,不保证具体方法有容量:进程能返回 /health,数据库连接池仍可能耗尽。被动摘除根据真实调用错误判断,但若把业务拒绝也算作节点故障,会错误移除健康实例。
连接排空期间,负载均衡器停止分配新请求,已有 stream 继续执行。发布系统若直接杀死进程,客户端会看到大量中断并重试。优雅下线需要先标记不接新流量,等待在途请求或 deadline,长任务则转移到持久化队列。
故障切换到另一个区域时,传输可达并不代表业务可写。目标区域可能缺少最新订单版本、幂等记录或 Leader 租约。切流流程应先验证数据追平和写权限,再开放有副作用的方法;只读陈旧页面可以更早恢复。
七、流式 RPC 还有半关闭与部分结果
Unary RPC 是一次请求对应一次响应,流式 RPC 还会出现客户端半关闭、部分消息已接收、背压与中途取消。客户端发送十个任务后连接断开,服务端可能只接收前六个;若协议没有每条消息的序号与确认,双方无法知道从哪里恢复。
客户端流适合让服务端逐条确认 lastAcceptedSequence。重连后客户端从下一序号继续,并用 streamId 与 messageId 去重。服务端流可以返回 resume token,客户端保存最后已处理位置;token 的有效期与数据快照范围必须清楚。
双向流中的错误也有作用域。单条业务消息非法,未必需要关闭整个 stream;协议级解析失败或认证失效则应终止。若错误模型只有“stream closed”,调用方很难判断已确认消息是否仍有效。
背压是正确性的一部分。消费者处理不过来时,发送端必须限制在途消息;无限缓冲最终会以内存溢出和连接断开的形式丢失上下文。窗口大小、每条确认与重连恢复共同定义了流的交付语义。
八、排障需要一条能串起来的证据链
为每次调用记录业务键、调用 ID、尝试号、deadline、请求摘要、服务端接收时间、提交版本、下游请求 ID 和最终响应。事故发生时,这条链能回答“用户的超时请求是否已扣库存”,也能区分客户端重试造成的重复与服务端内部重复。
测试时应主动注入响应丢失、服务端提交后崩溃、重复请求和慢副本。验收目标是让同一个业务意图在超时后仍会收敛到可查询、可审计的结果;请求本身仍然可能超时。
这项能力决定接口在故障中是否仍可使用。
一条 trace 应怎样解释多次 attempt
顶层 span 表示逻辑操作 reserveStock(o-1),下面挂 attempt 1 与 attempt 2。每个 attempt 记录目标实例、连接复用情况、请求字节是否写出、response header、状态码与 deadline;服务端 span 记录排队、handler、数据库和下游调用。业务日志再以 orderId 关联持久化状态。
如果客户端只有 attempt 1,而服务端存在对应 span 且数据库提交成功,问题在响应路径;若客户端显示连接失败且服务端没有任何接收记录,才接近明确未执行。采样策略要保留错误和高延迟 trace,否则最需要的 UNKNOWN 可能因低采样率消失。
指标也应按方法与结果语义拆分。DEADLINE_EXCEEDED 数量、服务端在客户端取消后继续运行的时长、每逻辑调用 attempt 数、幂等命中率、PROCESSING 年龄和对账积压,都比一个总错误率更能指导修复。
九、用故障注入验证每个状态
测试可以在调用链的不同位置断开:连接建立前、请求写出一部分、服务端进入 handler 后、数据库 commit 后、响应 header 后。每个位置都验证客户端分类、重试策略和业务终态。重点窗口是“副作用已发生、响应未到达”。
并发发送相同业务键,确认不同实例只创建一条预占;让旧处理者暂停至 lease 过期,新处理者接管后再恢复旧处理者,确认 fencing version 拒绝旧写;让查询命中落后副本,确认它不会把 UNKNOWN 误报为 NOT_FOUND。
对 deadline 传播,可以让上游先消耗大部分预算,再检查下游收到的剩余时间;让 handler 忽略取消,监控应暴露取消后工作;修复后则验证下游调用被及时终止。流式 RPC 还要测试断线续传、重复序号、部分确认和背压。
最终验收要求每种客户端观察都有明确下一步:确定成功保存结果,确定拒绝停止重试,确定未执行可以有限重放,结果未知按同一业务键查询,长时间无法确认进入对账。
一个服务端 handler 应保存哪些状态
下面的伪代码展示 reserveStock 的关键顺序。幂等记录与预占写入位于同一事务,重复请求读取已有结果;外部调用若无法进入同一事务,则先保存可恢复状态与外部请求 ID。
reserveStock(request, context):
validate(request)
key = (tenantId, "reserve-stock", request.orderId)
transaction:
record = idempotency.getForUpdate(key)
if record is terminal:
assertSamePayload(record.requestHash, hash(request))
return record.savedResult
if record is owned by another live lease:
return PROCESSING(record.operationId)
record = acquireOrTakeOver(record, fencingVersion + 1)
reservation = inventory.conditionalReserve(request, fencingVersion)
record.complete(reservation.result)
return reservation.result
getForUpdate 只是示意,可以换成唯一插入和条件更新。关键约束是:并发首次请求只能有一个 owner;同键不同参数被拒绝;终态结果可重放;接管者带更高 fencing version;库存写与幂等终态不会出现一个提交、另一个丢失。
客户端调用也应按状态分支:
result = rpc.reserveStock(request, deadline, idempotencyKey=orderId)
if result is CONFIRMED or REJECTED:
persist(result)
elif error proves request never left client:
retryWithinDeadline(sameKey)
else:
status = rpc.getReservation(orderId, consistency="authoritative")
persist(status or PENDING_CONFIRMATION)
这里没有 catch TimeoutException -> reserveStock(newRequestId)。UNKNOWN 路径转向查询,查询也无法确认时才保存待对账状态。SDK 可以封装样板代码,但不能替业务决定哪个 key 代表同一次意图。
灰度发布要验证旧客户端与新服务的错误语义
RPC schema 向后兼容不代表行为兼容。新服务增加 PROCESSING 状态,旧客户端可能把未知枚举当作默认 CONFIRMED;服务端把原来的 ALREADY_EXISTS 改成 OK + existingResource,上游告警与重试也会变化。灰度时要覆盖新旧版本组合。
错误字段应保持机器可读的稳定 code,文本 message 用于排障,不供程序分支。新增可重试状态前,要确认旧客户端不会立即重试形成流量尖峰;改变 deadline 或最大消息大小,也要检查代理与 SDK 的限制是否一致。
协议演进测试可以保存一批真实请求与响应样本,让新旧 codec 交叉解析,并执行状态机契约测试。重点不是字节能否解码,而是同一业务历史在升级前后得到同样的成功、拒绝、处理中和 UNKNOWN 判断。
十、Agent 与全栈应用为什么也要理解 RPC 状态
Agent 调用工具、浏览器或远程执行器,本质上也是远程调用。工具超时后,模型容易再次提出“发送消息”“部署”或“创建工单”,产生重复副作用。Harness 应保存业务动作 ID、工具 call ID 和外部回执,并在重试前查询世界状态。
前端也不能把所有网络异常展示成“操作失败,请重试”。创建订单或上传任务超时后,更合适的是进入确认中,用同一键轮询;按钮再次点击复用原意图或明确创建新意图。页面状态机是分布式协议的一部分。
后端负责把这些差异编码进 API:明确错误码、deadline、幂等键、查询接口和可观测字段。只有服务端知道的“其实已经成功”,对调用方没有帮助;只有客户端猜测的“应该没发出去”,也不能作为业务证据。
RPC metadata 还跨越认证与租户边界。重试时必须重新检查 credential 是否仍有效,不能因为幂等记录存在就把历史结果泄露给另一个用户;查询接口也要验证调用者有权读取该业务键。Trace ID 可以进入日志,access token、完整支付参数和隐私字段不应作为普通标签传播。
对于多租户服务,幂等键必须带租户作用域,限流和 retry budget 也应避免一个租户的重试耗尽全局容量。网关切换身份或服务间代调时,要保存原始主体与当前执行主体,便于审计“谁发起、谁代表谁执行”。这些要求不改变 RPC 的网络状态,却决定 UNKNOWN 恢复路径是否安全。
十一、设计评审检查表
- 请求从客户端到 handler 经过哪些代理、连接池和队列?
- 每种错误能否证明未执行,还是只能证明没收到响应?
- deadline 是否沿调用链扣减,取消后服务端会停止哪些工作?
- operation ID、attempt ID 和业务幂等键分别是什么?
- UNKNOWN 后查询哪个权威记录,会不会读到落后副本?
- 谁负责重试,最大尝试数、退避与 retry budget 是什么?
- 负载均衡、发布和跨区域切换是否共享幂等与状态记录?
- 流式调用如何确认部分消息并从断点恢复?
- trace、日志和业务记录能否还原“提交后响应丢失”?
- 故障注入是否覆盖了副作用前后两个窗口?
参考资料
- IETF, RFC 9110: HTTP Semantics
- gRPC, Deadlines
- gRPC, Error handling
- gRPC, Status codes
- gRPC, Retry
- gRPC, Cancellation
如果这篇文章对你有帮助