序列化与通信协议
1. 这是什么
很多人学 RPC 时,前面几章都还能跟上:
- 有 Provider
- 有 Consumer
- 有注册中心
- 有负载均衡
但一到“序列化”和“通信协议”,就开始觉得抽象。
其实先别把它想复杂,你可以这样理解:
- 序列化:把 Java 对象变成可以在网络上传输的字节数据
- 反序列化:把收到的字节数据再还原成对象
- 通信协议:规定这些字节怎么组织、怎么识别、怎么解析
如果只记一句话:
序列化解决“对象怎么传”,协议解决“这段数据该怎么读”。
例如一次 Dubbo 调用里,Consumer 想传一个请求对象:
new UserQuery(1001L)这个对象本身不能直接在网络里“原样飞过去”。
必须先做两件事:
- 把对象编码成字节流
- 规定接收方怎么从字节流中正确拆出一条完整消息
这就是这一层存在的意义。
2. 为什么重要
这一层看似底层,实际上直接影响:
- 性能
- 稳定性
- 兼容性
- 调试难度
它决定了这些现实问题:
- 为什么有些 RPC 框架更快
- 为什么字段一改,调用就可能炸掉
- 为什么有时日志只看到“反序列化失败”
- 为什么协议一旦定下来,不能随便乱改
换句话说:
你看到的是一次方法调用,框架看到的是一段二进制消息的编码与解码。
如果这一层设计或使用不当,哪怕你的业务逻辑完全没问题,调用也照样会失败。
3. 先建立最核心的直觉
可以把一次 RPC 调用想成寄快递。
序列化像什么
像把物品装箱、贴标签:
- 原始对象不能直接发
- 要先打包成标准格式
- 对方收到后才能拆开还原
协议像什么
像快递单和包装规范:
- 这是谁寄的
- 发到哪里
- 包裹有多长
- 里面是什么类型
- 用什么方式解码
如果没有协议规范,会发生什么?
- 收件方根本不知道从哪里开始读
- 不知道一条消息什么时候结束
- 不知道字段如何解释
所以你可以先记住:
- 序列化更像“对象打包”
- 协议更像“打包规则 + 读取规则”
4. 为什么对象不能直接传
因为网络底层传输的不是 Java 对象,而是字节。
你在代码里写的是:
userService.getById(1001L)但到网络层,必须变成类似这样的信息:
- 调用的是哪个接口
- 调用的是哪个方法
- 参数类型是什么
- 参数值是什么
- 请求 ID 是多少
- 是否需要返回值
然后再编码成一段字节流发出去。
Provider 收到后,再按同样规则解码,还原出:
- 方法名
- 参数列表
- 调用上下文
然后才能真正执行本地方法。
所以序列化从来不只是“对象转字节”这么简单,它还关系到:
- 字段结构
- 类型信息
- 版本兼容
- 安全边界
5. 一条 RPC 消息通常包含什么
你可以先用最粗粒度理解一条消息结构:
| 协议头 | 消息体 |再展开一点看:
5.1 协议头可能包含
- 魔数(判断是不是这个协议)
- 协议版本
- 消息类型(请求/响应)
- 序列化方式
- 请求 ID
- 消息长度
5.2 消息体可能包含
- 接口名
- 方法名
- 参数类型
- 参数值
- 返回值
- 异常信息
这样设计的好处是:
- 接收方知道怎么解析
- 能区分请求和响应
- 能根据请求 ID 做请求响应匹配
- 能根据序列化类型选择对应解码器
6. 什么是协议头里的“魔数”
这是协议里一个很常见的概念。
魔数可以理解成:
- 一段固定标记
- 用来快速识别“这是不是我这个协议的数据”
通俗理解:
- 像文件格式里的签名
- 或像快递包裹上的特定标识
它的作用是:
- 避免把错误数据按错误协议去解
- 帮助快速判断数据是否合法
7. 常见序列化关注点
很多人一说序列化,只想到“快不快”。
其实至少要同时看 4 件事:
- 编码速度
- 解码速度
- 体积大小
- 兼容性
7.1 速度
序列化慢,会拖累 CPU。
7.2 体积
编码后数据太大,会浪费带宽,也会拉高延迟。
7.3 兼容性
这是实战里最容易被忽略的点。
比如你今天发的是:
class UserDTO {
Long id;
String name;
}明天改成:
class UserDTO {
Long id;
String name;
Integer age;
}如果 Provider 和 Consumer 版本不一致,就可能出现:
- 新字段丢失
- 反序列化失败
- 类型不匹配
所以序列化方案不是只看“跑分”,还要看“演进时稳不稳”。
8. Dubbo 为什么不能随意改协议
因为协议一旦两端约定好,就是双方沟通的共同语言。
如果 Consumer 认为:
- 前 2 个字节是魔数
- 后 4 个字节是长度
- 接下来是请求 ID
而 Provider 却按另一套规则解释,那结果一定是:
- 解码失败
- 消息错位
- 请求根本读不出来
所以协议不是“想怎么改就怎么改的内部实现细节”,而是:
通信双方必须长期共同遵守的一套规则。
这也是为什么协议演进一定要考虑兼容性。
9. 一个最小例子:请求是怎么被编码理解的
假设业务代码是:
userService.getById(1001L)框架层可能需要抽象出类似这样的请求:
{
"interface": "com.demo.UserService",
"method": "getById",
"parameterTypes": ["java.lang.Long"],
"args": [1001],
"requestId": 123456
}然后再把这段结构编码成字节。
接收方收到后,再还原回来,才能定位到:
- 调哪个服务
- 调哪个方法
- 参数是什么
所以你看到的是:
getById(1001L)
底层实际要处理的是:
- 一份结构化请求消息的编码与解码
10. 动手验证:从“能调通”到“能看懂”
如果你本地有 Dubbo 项目,可以做下面几步实验。
10.1 正常跑通一次调用
先保证 Consumer 和 Provider 能正常调用。
10.2 打印请求对象和返回对象
观察:
- 方法调用前的 Java 对象长什么样
- 调用返回后的对象长什么样
思考中间发生了什么:
- 对象在网络里不可能原样存在
- 中间一定经历过编码和解码
10.3 故意修改 DTO 字段
例如新增一个字段:
private Integer age;然后让 Consumer 和 Provider 保持不同版本,观察:
- 调用是否还能成功
- 新字段是否丢失
- 是否出现兼容性问题
这一步能帮你真正理解:
- 协议兼容不是理论问题,而是升级时马上会碰到的问题
11. 为什么字段变更很危险
很多人以为 DTO 改个字段只是“小事”。
但在 RPC 里,字段变更可能影响:
- 老版本 Consumer
- 老版本 Provider
- 序列化兼容
- 反序列化结果
常见危险操作包括:
- 删除字段
- 修改字段类型
- 调整字段语义
- 不做版本隔离直接上线
相对更稳妥的思路通常是:
- 优先新增字段,而不是粗暴改旧字段语义
- 做灰度验证
- 确保两端兼容窗口存在
12. 最容易踩的坑
12.1 只关心速度,不关心兼容性
这是最典型的错误。
序列化跑得再快,如果版本一升级就炸,线上成本会非常高。
12.2 DTO 字段随意改
尤其是共享接口模型,一旦随便改字段类型或删除字段,就容易让老调用方出问题。
12.3 看到“反序列化失败”只怀疑网络
很多时候网络没问题,真正的问题是:
- DTO 结构不一致
- 协议版本不匹配
- 序列化方式不一致
12.4 不理解协议头字段的作用
如果连:
- 长度字段是干嘛的
- 请求 ID 是干嘛的
- 消息类型是干嘛的
都不理解,排查编解码问题会很痛苦。
13. 一套排查思路
以后你遇到“Dubbo 调用报编解码/序列化错误”,可以按这个顺序排:
- Consumer 和 Provider 的接口版本是否一致
- DTO 字段结构是否兼容
- 序列化方式是否一致
- 协议版本是否兼容
- 是否有字段类型变更或删除
- 是否能从日志里定位到具体哪个类/字段反序列化失败
这个顺序比一上来就怀疑“网络不稳”更有效。
14. 练习建议
练习 1:画一张消息结构图
自己画出:
- 协议头里有哪些字段
- 消息体里有哪些内容
练习 2:设计一个最小请求模型
例如给 getUserById 设计一份最小 RPC 请求结构,包含:
- 接口名
- 方法名
- 参数类型
- 参数值
- 请求 ID
练习 3:模拟兼容性问题
故意让 Consumer 和 Provider 的 DTO 不一致,然后观察:
- 是否还能调通
- 哪些字段丢了
- 是否直接异常
15. 自测问题
- 序列化和通信协议分别解决什么问题?
- 为什么网络里传的不是 Java 对象本身?
- 协议头里为什么常常要有长度、请求 ID、消息类型这些字段?
- 为什么协议和 DTO 结构不能随意变动?
- 为什么说兼容性常常比“极限性能”更重要?
16. 这一章你至少要带走什么
如果你看完只记住 4 件事,就记这 4 件:
- 序列化解决的是“对象怎么变成字节”
- 协议解决的是“这些字节怎么组织、怎么识别、怎么解析”
- RPC 稳定性不仅取决于网络,还取决于编解码和兼容性
- 字段演进和协议升级必须考虑兼容,否则很容易线上炸调用
把这一层理解透了,你再看 Dubbo 的协议、序列化和链路排障,就不会觉得它只是“底层黑魔法”了。