ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot Redis序列化配置:原理、方案与避坑实践

Spring Boot Redis序列化配置:原理、方案与避坑实践 1. 为什么说Redis序列化配置是缓存坑的开始用Spring Boot操作Redis业务跑了几天打开Redis Desktop Manager一看key全是\u4E2D\u6587这种转义字符value是一坨看不懂的二进制当场心态就崩了。如果遇到这种情况多半是没做Redis序列化配置或者还在用Spring Data Redis默认的JDK序列化策略。我写这篇是想把Redis序列化配置的原理、主流方案、完整代码和踩坑经验一次说清楚适合正在做缓存、分布式锁、接口幂等的Java开发者也适合准备面试时想把Redis原理捋明白的朋友。1.1 Redis存的是字节不是Java对象Redis是一个基于内存的key-value存储value支持String、List、Hash、Set、ZSet这些数据类型但无论哪一种底层最终都是字节数组。Java程序里的对象在JVM内存中是对象引用想把它塞进Redis必须先序列化成字节序列下次用的时候再从Redis取出字节反序列化回Java对象。这个过程对很多初学者来说是透明的因为Spring Data Redis把细节封装好了你只管redisTemplate.opsForValue().set(user:1, user)。但封装不代表不犯错如果序列化器选错数据存进去容易取出来就会出问题。Spring Data Redis里负责序列化的组件叫RedisSerializer它定义了serialize和deserialize两个方法分别处理对象到字节、字节到对象的转换。RedisTemplate默认使用的序列化器是JdkSerializationRedisSerializer这是Spring的历史选择目的是让普通的Java对象直接实现Serializable就能存取。问题是这个默认方案在真实业务里并不好用甚至会让一个看起来很简单的缓存功能变成排查噩梦。1.2 默认JDK序列化的两大硬伤第一是“人看不懂”。JDK序列化输出的是Java特有的二进制格式不是UTF-8字符串。你往Redis里存一个user:1的key它底层的字节可能是\xAC\xED\x00\x05t\x00\x06user:1用redis-cli或Redis Desktop Manager看自然就是乱码。key乱码还能忍value乱码更难受你想确认缓存里到底有没有值、过期时间还剩多少结果只能看到一堆十六进制。第二是“跨语言没法用”。JDK序列化格式只有Java能反序列化如果你的系统里有Python、Go、Node.js它们拿到这段字节完全没有办法。现在很多项目已经不是单语言单体应用了网关、报表、数据处理服务可能各用一个技术栈缓存数据作为中间交换结果必须让多方都能读懂。一旦用JDK序列化等于把Redis锁死在Java生态里后续要接其他语言只能重做。除了这两点JDK序列化还有“体积大”的问题。它会写入类名、继承关系、对象结构描述等大量元信息同样一个对象序列化出来的体积可能是JSON的3到5倍。Redis是内存数据库数据量大了以后这部分白白浪费的内存和网络带宽都算你的成本。1.3 不配置序列化迟早会遇到这些诡异现象我在实际项目里见过的典型“诡异现象”至少有这么几种。明明存的是user:1这个key换一个客户端用同样的key去SET结果RedisTemplate查不到。get出来的对象直接强转User结果抛java.lang.ClassCastException因为反序列化出来的是LinkedHashMap。同一个缓存数据用RedisTemplate写入用StringRedisTemplate读取读到null反过来也一样。分布式锁的key在监控页面里完全不可读出问题时想手工删除都费劲。这些问题的根源都不是Redis本身而是序列化器不一致。序列化器不一致意味着同一个逻辑key经过不同序列化算法后变成不同的字节Redis底层认为它们是两个完全不同的key。你看着代码里都是同一个字符串实际存储时早就分道扬镳了。1.4 序列化配置到底要解决什么问题说白了配置Redis序列化就是要解决四个问题可读性、正确性、兼容性、性能。可读性是指key和value尽量用肉眼能看懂的字符串或JSON正确性是指对象序列化后还能还原本来的类型而不是变成一个Map兼容性是指不同语言和不同版本的服务都能处理这份数据性能则是指序列化体积和速度不要成为瓶颈。这四个目标不是每一项都要做到极致因为它们之间有取舍。团队协作项目里可读性和正确性往往比极致性能更重要因为出问题的时候人能看懂才是第一生产力。所以接下来的方案对比我不会只看性能数字而是结合真实使用场景来聊。2. 主流Redis序列化方案对比看清默认值和推荐值2.1 四位常驻选手逐个过一遍先看看最常碰到的几个序列化器。JdkSerializationRedisSerializerSpring的默认选择底层用Java原生序列化要求对象实现Serializable。前面说过它的问题是可读性差、跨语言差、体积大。但它也不是一无是处对象不用额外配置无脑序列化就能用适合临时调试或数据无所谓的场景。StringRedisSerializer用UTF-8编码把字符串转成字节专治key乱码。它只支持String类型如果value想存任意对象它无能为力。在RedisTemplate配置里它通常只承担key、hash key的序列化工作。Jackson2JsonRedisSerializer用Jackson把对象转成JSON字符串然后按UTF-8存进去。相比JDK序列化JSON可读性好、体积小、跨语言友好。缺点是需要指定目标类型或者配合ObjectMapper启用类型信息否则反序列化时不知道要还原成哪个类。GenericJackson2JsonRedisSerializer可以看成Jackson2JsonRedisSerializer的增强版它会在JSON里额外写入一个class字段记录原始类型的全限定名。这样反序列化时可以自动恢复成原来的类型省去很多手动指定的麻烦。代价是JSON里多几个字符增加一点点存储和带宽但换来的是“无脑对象存储”。FastJsonRedisSerializer国内早期用得比较多性能和JSON处理都不差但FastJson公开过不少反序列化漏洞安全要求高的场景建议谨慎。新项目我更推荐直接用Jackson系列避免额外依赖。2.2 一张表看清差异方案存储内容可读性跨语言类型还原推荐场景JdkSerializationRedisSerializerJDK二进制差仅Java自动保留类型不推荐生产StringRedisSerializer字符串好好仅字符串key或纯字符串valueJackson2JsonRedisSerializerJSON好好需指定类型可指定类型的对象GenericJackson2JsonRedisSerializerJSON类型信息好好自动保留类型通用对象存取FastJsonRedisSerializerJSON好好需指定类型历史遗留项目这张表里最关键的判断点是“类型还原”。如果包一层对象用了不带类型信息的Jackson序列化器读出来的对象只会是LinkedHashMap。这不是Jackson的bug而是JSON本身没有类型信息反序列化目标类型只能靠你指定或者额外写入。2.3 为什么StringJSON组合成了大众选择大多数团队最终选择的组合是key用StringRedisSerializervalue用GenericJackson2JsonRedisSerializer。原因很直白。key用String是因为key本身就是一个标识字符串没必要序列化成二进制。可读性直接拉满运维排查、手动删key、看监控都很方便。value用GenericJackson是因为业务对象五花八门用JSON存既保留了跨语言能力又能在读取时自动还原类型。Hash结构里field同样建议用String序列化器否则field也会变成乱码。这个组合在可读性和正确性之间取得了很好的平衡。它不像JDK序列化那样黑盒也不像CBOR、ProtoStuff那样需要引入额外schema更不用像Kryo那样维护类型注册表。Redis里看到的就是你能理解的JSON心里有底。2.4 别迷信“最快”Kryo和ProtoStuff的代价我在评估序列化方案时经常看到有人把Kryo的性能数据放在最前面然后建议替换所有JSON序列化。我不能说Kryo不好但它的高性能是有前提的。Kryo需要注册类或者依赖完整类路径类结构一变更老数据反序列化很可能直接失败。ProtoStuff也有类似问题它的schema绑定和版本兼容要自己处理。团队里如果没有专人维护这类性能优化很容易变成线上事故根源。我的建议是绝大多数业务系统先考虑可维护性。StringJSON已经能覆盖90%以上的缓存和访问场景。只有当你真的遇到网络带宽或Redis内存吃紧、序列化耗时成为热点时再考虑Kryo或ProtoStuff。到那一步时需要配套做版本管理和兼容性测试。3. 动手实战Spring Boot中配置好RedisTemplate3.1 准备依赖和基础连接参数既然要配置先保证环境里能跑起来。使用Maven的话在pom.xml里引入Spring Data Redis依赖。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency连接池依赖不是必须的但高并发下建议加上避免频繁创建连接。application.yml里给出Redis连接参数我习惯把连接池上限、超时时间都显式写出来方便后续调优。spring: data: redis: host: 127.0.0.1 port: 6379 password: timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2这里只负责连接工厂不负责序列化。很多新手会在yml里找serializer配置项实际上Spring Boot并没有提供一个外部化配置来直接替换序列化器需要自己定义RedisTemplate的Bean。3.2 配置类的核心思路RedisTemplate默认的两个序列化器都是JDK所以我们要做的是通过setKeySerializer、setValueSerializer、setHashKeySerializer、setHashValueSerializer这四个方法分别替换。不要只改key和valuehash key和hash value也一定要改否则你用opsForHash()存字段时照样乱码。我建议配置成一个Spring管理的Bean让整个项目注入的是同一个模板而不是业务代码里到处new RedisTemplate()。手动new出来的模板没有连接工厂也不受容器管理很容易造成序列化器不一致的问题。3.3 完整配置类代码一个简单且稳妥的版本是这样Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer stringRedisSerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonRedisSerializer new GenericJackson2JsonRedisSerializer(); // key、hash key 使用字符串序列化器 template.setKeySerializer(stringRedisSerializer); template.setHashKeySerializer(stringRedisSerializer); // value、hash value 使用 JSON 序列化器 template.setValueSerializer(jsonRedisSerializer); template.setHashValueSerializer(jsonRedisSerializer); // 确保设置生效 template.afterPropertiesSet(); return template; } }这里说几个容易被忽略的点。afterPropertiesSet()是在模板属性设置完成后做校验和初始化Spring容器在Bean初始化的时候也会调用但这里手动调用一次更稳妥避免在Bean正式发布前序列化器还没生效。GenericJackson2JsonRedisSerializer内部已经配置了配套的ObjectMapper默认会启用类型信息所以不用再自己new一个ObjectMapper。如果你的项目里用了LocalDateTime、LocalDate这类Java时间类型GenericJackson2JsonRedisSerializer虽然能处理一部分但在某些Spring Boot版本里还是需要额外注册JavaTimeModule。这种情况下我会选择自定义ObjectMapper的Jackson2JsonRedisSerializer版本。3.4 想要更精细控制可以自定义ObjectMapper如果不想用Generic版本或者项目里有自定义类型转换的需求可以用下面这种方式ObjectMapper objectMapper new ObjectMapper(); objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); objectMapper.activateDefaultTyping( LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY ); Jackson2JsonRedisSerializerObject jackson2JsonRedisSerializer new Jackson2JsonRedisSerializer(objectMapper, Object.class);这段代码在Spring Boot 2.x环境下很常见注意activateDefaultTyping用来把类型信息写入JSON避免反序列化变成LinkedHashMap。在Spring Boot 3.x里Jackson2JsonRedisSerializer的构造方法有些变化不同版本参数不一致所以更推荐直接用Generic版本把版本的坑交给Spring去填。3.5 写个接口验证配置有没有生效配置完之后不要只靠“代码没报错”来判断一定要实际往Redis里写一条数据再读出来用客户端看一下。RestController RequestMapping(/redis) public class RedisTestController { private final RedisTemplateString, Object redisTemplate; public RedisTestController(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; } GetMapping(/set) public String set() { User user new User(1L, 张三, 28); redisTemplate.opsForValue().set(user:1, user); return saved; } GetMapping(/get) public Object get() { return redisTemplate.opsForValue().get(user:1); } }调用/redis/set之后用redis-cli执行keys *如果配置正确你会看到user:1这个key本身是明文。再用get user:1查看value会看到类似下面这样的一段JSON{class:com.example.User,id:1,name:张三,age:28}class字段说明类型信息生效了。如果不是这样key变成二进制或者value变成乱码回到配置类检查哪里漏配了。3.6 什么时候直接用StringRedisTemplate就好如果你的缓存内容只有字符串比如短信验证码、Token、分布式锁的value那么直接用StringRedisTemplate最合适。它的key和value都默认使用StringRedisSerializer省去配置也不会有类型还原问题。RedisTemplateString, Object和StringRedisTemplate不能随意混用哪怕泛型看起来很接近因为RedisTemplate的value序列化器是JSONStringRedisTemplate的是String读写规则完全不一样。项目里最好统一一种用法。要么全部用自定义的RedisTemplate要么全部用StringRedisTemplate最怕一半代码用这个、一半用那个缓存数据被两套字节规则隔开互相看不到排查起来非常痛苦。4. 这些序列化坑我都踩过问题排查与避坑4.1 乱码、强转异常、局部覆盖先看一个典型的线上事故。同事在代码里用RedisTemplate保存了一个User对象第二天另一个服务读取后强转User结果报ClassCastException一看日志反序列化出来的是LinkedHashMap。为什么会这样因为他的value序列化器用的是不带类型信息的Jackson2JsonRedisSerializerJSON本身只保存字段不保存类名读取时Jackson不知道目标类只能默认转成Map。解决方案有两个方向一是改用GenericJackson2JsonRedisSerializer让类型信息写入class字段二是在读取时指定类型比如objectMapper.readValue(json, User.class)。前者更省心后者更适合需要严格控制输出格式的场景。还有一个常见的局部乱码问题只配置了keySerializer和valueSerializer忘了hashKeySerializer和hashValueSerializer。结果opsForHash().put(hashKey, field1, value)后hashKey整体是字符串但field显示乱码。排查这类问题有一个快捷方法先在Redis Desktop Manager里看key是否可读再看field是否可读再确认value类型就能快速定位是哪个序列化器失效。4.2 RedisTemplate和StringRedisTemplate混用数据“消失”事件有次项目里出现“缓存写了却读不到”的诡异情况。A服务用StringRedisTemplate写入token:10086B服务用RedisTemplate读取结果永远是null。两个服务里的key肉眼看着都是token:10086但Redis底层存的是两组不同字节。StringRedisTemplate把key按UTF-8编码RedisTemplate默认按JDK二进制编码两边根本对不上。这种情况不需要怀疑Redis故障先检查两边用的序列化器是否一致。我踩过之后给自己定了一个规矩项目里所有Redis操作入口统一放在一个RedisService里由它决定使用哪个Template业务层不允许自己选择。这样即使后续要调整序列化方案也只需要改一处。4.3 分布式锁锁不住先检查序列化器分布式锁通常用SET key value NX EX实现。如果两个服务持有的RedisTemplate序列化器不同一个把锁key按String存一个按JDK存那么它们实际上在争夺两个完全不同的锁效果就是“锁了个寂寞”。这类问题很容易被忽略因为业务日志里看不到异常只会在高并发时偶发出现重复执行。另外锁的value也建议使用足够随机的字符串比如UUID。释放锁时需要先比较value再删除必须用Lua脚本保证原子性。这些虽然不完全是序列化配置的范畴但都和“key用什么编码进Redis”强相关。锁场景下我的建议是单独封装一个锁工具内部强制使用固定序列化器。4.4 缓存穿透、缓存空值与序列化的关系缓存穿透的常用手段之一是缓存空值比如数据库里查不到用户就在Redis里存一个null或空对象。很多序列化器遇到null时要么返回空数组要么报错。Spring Cache里的Cacheable如果方法返回null默认不会写入缓存需要开启cacheNullValues属性。手动操作RedisTemplate时如果要缓存null最好在业务层统一包装成一个空对象不要直接塞null。我见过一个项目把空值处理不当导致缓存里出现了大量无法反序列化的数据最后只能清空缓存重建。这里的关键不是序列化器本身而是团队在缓存空值上的策略要统一。要么放一个约定好的空对象要么设置极短的过期时间千万不要用一种“今天能用、明天看着就怪”的方式。4.5 升级缓存格式老的二进制数据怎么办如果项目之前用了JDK序列化现在想切换到JSON序列化最怕的就是老数据还没过期新代码读取时报错。JDK二进制数据按JSON解析必然反序列化失败。切换前一定要考虑存量数据。我常用的办法有三种。第一低峰期直接清空相关缓存key简单粗暴但有效第二做一个兼容读取反序列化时先尝试JSON失败后走旧逻辑第三给key加版本号比如user:v2:1新数据用新格式旧数据自然淘汰。加版本号这个方式看起来略费存储但能让新旧逻辑共存很长一段时间适合不能立刻清缓存的大型系统。4.6 问题速查表现象可能原因解决方案key显示\uXXXX或二进制keySerializer用了JDKkey改为StringRedisSerializervalue反序列化强转失败valueSerializer未带类型信息改GenericJackson或读取时指定类型Hash的field乱码未设置hashKey/hashValue序列化器设置hashKey为StringhashValue视情况两个服务读不到对方写的缓存序列化器不一致统一Template或统一RedisService分布式锁不生效锁key在各服务中编码不同锁工具强制使用相同序列化器升级序列化方案后老数据读不了旧数据格式不同清缓存/兼容读/加key版本号LocalDateTime序列化失败ObjectMapper缺模块注册JavaTimeModule或使用Generic版本这张表我平时会贴在项目文档里新同学接过Redis相关需求时先看表再动手能少踩很多坑。5. 再进一步序列化性能优化和自定义方案5.1 大Value先压缩再存有些缓存value虽然不多但单个对象特别大比如商品详情、报表结果JSON字符串可能达到几十KB甚至几百KB。这种情况下Redis内存和网络带宽的消耗都会很突出。一个比较实用的优化思路是先序列化成JSON再判断长度超过阈值就做gzip压缩然后存进Redis。压缩带来的代价是CPU占用和调试不便所以不是所有数据都要压缩。数据只有几百字节时压缩反而增加开销。一般我用4KB作为阈值超过再压缩。压缩后的二进制在Redis客户端里不可读排查时需要解压才能看所以在value里加一个标记字段比如{compress:gzip,data:...}或者自定义一个RedisSerializer来收口压缩逻辑。5.2 实现自定义RedisSerializer的通用套路如果项目里确实需要定制序列化可以自己实现RedisSerializerT。核心只需要两个方法public class GzipJsonRedisSerializer implements RedisSerializerObject { private final GenericJackson2JsonRedisSerializer delegate new GenericJackson2JsonRedisSerializer(); Override public byte[] serialize(Object value) throws SerializationException { if (value null) { return new byte[0]; } byte[] jsonBytes delegate.serialize(value); if (jsonBytes.length 4096) { return compressWithFlag(jsonBytes); } return jsonBytes; } Override public Object deserialize(byte[] bytes) throws SerializationException { if (bytes null || bytes.length 0) { return null; } return delegate.deserialize(decompressIfNeeded(bytes)); } }注意空值处理null要返回空数组否则序列化器可能被框架认为异常。压缩标记可以用第一个字节表示也可以用魔数。自定义Serializer看起来不复杂但它影响的是所有Redis读写路径上线前一定要做充值的接口测试和兼容性验证。5.3 到底该选哪种序列化方案我的决策建议结合我自己的项目经验给出一个比较务实的选型建议。初创或中小型项目直接用String GenericJackson2JsonRedisSerializer省事、可读、类型还原没问题。业务中只有字符串数据比如验证码、Token、幂等键用StringRedisTemplate就够了不搞复杂配置。高吞吐、超大数据量再去考虑Kryo、ProtoStuff同时要有专门的人维护类型注册和版本兼容不是复制一段代码就完了。多语言异构系统优先用JSON或Protobuf不要碰JDK二进制。安全敏感项目不要使用老版本的FastJsonJackson也要升级到当前稳定版本避免反序列化漏洞。我参与过不少Redis问题排查最后基本都能查到序列化配置上。我的习惯是序列化方案统一在配置层收口所有注入的RedisTemplate只允许由Spring容器管理业务代码不要自己new。还有一个很接地气的技巧配置完成后写一个单元测试往Redis里放一个对象再读出来断言类型和关键字段一致这一个测试能挡住80%的序列化坑。配置完之后记得用Redis Desktop Manager亲眼看一眼key明确、value可读心里才踏实。
返回列表