ARTICLE DETAIL

资讯详情

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

Redis序列化乱码?StringRedisTemplate与RedisTemplate选型实战

Redis序列化乱码?StringRedisTemplate与RedisTemplate选型实战 1. 乱码Key背后的真相StringRedisTemplate和RedisTemplate到底差在哪新接手一个项目连上Redis客户端一看满屏的\xAC\xED\x00\x05开头的key那种感觉经历过的人都懂。用命令行SCAN匹配根本无从下手TYPE、TTL这些命令在乱码key上倒是能跑但可读性几乎为零。这时候不用怀疑写这些数据的代码一定用的是RedisTemplate而且是默认配置。这个问题几乎是Spring Boot社区提问频率最高的几个问题之一。乱码的根源不复杂Spring Data Redis给RedisTemplate默认配的是JdkSerializationRedisSerializer这个序列化器会把整个对象包括key走一遍Java原生序列化流序列化之后的产物天然就是二进制开头固定带\xAC\xED\x00\x05这几个字节是Java序列化流的魔数Stream Magic Number。所以在Redis里看到的那些乱码本质是Java序列化协议的头部。1.1 默认序列化器为什么这么坑先看一段最普通的代码redisTemplate.opsForValue().set(user:1001, 张三);用RedisTemplate的默认配置跑完你在Redis客户端看到的不是user:1001而是一长串不可读的二进制。原因在于默认情况下不仅是value走了JDK序列化key同样走了JDK序列化。大多数人刚开始用RedisTemplate时都会踩这个坑然后到处搜索Redis key乱码怎么解决。1.2 StringRedisTemplate的设计取舍而StringRedisTemplate之所以能绕开这个坑是因为它继承自RedisTemplateString, String并且在构造方法里强制把四个核心序列化器全部设置成了StringRedisSerializerpublic StringRedisTemplate() { RedisSerializerString stringSerializer RedisSerializer.string(); setKeySerializer(stringSerializer); setValueSerializer(stringSerializer); setHashKeySerializer(stringSerializer); setHashValueSerializer(stringSerializer); }也就是说无论key还是value只要走StringRedisTemplate都会按字符串直接编码存储。存进去是明文读出来是明文在Redis客户端里看得到、删得掉、查得了配合命令行做临时排查特别方便。这里有个很多人都忽略的细节StringRedisSerializer本身也有两种实现——UTF_8和US_ASCII。Spring Data Redis默认用的是UTF_8所以中文value存进去是不会乱码的。但如果你在某些老项目里看到有人手动new了StringRedisSerializer(Charset.forName(ISO-8859-1))那就要小心了中文写进去再读出来就是一堆问号。1.3 RedisTemplate和StringRedisTemplate读同一个key的结果差异再强调一个很多人踩过的坑用StringRedisTemplate写入的数据可以通过RedisTemplate读出来吗反过来呢这取决于RedisTemplate当前使用的序列化器。如果项目的RedisTemplate还被替换成了StringRedisSerializer那两边读写是互通的。但如果RedisTemplate用的还是默认的JDK序列化就会出现一种诡异的现象RedisTemplate读不到StringRedisTemplate写入的值因为它在反序列化时拿二进制数据当Java对象流处理解析直接报错反过来StringRedisTemplate读RedisTemplate写入的值能看到value因为JDK序列化后的byte[]本身是二进制但那串东西只是序列化后的一堆字节看得到用不上。所以在同一个项目里必须严格区分数据写入通道。我的建议是干脆把RedisTemplate默认序列化器全部换成StringRedisSerializer所有业务数据统一走StringRedisTemplateRedisTemplate只保留给极少数需要直接操作二进制value的场景。这样整个缓存体系的数据边界非常清晰。2. 自动配置的加载逻辑StringRedisTemplate是怎么在你的项目里无声无息出现的很多人的Spring Boot项目里一行配置都没写过StringRedisTemplate就能直接注入使用。这个背后是Spring Boot的RedisAutoConfiguration在处理。2.1 自动配置源码的加载路径RedisAutoConfiguration在项目启动时会做两件事一是往容器里放一个RedisTemplateObject, Object类型的Bean二是放一个StringRedisTemplate类型的Bean。这两个Bean都有条件注解限制——ConditionalOnMissingBean。意思是只要你的项目里没有自定义同名或同类型的Bean自动配置才会生效。Bean ConditionalOnMissingBean(name redisTemplate) public RedisTemplateObject, Object redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplateObject, Object template new RedisTemplate(); template.setConnectionFactory(redisConnectionFactory); return template; } Bean ConditionalOnMissingBean public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { return new StringRedisTemplate(redisConnectionFactory); }看到这里应该明白了ConditionalOnMissingBean是个双刃剑。好处是开箱即用坏处是它拦截了你的自定义Bean。2.2 自动配置失效的典型场景我见过一个项目开发在配置类里写了一个RedisTemplateString, String类型的Bean想着把默认的覆盖掉结果一启动发现其他模块的Redis写入全部报错。排查到最后发现他自定义的Bean类型是RedisTemplateString, String但自动配置判断ConditionalOnMissingBean(name redisTemplate)时匹配的是Bean的name一旦你自定义的Bean名不叫redisTemplate自动配置照样会创建一个名为redisTemplate的Bean顶上来容器里同时存在两个RedisTemplate注入时Spring按类型匹配就会歧义。所以我的建议是如果确实要自定义RedisTemplate或StringRedisTemplateBean方法名就老老实实叫redisTemplate或stringRedisTemplate不要图省事命名成myRedisTemplate。这能少踩很多坑。2.3 自定义配置的推荐姿势实际项目中我一般只在RedisConfig里配两个东西一个是RedisTemplateString, String序列化器全部改成StringRedisSerializer另一个是RedisTemplateString, Objectvalue用Jackson序列化分别对应纯字符串缓存和对象缓存两种场景。StringRedisTemplate本身直接用自动配置的就行不用重复造。Configuration public class RedisConfig { Bean public RedisTemplateString, String strRedisTemplate(RedisConnectionFactory factory) { RedisTemplateString, String template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setValueSerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setHashValueSerializer(stringSerializer); template.afterPropertiesSet(); return template; } }注意最后那个afterPropertiesSet()很多人自定义完RedisTemplate忘了调用会导致序列化器没初始化生效写进Redis的数据还是JDK序列化的二进制。这是一个非常隐蔽的坑查一次浪费一整天。3. 五大数据类型操作实战从opsForValue到BoundOperationsStringRedisTemplate最常被用到的场景就是操作Redis的五大基础数据结构。每个结构对应一组Operations接口开发的时候知道用哪个接口、有哪些隐藏语义写出来的代码才经得起推敲。3.1 ValueOperations基础读写和正确设置过期时间的姿势通过stringRedisTemplate.opsForValue()拿到的就是ValueOperationsString, String。日常get/set就不说了我想重点说两个高频场景的写法。第一个是不存在才写入。这个在分布式场景里很重要比如防止重复提交。用setIfAbsent(key, value)方法相当于Redis原生的SET NX加过期时间时可以配合setIfAbsent(key, value, timeout, TimeUnit)一条命令完成加锁设过期时间的原子操作。为什么强调原子性因为分开写Boolean success redisTemplate.opsForValue().setIfAbsent(lock:order:1001, 1); redisTemplate.expire(lock:order:1001, 30, TimeUnit.SECONDS);如果第二步设置过期时间前应用宕机了Redis里就留下一个没有TTL的永久key锁永远释放不了。这个坑我亲眼见过三次线上事故。第二个是增量操作。用increment(key)做原子自增没问题但想给自增结果设过期时间也必须用带超时参数的重载方法比如Long count redisTemplate.opsForValue().increment(visit:count, 1); redisTemplate.expire(visit:count, 60, TimeUnit.SECONDS);这里要留意一个细节increment之后单独调expire会多一次RTTRound Trip Time如果对性能有极致要求建议用Lua脚本把自增和过期合并成一个原子操作。后面会专门讲Lua姿势。3.2 HashOperations对象缓存的正确打开方式HashOperationsString, String, String是stringRedisTemplate.opsForHash()返回的操作接口。很多人习惯把整个对象先序列化成JSON字符串然后用ValueOperations存一个大字符串。那Hash的优势在哪在于字段级别的读写。比如用户信息缓存HashOperationsString, String, String hashOps stringRedisTemplate.opsForHash(); hashOps.put(user:1001, name, 张三); hashOps.put(user:1001, age, 25); hashOps.put(user:1001, city, 深圳);后面业务只需要用户的city字段直接String city hashOps.get(user:1001, city);这个操作在Redis底层是一个HGET不用把整个对象从网络传输过来再反序列化。如果场景是读取大对象中的个别字段Hash比存JSON字符串省太多IO和CPU。但Hash也有一个要注意的地方put单个字段时会覆盖旧值如果多个线程同时更新同一个user的不同字段最好用putIfAbsent判断一下或者业务上做好幂等控制。另外如果field很多entries(key)一次拿全量也要考虑网络流量尽量按需取字段。3.3 List、Set、ZSet的常用语义和注意点ListOperations的典型场景是消息队列、最新列表、操作日志。用leftPush往队首插入用range(key, start, end)分页拉取。有一点值得提醒rightPush和leftPush插入位置的语义完全相反如果一个模块用leftPush写入另一个模块用rightPopAll消费那顺序就是反的。排查这个问题的时候往往不是因为代码写错而是因为两个模块用的API方向不一致。SetOperations适合做去重、交集、并集运算。比如你可能需要用户关注列表和用户粉丝列表做交集找出互相关注的人SetString intersect stringRedisTemplate.opsForSet().intersect(user:1001:follows, user:1001:fans);这个操作底层是SINTER大数据集下效率非常高。要注意的是intersect返回的是结果集快照如果两个Set都很大结果集也会很大网络上要有心理准备。ZSetOperations是权重排序利器。add(key, value, score)可以维护排行榜。但读取时有个坑rangeWithScores返回的是SetTypedTupleString很多人拿到这个Set去遍历发现顺序对不上。因为TypedTuple本质上是一个带score元素的封装它在Set里的顺序是score排序后的结果但如果你把它塞进HashMap或者用stream的toMap转顺序就会丢失。所以转成有序结构时要明确用LinkedHashMap或者直接用ListTypedTupleString接结果。3.4 BoundOperations同一个key连续操作时别重复拼接key每调用一次opsForValue()、opsForHash()、opsForList()都相当于重新绑定一个操作视图底层还是要拿你的key去执行命令。如果对一个key有多步操作比如先查再写再删每次都写一遍完整的key字符串不仅代码重复度高而且容易把key写错。BoundValueOperationsString, String boundValueOps stringRedisTemplate.boundValueOps(cache:order:1001);声明一次后后续get()、set()、increment()、expire()都不用再传key了。还有一个很多人忽略的好处BoundOperations会把操作请求路径缩短一层从代码可读性和重构安全性来说都有价值。如果你后续打算调整key的命名规范只需要改一处声明的地方而不是全局搜索替换所有ops调用点。这个看起来是小事但真实项目中超过50个调用点的时候你就知道好处了。4. 事务、Pipeline与LuaStringRedisTemplate的高阶玩法基础操作只能应付简单场景。到了要保证批量操作原子性、或者对性能有高要求的场景就得动用事务、Pipeline和Lua了。4.1 事务的坑Transactional对Redis无效新人在Spring Boot项目里写Redis事务第一时间想到的往往是给方法加Transactional注解。但实际上这个注解管的是数据库事务和Redis一点关系都没有。Spring Data Redis对事务的支持是通过SessionCallback接口实现的操作绑定它会把一组Redis命令打包发送在Redis端用MULTI/EXEC执行。ListObject results stringRedisTemplate.execute(new SessionCallbackListObject() { Override public ListObject execute(RedisOperations operations) throws DataAccessException { operations.multi(); operations.opsForValue().set(tx:key1, value1); operations.opsForValue().set(tx:key2, value2); return operations.exec(); } });注意这里有个容易踩的坑在multi()之后你用operations接口做的set、get操作并不会立即返回真实结果而是返回null或者一个排队状态的值这是Redis事务的预期行为。如果你在事务体内做了判断operations.multi(); Boolean result operations.opsForValue().set(tx:key1, value1); if (result) { // 这里永远为null // ... }那业务逻辑就全部错乱了。正确的做法是事务执行完后通过exec()返回的ListObject里拿到每条命令的执行结果。4.2 Pipeline批量写入的极速体验Redis的Pipeline机制本质上就是客户端把一批命令攒在一起最后一次性发送给服务端减少网络RTT。对那种循环几千次、每次单独set的场景Pipeline的优化效果是非常明显的。实测下来批量写入1000条数据不用Pipeline大概需要2到3秒用Pipeline只需要几十毫秒性能提升是数量级的。在StringRedisTemplate中用Pipeline有两种方式。第一种是executePipelinedListObject results stringRedisTemplate.executePipelined(new SessionCallbackObject() { Override public Object execute(RedisOperations operations) throws DataAccessException { for (int i 0; i 1000; i) { operations.opsForValue().set(pipeline:key: i, value i); } return null; } });第二种是用execute方法配合RedisCallback但需要手动处理流水线的命令结果。实际项目中我更推荐executePipelined因为它的返回结果是一一对应的方便校验每条命令是否执行成功。有一个大坑Pipeline模式下命令的返回结果并不会在命令执行时立刻返回到你的回调里而是在所有命令执行完之后一次性返回。所以在回调代码里不要尝试读取中间结果否则会拿到null。另外Pipeline虽然快但不是万能的。它把多条命令打包发送本质上牺牲了每条命令的实时响应。如果业务是那种每写一条就必须立刻确认结果的场景不要用Pipeline。它在批量补数据、初始化缓存、清理临时key时最香。4.3 Lua脚本复杂原子操作的正确打开方式Pipeline解决的是批量操作的性能问题但无法保证这批量操作是一个不可分割的原子操作。如果一批命令中间某个失败了前面已经执行的命令不会回滚。要真正的原子性还得靠Lua脚本。举个例子库存扣减。要求在扣减前判断库存是否够不够就返回失败够就原子扣减。用Java代码分步执行get再set在高并发下必然出现超卖。用Lua脚本一条指令完成判断和扣减就没这个问题local stock tonumber(redis.call(GET, KEYS[1])) if stock tonumber(ARGV[1]) then redis.call(DECRBY, KEYS[1], ARGV[1]) return 1 end return 0在Java里用DefaultRedisScript执行DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(scripts/stock_decr.lua)); script.setResultType(Long.class); Long result stringRedisTemplate.execute(script, Collections.singletonList(stock:item:1001), 1);这里要特别注意三个细节。第一个是脚本文件编码Lua文件必须存为UTF-8无BOM格式否则中文注释会乱码。第二个是setResultType的类型一定得和脚本返回值匹配返回1和0就用Long.class。第三个是Lua脚本在Redis中是缓存的每次执行都会用EVALSHA但如果你改脚本内容后没有及时清理Redis里的脚本缓存老脚本还会继续执行一段时间。4.4 Lettuce连接模型对StringRedisTemplate并发的影响Spring Boot 2.x和3.x默认的Redis客户端是Lettuce它底层基于Netty用一个共享的native connection来执行命令。但这个共享连接在高并发下有个隐藏风险一旦某个命令长期阻塞比如BLPOP、BRPOP或者执行了一个耗时很长的Lua脚本其他命令会被堵在同一个连接上等待表现就是接口响应突然变慢超时甚至抛出RedisConnectionFailureException。我自己就遇到过某次上线后突然大量报错查看详情发现是某个服务调了一个BLPOP超时时间设了30秒这个命令占住了Lettuce的连接其他所有Redis操作全部排队。那次的教训是凡是用阻塞类命令一定要严格控制超时时间比如改为rightPop(key, 2, TimeUnit.SECONDS)这种带超时的变体。如果并发量确实大可以配置Lettuce连接池spring: data: redis: lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0注意这个配置的前提是依赖里引入了commons-pool2否则连接池参数不生效Lettuce还是走共享连接的模型。这个坑很隐蔽配置的时候容易忽略。5. 封装一个不给自己挖坑的Redis工具类把StringRedisTemplate直接注入到各个Service里代码重复率高且风格不统一。很多团队会选择封装一个RedisUtil工具类这个方向没问题但封装得不好反而会引入一堆隐性问题。5.1 先想清楚封装的边界封装之前先问自己一个问题这个工具类是要面向对Redis操作不熟的人的还是面向对Redis有基本认知的人的很多人封装的时候把各种操作一股脑暴露出去包括set、get、delete、expire、hashSet、hashGet、listPush、listPop、zadd……看起来功能齐全但用起来并不舒服。我的建议是只封装那些高频出现的、语义定义清晰的操作。比如set、get、delete、expire、hashPut、hashGet这些足够覆盖八成业务场景。剩下的复杂操作直接暴露原始的StringRedisTemplate反而更好。因为每封装一个方法就多一个理解上的信息损失点封装者定义的语义和调用者的理解稍微不对齐就会出问题。5.2 序列化职责必须独立出来使用工具类的最大风险是调用方传进来一个Java对象但什么都没说你就往Redis里set等到读出来反序列化时发现类型对不上。所以工具类里涉及对象转换的部分要单独定义序列化策略。比如setObject(key, Object object)方法内部用Jackson把对象转成JSON字符串再set。对应的getObject(key, ClassT clazz)反序列化回来。但这里有个问题如果object本身是一个已经序列化好的JSON字符串再套一层序列化就会变成JSON字符串的JSON。这个情况一定要在方法命名里明确区分比如setString和setObject两个方法让调用者自己选清楚。我见过有个项目就是因为在setObject里传了一个JSON字符串导致Redis里所有的value都是双重转义排查了整整两天。对象缓存还有一个序列化细节泛型擦除。用Jackson反序列化时如果你调用getObject(key, clazz)传的是List.class那返回的List里的元素全是LinkedHashMap强转成ListOrder会直接ClassCastException。所以工具类的泛型反序列化要提供TypeReference重载或者要求调用方传入带泛型的Class对象。5.3 默认值、空值和超时工具类的另一个常见问题是Redis里没有key时get返回null这常常导致业务代码里大面积空指针。要么让工具类在返回前统一做一次默认值处理比如提供一个getOrDefault(key, defaultValue)要么在方法注释里强制说明返回null时业务自行处理。这两个方向都可以但必须二选一否则调用方一会儿看到null一会儿看到空字符串很快就会发生混乱。设置超时时间的逻辑也要统一。比如可以在工具类里分两个方法public void set(String key, String value) { stringRedisTemplate.opsForValue().set(key, value); } public void set(String key, String value, long timeout, TimeUnit unit) { stringRedisTemplate.opsForValue().set(key, value, timeout, unit); }第一个方法表示永久key第二个表示带过期时间的key。调用方在选择时就会主动思考这个key该不该过期而不是稀里糊涂全都设成永久最后Redis里堆了一堆永远不用的垃圾数据。6. 线上Connection reset事故复盘一次StringRedisTemplate引发的全面故障分享一个我真实处理过的线上故障它和StringRedisTemplate的关系很大排查过程也比较经典写出来供大家参考。6.1 故障现象某天下午线上监控突然报警服务可用性从99.95%掉到97%。看日志发现大量异常org.springframework.data.redis.RedisConnectionFailureException: Connection reset by peer; nested exception is io.lettuce.core.RedisException: Connection reset by peer异常点集中在所有走StringRedisTemplate的操作上包括get、set、expire。看起来像是Redis连接全部被重置。但Redis服务端监控显示CPU、内存、网络流量都很正常没有明显瓶颈。6.2 排查链路第一步先确认是不是Redis服务器主动断连。查Redis服务端日志发现有很多Client closed connection的日志时间点和报错对得上说明是客户端主动断开连接导致服务端记录了这个事件但服务端这边没杀掉任何连接。第二步转向客户端。我们的应用跑在K8s集群里Pod被重新调度过旧Pod上建立的长连接会因为旧Pod销毁而被重置。但这解释不了为什么新Pod也在持续报错。第三步看代码。查了最近的发布记录有一个版本对某个热点接口做了性能优化把原来的单次Redis读取改成了先get后set的双重检查double-check并且没走StringRedisTemplate的ops而是直接在代码里用executePipelined批量写入。问题就在这里浮出水面了。6.3 根因和修复那批批量写入的代码每次请求会往Redis写入100个key用的是Pipeline。在Pipeline执行过程中如果某个key写入失败整个Pipeline的响应会是一串错误结果。Lettuce处理这种响应时如果发现某个命令编码异常或响应数据异常可能直接关闭底层连接于是其他同时复用到这个连接上的请求就集体报了Connection reset by peer。修复方案分两步第一步把executePipelined的调用改成带超时控制的写法并且加上try-catch把异常隔离在批次内确保Pipeline内某条命令的失败不会拖垮整个连接try { stringRedisTemplate.executePipelined((RedisCallbackObject) connection - { for (String key : keys) { connection.set(key.getBytes(StandardCharsets.UTF_8), value.getBytes(StandardCharsets.UTF_8)); } return null; }); } catch (RedisSystemException e) { log.error(pipeline write failed, count{}, keys.size(), e); }第二步给Lettuce配上了连接池把max-active设置成16避免所有Redis命令挤在一个共享连接上。这次事故的深层教训是StringRedisTemplate本身很稳定但底层连接模型和Pipeline的交互很容易被忽略。在高并发场景下Pipeline确实是性能利器但它也意味着一批命令中一旦有一条异常可能触发底层连接的重建。所以Pipeline的批量操作务必做失败隔离和超时控制。7. 选型指南StringRedisTemplate还是RedisTemplate把这个问题放在最后是因为很多人一开始就纠结但实际上只有把前面的原理和场景都理解透了才能真正知道该怎么选。7.1 一张表讲清楚区别对比维度StringRedisTemplateRedisTemplate默认key序列化StringRedisSerializer明文JdkSerializationRedisSerializer二进制乱码默认value序列化StringRedisSerializer明文JdkSerializationRedisSerializer二进制存储类型必须是String可以是任意Java对象前提是走JDK序列化可读性Redis客户端直接查看方便排查问题二进制基本不可读跨语言兼容好只要按UTF-8编码差Java专属序列化格式性能序列化开销低序列化开销高尤其大数据量时明显使用场景字符串缓存、计数、队列、分布式锁存Java对象且不想手动做JSON转换7.2 什么时候用StringRedisTemplate读到这里其实答案已经很清楚业务里九成以上的Redis操作都应该用StringRedisTemplate。比如缓存用户会话、验证码、分布式锁、计数器、关注列表、排行榜这些场景的value要么本身就是字符串要么把它转成JSON字符串存进去反序列化时再转回来。用StringRedisTemplate的好处是key可读、value可读、出问题好排查、跨语言兼容性好如果未来有非Java服务要消费这些数据JSON字符串直接就能解析。从运维角度讲一个Redis实例能在客户端直接看到key长什么样比对着二进制猜内容要省太多事。我个人的习惯是把JSON序列化的工作放到业务层或者封装的工具类里Redis存取层只认字符串。这样无论将来是换缓存中间件还是扩展其他语言的消费端改动的面积都能控制在最小范围。7.3 什么时候必须用RedisTemplate有一种场景确实需要保留RedisTemplate直接存储Java对象而且不想手动转JSON。比如一个内部系统临时缓存一段复杂数据这个数据除了Java端没有其他消费方而且结构比较复杂、字段多、变化频繁。每次转JSON再转回来确实麻烦。这时候用默认序列化的RedisTemplate没问题但必须接受一个代价Redis里的数据不可读。如果团队决定用RedisTemplate还有一个更好的替换方案保留RedisTemplate这个类但把value序列化器换成GenericJackson2JsonRedisSerializer。这样存储可读性比JDK序列化好得多同时反序列化时能拿到完整的类型信息。但注意这也要求存入的对象具备无参构造函数否则反序列化会失败。最后说一个比较务实的判断标准你的Redis是被当作缓存用还是被当作数据存储用当作缓存用反正丢了也能从数据库拉回来那就优先考虑可观察性用StringRedisTemplate更稳妥。当作数据存储用数据的可靠性、一致性和吞吐量是第一位的序列化方式的选择就要更慎重甚至可以考虑专门的序列化方案比如Protobuf、Kryo但那是另一个复杂话题了不建议在没搞懂StringRedisTemplate之前轻易尝试。
返回列表