RPC:从本地函数到现代服务通信

From Local Calls to gRPC

4,184 words 30 min read
目录 34 节
  1. 如果没有 RPC,我们需要做什么?
  2. 字节流里没有消息
  3. 消息里需要描述一次调用
  4. Stub:让调用重新看起来像函数
  5. IDL:谁来保证双方说的是同一种语言?
  6. 1984:RPC 抽象被清楚地写下来
  7. ONC RPC:过程调用走进网络基础设施
  8. 从远程过程到远程对象
  9. CORBA:让异构对象彼此调用
  10. Java RMI:在同一种语言里走得更远
  11. SOAP 与 WSDL:把契约带到企业边界
  12. REST:不是更轻的 RPC
  13. 现代 RPC:Thrift、Protobuf 与 gRPC
  14. 一份定义,两端代码
  15. 服务端也有端点
  16. 不只有一问一答
  17. 今天的一次 RPC 到底经过了什么?
  18. 远程调用永远不是本地调用
  19. 第三种结果:Unknown
  20. Timeout 与 Deadline
  21. Cancellation 也不是时间机器
  22. Retry:一次逻辑调用,多次物理尝试
  23. 应该在哪一层重试?
  24. Idempotency:让重试不改变意图
  25. At-least-once 与 At-most-once
  26. 服务发现与负载均衡
  27. 错误不只是一个状态码
  28. 契约演进:能解析,不等于兼容
  29. Observability:函数名已经不够了
  30. RPC、REST 与消息该怎么选?
  31. RPC:我现在需要你完成一个操作
  32. REST:我通过统一接口操作资源
  33. Message / Event:我发布一个已经发生的事实
  34. RPC 的边界

假设我们正在写一个订单服务。

计算价格只是一次普通的函数调用:

Money total = pricing.calculate(order);

调用者把参数放进去,控制权进入 calculate。函数执行完以后,要么返回一个 Money,要么抛出异常。

整个过程发生在同一个进程里:

Order Service

    └── calculate(order)

            └── return total

后来价格规则越来越复杂,我们把它拆成独立的 Pricing Service。

代码看起来仍然希望保持原样:

Money total = pricing.calculate(order);

pricing 已经不在当前进程,甚至不在当前机器。

原来由语言运行时完成的函数调用,现在横跨了网络:

Order Service                         Pricing Service
     │                                      │
     ├── calculate(order)                   │
     │          ─────── network ───────>    │
     │                                      ├── calculate
     │          <────── network ───────     │
     └── total                              │

参数不再能通过寄存器或栈传递,返回值也不能直接留在内存里。

我们不得不回答一连串新问题:

  • Order 如何变成可以发送的字节?
  • 服务端怎么知道要调用 calculate
  • 多个请求同时发送时,响应属于谁?
  • 服务地址从哪里获得?
  • 网络断开时,调用到底有没有执行?

RPC,Remote Procedure Call,就是从这里开始的。

它想做一件很诱人的事:

把跨进程、跨机器的通信,重新包装成接近函数调用的编程模型。

这个想法已经存在了几十年。从早期 RPC、分布式对象、SOAP,到今天的 gRPC,工具换了许多代,目标却一直没有真正改变。

但 RPC 的历史还有另一条暗线:

人们不断尝试隐藏网络,又不断发现网络无法被彻底隐藏。

如果没有 RPC,我们需要做什么?

先不使用任何框架,只在两个服务之间建立一条 TCP 连接。

客户端可以发送:

calculate,order-42

服务端解析字符串,找到订单,计算价格,再返回:

order-42,19900

一个最小的远程调用似乎已经完成。

可一旦进入真实系统,事情马上复杂起来。

字节流里没有消息

TCP 提供的是有序字节流,不会替应用标记消息边界。

客户端连续发送两条消息:

calculate,order-42
calculate,order-43

服务端一次读取可能得到:

calculate,order-42cal

下一次才读到剩余部分。

所以协议首先需要 framing:

┌──────────────┬─────────────────────┐
│ payload size │ payload             │
├──────────────┼─────────────────────┤
│ 4 bytes      │ N bytes             │
└──────────────┴─────────────────────┘

先读长度,再读取完整 payload,一条消息才有明确边界。

消息里需要描述一次调用

只有参数还不够。

一条请求至少需要说明:

request_id
service
method
arguments
metadata

响应也需要自己的结构:

request_id
result
error

request_id 让客户端能够把响应与请求对应起来:

Client                       Server
  │                            │
  ├── request 101 ───────────> │
  ├── request 102 ───────────> │
  │                            │
  │ <──────── response 102 ────┤
  │ <──────── response 101 ────┤

服务端还需要一个 dispatcher:

method = "Pricing.Calculate"


      find method handler


       decode arguments


         invoke method


        encode response

做到这里,我们其实已经手写了一个非常简陋的 RPC 协议。

真正的问题不是它能不能运行,而是每个服务都要重复这些代码。

Stub:让调用重新看起来像函数

如果所有客户端都手写网络通信,业务代码很快会被连接、编解码和错误处理淹没:

byte[] payload = encode(order);
Connection connection = pool.acquire("pricing-service");
connection.write(frame("Pricing.Calculate", payload));
Response response = connection.read();
Money total = decode(response);

我们希望调用者只看到:

Money total = pricing.calculate(order);

于是可以在客户端放一个代理:

Business Code


Client Stub

     ├── encode arguments
     ├── select connection
     ├── send request
     ├── wait for response
     └── decode result

pricing 不再是真正的 Pricing Service,而是一个本地 Stub。

它实现相同的接口,却把方法调用翻译成网络消息:

final class PricingClient implements PricingService {
    public Money calculate(Order order) {
        return rpc.invoke("Pricing.Calculate", order);
    }
}

服务端做相反的工作。

早期 RPC 文献常把客户端一侧称为 Stub,把服务端负责解码与分发的一侧称为 Skeleton:

Caller


Client Stub

  │ request bytes

Server Skeleton


Implementation

Java RMI 也曾使用非常直观的 Stub / Skeleton 模型:客户端调用本地代理,代理负责连接、序列化参数并等待结果;服务端 Skeleton 解码参数,调用真实对象,再把返回值或异常发回来。

现代框架的服务端实现未必还生成一个名为 Skeleton 的类,但这套职责仍然存在。

IDL:谁来保证双方说的是同一种语言?

Stub 解决了重复代码,但如果客户端和服务端分别手写接口,新的问题又来了:

Client believes:
calculate(Order) -> Money

Server implements:
calculate(Order, Currency) -> Price

双方都能编译,直到运行时才发现协议已经不一致。

更好的方法,是先定义一份与具体编程语言相对独立的契约:

service PricingService {
  rpc Calculate(CalculateRequest)
      returns (CalculateResponse);
}

message CalculateRequest {
  string order_id = 1;
}

message CalculateResponse {
  int64 amount_in_cents = 1;
  string currency = 2;
}

这就是 IDL,Interface Definition Language。

代码生成器根据同一份定义生成不同语言的类型和调用代码:

                 pricing.proto

          ┌────────────┼────────────┐
          ▼            ▼            ▼
      Java Stub     Go Client    Rust Types
          │            │            │
          └────────────┴────────────┘

                    same wire
                    contract

从这里可以看到 RPC 的基本组成:

RPC
├── interface contract
├── serialization format
├── wire protocol
├── client proxy
├── server dispatch
└── runtime policies

前五项解决“如何完成一次调用”。

最后一项决定这次调用在真实系统里是否可靠:连接怎样复用、服务怎样发现、超时多久、是否重试、错误怎样传播。

1984:RPC 抽象被清楚地写下来

1984 年,Andrew Birrell 和 Bruce Nelson 发表了经典论文 Implementing Remote Procedure Calls

论文开头对 RPC 的描述非常直接:过程调用是单机程序里已经被理解的控制与数据传递机制,因此可以尝试把同样的机制扩展到网络两端。

调用方暂停执行,参数被送到远端;远端过程运行后把结果送回来,调用方再继续。

local procedure call

caller ── parameters ──> procedure
caller <── results ───── procedure


remote procedure call

caller ── parameters ──> network ──> callee
caller <── results ───── network <── callee

论文讨论的并不只是“把数据发出去”,还包括:

  • 客户端如何绑定到服务
  • 接口如何生成 Stub
  • 参数如何传递
  • 调用包如何确认和重传
  • 服务器如何管理大量客户端

今天 RPC 框架里的许多问题,在那时已经有了清晰轮廓。

RPC 不是某个特定协议的名字,而是一种编程与通信模型。后来的 ONC RPC、DCE/RPC、CORBA、Java RMI 和 gRPC,都可以看作这个模型在不同时代的实现。

1980s              1990s                 2000s                 2010s
  │                  │                     │                     │
  ▼                  ▼                     ▼                     ▼
Procedure RPC → Distributed Objects → Web Services / REST → Modern Typed RPC
ONC RPC         CORBA / Java RMI      SOAP / HTTP APIs      Thrift / gRPC

ONC RPC:过程调用走进网络基础设施

Sun 的 ONC RPC 是早期影响很广的一套实现,NFS 就建立在它之上。

它的调用消息使用程序号、版本号和过程号定位操作:

program
version
procedure
arguments

这很像在网络上寻找一个函数:

(program, version, procedure)


        remote operation

ONC RPC 还强调传输层独立。它可以运行在 TCP 上,也可以运行在 UDP 上。

但这意味着 RPC 本身不能假设所有传输都提供同样保证。使用不可靠传输时,超时、重传和重复检测需要由上层策略处理。

这里已经出现一个以后会不断回来的问题:

“调用一次”描述的是程序员的意图,不一定是网络上的实际尝试次数。

从远程过程到远程对象

面向对象流行以后,人们自然想把 RPC 再向前推一步。

如果函数可以远程调用,那么对象是不是也可以分布在网络里?

调用代码看起来非常自然:

Account account = registry.lookup("account-42");
Money balance = account.getBalance();

account 像一个普通对象引用,实际却可能指向另一台机器上的对象。

CORBA:让异构对象彼此调用

CORBA 的目标很大。

它希望不同语言、不同操作系统和不同厂商的对象可以互操作:

C++ Client


Object Request Broker


Java / Ada / COBOL Object

ORB,Object Request Broker,负责把请求路由到目标对象,再把响应送回来。

CORBA 还拥有:

  • IDL 与多语言映射
  • 对象引用
  • 命名服务
  • 动态调用
  • 安全与事务等服务
  • 跨 ORB 的互操作协议

它并不是“把 XML 写得很复杂”的那类方案。它面对的是一个真实而艰难的问题:如何让异构平台上的分布式对象形成统一系统。

代价也很明显。

对象定位、生命周期、语言映射、互操作和各种横切服务叠在一起后,抽象越来越重。一个看起来简单的远程方法,背后可能依赖庞大的运行时与配置体系。

Java RMI:在同一种语言里走得更远

Java RMI 缩小了问题范围:既然两端都是 JVM,就可以直接使用 Java 接口、对象序列化和异常模型。

远程对象通过 Stub 暴露相同的远程接口:

public interface PricingService extends Remote {
    Money calculate(Order order) throws RemoteException;
}

这里的 RemoteException 很值得注意。

它在类型签名上承认:远程调用拥有本地方法没有的失败方式。

不过 RMI 也展示了分布式对象的边界。

线程、文件描述符和进程内锁这类东西不能因为“都是对象”就自然穿过网络;对象身份、按值传递还是按引用传递,也必须重新定义。

1994 年的 A Note on Distributed Computing 专门提醒过这一点:试图抹平本地对象与远程对象的差异,会让程序忽略延迟、部分失败和并发等根本区别。

远程对象最吸引人的地方,是它像本地对象。

最危险的地方也是如此。

SOAP 与 WSDL:把契约带到企业边界

互联网和企业系统需要跨组织、跨厂商通信。

SOAP 使用 XML 表达结构化消息:

<soap:Envelope>
  <soap:Body>
    <CalculatePrice>
      <OrderId>order-42</OrderId>
    </CalculatePrice>
  </soap:Body>
</soap:Envelope>

WSDL 描述服务、操作、输入输出与绑定。

它们共同提供了一套与语言无关的 Web Service 契约:

WSDL

  ├── operations
  ├── messages
  ├── types
  └── protocol bindings

SOAP 不只支持 RPC 风格,也可以表达更一般的消息交换模式。它的扩展体系能够承载安全、路由、可靠消息和事务等能力。

这解释了它为什么在企业集成中重要,也解释了它为什么越来越重。

当简单调用也要面对复杂 XML、生成工具和一系列 WS-* 规范时,开发者开始寻找更贴近 Web 本身的方式。

REST:不是更轻的 RPC

REST 经常和 RPC 放在一起比较,但两者的抽象中心不同。

RPC 暴露操作:

CreateOrder
CancelOrder
GetOrder

REST 以资源和统一接口组织交互:

POST   /orders
GET    /orders/42
DELETE /orders/42

这里先区分一下层级:REST 与 RPC 是两种交互模型,HTTP 是传输协议。REST 通常直接使用 HTTP 的资源、方法和状态码;gRPC 同样建立在 HTTP 之上,只是使用 HTTP/2 承载 RPC 消息。

所以“用了 HTTP”不等于“采用 REST”。下面这种接口虽然发送 JSON,也更接近 RPC over HTTP:

POST /orders/42/cancel

它直接表达一个动作,而不是通过统一接口操作资源。

Roy Fielding 在 REST 架构风格中强调 uniform interface。组件通过资源标识、表示和自描述消息交互,而不是让每个服务发明一套完全不同的操作协议。

这带来了很适合 Web 的性质:

  • HTTP 方法与状态码具有通用语义
  • 中间代理更容易理解请求
  • 缓存机制可以复用
  • 浏览器和第三方容易接入
  • 客户端不必共享某一种语言运行时

代价是统一接口不会为每个业务操作量身定制。

所以 REST 不是“RPC 失败后的替代品”,更不是把 JSON 放进 HTTP 就自动得到 REST。

它解决的是另一类问题:如何让开放、松耦合的网络组件通过统一语义长期演进。

RPC 也没有因此消失。

在组织内部,服务数量继续增长以后,人们重新需要强类型契约、高效传输和统一代码生成。

现代 RPC:Thrift、Protobuf 与 gRPC

现代 RPC 没有回到远程对象的宏大模型。

它通常更克制:

define service
define messages
generate code
invoke operation

Apache Thrift 把 IDL、代码生成、传输和协议组合成跨语言 RPC 框架。

Google 内部长期使用 Stubby 连接大量服务。2015 年公开 gRPC 时,HTTP/2 和 Protocol Buffers 让类似的模型能够建立在公开标准与开源生态上。

gRPC 的典型组成是:

Protocol Buffers
        +
generated client/server APIs
        +
HTTP/2 transport
        +
RPC runtime

一份定义,两端代码

还是那个 Pricing Service:

service PricingService {
  rpc Calculate(CalculateRequest)
      returns (CalculateResponse);
}

生成后的客户端调用仍然很接近普通方法:

CalculateResponse response =
    pricingStub.calculate(request);

但 Stub 背后已经包含:

method path
message serialization
HTTP/2 stream
metadata
status
deadline
interceptors

服务端也有端点

RPC 把 URL 和编解码藏在了 Stub 后面,并不意味着服务端没有网络端点。

生成器除了创建客户端 Stub,也会生成服务端需要实现的基类。以 gRPC Java 为例,Pricing Service 可以这样接入自己的业务实现:

final class PricingRpcService
        extends PricingServiceGrpc.PricingServiceImplBase {

    @Override
    public void calculate(
            CalculateRequest request,
            StreamObserver<CalculateResponse> observer) {

        CalculateResponse response = doCalculate(request);
        observer.onNext(response);
        observer.onCompleted();
    }
}

启动时,服务端监听一个端口,并把实现注册给 RPC Runtime:

Server server = ServerBuilder
    .forPort(9090)
    .addService(new PricingRpcService())
    .build()
    .start();

Runtime 收到请求后,会根据服务与方法找到对应的 handler:

/PricingService/Calculate


PricingRpcService.calculate(...)

REST 把 POST /orders 这样的资源端点直接呈现给调用者;RPC 则把 PricingService.Calculate 包装成一个生成的方法。两边最终都要监听端口、接收字节并完成分发,只是暴露给开发者的接口模型不同。

不只有一问一答

gRPC 定义了四种调用形态:

Unary
request ───────────────> response

Server Streaming
request ───────────────> response...

Client Streaming
request... ────────────> response

Bidirectional Streaming
request... <──────────> response...

Streaming 很适合长时间存在的逻辑数据流,例如:

  • 实时日志
  • 模型推理结果
  • 遥测数据
  • 双向控制通道

但 Streaming 不是免费的性能按钮。

长连接建立后不容易重新负载均衡,故障恢复也比一次 Unary RPC 更复杂。只有业务本身就是流,或者流确实减少了重复建连与协议开销时,才值得使用。

今天的一次 RPC 到底经过了什么?

业务代码里只有一行:

pricing.calculate(request);

真实调用路径可能是:

Application


Client Stub

    ├── interceptor
    ├── deadline
    ├── credentials
    ├── serialization

Name Resolver


Load Balancer


Connection / HTTP/2 Stream


Proxy / Service Mesh


Server Runtime

    ├── authentication
    ├── deserialization
    ├── server interceptor

Service Implementation

这也是现代 RPC 框架与“一个序列化库”的区别。

序列化只回答对象怎样变成字节。

RPC Runtime 还要回答:

  • 请求送到哪个实例?
  • 一条连接能承载多少并发请求?
  • 服务端不可用时是否重试?
  • 时间预算如何沿调用链传播?
  • 身份、追踪和业务元数据放在哪里?

RPC 简化了业务代码,但复杂性并没有消失,只是进入了基础设施层。

远程调用永远不是本地调用

现在回到最开始的代码:

Money total = pricing.calculate(order);

语法很像本地函数。

语义却完全不同。

本地调用远程调用
延迟通常很小且稳定延迟跨越网络并存在长尾
进程存在时函数通常可达对端、网络、代理都可能失败
参数在同一地址空间传递参数需要序列化与复制
调用结果通常明确可能出现未知结果
取消通常不涉及另一台机器客户端放弃后服务端可能仍在运行

RPC 最需要防范的,不是序列化速度慢一点,而是它让这两列看起来过于相似。

第三种结果:Unknown

本地调用通常被理解为成功或失败。

远程调用还有第三种结果。

假设 Order Service 请求 Payment Service 扣款:

Order Service                  Payment Service
      │                               │
      ├── Charge(100) ───────────────>│
      │                               ├── charge success
      │             X response lost   │
      │                               │
      └── timeout                     │

Order Service 看到的是超时。

但支付已经成功。

超时只说明:

调用方没有在预期时间内得到响应。

它不等于:

服务端没有执行操作。

这就是 Unknown。

调用方不知道请求是在发送前失败、执行中失败,还是执行成功后丢失了响应。

如果此时简单重试:

Charge(100)
Charge(100)

用户可能被扣款两次。

网络把原来清晰的函数结果,变成了一个需要业务协议处理的不确定状态。

Timeout 与 Deadline

任何同步 RPC 都应该有时间边界。

如果没有 timeout,一个失去响应的下游可能让线程、连接和请求一直堆积:

slow dependency


waiting requests


thread / connection exhaustion


upstream also fails

Timeout 表示一次操作最多等待多长时间。

Deadline 表示整个调用不能晚于哪个时间点结束。

两者可以转换,但 Deadline 更容易沿调用链表达剩余预算:

Gateway        budget 1000ms


Order          remaining 820ms


Pricing        remaining 500ms


Database       remaining 180ms

如果入口只剩 180ms,Pricing Service 不应该再以自己的默认配置等待数据库 3 秒。

gRPC 支持把 Deadline 传播到下游,并扣除已经消耗的时间。

这不是为了让每个服务都更容易超时,而是避免上游已经放弃以后,下游还在继续制造无用工作。

Cancellation 也不是时间机器

客户端取消 RPC,并不能撤销已经发生的副作用:

Client cancel


Server receives cancellation

      ├── computation may stop
      └── committed transaction cannot disappear

服务端应该尽快感知取消,停止尚未必要的计算。

但如果数据库事务已经提交、消息已经发送,Cancellation 不会自动把它们回滚。

所以 Deadline 解决资源边界,幂等和状态机解决业务结果。它们不是同一层能力。

Retry:一次逻辑调用,多次物理尝试

短暂网络抖动或服务实例重启时,重试很有效。

logical RPC

    ├── attempt 1 → connection reset
    ├── attempt 2 → unavailable
    └── attempt 3 → success

但重试会改变系统负载。

如果一个服务已经过载,每个上游都立即重试三次:

original traffic  10,000 req/s


three retries     30,000 attempts/s

故障会被调用者主动放大。

因此重试至少需要:

  • 只重试明确可恢复的错误
  • 限制最大次数
  • 指数退避
  • 加入随机抖动
  • 受原始 Deadline 约束
  • 监控 attempt,而不只监控逻辑调用

随机抖动很重要。

如果所有客户端都按完全相同的时间表重试,它们会在下一秒再次同时击中服务端:

without jitter

failure ───── retry ───── retry
failure ───── retry ───── retry
failure ───── retry ───── retry


with jitter

failure ─── retry ───────── retry
failure ────── retry ──────── retry
failure ──────── retry ───────── retry

应该在哪一层重试?

假设调用链有三层,每层都重试三次:

Gateway  ×3
  └── Order  ×3
        └── Inventory  ×3

最坏可能形成 3 × 3 × 3 = 27 次下游尝试。

重试不应该由每一层随意开启。

通常需要选择一个拥有业务语义、又能控制整体时间预算的位置,并配合 retry budget 或重试节流,限制额外流量。

gRPC 能提供重试机制,但它不能替业务判断某个方法是否适合重试。

Idempotency:让重试不改变意图

安全重试的关键不是“再调用一次”,而是:

多次发送同一个业务意图,不应该产生更多副作用。

创建订单时,客户端可以生成一个业务请求 ID:

CreateOrder
request_id = req-901
user_id    = user-42

服务端持久化请求 ID 与执行结果:

req-901
  status   = SUCCEEDED
  order_id = order-88

第一次响应丢失后,客户端仍使用 req-901 重试。

服务端发现请求已经完成,不再创建第二个订单,而是返回第一次的结果。

attempt 1 ──> create order-88 ──> response lost
attempt 2 ──> req-901 exists  ──> return order-88

真正可靠的幂等设计还要注意:

  • 请求 ID 与副作用必须原子持久化
  • 相同请求 ID 携带不同参数时应该拒绝
  • 幂等记录需要覆盖可能的重试窗口
  • 下游服务也要理解同一个业务标识

在支付、创建资源和发货这类操作里,幂等不是 RPC 框架的可选优化,而是远程调用协议的一部分。

At-least-once 与 At-most-once

当响应丢失时,调用方必须在两个风险之间选择:

不重试
  └── 操作可能根本没有执行

重试
  └── 操作可能执行多次

这对应两种常见语义:

at-most-once
最多执行一次,但可能一次也没有

at-least-once
至少尝试到成功,但可能执行多次

“Exactly once RPC”听起来像理想答案,但仅靠网络协议无法让任意外部副作用天然只发生一次。

更现实的实现通常是:

at-least-once delivery
        +
idempotent operation
        +
durable result

从业务观察上得到“好像只发生一次”的效果。

框架能够减少重复尝试,业务存储负责识别重复意图,两者缺一不可。

服务发现与负载均衡

调用 pricing.calculate 时,pricing-service 往往不只一台机器:

pricing-service

    ├── 10.0.1.11
    ├── 10.0.1.12
    └── 10.0.1.13

RPC 客户端需要把逻辑服务名解析为实例列表,再选择一个可用端点。

service name


name resolver


endpoint list


load balancer


connection

实例列表可能来自注册中心、DNS 或 Kubernetes。服务启动和下线时,平台会更新这份映射:

pricing-service

    ├── 10.0.1.11:9090  READY
    ├── 10.0.1.12:9090  READY
    └── 10.0.1.13:9090  DRAINING

注册中心通常不转发 RPC,也不会出现在每次调用的同步链路里。客户端第一次解析服务名时取得实例列表,随后保存在本地;实例变化通过 Watch、DNS TTL 或定期刷新同步过来。

Registry ── endpoint updates ──> Local Endpoint Cache

Caller   ─────── actual RPC ───> 10.0.1.12:9090

这样即使注册中心短暂不可用,已有调用仍然可以使用缓存中的地址。代价是缓存可能暂时陈旧:已经下线的实例尚未被移除,新启动的实例也可能还没有进入列表。

实例选择不一定发生在业务进程里,常见方式有三种:

client-side
Stub → Resolver → Load Balancer → Instance

proxy
Stub → Internal Load Balancer → Instance

service mesh
Stub → Sidecar → Remote Sidecar → Instance

无论由谁选择,业务代码面对的都是逻辑名称 pricing-service;真正建立连接的是其中一个具体地址。

负载均衡也不只是 Round Robin。

长连接、HTTP/2 多路复用和 Streaming 会影响请求分布。一个已经建立的长时间 Stream 不会在中途自动迁移到新实例。

这一点在 gRPC 中尤其容易被忽略。L4 负载均衡通常在建立连接时选择后端,而大量 Unary RPC 可以长期复用同一条 HTTP/2 连接。入口看起来有三个实例,请求却可能持续集中在最初选中的一个实例。客户端直接持有多个连接、使用支持 gRPC 的 L7 代理,或者交给 Service Mesh 处理,才能在请求层面得到更均匀的分布。

客户端还需要面对:

  • 实例正在下线
  • 服务发现信息暂时陈旧
  • 连接已经建立但对端失去响应
  • 某个实例延迟很高却仍然健康

所以服务发现回答“它在哪里”,健康检查回答“它是否活着”,负载均衡还要判断“这次应该选谁”。

错误不只是一个状态码

RPC 调用可能失败在不同层次:

Transport Error
    connection reset

RPC Error
    deadline exceeded
    unavailable
    unauthenticated

Business Error
    insufficient balance
    order already closed

业务错误不应该一律伪装成网络错误。

例如余额不足是一次成功到达服务端、也被正确处理的调用,只是业务拒绝了操作。

反过来,UNAVAILABLE 只说明当前无法完成 RPC,不能被解释成“订单不存在”。

错误分类会直接影响:

  • 客户端是否重试
  • 是否触发熔断
  • 日志记录为 warning 还是 error
  • 指标统计为系统失败还是业务拒绝
  • 是否需要向用户暴露

一个只有 code = 500 和字符串 message 的接口,最终会迫使每个调用方自行猜测错误语义。

契约演进:能解析,不等于兼容

IDL 让接口显式,却不会让版本演进自动安全。

Protocol Buffers 使用字段编号标识数据:

message User {
  string id = 1;
  string name = 2;
}

字段被删除以后,不应该把编号 2 分给另一个含义:

message User {
  string id = 1;

  reserved 2;
  reserved "name";
}

否则旧数据或旧客户端仍可能把字段 2 当作原来的 name

常见的兼容原则包括:

  • 不复用已经发布的字段编号
  • 删除字段后保留编号和名称
  • 新增字段时让旧客户端可以忽略
  • 客户端与服务端永远按不能同时升级来设计
  • 枚举为未来的未知值保留处理路径

但 wire compatibility 只是第一层。

下面的改动在序列化上完全兼容:

amount = 100

before: 100 cents
after:  100 dollars

程序仍然能够解析,业务含义已经彻底改变。

契约演进真正要维护的是语义:

  • 单位是什么?
  • 字段缺失代表什么?
  • 默认值是否具有业务含义?
  • 新服务返回的新枚举,旧客户端怎样处理?
  • 一个操作以前可重试,现在是否仍然可重试?

类型系统能帮助我们发现结构变化,不能替团队决定业务含义。

Observability:函数名已经不够了

本地函数慢,可以直接分析当前进程。

一次远程调用慢,问题可能出现在:

client queue
DNS / service discovery
connection establishment
TLS handshake
proxy
server queue
application
database
response path
retry attempt

RPC 需要把可观测性作为协议运行时的一部分。

至少应该记录:

  • service 与 method
  • peer / endpoint
  • status code
  • latency
  • request 与 response size
  • deadline
  • retry attempt
  • trace ID

分布式追踪把一次逻辑请求串起来:

HTTP POST /orders

    ├── RPC Pricing.Calculate
    │       └── SQL select promotion

    └── RPC Inventory.Reserve
            └── Redis script

但也要控制指标维度。

user_idorder_id 放进指标 label,会制造近乎无限的时间序列;这些高基数字段应该进入日志或 Trace,而不是普通 Metrics。

RPC、REST 与消息该怎么选?

它们不是一条从落后到先进的时间线。

不同模型表达的是不同关系。

RPC:我现在需要你完成一个操作

适合:

  • 组织内部服务通信
  • 强类型接口
  • 多语言代码生成
  • 低延迟请求响应
  • 流式交互

调用方与服务端通常在时间上耦合:调用发生时,双方都要可用。

REST:我通过统一接口操作资源

适合:

  • 对外开放 API
  • 浏览器与第三方客户端
  • HTTP 缓存和中间代理
  • 资源模型比较自然
  • 可读性与通用工具优先

REST 的价值不只是 JSON,而是统一接口带来的开放性和可演进性。

Message / Event:我发布一个已经发生的事实

适合:

  • 不要求立即返回结果
  • 需要削峰
  • 多个消费者独立处理
  • 生产者与消费者不必同时在线
  • 需要重放与异步工作流
必须在当前请求里拿到结果吗?

    ├── 是
    │    ├── 内部强契约、低延迟 → RPC
    │    └── 开放接口、资源语义 → REST

    └── 否
         └── Message / Event

真实系统经常同时使用三者:

Public Client
      │ REST

Order API
      │ RPC

Pricing Service
      │ Event

Analytics / Notification

选择的重点不是团队更喜欢哪一种语法,而是调用双方需要怎样的时间耦合、契约强度和失败语义。

同一个业务能力也可以同时拥有 REST 与 RPC 两个适配器:

REST Controller ─┐
                 ├──> Application Service
RPC Handler ─────┘

网络层面仍然是两套接口,但核心业务逻辑不必复制。只有外部开放与内部调用确实存在不同需求时,这样做才值得;不需要为每个服务默认维护两份协议。

RPC 的边界

回看 RPC 的演化,会发现每一代都在拿走一部分重复工作:

raw socket


message protocol


stub / skeleton


IDL + code generation


service discovery + load balancing


deadline + retry + observability

现代 RPC 框架已经非常成熟。

它能把参数序列化,能复用连接,能生成类型安全的客户端,也能提供 Deadline、重试、负载均衡和追踪能力。

但它始终无法替业务回答:

  • 超时以后,操作有没有执行?
  • 重复调用是否安全?
  • 响应丢失后怎样查询结果?
  • 两个版本是否仍然表达同一个含义?
  • 下游失效时,应该重试、降级还是拒绝?

RPC 最大的成功,是让远程调用足够像本地调用,开发者可以专注于业务。

RPC 最大的风险,也是让远程调用太像本地调用,开发者忘记中间隔着网络。

所以理解 RPC,不只是知道 Protobuf 如何生成 Stub,也不是记住 gRPC 建立在 HTTP/2 上。

更重要的是,在写下:

pricing.calculate(order);

这一行代码时,仍然看得见它背后的远程世界。

References

  1. Andrew D. Birrell & Bruce Jay Nelson: Implementing Remote Procedure Calls
  2. RFC 5531: RPC — Remote Procedure Call Protocol Specification Version 2
  3. Object Management Group: CORBA History
  4. Object Management Group: ORB Basics
  5. Oracle Java RMI: System Overview
  6. Jim Waldo et al.: A Note on Distributed Computing
  7. W3C: SOAP Version 1.2 Messaging Framework
  8. W3C: Web Services Description Language 1.1
  9. Roy Fielding: Representational State Transfer
  10. gRPC: Motivation and Design Principles
  11. gRPC: Core Concepts, Architecture and Lifecycle
  12. gRPC: Deadlines
  13. gRPC: Retry
  14. Protocol Buffers: Proto Best Practices
  15. AWS Builders’ Library: Making Retries Safe with Idempotent APIs