RPC:从本地函数到现代服务通信
From Local Calls to gRPC
目录
- 如果没有 RPC,我们需要做什么?
- 字节流里没有消息
- 消息里需要描述一次调用
- Stub:让调用重新看起来像函数
- IDL:谁来保证双方说的是同一种语言?
- 1984:RPC 抽象被清楚地写下来
- ONC RPC:过程调用走进网络基础设施
- 从远程过程到远程对象
- CORBA:让异构对象彼此调用
- Java RMI:在同一种语言里走得更远
- SOAP 与 WSDL:把契约带到企业边界
- REST:不是更轻的 RPC
- 现代 RPC:Thrift、Protobuf 与 gRPC
- 一份定义,两端代码
- 服务端也有端点
- 不只有一问一答
- 今天的一次 RPC 到底经过了什么?
- 远程调用永远不是本地调用
- 第三种结果:Unknown
- Timeout 与 Deadline
- Cancellation 也不是时间机器
- Retry:一次逻辑调用,多次物理尝试
- 应该在哪一层重试?
- Idempotency:让重试不改变意图
- At-least-once 与 At-most-once
- 服务发现与负载均衡
- 错误不只是一个状态码
- 契约演进:能解析,不等于兼容
- Observability:函数名已经不够了
- RPC、REST 与消息该怎么选?
- RPC:我现在需要你完成一个操作
- REST:我通过统一接口操作资源
- Message / Event:我发布一个已经发生的事实
- 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_id、order_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
- Andrew D. Birrell & Bruce Jay Nelson: Implementing Remote Procedure Calls
- RFC 5531: RPC — Remote Procedure Call Protocol Specification Version 2
- Object Management Group: CORBA History
- Object Management Group: ORB Basics
- Oracle Java RMI: System Overview
- Jim Waldo et al.: A Note on Distributed Computing
- W3C: SOAP Version 1.2 Messaging Framework
- W3C: Web Services Description Language 1.1
- Roy Fielding: Representational State Transfer
- gRPC: Motivation and Design Principles
- gRPC: Core Concepts, Architecture and Lifecycle
- gRPC: Deadlines
- gRPC: Retry
- Protocol Buffers: Proto Best Practices
- AWS Builders’ Library: Making Retries Safe with Idempotent APIs