Protobuf vs JSON:为什么 IM 系统选择二进制协议?
2026/7/15 2:11:00 网站建设 项目流程

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 项目的实际测试):

操作JSONProtobuf性能提升
序列化0.5ms0.1ms5倍
反序列化0.6ms0.15ms4倍
消息体积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 类型说明
stringstring字符串
numberint32/int64整数
booleanbool布尔值
arrayrepeated数组
objectmessage对象

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 模式:链式调用,代码简洁
  • 序列化/反序列化方法:toByteArraymergeFrom
  • 字段访问器: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 后,系统性能显著提升,证明了二进制协议在高并发场景下的优势。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询