Protobuf vs JSON:为什么 IM 系统选择二进制协议?
在 IM 系统中,消息序列化协议的选择直接影响性能和用户体验,本文对比 Protobuf 与 JSON,并说明为什么选择 Protobuf。
一、为什么需要关注序列化协议?
在 IM 系统中,每条消息都需要序列化后通过网络传输。假设系统每秒处理 1 万条消息:
- 如果每条消息序列化耗时 1ms,每秒会消耗 10 秒 CPU 时间
- 如果每条消息体积 1KB,每秒需要传输 10MB 数据
在高并发场景下,序列化协议的选择会显著影响系统性能。
二、Protobuf vs JSON:性能对比
1.体积对比
JSON 格式示例:
{"msgId":"msg_1234567890","roomId":"room_abc123","userId":"user_xyz789","msgType":0,"msg":"这是一条测试消息","ext":""}体积:约 150 字节(包含字段名、引号、逗号等)
Protobuf 格式示例:
message SendMsgCmd { string msgId = 1; string roomId = 2; int32 msgType = 3; string msg = 4; string ext = 5; }体积:约 50-80 字节(二进制格式,无字段名)
对比结果:
- JSON:150 字节
- Protobuf:50-80 字节
- 体积减少:30-50%
2.序列化/反序列化性能对比
测试代码
// JSON 序列化Stringjson=JSONObject.toJSONString(messageDto);byte[]jsonBytes=json.getBytes(StandardCharsets.UTF_8);// Protobuf 序列化byte[]protoBytes=sendMsgCmd.toByteArray();性能测试结果(基于 AQChat 项目的实际测试):
| 操作 | JSON | Protobuf | 性能提升 |
|---|---|---|---|
| 序列化 | 0.5ms | 0.1ms | 5倍 |
| 反序列化 | 0.6ms | 0.15ms | 4倍 |
| 消息体积 | 150字节 | 60字节 | 60% 减少 |
实际效果:
- 响应时间:从 50ms 降到 < 10ms(序列化性能提升重要因素之一)
- 带宽节省:10 万并发用户,每秒节省约 9MB 贷款
- CPU 使用:序列化 CPU 时间减少 80%
三、Protubuf 向后兼容性
1.字段扩展
场景:需要在消息中新增字段,但不影响旧版本客户端。
JSON 方案:
// 旧版本{"msgId":"123","msg":"hello"}// 新版本(新增 userId 字段){"msgId":"123","msg":"hello","userId":"user_123"}问题:旧版本客户端解析新版本消息时,可能因为未知字段出错。
Protobuf 方案:
// 旧版本 message SendMsgCmd { string msgId = 1; string msg = 2; } // 新版本(新增 userId 字段,编号 3) message SendMsgCmd { string msgId = 1; string msg = 2; string userId = 3; // 新增字段,编号不能重复 }优势:
- 旧版本客户端可以正常解析(忽略未知字段)
- 新版本客户端可以读取新字段
- 字段编号一旦使用,不能修改(保证兼容性)
3.实际案例
在 AQChat 项目中,消息协议经历了多次扩展:
// 最初版本 message SendMsgCmd { string msgId = 1; string roomId = 2; string msg = 3; } // 扩展版本(新增 msgType 和 ext 字段) message SendMsgCmd { string msgId = 1; string roomId = 2; MsgType msgType = 3; // 新增:消息类型 string msg = 4; string ext = 5; // 新增:扩展字段 }旧版本客户端仍可以正常解析,只是无法读取新字段,保证了向后兼容。
四、消息识别器的设计思路
1.问题:如何根据指令编号设计消息类型?
在自定义协议中,消息格式是:消息长度(4 字节)+ 消息指令(2 字节)+ Protobuf消息体
解码时,如何根据指令编号(如 10)获取对应的 Protobuf Builder?
2.解决方案:反射机制自动映射
核心思路:
- 通过反射扫描 Protobuf 协议中的所有消息类型
- 通过类名匹配消息指令枚举
- 自动建立双向映射关系
代码实现:
@ComponentpublicclassMessageRecognizerimplementsInitializingBean{// 指令编号 -> 消息实例的映射(用于解码)privatefinalMap<Integer,GeneratedMessageV3>msgCommandAndMsgBodyMap=newHashMap<>();// 消息类型 -> 指令编号的映射(用于编码)privatefinalMap<Class<?>,Integer>msgClazzAndMsgCommandMap=newHashMap<>();publicvoidinit(){// 1. 获取 Protobuf 协议文件中的所有内部类Class<?>[]innerClazzArray=AQChatMsgProtocol.class.getDeclaredClasses();for(Class<?>innerClazz:innerClazzArray){// 2. 过滤出消息类型(继承自 GeneratedMessageV3)if(!GeneratedMessageV3.class.isAssignableFrom(innerClazz)){continue;}// 3. 获取类名并转换为小写(去掉下划线)StringclazzName=innerClazz.getSimpleName().toLowerCase();// 例如:UserLoginCmd -> userlogincmd// 4. 遍历消息指令枚举,匹配类名for(AQChatMsgProtocol.MsgCommandmsgCommand:AQChatMsgProtocol.MsgCommand.values()){StringstrMsgCode=msgCommand.name().replaceAll("_","").toLowerCase();// 例如:USER_LOGIN_CMD -> userlogincmd// 5. 类名匹配指令名if(strMsgCode.equals(clazzName)){try{// 6. 通过反射调用 getDefaultInstance() 获取默认实例ObjectreturnObj=innerClazz.getDeclaredMethod("getDefaultInstance").invoke(innerClazz);// 7. 建立双向映射msgCommandAndMsgBodyMap.put(msgCommand.getNumber(),(GeneratedMessageV3)returnObj);msgClazzAndMsgCommandMap.put(innerClazz,msgCommand.getNumber());LOGGER.info("消息识别器初始化: {} => {}",innerClazz.getName(),msgCommand.getNumber());}catch(Exceptionex){LOGGER.error(ex.getMessage(),ex);}}}}}// 解码时:根据指令编号获取 BuilderpublicMessage.BuildergetMsgBuilderByMsgCommand(intmsgCommand){GeneratedMessageV3msg=msgCommandAndMsgBodyMap.get(msgCommand);if(msg==null){returnnull;}returnmsg.newBuilderForType();}// 编码时:根据消息类型获取指令编号publicintgetMsgCommandByMsgClazz(Class<?>msgClazz){Integercommand=msgClazzAndMsgCommandMap.get(msgClazz);returncommand==null?-1:command;}}3.命名规则
为了保证自动映射,需要遵循命名规则:
消息类型命名:
- 请求消息:
XxxCmd(如UserLoginCmd) - 响应消息:
XxxAck(如UserLoginAck) - 通知消息:
XxxNotify(如JoinRoomNotify)
消息指令枚举:
- 请求指令:
XXX_CMD(如USER_LOGIN_CMD) - 响应指令:
XXX_ACK(如USER_LOGIN_ACK) - 通知指令:
XXX_NOTIFY(如JOIN_ROOM_NOTIFY)
匹配规则:
- 去掉下划线和大小写,进行匹配
- UserLoginCmd ↔
USER_LOGIN_CMD - JoinRoomNotify ↔
JOIN_ROOM_NOTIFY
4.优势
1.自动映射:新增消息类型时,只需要在.protobuf文件中定义,无需手动维护映射关系
2.类型安全:编译时检查,避免运行时错误
3.易于维护:映射关系自动生成,减少人工错误
如何从 JSON 迁移到 Protobuf
1.迁移步骤
第一步:定义 Protobuf 协议文件
// AQChatMsgProtocol.proto syntax="proto3"; package chat_msg; option java_package = "com.howcode.aqchat.message"; enum MsgCommand{ USER_LOGIN_CMD = 0; USER_LOGIN_ACK = 1; SEND_MSG_CMD = 10; SEND_MSG_ACK = 11; // ... 更多指令 } message UserLoginCmd{ string userName = 1; string userAvatar = 2; } message SendMsgCmd{ string msgId = 1; string roomId = 2; int32 msgType = 3; string msg = 4; string ext = 5; }第二步:编译生成 Java 类
# 使用 protoc 编译器 protoc --java_out=src/main/java AQChatMsgProtocol.proto第三步:实现编解码器
// 编码器publicclassMessageEncoderextendsMessageToMessageEncoder<GeneratedMessageV3>{@Overrideprotectedvoidencode(ChannelHandlerContextctx,GeneratedMessageV3msg,List<Object>list){// 1. 获取消息指令编号intmsgCommand=recognizer.getMsgCommandByMsgClazz(msg.getClass());// 2. 序列化为字节数组byte[]msgBody=msg.toByteArray();// 3. 写入协议头部ByteBufbyteBuf=ctx.alloc().buffer();byteBuf.writeInt(msgBody.length);// 消息长度byteBuf.writeShort((short)msgCommand);// 消息指令byteBuf.writeBytes(msgBody);// 消息体list.add(newBinaryWebSocketFrame(byteBuf));}}// 解码器publicclassMessageDecoderextendsMessageToMessageDecoder<BinaryWebSocketFrame>{@Overrideprotectedvoiddecode(ChannelHandlerContextctx,BinaryWebSocketFramemsg,List<Object>list){ByteBufbyteBuf=msg.content();// 1. 读取消息长度和指令intmsgLength=byteBuf.readInt();shortcommand=byteBuf.readShort();// 2. 根据指令获取对应的 BuilderMessage.Builderbuilder=recognizer.getMsgBuilderByMsgCommand(command);// 3. 读取消息体并反序列化byte[]msgBody=newbyte[msgLength];byteBuf.readBytes(msgBody);builder.mergeFrom(msgBody);list.add(builder.build());}}第四步:修改业务代码
// 原来的 JSON 方式Stringjson=JSONObject.toJSONString(messageDto);ctx.writeAndFlush(json);// 改为 Protobuf 方式AQChatMsgProtocol.SendMsgCmdcmd=AQChatMsgProtocol.SendMsgCmd.newBuilder().setMsgId(messageDto.getMessageId()).setRoomId(messageDto.getRoomId()).setMsgType(messageDto.getMessageType()).setMsg(messageDto.getMessageContent()).build();ctx.writeAndFlush(cmd);2.迁移注意事项
1.字段类型映射
| JSON 类型 | Protobuf 类型 | 说明 |
|---|---|---|
| string | string | 字符串 |
| number | int32/int64 | 整数 |
| boolean | bool | 布尔值 |
| array | repeated | 数组 |
| object | message | 对象 |
2.默认值处理
Protobuf 的默认值:
- string:空字符串
"" - int32/int64:
0 - bool:
false - repeated:空列表
JSON 中可能没有这些默认字段,需要特殊处理
3.版本兼容
迁移时建议:
- 先支持双协议(JSON 和 Protobuf)
- 逐步迁移客户端到 Protobuf
- 最后完全切换到 Protobuf
六、Protobuf 的其他优势
1.类型安全
JSON 的问题:
{"msgType":"0",// 字符串类型,容易出错"roomId":12345// 数字类型,应该是字符串}Protobuf 的优势
message SendMsgCmd { int32 msgType = 3; // 编译时检查类型 string roomId = 2; // 类型明确 }编译时检查类型,避免运行时错误
2.代码生成
Protobuf 自动生成 Java 类,包含:
- Builder 模式:链式调用,代码简洁
- 序列化/反序列化方法:
toByteArray、mergeFrom - 字段访问器:
getMsgId()、setMsgId()
3.跨语言支持
Protobuf 支持多种语言:
- Java、C++、Python、JavaScript 等
- 同一份
.proto文件可以生成不同语言的代码 - 便于多语言系统集成
七、Protobuf 的局限性
1.可读性差
- JSON:人类可读,便于调试
- Protobuf:二进制格式,不可读
解决方法:开发时使用 JSON,生产环境使用 Protobuf。
2.需要编译
- JSON:无需编译,直接使用
- Protobuf:需要编译
.proto文件生成代码
解决方法:使用 Maven/Gradle 插件自动编译。
3.学习成本
- JSON:简单直接
- Protobuf:需要学习
.proto语法
解决方案:.proto语法简单,学习成本低。
八、实际应用效果
在 AQChat 项目中,使用 Protobuf 后的效果:
1. 性能提升
- 响应时间:从 50ms 降到 < 10ms(序列化性能提升是重要因素)
- 带宽节省:消息体积减少 30-50%,节省带宽
- CPU 使用:序列化 CPU 时间减少 80%
2. 代码质量
- 类型安全:编译时检查,减少运行时错误
- 代码生成:自动生成代码,减少手写代码
- 易于维护:协议定义集中,便于管理
3. 扩展性
- 向后兼容:支持字段扩展,不影响旧版本
- 跨语言:支持多语言,便于系统集成
九、总结
Protobuf 相比 JSON 的优势:
1.性能:体积小 30-50%,序列化速度快 3-5 倍
2.类型安全:编译时检查,避免运行时错误
3.向后兼容:支持字段扩展,不影响旧版本
4.代码生成:自动生成代码,减少手写代码
在 IM 系统这种高并发、低延迟的场景下,Protobuf 是更好的选择。虽然 JSON 更易读、更简单,但在性能要求高的场景下,Protobuf 的优势更明显。
选择建议:
- 高并发、低延迟场景:选择 Protobuf
- 简单场景、可读性要求高:选择 JSON
- 需要跨语言集成:选择 Protobuf
在 AQChat 项目中,使用 Protobuf 后,系统性能显著提升,证明了二进制协议在高并发场景下的优势。