跳到正文

序列化与负载均衡 ​

一句话:一次 RPC 有两端——请求怎么编码成字节,以及请求该落到哪个节点;前者决定带宽和兼容性,后者决定并发是否均匀。

适用:Protocol Buffers 3.x(protoc 25+)、Nginx 1.24+、Dubbo 3.x。性能数字随 payload 和硬件变化,文中只给量级。

为什么需要 ​

服务拆分后,每次调用都要跨进程。编码选错,同样的数据多传几倍字节,序列化 CPU 也会成为瓶颈;节点选错,流量会堆在少数实例上,扩容了也不均衡。

这两个问题经常被放在一起,但它们的取舍标准不同:序列化看 schema、兼容性和跨语言;负载均衡看状态、会话和故障转移。

核心机制 ​

序列化选型看什么 ​

维度要问的问题
体积字段名是否重复传输,整数是否变长编码
性能序列化/反序列化 CPU,是否要反射
Schema是否有强类型定义,能否生成代码
兼容性新增/删除字段后,老客户端会不会崩
跨语言是否所有调用方都有成熟实现
可调试抓包和日志能不能直接看懂

Protobuf 为什么小 ​

Protobuf 用字段编号代替字段名,并用 wire type 标记编码方式。以 id=1, name="张三", age=25 为例,编码是:

text
08 01 12 06 E5 BC A0 E4 B8 89 18 19

逐段读:

  • 08:字段号 1,wire type 0(Varint);01 是值 1。
  • 12:字段号 2,wire type 2(长度前缀);06 表示后面 6 字节;再往后是 UTF-8 的「张三」。
  • 18:字段号 3,wire type 0;19 是 25。

同样的数据用 JSON 大约 33 字节,Protobuf 是 12 字节,量级上小 2–3 倍。字段名完全不出现在报文里,字段越多差距越明显。

Varint 用每字节低 7 位存数据、最高位表示「还有后续字节」。300 编码为 AC 02:0x2C 的低 7 位是 0101100,0x02 是高位部分。小整数只占 1 字节,这是它省空间的主要原因。

兼容性规则 ​

  • 新增字段用新编号,老版本会忽略未知字段(多数实现会保留)。
  • 不要改已有字段编号;删除字段要把编号和名字加进 reserved,防止后人复用。
  • int32/int64/uint32/uint64/bool 都是 Varint,线格式兼容;但 sint32/sint64 用 ZigZag,和 int32/int64 不兼容。
  • fixed32/fixed64 与 Varint 不兼容;string 和 bytes 线格式相同,但语义不同。
  • 字段编号 1–15 只占 1 字节 tag,热字段优先放这里;19000–19999 是保留区。

JSON、Protobuf、Hessian2 怎么选 ​

  • 对外 API、配置、需要人看和 curl 调试:JSON。缺点是体积大、无强 schema、数字精度要额外处理。
  • 内部 RPC、消息体、跨语言:Protobuf。需要 .proto 和代码生成,换来小体积和明确兼容规则。
  • 纯 Java 遗留 Dubbo:Hessian2。不需要 .proto,对 Java 类型友好;但跨语言弱,且反序列化不可信数据有安全风险。Dubbo 3 起主推 Triple 协议,跨语言场景用 Protobuf。

不要用 Java 原生序列化接收不可信输入;Hessian2 也要配反序列化白名单。

负载均衡算法 ​

算法适合盲点
轮询无状态、实例同构不看当前负载
加权轮询实例规格不同权重是静态的
随机无状态、实例多小样本下不均
最小连接数请求耗时差异大需要准确的在途计数
最少响应时间延迟敏感可能把流量追到刚变快的节点
一致性哈希有状态、本地缓存节点变化会迁移部分 key
源地址哈希会话亲和NAT 后大量用户共享 IP

Nginx 平滑加权轮询 ​

普通加权轮询会连续把请求发给高权重节点。Nginx 的做法是每轮给每个节点加 effective_weight,选 current_weight 最大的,选中后减去总权重:

text
每个节点 current_weight 初始为 0,total_weight = 5 + 1 + 1 = 7
每轮:current += effective;选最大;选中者 current -= total

轮次   A     B     C     选中
1      5     1     1     A
2      3     2     2     A
3      1     3     3     B
4      6    -3     4     A
5      4    -2     5     C
6      9    -1    -1     A
7      7     0     0     A

七轮结果是 A A B A C A A,A 恰好 5 次、B 1 次、C 1 次,而且被分散开。这里的关键是「先加后选、选中再减」,只加不减会把权重算成累计值。

在哪一层做负载均衡 ​

  • L4:按 IP + 端口转发,内核态或四层代理,性能高,但看不到 URL/Header。代表 LVS、F5、HAProxy TCP 模式。
  • L7:按 HTTP Header/Path/Cookie 路由,能做灰度、限流、重写,性能低于 L4。代表 Nginx、Envoy、Traefik。
  • 客户端 LB:客户端从注册中心拿实例列表自己选,少一跳中心代理,但客户端逻辑变重。代表 Dubbo、gRPC。

最小上手 ​

编译一个 .proto 并查看输出。先写 user.proto:

proto
syntax = "proto3";
message User {
  int32 id = 1;
  string name = 2;
  int32 age = 3;
}
bash
protoc --version
protoc --python_out=. user.proto
python -c "import user_pb2; print(user_pb2.User(id=1, name='张三', age=25).SerializeToString().hex())"
# 期望看到 08011206e5bca0e4b8891819

Nginx 最小配置(L7,最小连接数 + 一致性哈希二选一):

nginx
upstream backend {
    least_conn;
    server 10.0.0.1:8080 max_fails=3 fail_timeout=10s;
    server 10.0.0.2:8080 max_fails=3 fail_timeout=10s;
}
# 有状态场景改用:
# hash $arg_user_id consistent;

生产动作 ​

  • 超时与重试:连接超时、读超时要分别设置;只对幂等请求重试,重试次数有上限,避免放大故障。
  • 健康检查:max_fails=3 fail_timeout=10s(Nginx 默认 max_fails=1、fail_timeout=10s),被动检查之外最好加主动探活。
  • 慢启动:新实例刚上线时逐步加流量,避免冷 JVM、空缓存被瞬间打满。
  • 一致性哈希:虚拟节点常见经验值是每物理节点 150 个,但这是经验值;节点越多、key 越少,越要验证倾斜。
  • 序列化兼容:上线前用新旧版本互相反序列化一次;字段编号变更要进代码评审。
  • 排障:体积异常先抓包看字段名是否还在;路由倾斜看各实例 QPS 和在途连接数,不要只看 CPU。

边界 ​

  • Protobuf 不一定比 JSON 小:字段很少、字符串很长时,差距会缩小;可读性和调试成本却是实打实的。
  • 一致性哈希只适合有状态场景;无状态服务用它,节点变化时的迁移没有收益。
  • 最小连接数依赖准确的在途请求数,长连接和流式请求下容易失真。
  • 加权轮询的权重是静态配置,无法反映实时负载;需要动态权重时看最少响应时间或自适应算法。
  • 序列化协议不是安全边界;反序列化不可信数据必须做类型白名单和大小限制。

讲清楚 ​

序列化先看 schema 和兼容性,再看体积。Protobuf 用字段编号和 Varint 把字段名和多余字节省掉,适合内部 RPC 和跨语言;JSON 适合对外 API 和调试;Hessian2 是 Java 遗留生态的选择。兼容的关键是字段编号不复用、删除字段进 reserved、别混用 Varint 和 ZigZag。负载均衡则是另一个问题:无状态用轮询或随机,规格不同用加权轮询,耗时差异大用最小连接数,有状态用一致性哈希。Nginx 的平滑加权轮询是「先加权重、选最大、选中减总权重」,这样高权重节点不会连续霸占流量。

把一门技术讲透到既懂也会用