主题
序列化与负载均衡
一句话:一次 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())"
# 期望看到 08011206e5bca0e4b8891819Nginx 最小配置(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 的平滑加权轮询是「先加权重、选最大、选中减总权重」,这样高权重节点不会连续霸占流量。