news 2026/5/13 7:30:20

Protobuf vs JSON:为什么 IM 系统选择二进制协议?

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Protobuf vs JSON:为什么 IM 系统选择二进制协议?

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 后,系统性能显著提升,证明了二进制协议在高并发场景下的优势。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/8 17:36:16

固长协议设备,如何 10 分钟接入物联网平台并实现报警与反控?

在实际物联网项目中&#xff0c;固长协议设备往往被认为是“简单设备”&#xff0c;但真正落地时却经常成为系统复杂度的来源。 看似字段固定、结构清晰&#xff0c;但在项目推进过程中&#xff0c;常见问题包括&#xff1a; 每新增一种设备&#xff0c;都需要单独编写协议解析…

作者头像 李华
网站建设 2026/5/13 7:30:20

(视频内容检索新突破):Dify模糊匹配如何实现毫秒级响应与高召回率

第一章&#xff1a;视频字幕检索的 Dify 模糊匹配在处理多语言视频内容时&#xff0c;精确查找特定语句或片段是一项挑战。Dify 平台提供的模糊匹配能力&#xff0c;结合自然语言处理技术&#xff0c;能够有效提升字幕检索的准确率与召回率。该机制不依赖完全一致的文本匹配&am…

作者头像 李华
网站建设 2026/5/13 7:30:19

Data Agent:基于 LangChain 1.1 的智能数据分析助手

最近在折腾数据分析项目时&#xff0c;发现传统的数据分析流程往往需要反复切换工具&#xff1a;上传数据、写 Python 脚本、生成图表、分析结果。有没有一种方式能让 AI 直接理解数据并执行分析&#xff1f;基于这个需求&#xff0c;我实践了一个基于 LangChain 1.1 的智能数据…

作者头像 李华
网站建设 2026/5/9 1:40:29

仅限内部使用的监控策略:私有化Dify资源观测性实践秘籍

第一章&#xff1a;私有化 Dify 资源监控的背景与意义在企业级 AI 应用快速落地的今天&#xff0c;大模型服务平台 Dify 因其灵活的编排能力和低代码开发体验被广泛采用。然而&#xff0c;当 Dify 部署于私有化环境时&#xff0c;资源使用情况变得复杂且难以统一掌控。服务器 C…

作者头像 李华
网站建设 2026/5/9 2:06:45

打通 C++ 与 Node.js 的跨语言交互通道

这里写自定义目录标题从实际需求出发&#xff1a;为何需要 callJS&#xff1f;核心功能&#xff1a;从注册到调用的完整闭环注册回调&#xff1a;setCallBack 搭建沟通桥梁合理的创建标题&#xff0c;有助于目录的生成同步调用&#xff1a;call 实现即时交互异步调用&#xff1…

作者头像 李华