尧图精选

Redis序列化改造实战:用Protobuf解决跨语言与性能痛点

🕒 发布时间:2026/9/12 8:07:19 📁 来源:尧图网络
不知道你有没有遇到过这种场景服务是Java的Redis里缓存的对象默认走JDK原生序列化系统单机跑着没毛病但一到跨团队联调就尴尬了。对方用Go写了个数据修复工具想直接从Redis里读业务数据打开客户端一看key是\xAC\xED\x00\x05t\x00开头value一坨十六进制别说解析连肉眼识别都做不到。更麻烦的是JDK序列化之后体积膨胀得厉害一个几十个字段的对象序列化完动不动几百字节甚至上KB写进Redis之后带宽和内存都白白浪费。后来我把序列化方案换成了ProtobufProtocol Buffers一次性把这些坑基本都填平了。这篇文章就把这次改造里最核心的东西梳理一遍——不是单纯贴配置而是把“为什么用Protobuf、key怎么设计、value怎么组织、序列化器怎么写、上线之后有哪些坑”整个链路讲清楚。适合正在用RedisTemplate、被JDK序列化或JSON序列化折磨过的后端同学参考。1. 序列化方案之间的真实差距为什么我盯上了Protobuf1.1 默认的JdkSerializationRedisSerializer到底坑在哪大多数Spring Boot项目刚接入Redis时用的都是RedisTemplate默认的序列化器。Spring为了方便直接把Java原生的ObjectOutputStream拿过来做value序列化。这套方案在“存进去再读出来”这个最简单的闭环里看不出毛病但一到生产环境问题一个接一个。首先是体积问题。Java序列化会把类的完整包名、类的结构信息、甚至内部的一些元数据全部写进字节流里。比如一个订单对象com.company.trade.domain.Order包名就占了三十多个字节还有一串serialVersionUID、class descriptor之类的辅助信息。实际业务字段才几个字节序列化之后体积直接翻了好几倍。Redis是纯内存数据库存储成本就是内存成本体积膨胀意味着同样的内存只能缓存更少的数据频繁淘汰又会打穿缓存形成雪崩效应。其次是跨语言问题。只要你的系统不是纯Java封闭生态迟早会遇到别的团队要读同一份缓存数据的场景。JDK序列化的二进制格式只有JVM能反序列化Go、Python、Node.js端根本无能为力。我那次就是被跨团队联调逼着改方案的——对方拿着redis-cli看数据看到\xAC\xED开头就直摇头。第三个问题是安全。Java原生反序列化是个老话题了历史上有过不少攻击链。如果Redis被未授权访问攻击者往缓存里植入一段精心构造的序列化字节流业务侧反序列化时就有可能被利用。虽然Redis本身有密码保护和网络隔离但“默认序列化器自带攻击面”这个事实在安全评审的时候属于硬伤。1.2 JSON序列化看起来很香为什么高性能场景还是不够用很多人说那我不用JDK序列化改用Jackson把对象转成JSON字符串存进去不就行了JSON确实是兼容性最好的方案可读性也强排查问题方便。但JSON的问题在于它是文本格式喜欢把字段名原样写进去。一个orderId占7个字节再来一个productName占11个字节字段一多整体体积立刻上去了。我实测过一个常见的订单消息体40多个字段JSON序列化后大概230字节同一个对象用Protobuf序列化后只有80多字节差距近三倍。在每秒上万次缓存读写的场景下这个差距直接反映在网络耗时和Redis吞吐上。当然JSON还有一个性能问题——序列化和反序列化的CPU开销比Protobuf高一个量级。JSON库要做字符串解析、字符转义、动态反射Protobuf走的是紧密编码的二进制流解析起来基本都是位运算级别的操作。尤其是反序列化JSON处理复杂嵌套结构时非常吃CPU而Protobuf的解析器和生成的代码高度优化速度可以差一个数量级。1.3 Protobuf在这个组合里解决的核心问题Protobuf是Google开源的结构化数据序列化框架核心思想是用.proto文件定义数据结构通过编译器生成对应语言的代码。它和Redis搭配解决的核心问题可以归纳成四点。第一体积小、编码紧凑。Protobuf使用变长整数varint和自定义的tag编码字段名完全不出现在序列化结果里只保留字段编号和值。同样的业务对象Protobuf几乎是体积最小的方案。第二跨语言天然友好。只要大家约定同一个.proto定义Java、Go、Python、C都能生成各自语言的代码解析同一份二进制数据。第三数据兼容性设计完善。Protobuf把字段设计成可增可减的演进模式字段编号唯一且稳定老字段删除后编号不回收新字段追加编号即可。新老版本代码读写同一份数据都不会崩。第四反序列化速度快、无反射依赖。Protobuf的解析和构建都在生成的代码里完成了不依赖运行时的反射去猜类型天然规避了一大类反序列化安全风险。这也是为什么如果你的系统对性能有要求、或者未来有跨语言读同一份Redis数据的规划Redis Protobuf基本是综合成本最低的组合。2. 工程初始化与依赖选型先想清楚版本再动手写代码2.1 我用的是这套依赖组合先说我的环境基线给大家一个参照组件版本说明Java118也能跑但11更省心Spring Boot2.7.x3.x同理只是部分包名有变化spring-boot-starter-data-redis2.7.x使用Lettuce连接池protobuf-java3.21.123.x系列稳定版本protobuf-maven-plugin0.6.1编译期自动生成实体代码Redis服务端6.x以上7.x也兼容没有特殊依赖Spring Boot的Redis starter会自动带Lettuce客户端不用手动引入jedis。这里有个容易踩的坑如果你同时用了Redisson要注意Redisson和Lettuce的版本兼容性最好统一用Redisson自带的客户端不要混用。Maven里核心依赖长这样dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.google.protobuf/groupId artifactIdprotobuf-java/artifactId version3.21.12/version /dependency如果你不需要在编译期生成代码比如用的是IDEA插件手动生成生成结果提交到仓库可以不用protobuf-maven-plugin。但如果是团队协作、持续集成建议还是用Maven插件保证每个人拉下来代码都能一致地生成最新实体。2.2 生成代码的两种方式IDEA插件和Maven插件选哪个Protobuf本身只是一个描述格式真正干活的是生成的Java类。生成途径有两种。第一种IDEA插件方式。装一个Protobuf Support插件配上本地的protoc编译器在.proto文件上右键就能生成代码。这种方式适合个人调试、快速验证生成的代码会直接写到指定目录需要手动提交到git。缺点是CI环境没有IDEA团队成员如果没装插件pull代码后加新字段就会出问题。第二种Maven插件方式。在pom里配上protobuf-maven-plugin绑定了compile阶段每次mvn compile自动扫描src/main/proto目录下的.proto文件生成代码到target/generated-sources。这种方式适合团队协作我推荐优先用它。plugin groupIdorg.xolstice.maven.plugins/groupId artifactIdprotobuf-maven-plugin/artifactId version0.6.1/version configuration protocArtifactcom.google.protobuf:protoc:3.21.12:exe:${os.detected.classifier}/protocArtifact protoSourceRootsrc/main/proto/protoSourceRoot /configuration executions execution goals goalcompile/goal /goals /execution /executions /plugin注意${os.detected.classifier}需要配合os-maven-plugin扩展插件一起用否则Windows和macOS下的protoc二进制选择会出问题。完整写法是在build下的extensions里加这个extension groupIdkr.motd.maven/groupId artifactIdos-maven-plugin/artifactId version1.7.0/version /extension2.3 第一个验证用例让一个对象完成“进Redis再出Redis”闭环定义消息体不用太花哨先从一个朴素的订单对象开始syntax proto3; package cache.message; option java_package com.example.demo.cache.message; option java_outer_classname OrderMessage; message Order { int64 order_id 1; string user_id 2; int32 status 3; int64 amount 4; int64 created_at 5; }这里有个基本功字段编号一旦启用就不要再修改。order_id1就永远是1后面的代码可能已经把缓存数据写进Redis了改编号相当于给旧数据判了死刑。编译之后写一个最小的读写用例验证闭环OrderMessage.Order order OrderMessage.Order.newBuilder() .setOrderId(100001L) .setUserId(u_8888) .setStatus(1) .setAmount(9900L) .setCreatedAt(System.currentTimeMillis()) .build(); byte[] bytes order.toByteArray(); System.out.println(序列化字节数: bytes.length); OrderMessage.Order parsed OrderMessage.Order.parseFrom(bytes); System.out.println(parsed.getOrderId());执行到这一步说明你手上的protobuf环境已经跑通了。接下来才到正题把它嵌到Redis的读写链路里。3. 缓存数据结构设计key、value、TTL一起想清楚3.1 key命名能不能批量扫、能不能避免热key取决于这一步很多人用Rediskey习惯随手写一个order:12345短是短但线上追问题的时候根本看不清key属于哪个业务、哪个租户、哪个环境。我建议的命名规范是{业务域}:{对象类型}:{租户/环境}:{业务ID}:{可选后缀}例如cache:order:tenant_1001:2024091500001:base cache:order:tenant_1001:2024091500001:full cache:promotion:seckill:sku_999:stock好处有三个同一业务域的数据在Redis Desktop Manager或者redis-cli的keys操作里能聚合排查问题效率高。天然支持按前缀清理。灰度或故障恢复时可以SCAN cache:order:tenant_1001:*定向删数据避免误伤其他业务。可读性强。你看到一个key就知道它存的是什么不需要再去翻代码。有个细节要提醒key别用太长。一个key少则三五十字节多则上百字节如果一天写入几百万次key体积对内存的消耗完全不可忽略。所以业务域和对象类型用短单词别用businessCacheOrderInfoDetail这种又臭又长的风格。3.2 value组织整个对象一把梭还是拆成字段存这是设计上值得纠结一下的地方。Redis的value类型里string是最泛用的hash适合做部分字段操作。那一个订单对象存成哪种好先说结论我在绝大多数场景下都用String Protobuf序列化。因为Protobuf本身已经把对象序列化成紧凑的byte[]作为string的value存进去读写都简单。订单状态变了就把整个对象反序列化出来改一个字段再序列化写回。这个“读改写”操作在Redis里不是原子的但可以配合分布式锁或Redis的Lua脚本保证并发安全大部分场景足够用。什么时候用hash如果你真的只需要高频读写某几个字段比如库存量、状态值而其他字段很少动就可以把对象拆成多个field。但这里有个坑hash Protobuf会牺牲Protobuf的跨语言优势。hash本身是Redis的数据结构其他语言读的时候要逐个field拿没法像解析一个完整byte[]那样干净利落。我的原则是高频整读整写的场景直接String存整个Protobuf高频单字段刷新的场景拆出来一个专门的冗余字段存进hash但不要把一个完整的Protobuf对象拆进多个小字段里那会让代码维护成本直线上升。3.3 二进制安全认知看到乱码不等于数据坏了用上Protobuf之后你打开Redis Desktop Managervalue列显示的是一堆乱码。刚切换方案的同事经常以为数据写坏了跑过来问。这里要明确一点Redis的string value本质上是字节数组不是文本。它以二进制安全的方式存储数据里面是什么字节都无所谓。Protobuf产生的二进制流本来就不是给人直接看的乱码是正常现象而不是异常。想验证数据真的没问题不要靠肉眼看GUI客户端用以下几种方式用RedisTemplate的value序列化器自动反序列化打印成业务Object。在排查环境用redis-cli --raw get key把二进制输出重定向到文件再用写一个小脚本调用protobuf解析。给value加一个1字节的前缀标记比如0x50代表PROTOBUF万一将来一个key存过多种格式也能根据前缀分流处理。4. 序列化器实现与RedisTemplate集成核心代码逐行拆解4.1 手写一个ProtobufRedisSerializerSpring Data Redis最方便的一点是定义了RedisSerializerT接口我们只需要实现自己的序列化器然后塞给RedisTemplate就行。public class ProtobufRedisSerializerT extends com.google.protobuf.MessageLite implements RedisSerializerT { private final ClassT clazz; public ProtobufRedisSerializer(ClassT clazz) { this.clazz clazz; } Override public byte[] serialize(T t) throws SerializationException { if (t null) { return new byte[0]; } return t.toByteArray(); } Override SuppressWarnings(unchecked) public T deserialize(byte[] bytes) throws SerializationException { if (bytes null || bytes.length 0) { return null; } try { // 利用getDefaultInstance拿到默认实例再取它的Parser com.google.protobuf.MessageLite defaultInstance (com.google.protobuf.MessageLite) clazz.getMethod(getDefaultInstance).invoke(null); return (T) defaultInstance.getParserForType().parseFrom(bytes); } catch (Exception e) { throw new SerializationException(protobuf反序列化失败, clazz clazz.getName(), e); } } }这个序列化器有几个设计考量构造时传入具体消息类。反序列化必须知道目标类型所以new的时候绑死。serialize返回空数组而不是null。Spring Data Redis内部对null的处理有歧义返回空数组更稳妥。用getParserForType().parseFrom而不是反射对比静态parseFrom方法。前者是protobuf生成代码的标准入口不依赖方法名的反射约定更稳。4.2 RedisTemplate整体配置与用法示例有了序列化器接下来配置RedisTemplate。这里有个关键点key和value要用不同的序列化器。key我希望保持可读性用String序列化器value为了让业务对象紧凑用Protobuf序列化器。Configuration public class RedisConfig { Bean public RedisTemplateString, OrderMessage.Order orderRedisTemplate( RedisConnectionFactory connectionFactory) { RedisTemplateString, OrderMessage.Order template new RedisTemplate(); template.setConnectionFactory(connectionFactory); // key使用String序列化器value使用Protobuf序列化器 StringRedisSerializer keySerializer new StringRedisSerializer(); ProtobufRedisSerializerOrderMessage.Order valueSerializer new ProtobufRedisSerializer(OrderMessage.Order.class); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }注意afterPropertiesSet()不要漏。Spring Data Redis的RedisTemplate在init的时候会校验serializer是否为空如果漏了这步某些版本启动就会报NPE。使用的时候很简单// 写入缓存 OrderMessage.Order order buildOrder(); redisTemplate.opsForValue().set(cache:order:tenant_1001:2024091500001, order, 30, TimeUnit.MINUTES); // 读取缓存 OrderMessage.Order cached redisTemplate.opsForValue().get(cache:order:tenant_1001:2024091500001); if (cached ! null) { // 直接用业务字段 System.out.println(cached.getStatus()); }4.3 一个对比实验JDK序列化、JSON、Protobuf的实测结果为了说服团队切换方案我当时做了一个同机对比实验。对象是同一个OrderMessage10个字段循环10万次记录总耗时和字节数。结果大概是这样不同机器略有差异但趋势一致序列化方案单条字节数10万次序列化耗时10万次反序列化耗时跨语言可读性JDK序列化245620ms610ms不支持差Jackson JSON117350ms380ms支持好Protobuf58120ms90ms支持差5. 缓存框架切换的稳定性灰度、兼容和性能卡点5.1 老数据怎么办双读双写和版本标记把Redis的序列化方式从JDK或JSON换成Protobuf最怕的就是线上还有老数据。老数据是JDK序列化字节流新的消费者用Protobuf去解析直接抛异常。我有两个处理思路。思路一换key前缀新老数据物理隔离。在key命名里加一个类似v2的版本段比如cache:order:tenant_1001:2024091500001改成cache:v2:order:tenant_1001:2024091500001。新代码只读写新key老代码报废前还能读旧key两边互不干扰。等到旧key里的数据自然过期再切回干净命名。思路二双读双写。写入的时候同时写新格式和旧格式读取的时候优先读新格式读不到再读旧格式。上线观察一段时间确认新格式稳定后再停掉旧格式的写最后干掉旧格式的读。我上线时用的是思路一因为最简单切换完之后把旧key前缀在代码里彻底留在历史版本里不用维护双份逻辑。如果你的业务对缓存命中率极其敏感双读双写更平滑但代码复杂度高。5.2 Protobuf字段演进的红线这些事千万别做Protobuf能保证新旧协议兼容但前提是你遵守游戏规则。实战中最容易踩的红线有几条字段编号不能变。一旦被线上数据使用改成别的编号等于让旧数据无人认领。不能改变字段的wire type。一个int32变成string解析器会直接把字节流解析成一坨不可信数据轻则字段错乱重则反序列化失败。删除字段时保留编号。不用的字段别“回收”给新字段用否则旧数据上残留的字节段会被新字段错误消费。尽量不要用required。Proto2里的required字段在proto3里已经去掉proto3默认所有字段都是optional。新代码读取缺失字段时拿到的是默认值这不会抛错比required安全得多。还有一条实践心得在消息体里显式加一个version字段。比如int32 schema_version 100;每次结构有重大调整时1。排查线上问题时这个版本号能瞬间帮你确认“这份数据是哪个版本写进去的”避免猜谜。5.3 性能退化排查序列化器不是唯一瓶颈有个容易误判的坑切换Protobuf之后如果整体RT没有明显下降别急着怀疑序列化器。大多数情况下瓶颈在Redis连接池和网络。Lettuce默认的连接数偏保守高并发下连接等待会成为主要开销。建议在生产环境调整spring: redis: lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 max-wait: 500ms再者大key问题。Protobuf只是让每个对象小了一圈但如果你的业务把一张10万行的表全塞进一个key序列化方式换什么都不解决根本问题。建议加一个“单对象序列化后不超过10KB”的埋点超过就报警防止某天有人把大对象塞进来拖垮Redis。6. 生产环境高频坑清单从能Review到能上线6.1 Redis Desktop Manager显示乱码的正确应对方式切换到Protobuf之后用Redis Desktop Manager或者Another Redis Desktop Manager看value满屏乱码。这个现象最早把组里测试同学吓到了以为缓存写入失败了。应对方式我在第3节说过但这节补充一个实用技巧。你可以在Redis Desktop Manager设置里调整value显示编码有的版本支持Hex显示。切到Hex之后你看到的是一串十六进制字节而不是乱码文本。观察前几个字节就能判断数据格式0A开头大概率是Protobuf数据字段1类型为length-delimited。\xAC\xED开头是JDK序列化的固定头。{开头说明是JSON文本。这个排查口诀真的能救命尤其是团队里有人不小心把一个key写成了别的格式。6.2 局部字段更新别把“读改写”当成原子操作有同学问订单状态高频变化每次状态变更都把整个订单对象读出来、改字段、再写回去会不会有并发问题答案是会。假设两个线程同时读到同一个订单对象一个把状态从1改成2另一个把金额从100改成200后写回的那个线程会把先写回的更新覆盖掉这就是经典的丢失更新问题。我解决这个问题的方式是组合拳对于“状态”这种高频单字段变更单独设计一个轻量状态消息体塞到hash的某个field里或者用Redis的SET一个独立的key。对于真正需要全量更新的场景用分布式锁保护或者直接用Lua脚本在Redis端完成“读-改-写”。Lua脚本的大致思路是把反序列化逻辑写成Redis脚本不现实protobuf解析在客户端所以更实际的做法是小字段走独立key大对象走全量覆盖。全量覆盖的并发问题通过redisTemplate.opsForValue().set()的原子性解决——只要你不做“先读后写”而是每次都用最新数据直接覆盖就不会有丢失更新。6.3 反序列化安全Protobuf为什么相对更让人省心序列化方案的安全性是安全评审绕不开的话题。JDK原生反序列化历史上出过不少高危问题原因是反序列化时会根据字节流里携带的类名动态加载类一旦攻击者能控制字节流内容就可能构造恶意利用链。JSON序列化也不省心某些JSON库如果开启了自动类型绑定解析时同样可能触发类似的危险路径。Protobuf的机制和它们不同。它反序列化时不需要从字节流里读取类名目标Message类型是在代码里写死的——也就是说parseFrom方法本身就是针对特定类型的传入的字节流只会被解析成该类型的字段结构不存在“字节流里藏着类名导致动态加载”的问题。攻击面一下子缩小了很多。当然后端还要做好Redis的访问控制、网络隔离和权限最小化这是另一层防护。6.4 慢查询与监控让问题是“可观测”的最后聊一下上线之后怎么观测。不要等缓存出问题了再去连客户端看数据那基本为时已晚。我给自己的服务配了这几项Redis慢查询日志。slowlog get能看到超过阈值的命令如果发现有大批GET命令都慢大概率是Redis端有大key或者网络抖动。业务侧记录序列化耗时。在自定义序列化器里加一个简单的耗时采样器超过阈值就日志告警。正常情况下protobuf序列化一个对象应该在微秒级超过1ms就说明对象大到不正常了。key命中率。通过Redis的INFO stats和业务侧埋点持续观察缓存命中率波动。切换序列化方案后命中率不该有变化如果下降了先检查key是否被改掉了。还有一个日常经验用redis-cli --bigkeys定期扫描大key把结果接入告警平台。Redis不怕热key多就怕大key和热key叠加那才是真正的性能杀手。这块改造做完之后我最大的感受是序列化方案不是“换个实现类”那么简单它牵扯到key设计、兼容策略、监控告警是一整套链路决策。但也正因为如此一旦你把这套链路跑通后续扩展新业务时Redis这块基本就不用再操心了。团队再有人问为什么Redis里是乱码直接把这篇的排查思路丢过去就行。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →