RPC:一次远程调用可能处于哪些状态

从客户端超时看清一次 RPC 的发送、执行与响应状态,避免把网络错误误当成业务失败。

一次 RPC 并不只有成功和失败。客户端收到响应时可以确认成功或明确错误;连接失败且请求尚未写出时可以确认未执行;其余大量超时、断连和进程崩溃情况都属于结果未知。调用方不知道远端是否已执行,也不知道执行到哪一步。

这篇把一次 reserveStock 调用拆成状态,说明哪些错误能重试,哪些必须按业务键查询。HTTP 的请求与响应语义可参照 RFC 9110,同样的边界适用于 gRPC、消息消费和第三方 SDK。

RPC 调用的观察状态与远端事实

先分清三层状态

一次 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 恢复路径是否安全。

十一、设计评审检查表

  1. 请求从客户端到 handler 经过哪些代理、连接池和队列?
  2. 每种错误能否证明未执行,还是只能证明没收到响应?
  3. deadline 是否沿调用链扣减,取消后服务端会停止哪些工作?
  4. operation ID、attempt ID 和业务幂等键分别是什么?
  5. UNKNOWN 后查询哪个权威记录,会不会读到落后副本?
  6. 谁负责重试,最大尝试数、退避与 retry budget 是什么?
  7. 负载均衡、发布和跨区域切换是否共享幂等与状态记录?
  8. 流式调用如何确认部分消息并从断点恢复?
  9. trace、日志和业务记录能否还原“提交后响应丢失”?
  10. 故障注入是否覆盖了副作用前后两个窗口?

参考资料