Skip to content

元数据卡

  • 前置知识: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)
  • 和浏览器天然对接

但到了分布式系统内部服务间通信时,问题来了:

  1. JSON 的序列化/反序列化太慢。每条消息都要把字符串解析成对象,大整数溢出(JavaScript 的 Number 类型限制),schema 不固定导致每次都要动态解析。

  2. JSON 没有 schema 约束"age": "25""age": 25 都合法,服务端自己判断。类型错误要在运行时才能发现。

  3. REST 的语义太宽松POST /orderPUT /order/123 的语义边界模糊。内部服务通信需要更精确的接口契约。

  4. 流式通信支持差。长连接推送数据、双工流——HTTP/1.1 做得很别扭。虽然 HTTP/2 有了改进,但 REST 设计模式本身没有对流式通信提供原生抽象。

  5. 缺乏 IDL(接口定义语言)。没有"一份文件,多语言客户端生成"的标准做法。文档和代码容易不一致。

gRPC 和 Thrift 登场了。


gRPC:以 Protobuf 为契约的 RPC 框架

gRPC 是 Google 开源的 RPC 框架,底层使用 HTTP/2 传输,序列化使用 Protocol Buffers。

先看一个服务定义:

protobuf
// 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

服务端实现:

java
// 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();
    }
}

客户端调用:

java
// 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 中的二进制编码:

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) = 0x18

Protobuf 不发送字段名,只发送字段编号。这意味着:

  • 消息体积小(字段名通常是体积最大的部分)
  • 新增字段不需要旧客户端重新编译(只要不重用已删除的字段编号)
  • 编码和解码只依赖 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 解决相同的问题,但设计哲学不同。

thrift
// 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 的设计差异:

维度gRPCThrift
传输层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 或 RESTgRPC 浏览器支持不够成熟
移动端-后端gRPCProtocol Buffers 节省带宽和电量
遗留系统桥接Thrift多语言支持更广,可自定义传输
需要极致序列化性能FlatBuffers / Cap'n Proto零反序列化(mmap 后直接读)

常见陷阱

  1. gRPC 把 .proto 文件当成实现细节,直接生成代码就完了。 .proto 文件是你的接口契约——它应该像 API 文档一样受版本控制、review、管理。跨团队依赖时,.proto 文件应该放在共享仓库中,而不是各自维护一份。

  2. 忽视 gRPC 的 Deadline 配置。 默认情况下 gRPC 没有超时。如果不显式设置 deadline,一个死锁的服务端会让客户端无限等待:

java
// 必须设置 deadline
stub.withDeadlineAfter(5000, TimeUnit.MILLISECONDS)
    .getUser(request);
  1. 频繁修改字段编号。 Protobuf 的字段编号是消息格式的一部分——改编号会破坏已有数据的兼容性。一旦发布,字段编号就冻结了。预留字段编号范围供未来使用:
protobuf
message User {
  reserved 4, 8, 15;   // 这些编号不可再用
  reserved "obsolete_field";  // 字段名也不可重用
  
  string user_id = 1;
  string name = 2;
  // ...
}
  1. 认为 gRPC 自动处理重试和负载均衡。 gRPC 提供了重试策略和负载均衡策略的配置接口,但默认是关闭的。你需要显式配置:
java
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"——每个节点投票,大家怎么保证选出来的是同一个人?下一章,你会深入最核心的分布式共识协议。

Built with VitePress | Software Systems Atlas