ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java中RedisTemplate序列化配置与乱码根治实战

Java中RedisTemplate序列化配置与乱码根治实战

1. 从一次线上乱码事故说起:为什么Redis存进去的中文变成了问号

前两年接手一个老项目,登录接口突然开始报错,排查了半天发现是Redis里存的用户会话信息全变成了\xac\xed\x00\x05t\x00这种鬼东西。当时第一反应是序列化问题,但项目里明明配了StringRedisSerializer,怎么会这样?后来翻代码才发现,有人图省事直接注入了默认的RedisTemplate,而那个Bean压根没做序列化配置。这件事让我意识到,Redis在Java项目里的配置远不是加个依赖、写两行配置就完事的,序列化方式选错、客户端编码没对齐、多例场景没处理好,任何一个环节出问题都会让你在深夜对着日志怀疑人生。

这篇文章就围绕这几个高频痛点展开:怎么在Java项目里正确配置Redis、RedisTemplate的序列化该怎么设、客户端乱码到底怎么根治、怎么优雅地获取所有key和value、以及什么场景下需要把RedisTemplate改成多例。内容偏向实战,适合已经用过Redis但被序列化和多例问题折腾过的开发者,也适合刚开始接触Spring Data Redis、想少走弯路的朋友。我会把每个配置项背后的原因讲清楚,而不是只丢一段代码让你复制。

2. RedisTemplate的序列化机制:乱码的根因不在Redis本身

2.1 默认JDK序列化到底做了什么

很多人第一次用RedisTemplate时会发现,用redis-cli去看存进去的key,前面多了一串看不懂的字符。这不是Redis的锅,而是RedisTemplate默认使用了JdkSerializationRedisSerializer。这个序列化器会把Java对象通过ObjectOutputStream转成字节数组,再存进Redis。问题是,它会在字节流前面加上类名、序列化版本号等元信息,所以你在客户端看到的就是一堆带前缀的二进制。

用生活化的类比来说,这就像你把一个快递包裹直接扔进柜子,外面还裹了好几层写着寄件人信息的胶带。Redis本身只认字节,它不知道你存的是字符串还是对象,所以你怎么序列化,它就怎么存。乱码的本质是"序列化格式"和"读取方式"不匹配,而不是Redis把数据搞坏了。

2.2 四种常用序列化器的适用边界

Spring Data Redis提供了多种序列化器,选哪个取决于你的数据形态和是否需要跨语言读取。下面这张表是我在实际项目中总结的对比:

序列化器存储形态可读性跨语言典型场景
JdkSerializationRedisSerializer二进制差不支持纯Java对象缓存,不关心可读性
StringRedisSerializer纯字符串好支持key、简单value、计数器
GenericJackson2JsonRedisSerializerJSON较好支持对象缓存,需保留类型信息
Jackson2JsonRedisSerializerJSON好支持指定类型的对象缓存

我个人的经验是:key一律用StringRedisSerializer,这一点几乎没有争议,因为key需要可读、可查、可维护。value则看情况,如果只是存字符串或数字,直接用StringRedisSerializer最省事;如果存对象,用GenericJackson2JsonRedisSerializer能在反序列化时自动还原类型,但会多存一个@class字段,稍微占空间。

2.3 一个可直接抄的RedisTemplate配置

下面这段配置是我在多个项目里验证过的,key用字符串、value用JSON、hash结构也统一处理:

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }

这里有个细节容易被忽略:afterPropertiesSet()必须调用。它负责校验序列化器是否设置完整,如果不调用,某些版本的Spring Data Redis在运行时才会抛异常,排查起来很费时间。另外,GenericJackson2JsonRedisSerializer默认会往JSON里写@class,如果你不希望暴露类名,可以自定义ObjectMapper并关闭activateDefaultTyping,但那样反序列化时就需要手动指定类型。

提示:如果你的项目里同时存在StringRedisTemplate和自定义的RedisTemplate,注意它们的Bean名称不能冲突,否则注入时会报NoUniqueBeanDefinitionException。

3. 客户端乱码的完整排查链路:从现象到根治

3.1 乱码的三种典型表现

乱码不是只有一种样子,不同原因导致的乱码现象不同,排查方向也不一样。我遇到过的主要有三种:

第一种是key前面带\xac\xed前缀,这是JDK序列化的典型特征,说明key用了默认序列化器。第二种是中文value显示为\xe4\xbd\xa0这样的十六进制转义,这通常是客户端连接时没有指定正确的字符集,或者序列化器用了ISO-8859-1。第三种是value完全乱码但长度正常,这种情况多半是序列化和反序列化用了不同的序列化器。

3.2 逐步定位:先看key还是先看value

排查乱码我一般遵循"先key后value、先配置后代码"的顺序。第一步,用redis-cli执行KEYS *,看key的形态。如果key带二进制前缀,直接去检查RedisTemplate的keySerializer配置。第二步,如果key正常但value乱码,用GET命令看value的原始字节,判断是JSON格式还是二进制格式。第三步,对照代码里注入的RedisTemplate和配置类里的Bean是否是同一个。

这里有个很隐蔽的坑:Spring Boot自动配置会提供一个默认的RedisTemplate<Object, Object>,如果你自定义的Bean泛型是RedisTemplate<String, Object>,两者类型不同,@Autowired按类型注入时可能注入到默认的那个。解决办法是给自定义Bean起个明确的名字,注入时用@Qualifier指定。

3.3 连接层面的字符集配置

除了序列化器,连接工厂的字符集也会影响乱码。虽然现代版本的Lettuce和Jedis默认都用UTF-8,但在一些老项目里,如果手动配置了RedisStandaloneConfiguration而没设置字符集,或者用了自定义的编解码器,就可能出问题。我建议在配置文件里显式声明:

spring: redis: host: 127.0.0.1 port: 6379 database: 0 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0

Lettuce作为默认客户端,底层用Netty处理编解码,字符集默认就是UTF-8,一般不需要额外配置。如果你用的是Jedis,可以在JedisConnectionFactory里设置setCharset(StandardCharsets.UTF_8),但新项目基本不用这么做了。

3.4 一个真实踩坑案例的复盘

之前有个同事在项目里同时用了StringRedisTemplate和自定义的RedisTemplate,他往StringRedisTemplate里存了一个对象(手动转成JSON字符串),然后用自定义RedisTemplate去读,结果反序列化报错。原因是StringRedisTemplate存的是纯字符串,而自定义RedisTemplate的value序列化器是GenericJackson2JsonRedisSerializer,它期望读到带类型信息的JSON,结果读到的是普通字符串,自然解析失败。

这个案例的教训是:同一个业务数据,读写必须用同一个Template。如果团队里多人协作,最好在项目规范里明确哪个场景用哪个Template,避免混用。

4. 获取所有key和value:别再用KEYS命令了

4.1 KEYS命令的性能陷阱

新手最常写的代码是redisTemplate.keys("*"),然后在循环里redisTemplate.opsForValue().get(key)。这段代码在测试环境没问题,因为数据量小,但上了生产环境,如果Redis里有几十万个key,KEYS *会阻塞Redis主线程,导致其他请求全部超时。Redis官方文档明确建议生产环境禁用KEYS命令,这不是危言耸听,我亲眼见过一次因为KEYS *导致整个缓存集群响应时间从1毫秒飙到3秒的事故。

替代方案是SCAN命令,它通过游标分批遍历,每次只返回少量key,不会长时间阻塞。Spring Data Redis提供了scan方法,可以配合ScanOptions使用。

4.2 用SCAN优雅遍历所有key

下面这段代码是我常用的SCAN遍历模板,配合RedisTemplate使用:

public Set<String> scanKeys(String pattern, int count) { Set<String> keys = new HashSet<>(); ScanOptions options = ScanOptions.scanOptions() .match(pattern) .count(count) .build(); try (Cursor<byte[]> cursor = redisTemplate.getConnectionFactory() .getConnection().scan(options)) { while (cursor.hasNext()) { keys.add(new String(cursor.next(), StandardCharsets.UTF_8)); } } catch (Exception e) { log.error("scan keys error", e); } return keys; }

这里有几个关键点:count参数是提示值,不是精确值,Redis可能返回多于或少于count的结果,但一般设置在100到1000之间比较合适。Cursor实现了Closeable,必须用try-with-resources关闭,否则连接会泄漏。另外,scan返回的是byte[],需要手动转成字符串,转换时指定UTF-8避免乱码。

4.3 批量获取value的两种策略

拿到key集合后,获取value有两种方式。第一种是逐个get,简单但网络往返次数多,适合key数量少(比如几百个)的场景。第二种是用MGET批量获取,一次网络请求拿多个value,效率高很多。Spring Data Redis里可以用opsForValue().multiGet(keys)实现。

不过multiGet有个限制:它只能用于字符串类型的value。如果你的value是对象(用JSON序列化存的),multiGet返回的是反序列化后的对象列表,但前提是序列化器配置正确。如果key数量特别大,比如上万个,建议分批调用multiGet,每批500到1000个,避免单次请求数据量过大。

4.4 遍历时的注意事项

遍历Redis数据时,有几个坑我踩过。第一,SCAN不保证返回的key不重复,所以要用Set去重。第二,遍历过程中如果有写入操作,可能会漏掉部分key,这是SCAN的固有特性,不是bug。第三,如果只是统计key的数量,用DBSIZE命令比遍历快得多,但DBSIZE返回的是当前库的所有key数量,不支持pattern匹配。

注意:在生产环境执行遍历操作时,尽量避开业务高峰期,并且给遍历逻辑加上超时控制,防止因为网络抖动导致线程长时间挂起。

5. 把RedisTemplate改成多例:什么场景下需要这么做

5.1 单例RedisTemplate的局限性

Spring默认把RedisTemplate注册为单例Bean,这在绝大多数场景下没问题,因为RedisTemplate本身是线程安全的。但有一种场景会出问题:当你在同一个应用里需要连接多个Redis实例,并且不同实例需要不同的序列化策略时,单例就不够用了。比如A实例存的是JSON格式的用户数据,B实例存的是JDK序列化的会话数据,用一个Template没法同时满足。

另一种场景是动态切换数据库。RedisTemplate的database属性在连接工厂层面设置,单例情况下没法在运行时切换。虽然可以用RedisConnectionFactory动态改,但那样会影响所有使用该工厂的Template,风险很大。

5.2 多例配置的实现方式

把RedisTemplate改成多例,核心是把@Bean的作用域改成@Scope("prototype"),并且每次获取时传入不同的连接工厂或序列化配置。下面是一个多例配置的示例:

@Configuration public class MultiRedisConfig { @Bean @Scope("prototype") public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory, RedisSerializer<Object> valueSerializer) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(valueSerializer); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }

使用时通过ApplicationContext.getBean(RedisTemplate.class, factory, serializer)获取不同配置的实例。注意,多例意味着每次注入都会创建新对象,如果在一个循环里频繁获取,会造成大量对象创建,影响性能。所以多例Template最好在初始化阶段获取一次并缓存,而不是每次业务调用都重新拿。

5.3 多例带来的资源管理问题

多例Template本身不持有连接,连接由RedisConnectionFactory管理,所以多例不会直接导致连接泄漏。但如果你为每个Template都创建了独立的连接工厂,那连接池的数量就会成倍增加。比如原本一个工厂配了8个最大连接,创建3个工厂就是24个连接,可能超过Redis服务端的maxclients限制。

我的建议是:多个Template共享同一个连接工厂,只让序列化策略不同。如果确实需要连接不同Redis实例,用不同的工厂,但要严格控制工厂数量,并且给每个工厂配置合理的连接池参数。

5.4 替代方案:用RedisTemplate的子类或包装类

多例不是唯一解。如果只是序列化策略不同,可以定义多个RedisTemplate子类,每个子类固定一种序列化方式,然后注册为不同的单例Bean。比如:

@Bean("jsonRedisTemplate") public RedisTemplate<String, Object> jsonRedisTemplate(RedisConnectionFactory factory) { // JSON序列化配置 } @Bean("stringRedisTemplate") public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) { // 字符串序列化配置 }

这样既避免了多例的对象创建开销,又能满足不同序列化需求。只有在需要运行时动态决定序列化策略或连接目标时,才考虑多例。选择哪种方案,取决于你的业务是否真的需要在运行时切换,而不是为了"灵活"而过度设计。

6. 几个容易被忽略的配置细节和实操心得

6.1 连接池参数怎么调

连接池参数不是越大越好。max-active设置过大,Redis服务端压力大;设置过小,高并发时请求排队。我的经验值是:max-active设为业务峰值QPS的1.5倍左右,max-idle和min-idle设为max-active的一半。timeout建议设3000毫秒,太短容易误判超时,太长故障时恢复慢。

另外,lettuce默认是共享连接,多个线程复用一个连接,所以连接池参数对Lettuce的影响和Jedis不同。Lettuce的max-active限制的是并发请求数,不是连接数。如果你的项目用Lettuce,连接池配置可以更宽松一些。

6.2 序列化器的版本兼容问题

GenericJackson2JsonRedisSerializer在不同版本的Spring Data Redis里行为有差异。老版本可能不支持某些泛型,新版本对LocalDateTime等类型的处理也不一样。如果项目升级了Spring Boot版本,建议回归测试一下Redis的读写,确认序列化结果没有变化。我遇到过一次升级后LocalDateTime序列化成了数组格式,导致前端解析失败,最后自定义了ObjectMapper的日期格式才解决。

6.3 大key和热key的预防

不管序列化怎么配,大key和热key都是Redis的隐形杀手。大key会导致网络传输慢、反序列化耗内存,热key会导致单个节点CPU飙升。预防大key的办法是控制value大小,超过10KB的value考虑拆分或改用其他存储。热key的预防可以在应用层加本地缓存,或者对key做散列,把压力分散到多个key上。

6.4 监控和告警不能少

Redis配置得再好,没有监控也是盲跑。至少要监控连接数、内存使用率、命中率、慢查询数量这几个指标。慢查询尤其重要,SLOWLOG GET能帮你发现哪些命令执行时间过长,很多时候乱码和性能问题都能从这里找到线索。

7. 写在最后:一些个人体会

Redis在Java项目里的配置,表面上看是几行代码的事,但背后涉及序列化、连接管理、性能优化等多个层面。我自己的习惯是:新项目初始化时就把序列化器配好,key统一用字符串,value根据场景选JSON或字符串;遍历数据一律用SCAN,绝不在生产环境用KEYS;多例Template只在真正需要动态切换时才用,能单例就单例。

还有一个小心得:每次改完Redis配置,别只看代码能不能跑,一定要用redis-cli去实际看一眼存进去的数据长什么样。代码里看着正常的字符串,存进去可能是二进制,这种问题只有亲眼看到才能确认。另外,团队里最好有一份Redis使用规范,把序列化方式、key命名规则、过期时间策略写清楚,比事后排查乱码省事得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进