RedisTemplate实战避坑:序列化器、事务与管道用法全解析
我先把话放这儿RedisTemplate 这东西项目里几乎天天见但很多人是用砸了才回头补课。线上 Redis 里躺着一堆 \xAC\xED\x00\x05 开头的乱码 key缓存怎么都查不到数据点解多半就是 RedisTemplate 的序列化器没配明白。这篇文章只讲实操里真正会用到的 RedisTemplate 用法和坑从序列化方案选型到高级 API 的正确姿势再到事务、管道、连接管理这些容易翻车的点最后给一份排查速查表。都是我在真实项目里踩过坑、填过土之后留下的经验直接能抄。1. RedisTemplate 的核心机制与序列化方案选型1.1 为什么说序列化器是 RedisTemplate 的灵魂很多初学者刚开始用 RedisTemplate 时满脑子只有 set、get、delete 这几个方法觉得这不就是个封装好的客户端吗直到第一次在 redis-cli 里执行keys *看到一屏幕的\xAC\xED\x00\x05t\x00\x04user这种乱七八糟的 key才意识到事情没有那么简单。我打个比方RedisTemplate 就像你雇了一个跑腿小哥连接管理器他负责把你想存的数据送到 Redis 这个仓库里。但问题是Redis 只认字节流你的 Java 对象它不认识。这时候序列化器就充当了翻译官负责把 Java 对象转换成字节数组存进去再把字节数组读出来转换成 Java 对象。翻译官的水平直接决定了两边沟通的质量。Spring Data Redis 默认给 RedisTemplate 配的是 JdkSerializationRedisSerializer也就是 Java 原生的序列化机制。这个家伙的问题非常明显序列化后的数据含有大量的类型描述信息和类元数据体积膨胀得很厉害产生的二进制数据可读性极差redis-cli 里根本没法排查跨语言调用基本没戏其他系统想读这份数据还得用 Java 反序列化一旦实体类的结构发生变化比如加了字段、改了包名反序列化直接报错所以我的建议非常明确只要项目不是特别在意开箱即用的便利性一定要重新配置序列化器。哪种方案靠谱StringRedisSerializer GenericJackson2JsonRedisSerializer是当前实践中最稳的组合。Key 用 String 序列化是为了保证可读性和避免类型转换问题Value 用 JSON 序列化则兼顾了跨语言和可维护性。当然这个方案也有代价GenericJackson2JsonRedisSerializer 在序列化时会额外写入一个 class 字段来保存类型信息这会导致数据自带类型说明空间上略有膨胀但换来的灵活性完全值得。1.2 一套可以直接抄的 RedisTemplate 配置说再多不如直接上配置。下面这套是我反复在项目里用过的实测稳定照着配基本不会出问题Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(connectionFactory); // Key 使用 String 序列化器 StringRedisSerializer stringSerializer new StringRedisSerializer(); // Value 使用 JSON 序列化器 GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }有人问为什么 Hash 的 key 和 value 也要单独设置因为 Redis 的 Hash 结构在 Spring Data Redis 眼里是另一个维度的数据组织方式如果只设置了顶层 key 的序列化器Hash 内部的 field 和 value 可能还是默认的 JDK 序列化存进去照样一塌糊涂。这四个 setter 缺一不可。还有一个小细节配置完序列化器之后记得调用afterPropertiesSet()这个方法会校验并初始化内部的各个 helper不调用大概率会在运行期遇到奇怪的空指针问题。1.3 StringRedisTemplate 和 RedisTemplate 千万别混用Spring Boot 自动配置里默认有一个 StringRedisTemplate它是一个 key 和 value 都用 StringRedisSerializer 的 RedisTemplate 实例。很多人看到这里觉得无所谓反正都是操作 Redis混用一下又不碍事。碍事。这是我在项目里接到过的最常见的线上问题之一。用 StringRedisTemplate 写入的user:123就是一个纯字符串 key值为{name:张三}。但如果用默认 RedisTemplate没配序列化器去 get 这个 key它内部会用 JDK 序列化器把user:123这个字符串转成一堆 Java 字节码再去查询结果自然是查不到。反过来也一样用 RedisTemplate 写入的数据StringRedisTemplate 也可能读不回来。所以项目里最好定个规矩要么全部用改造后的 RedisTemplate要么全部用 StringRedisTemplate谁也不准中线混搭。如果迫不得已要混用务必确认两边的序列化方案完全一致否则就是自己给自己埋雷。2. RedisTemplate 高频 API 的正确打开方式2.1 set 和 setIfAbsent 的差别用错会出事redisTemplate.opsForValue().set(key, value);这行代码是写缓存最基础的姿势但很多人没注意到它的语义无脑覆盖。如果 key 已经存在新值直接替换旧值。在写缓存这种场景下没问题但如果你把它用在分布式锁、防并发重复提交这类场景里就会出大乱子。正确做法是要区分场景。分布式锁的场景我需要的是只有 key 不存在时才写入成功这样多个线程竞争同一个 key 时只有第一个能拿到锁。这时候必须用setIfAbsentBoolean locked redisTemplate.opsForValue().setIfAbsent(lock:order:123, 1, Duration.ofSeconds(30)); if (Boolean.TRUE.equals(locked)) { try { // 执行业务逻辑 } finally { redisTemplate.delete(lock:order:123); } }注意这里我加了第三个参数过期时间。为什么因为如果只调用 setIfAbsent 不加过期时间一旦业务逻辑执行时间过长或者进程突然挂掉锁就永远不释放后续所有线程全部卡死这就是经典的死锁问题。加了超时时间等于给锁加了一道保险丝到期自动熔断。还有一个细节setIfAbsent返回的是 Boolean 包装类型不是 boolean 基本类型。为什么强调这个因为如果 Redis 连接出现异常这个方法的返回值可能是 null 而不是 false这时候用if (locked)来判空会直接抛空指针。虽然 RedisTemplate 默认会把底层连接异常包装成 Spring 的 DataAccessException但谁会嫌多一层保护呢养成Boolean.TRUE.equals(locked)的习惯没有坏处。2.2 过期时间的设置——不要忽略 TimeUnitRedis 里设置过期时间有两个常用 APIredisTemplate.expire(key, 30, TimeUnit.SECONDS);这个是最常见的写法意思很直白让某个 key 在 30 秒后自动消失。看起来简单但里面蹲着一个特别容易忽略的规则如果 key 不存在expire 返回 false如果要设过期时间的 key 本身就被设置了永久有效这个操作仍然会生效。真正容易出问题的是opsForValue().set(key, value, timeout, unit)这种一步到位的写法它会把写入和过期时间绑定在一起不需要额外调用 expire。我习惯优先使用这种因为它是原子性的省了一次网络往返。关于过期时间的单位一定要养成显式传 TimeUnit 的习惯。虽然 Spring Data Redis 里也有一个直接传秒的重载方法但传 TimeUnit 可以让代码的意图更加清晰后面维护代码的人不用猜这里的数字到底是秒还是毫秒。2.3 Hash 结构操作的细节Hash 是 Redis 里很常用的一种结构RedisTemplate 也有对应的 opsForHash。比如一个用户对象拆成 loginCount、lastLoginTime 这种维度时就适合用 Hash。redisTemplate.opsForHash().put(user:123, loginCount, 5); redisTemplate.opsForHash().increment(user:123, loginCount, 1);increment这个操作要注意它只能作用于可以解析为数值的 value如果你存了一个非数字字符串进去再执行 increment 会直接报错。这个错误比较隐蔽因为从编码上看毫无问题只有运行到那一刻才会炸。Hash 里的 field 和 value 序列化问题我在前一节提过。如果只配了顶层 value 的 JSON 序列化器没有配 Hash 的 value 序列化器存进去再取出来类型信息可能已经丢失。这里我补充一个更细的点Hash 里的 value 如果没有配序列化器默认会走 JDK 序列化它的存储空间比 JSON 格式大好几倍内存成本直接抬升。所以配置的时候一定要把setHashValueSerializer也带上。2.4 批量操作性能优化我见过不少项目在循环里一个个调用 RedisTemplate比如一次性要写入 1000 个 key就 for 循环 1000 次 set。每次 set 都是一次网络往返1000 次就是 1000 次 RTTRound Trip Time网络往返时延本地环境可能感觉不明显但在高网络延迟环境下这个损耗会被放大很多倍。更离谱的还有一种写法循环里调用了 get。比如需要查询 1000 个 key 的值就 for 循环 1000 次 get。这种写法如果遇到 Redis 服务端负载高get 全部完成要等很久很久。正确的做法是用opsForValue().multiGet(keys)ListString keys Arrays.asList(user:1, user:2, user:3); ListObject values redisTemplate.opsForValue().multiGet(keys);multiGet是按照 Redis 的 mget 命令实现的一次网络请求就能把所有 key 的值取回来。同样批量写入也有对应的multiSet。但这里要提醒一句multiSet的语义是全有或全无吗不是Redis 的 mset 并不具备事务性它只是把多次 set 操作合并到一次网络请求里中间某个 key 失败不会回滚其他 key。所以需要原子性的批量写入时还是要走事务或者管道。2.5 Pipeline 管道技术一次请求完成多步操作Redis 的 Pipeline 机制可以把一组命令打包在一次网络请求里发给服务端大幅减少网络往返。RedisTemplate 里通过executePipelined方法支持这种方式ListObject results redisTemplate.executePipelined((RedisCallbackObject) connection - { for (int i 0; i 1000; i) { // 注意这里不要用 redisTemplate 的 API要用 connection 的原生命令 connection.stringCommands().set((key: i).getBytes(), (value: i).getBytes()); } return null; });这 1000 条 set 命令通过一次网络请求就全部发出去了服务端执行完再把结果打包返回。对比 1000 次普通 set 的耗时性能差距非常大。我做过一个简单测试在本地环境里 1000 次普通 set 大约耗时 200ms 左右而 pipeline 方式大约只需要 10ms 左右差距接近 20 倍。使用 pipeline 有两个注意点callback 里不能直接使用 redisTemplate 的 valueOperations必须通过 connection 对象调用原生命令因为 pipeline 模式下命令是打包批量发送的走模板方法反而会走单命令通道RedisTemplate 的executePipelined方法内部会返回命令执行结果的列表但这些结果基本都是字符串或者字节数组形式需要自己再做一次转换pipeline 比较适合大数据量的写入场景比如缓存预热、批量初始化等。2.6 分布式锁的高阶玩法前面讲过 setIfAbsent 可以实现简单的分布式锁但真实项目里总有一些边角问题要处理。比如锁的持有时间设 30 秒但业务逻辑执行了 1 分钟结果锁在业务还没结束时就被自动释放了另一个线程拿到锁进来了两个线程并发执行脏代码分布式锁形同虚设。推荐的解决思路有两条路设置一个足够大的过期时间让业务逻辑在预期范围内执行完毕简单粗暴但遇到极端情况还是可能出问题结合看门狗机制在执行业务期间定期续期保证锁不会中途失效用 RedisTemplate 写一个简单的续期方案并不难本质上就是把过期时间重置。核心是在业务执行过程中每隔一段时间重设一次 expire。这个逻辑可以直接封装在一个工具类里用定时任务或者线程池来触发。当然这种做法更精细要求你对锁的生命周期有很清楚的把控。如果项目对锁的可靠性要求极高可以引入现成的 Redisson 框架它有完善的看门狗实现不过那是另一个话题了这篇文章先不展开。3. 事务、连接管理与高阶使用姿势3.1 RedisTemplate 里的 Transactional 事务是伪命题吗很多人看到 Transactional 就以为数据库和 Redis 能保持事务一致性。实际上RedisTemplate 的事务机制和数据库事务完全不是一个概念。Spring 对 Redis 事务的支持是通过MULTI/EXEC命令实现的它把一组命令放进队列在 EXEC 时一起执行保证的是 Redis 服务端这批命令的原子性而不是业务层面的回滚性。看个典型的场景Transactional public void doSomething() { redisTemplate.opsForValue().set(key1, value1); // 执行业务逻辑 redisTemplate.opsForValue().set(key2, value2); }这段代码里的MULTI和EXEC通过 Spring 的事务管理器协调。但关键是如果业务逻辑在写入 key1 之后抛了异常Redis 事务会回滚key1 不会写入。这看起来是符合直觉的。但如果你用的是自定义的 RedisTemplate默认情况下 Spring Data Redis 并不会自动把事务操作织入 Redis 命令调用链也就是说你得依赖中间的模板方法把命令绑定到当前事务的连接上。很多问题是出现在这里事务管理器和 RedisTemplate 的 session 绑定没有做好导致命令走了不同的连接MULTI和EXEC命令发给了不同连接事务执行失败。实际上Redis 事务的实际应用场景远没有数据库事务那么频繁。因为 Redis 操作本身是单线程快速执行的真正需要事务守卫的场景并不多见。如果你需要在分布式条件下保证多步操作的原子性优先考虑 Lua 脚本方案而不是 Transactional。RedisTemplate 里也提供了对应的execute方法执行 Lua 脚本这才是更符合 Redis 设计哲学的做法。3.2 使用 session callback 正确操作事务RedisTemplate 里提供了execute(SessionCallback)方法用来在同一个连接上执行一组操作。下面是一个经典的使用姿势ListObject results redisTemplate.execute(new SessionCallbackListObject() { Override public ListObject execute(RedisOperations operations) throws DataAccessException { operations.multi(); operations.opsForValue().set(key1, value1); operations.opsForValue().set(key2, value2); try { return operations.exec(); } catch (Exception e) { operations.discard(); throw e; } } });注意这里的写法先是multi()开启事务然后执行多步操作最后exec()提交。如果 exec 抛异常就手动discard()丢弃事务队列。这里面的 key1 写入和 key2 写入之间如果发生异常事务会被丢弃两个 key 都不会写入实现了基本的原子性。这个方法有个细节值得多说一句execute内部的 RedisTemplate 操作只针对当前连接执行不会从连接池里取出别的连接这是保证MULTI和EXEC命令在同一个连接上的基础。如果你在同一个事务里调用额外的 redisTemplate 方法而这个方法内部又触发了新的连接获取那么整个事务就失效了。所以在事务逻辑里不要随便调用额外的模板方法最好全部写在 callback 内部。3.3 连接泄漏问题排查RedisTemplate 和底层连接工厂打交道的方式是每次操作从连接池中获取一个连接用完再还回去。正常情况下这个流程是无缝的但有些特殊操作会导致连接没有正确归还久而久之连接池被占满应用表现为操作 Redis 超时或者拒绝连接。典型的连接泄漏场景有手动获取了 connection 但没有在 finally 块里释放。很多人图方便直接调用redisTemplate.getConnectionFactory().getConnection()用完没有 close在回调函数里处理大数据时抛出了异常导致释放连接的代码没有走到自定义了 RedisCallback内部获取了 reactive connection 却没有释放这些问题的排查思路基本靠监控观察 Redis 服务端的连接数是否只增不减应用侧连接池的活跃连接数是否持续处于高位。一旦确认连接泄漏重点检查代码里是否有手动获取 connection 而没有通过 try-finally 管理的地方。用 execute 方法操作 Redis 的好处是它内部一定会走 finally 把连接归还连接池相当于帮你兜底了连接管理的责任。所以我很少在业务代码里直接操作 connection都是通过 RedisTemplate 的高层 API 来写。3.4 多实例 Redis 的注入与选择如果项目配置了多个 Redis 数据源注入 RedisTemplate 时就要特别小心。默认情况下 Spring 会从容器里找一个唯一的 RedisTemplate Bean 来注入如果存在两个 RedisTemplate Bean你会直接遇到 NoUniqueBeanDefinitionException。推荐的解决办法是定义多个带限定符的 RedisTemplate BeanBean(redisTemplateCache) public RedisTemplateString, Object redisTemplateCache(RedisConnectionFactory cacheConnectionFactory) { // 配置序列化器 return template; } Bean(redisTemplateSession) public RedisTemplateString, Object redisTemplateSession(RedisConnectionFactory sessionConnectionFactory) { // 配置序列化器 return template; }使用的时候通过Qualifier(redisTemplateCache)来指定注入哪一个。有一点要注意如果多个 ConnectionFactory 连的是同一个 Redis 实例那没问题如果连的是不同的实例那每个 RedisTemplate 都应该绑定各自的 ConnectionFactory否则连错实例数据就写岔了。这些细节很容易被忽略但排查起来非常费劲。4. 实战避坑常见问题速查与排查技巧实录4.1 乱码 key 与反序列化异常乱码 key 这个问题的根源我在第一节已经讲清楚了就是序列化器没有配置好或者 RedisTemplate 与 StringRedisTemplate 混用。如果线上已经出现了乱码 key当前的做法是先停掉写入方程序的错误逻辑然后把序列化器统一改成 String 序列化方案再对存量乱码 key 做清理。清理时不要直接在 redis-cli 中手动删除大量 key可以用scan命令游标式遍历删除避免keys *阻塞 Redis 服务。反序列化异常也要单独提一嘴。用 GenericJackson2JsonRedisSerializer 序列化时如果实体类里有一个没有默认构造函数的内部类或者泛型类型在反序列化时无法确定异常信息千奇百怪。我遇到过最坑的一种是序列化时正常反序列化时报 Jackson 的 InvalidDefinitionException提示没有找到无参构造函数。排查了半天才发现实体类内部类没加 static 关键字导致 Jackson 无法通过反射创建实例。用 JSON 序列化的方案时实体类设计一定要遵循一个原则必须有无参构造内部类必须 static尽量不要使用过于复杂的继承结构。4.2 查询不存在的数据引发的缓存穿透缓存穿透是指请求的 key 在 Redis 中不存在在数据库中也不存在导致每次请求都要打到数据库上Redis 被绕过数据库压力陡增。很多人会想到直接把空值也缓存起来但用 RedisTemplate 实现时有个细节redisTemplate.opsForValue().set(key, null, Duration.ofMinutes(5));注意不能只把空值缓存还要给它设置一个较短的过期时间。否则一旦某个 key 被落入永久空值缓存后续真正的数据写入进来时由于这个 key 已经存在写入不一定能覆盖成功取决于你用的 set 还是 setIfAbsent应用层面就会出现数据不一致。更好的做法是用布隆过滤器来拦截不存在的 key。布隆过滤器的特点是不存在的 key 一定不会过存在的 key 有一定概率误判但误判的概率可以通过设置合理的位数组大小和哈希函数个数来控制。这一点对于防御恶意攻击比如拿着随机不存在的 id 批量请求非常有用。4.3 热点 key 与缓存雪崩的代码层面规避热点 key 是指某个 key 被超高并发访问导致单个 Redis 节点成为瓶颈。用 RedisTemplate 直接 get 这个热点 key单个节点能撑住的 QPS 其实有限。如果热点 key 访问量实在太高常规的办法是引入本地缓存比如 Caffeine在应用内存里缓存一份热点数据过期时间设置极短比如 1 秒这样绝大多数请求都在应用内就直接命中了连 Redis 都不需要访问。缓存雪崩的不同点是大量 key 在同一时间集体过期导致请求全部打到数据库。解决的关键思路是给缓存过期时间增加一个随机的抖动值。比如你要让缓存存活 30 分钟可以写成 30 分钟加上一个 0~60 秒的随机偏移量让 key 的过期时间点错开避免大规模同时失效。4.4 慢查询与大 key 治理Redis 的执行是单线程的一个慢查询命令会阻塞后续所有命令的执行。用 RedisTemplate 操作时很多命令在设计之初就存在性能隐患。比如对一个大集合执行opsForList().range(key, 0, -1)会把整个 List 全部取出来如果这个 List 里有几十万条数据Redis 阻塞时间和网络传输开销都很大。大 key 的判断标准没有绝对数字但业内通常以 value 大小超过 10MB 或者集合元素超过 5000 个作为起点来衡量。针对大 key可以做的操作是拆分把一个大 Hash 拆成多个小 Hash每个小 Hash 按照业务维度切分。还有一个更简单的做法是定期扫描大 key用redis-cli --bigkeys或者在代码里用scan命令配合debug object查看编码长度来识别。识别出来之后该删的删、该拆的拆。4.5 RedisTemplate 使用问题速查表问题现象根本原因处理方案redis-cli 里 key 全是乱码序列化器未配置或配置错误统一改用 StringRedisSerializer 作为 key 序列化器get 明明有值却返回 nullRedisTemplate 与 StringRedisTemplate 混用序列化规则不一致统一使用同一种序列化方案setIfAbsent 拿到的锁迟迟不释放没有设置过期时间加过期时间业务执行完主动释放批量写 1000 个 key 特别慢循环单条写入网络往返次数过多用 multiSet 或 pipelineRedis 连接数持续增长连接泄漏手动获取的 connection 未释放使用 execute 方法或确保 finally 中释放连接反序列化时抛出 InvalidDefinitionException实体类缺少无参构造或内部类未加 static补全无参构造内部类加 static缓存雪崩导致数据库被压垮大量 key 在同一时间过期过期时间加随机偏移量Redis 出现超时阻塞存在大 key或执行了慢查询命令拆分大 key优化命令调用方式4.6 一个值得坚持的日常操作习惯最后分享一个我坚持了很久的习惯。在写任何一段 Redis 缓存逻辑之前我都会先在纸上或者脑子里把下面这四件事确认清楚这个 key 的命名规范是什么有没有带业务前缀和参数这个 key 该不该有过期时间过期时间设置多长这个 key 被并发访问时最坏的情况是什么样的这个 key 如果丢失或者数据不一致会影响什么业务这四个问题问下来基本上能筛选掉 80% 的低级设计错误。比如有些缓存数据根本不需要缓存有些 key 压根没必要设置永久过期有些并发场景需要的是原子操作而不是先读后写。这些问题在设计阶段想清楚比出了问题再排查要节省太多时间。RedisTemplate 本身是一个工具工具用得好不好其实主要看使用者的习惯。把这四个问题作为日常操作的基本盘你对 Redis 的使用就基本不会再出大岔子了。