元数据卡
- 前置知识:Vol 4 网络(TCP、HTTP/2)、第1章(分布式系统的挑战)
- 预计时间:40 分钟
- 核心难度:进阶
- 阅读模式:高度专注
- 完成标志:能够设计一个 Protobuf schema,使用 gRPC 的四种调用模式,理解 Thrift 与 gRPC 的设计差异
你的进度
你在战场地图上看到了不同堡垒之间的通信线——前哨站需要向指挥中心上报情况,侦查营需要从情报中心拉取数据。上一章你知道了这些通信线随时可能断,但你没来得及想:传输线上的数据包长什么样?收发的双方怎么保证彼此能理解?
林将军说:"哨兵和指挥部之间,不能各说各的方言。"
在分布式系统中,服务 A 和服务 B 之间怎么"说话"?这不是一个简单的"发 HTTP 请求"能回答的问题——协议的选择、数据的序列化方式、错误处理策略,决定了你的系统能走多远。
你的任务
掌握 RPC(远程过程调用)和序列化机制。你不仅要学会用 gRPC 定义服务,还需要理解 Protobuf 的编码原理、Thrift 的分层设计,以及为什么 JSON/REST 在大规模场景下不够用。这一章结束后,你能为你的服务选择正确的通信协议,并理解不同序列化格式的本质差异。
破局 · 溯源
为什么 REST + JSON 不够了?
你在 Vol 4 学会了 HTTP REST API——服务端暴露 URL,客户端用 JSON 发请求。这套方案在中小规模下很好用:
- 人类可读
- 工具支持好(curl、Postman)
- 和浏览器天然对接
但到了分布式系统内部服务间通信时,问题来了:
JSON 的序列化/反序列化太慢。每条消息都要把字符串解析成对象,大整数溢出(JavaScript 的 Number 类型限制),schema 不固定导致每次都要动态解析。
JSON 没有 schema 约束。
"age": "25"和"age": 25都合法,服务端自己判断。类型错误要在运行时才能发现。REST 的语义太宽松。
POST /order和PUT /order/123的语义边界模糊。内部服务通信需要更精确的接口契约。流式通信支持差。长连接推送数据、双工流——HTTP/1.1 做得很别扭。虽然 HTTP/2 有了改进,但 REST 设计模式本身没有对流式通信提供原生抽象。
缺乏 IDL(接口定义语言)。没有"一份文件,多语言客户端生成"的标准做法。文档和代码容易不一致。
gRPC 和 Thrift 登场了。
gRPC:以 Protobuf 为契约的 RPC 框架
gRPC 是 Google 开源的 RPC 框架,底层使用 HTTP/2 传输,序列化使用 Protocol Buffers。
先看一个服务定义:
// user_service.proto
// gRPC 服务定义文件
// 编译: protoc --java_out=. --grpc-java_out=. user_service.proto
syntax = "proto3";
package atlas.users;
service UserService {
// 一元 RPC:客户端发一个请求,服务端回一个响应
rpc GetUser (GetUserRequest) returns (User);
// 服务端流式 RPC:客户端一次请求,服务端多次返回
rpc ListUsers (ListUsersRequest) returns (stream User);
// 客户端流式 RPC:客户端多次发送,服务端一次返回
rpc BatchCreateUsers (stream CreateUserRequest) returns (BatchCreateUsersResponse);
// 双向流式 RPC:双方任意时刻发送消息
rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}
message GetUserRequest {
string user_id = 1;
}
message User {
string user_id = 1;
string name = 2;
string email = 3;
Role role = 4;
}
enum Role {
ROLE_UNSPECIFIED = 0;
ROLE_SCOUT = 1;
ROLE_ARCHER = 2;
ROLE_KNIGHT = 3;
}编译这个 .proto 文件后,gRPC 自动生成:
UserServiceGrpc.java— 服务端骨架和客户端桩UserOuterClass.java— 消息的 Java POJO 类 + Builder
服务端实现:
// UserServiceImpl.java
// 编译后实现的 gRPC 服务端
// 依赖: 上述 proto 生成的代码
import io.grpc.stub.StreamObserver;
public class UserServiceImpl extends UserServiceGrpc.UserServiceImplBase {
@Override
public void getUser(GetUserRequest request,
StreamObserver<User> responseObserver) {
// 模拟数据库查询
User user = User.newBuilder()
.setUserId(request.getUserId())
.setName("侦察兵张三")
.setEmail("zhang@outpost.com")
.setRole(Role.ROLE_SCOUT)
.build();
responseObserver.onNext(user); // 发送响应
responseObserver.onCompleted(); // 告知结束
}
@Override
public void listUsers(ListUsersRequest request,
StreamObserver<User> responseObserver) {
for (int i = 0; i < 10; i++) {
User user = User.newBuilder()
.setUserId("user-" + i)
.setName("士兵" + i)
.build();
responseObserver.onNext(user); // 多次响应
}
responseObserver.onCompleted();
}
}客户端调用:
// UserServiceClient.java
// gRPC 客户端
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
public class UserServiceClient {
public static void main(String[] args) {
ManagedChannel channel = ManagedChannelBuilder
.forAddress("localhost", 50051)
.usePlaintext() // 开发环境跳过 TLS
.build();
UserServiceGrpc.UserServiceBlockingStub stub =
UserServiceGrpc.newBlockingStub(channel);
// 一元调用
GetUserRequest request = GetUserRequest.newBuilder()
.setUserId("user-001")
.build();
User user = stub.getUser(request);
System.out.println("Got user: " + user.getName());
channel.shutdown();
}
}关键设计细节:
| gRPC 特性 | 解决的问题 |
|---|---|
| HTTP/2 多路复用 | 多个 RPC 共享一个 TCP 连接,避免连接风暴 |
| Protobuf 二进制编码 | 比 JSON 小 3-10 倍,解析快一个数量级 |
| 四种调用模式 | 覆盖请求-响应、推送、批量上传、聊天等场景 |
| Deadline 机制 | 每个 RPC 可以设置超时,不依赖客户端自觉 |
| 拦截器(Interceptor) | 统一的日志、鉴权、监控切面 |
深入:在 Protobuf 编码层发生了什么?
看一个简单消息在 Protobuf 中的二进制编码:
message Person {
string name = 1;
int32 age = 2;
bool active = 3;
}你设置 name = "alice", age = 25, active = true 后序列化——注意 Protobuf 并不编码字段名,只编码字段编号(field number)和类型(wire type)。
字段 1(string "alice"):
0A 05 61 6C 69 63 65
| | \--- value "alice" (UTF-8 bytes)
| \--- length = 5
\--- tag = (field 1 << 3) | wire_type 2(LEN) = 0x0A
字段 2(int32 25):
10 19
| \--- 25 的 varint 编码 = 0x19
\--- tag = (field 2 << 3) | wire_type 0(VARINT) = 0x10
字段 3(bool true):
18 01
| \--- true = varint 1
\--- tag = (field 3 << 3) | wire_type 0(VARINT) = 0x18Protobuf 不发送字段名,只发送字段编号。这意味着:
- 消息体积小(字段名通常是体积最大的部分)
- 新增字段不需要旧客户端重新编译(只要不重用已删除的字段编号)
- 编码和解码只依赖 schema,不依赖运行时反射
Varint 编码: Protobuf 对整数使用 varint——小整数用 1 字节,大整数用更多字节。25 = 0x19 只需 1 个字节。如果值是 300(二进制 10 0101100),varint 编码为 AC 02(2 字节)。这种设计让大部分实际值(小整数、枚举值、短文本)占用极少的字节。
对比 JSON 体积:
JSON: {"name":"alice","age":25,"active":true} = 40 字节
Protobuf: 0A05616C69... = 11 字节Thrift:Facebook 的 RPC 框架
Thrift 和 gRPC 解决相同的问题,但设计哲学不同。
// user_service.thrift
// Apache Thrift IDL 定义
// 生成: thrift --gen java user_service.thrift
namespace java atlas.users
enum Role {
SCOUT = 1,
ARCHER = 2,
KNIGHT = 3
}
struct User {
1: required string user_id,
2: required string name,
3: optional string email,
4: optional Role role
}
service UserService {
User GetUser(1: string user_id),
list<User> ListUsers(),
void CreateUser(1: User user)
}Thrift 与 gRPC 的设计差异:
| 维度 | gRPC | Thrift |
|---|---|---|
| 传输层 | HTTP/2(固定) | 可插拔(Socket、HTTP、Framed) |
| 序列化 | Protobuf(固定) | 可插拔(Binary、Compact、JSON) |
| 流式支持 | 原生(4 种模式) | 无原生流(需自定义) |
| 生态集成 | 与 K8s/Envoy/Istio 深度集成 | 较少 |
| 多语言 | 11 种官方语言 | 30+ 种语言 |
| 社区活跃度 | 高(CNCF 毕业项目) | 中等 |
当你的系统里 Java 和 Go 都是主角时,两个框架都可以。当你有大量 Python 或旧语言时,Thrift 的多语言支持更广。当你需要流式数据推送(实时监控、日志流)、需要深度集成到云原生生态时,gRPC 是更自然的选择。
深入:Thrift 的分层架构
Thrift 的设计比 gRPC 更"层化":
+-------------------+
| IDL 代码生成层 | 你的 .thrift 文件 -> 各语言代码
+-------------------+
| 序列化/反序列化层 | TBinaryProtocol / TCompactProtocol / TJSONProtocol
+-------------------+
| 传输层 | TSocket / TFramedTransport / TMemoryBuffer
+-------------------+
| 底层 IO | 阻塞 / 非阻塞 / 线程池 server
+-------------------+你可以换掉序列化协议而不影响传输层,换掉传输层而不影响序列化。这种灵活性在某些场景下很有用——比如你在嵌入式设备上只能用紧凑的二进制格式,服务器端用更丰富的 JSON 调试。
当我们选型时,我们在选什么?
| 场景 | 推荐 | 理由 |
|---|---|---|
| 微服务内部通信 | gRPC | 流式支持、HTTP/2、K8s 生态 |
| 多种语言互通 | gRPC(11 种)/ Thrift(30+ 种) | 按语言覆盖度选 |
| 浏览器-服务端 | gRPC-Web 或 REST | gRPC 浏览器支持不够成熟 |
| 移动端-后端 | gRPC | Protocol Buffers 节省带宽和电量 |
| 遗留系统桥接 | Thrift | 多语言支持更广,可自定义传输 |
| 需要极致序列化性能 | FlatBuffers / Cap'n Proto | 零反序列化(mmap 后直接读) |
常见陷阱
gRPC 把 .proto 文件当成实现细节,直接生成代码就完了。 .proto 文件是你的接口契约——它应该像 API 文档一样受版本控制、review、管理。跨团队依赖时,.proto 文件应该放在共享仓库中,而不是各自维护一份。
忽视 gRPC 的 Deadline 配置。 默认情况下 gRPC 没有超时。如果不显式设置 deadline,一个死锁的服务端会让客户端无限等待:
// 必须设置 deadline
stub.withDeadlineAfter(5000, TimeUnit.MILLISECONDS)
.getUser(request);- 频繁修改字段编号。 Protobuf 的字段编号是消息格式的一部分——改编号会破坏已有数据的兼容性。一旦发布,字段编号就冻结了。预留字段编号范围供未来使用:
message User {
reserved 4, 8, 15; // 这些编号不可再用
reserved "obsolete_field"; // 字段名也不可重用
string user_id = 1;
string name = 2;
// ...
}- 认为 gRPC 自动处理重试和负载均衡。 gRPC 提供了重试策略和负载均衡策略的配置接口,但默认是关闭的。你需要显式配置:
ManagedChannel channel = Grpc.newChannelBuilder()
.defaultServiceConfig(Map.of(
"loadBalancingConfig", List.of(
Map.of("round_robin", Map.of())
),
"methodConfig", List.of(Map.of(
"name", List.of(Map.of()),
"retryPolicy", Map.of(
"maxAttempts", 3.0,
"initialBackoff", "0.1s",
"maxBackoff", "1s",
"backoffMultiplier", 2.0,
"retryableStatusCodes", List.of("UNAVAILABLE")
)
))
))
.build();通关挑战
热身:用你熟悉的语言写一个 .proto 文件定义包含至少 3 个 message 和 1 个 service,然后编译生成代码。观察生成的代码结构——Builder 模式、字段访问器、序列化方法。
挑战:实现一个 gRPC 双向流通信的聊天服务。客户端发送消息,服务端广播给所有其他客户端。使用
stream关键字。观察:写一个 Java 程序比较 JSON 和 Protobuf 序列化同一数据的体积差异——用 10 个字段、含嵌套结构、1000 条记录。输出字节数对比和序列化/反序列化耗时。
旅人笔记
RPC 把远程调用伪装成本地调用——这是便利,也是危险的抽象。你必须理解底层发生了什么:Protobuf 如何在字段编号上编码数值,HTTP/2 如何多路复用流,Deadline 为什么不能省略。理解这些后,你才能正确地使用 RPC 框架,而不是在它出错时束手无策。
下一站预告
服务之间的通信解决了,但一个新的问题出现了:多个节点之间如何就"一个值"达成一致?假设你要决定"谁是这个集群的 Leader"——每个节点投票,大家怎么保证选出来的是同一个人?下一章,你会深入最核心的分布式共识协议。