做后端这一年多RedisTemplate 算是被我用得最多的 Redis 客户端封装了。很多人一上来就把它当成“往 Redis 里塞数据”的工具等缓存里出现一串\xAC\xED开头的乱码、或者同一个 key 用两个方法操作互相读不到、又或者线上 Redis 突然 CPU 飙高才发现问题的时候才会回头去研究 RedisTemplate 底层的序列化和连接管理。这篇文章我就围绕 RedisTemplate 的核心用法、序列化机制、工程化注意事项和几个经典实战场景展开适合刚接触 Spring Boot Redis 的同学也适合已经在用 RedisTemplate 但想理清楚底层原理的人。1. RedisTemplate 到底是什么定位与 Spring 集成原理1.1 为什么业务代码不直接操作 Jedis 或 Lettuce很多初学者会有这个疑问Spring Boot 项目里引入 redis 依赖后明明直接注入StringRedisTemplate就能用为什么还要关心底层是 Jedis 还是 Lettuce先把这个说透。RedisTemplate是 Spring Data Redis 提供的一个高层次的模板类它把连接创建、资源释放、异常转换、序列化、事务回调解等脏活都封装好了。如果你直接用 Jedis你得自己管理 Jedis 连接池、处理异常、把 Java 对象转换成字节数组再传进去。比如你要缓存一个 User 对象直接写原生 Jedis 可能是这样的Jedis jedis jedisPool.getResource(); try { byte[] key (user: userId).getBytes(StandardCharsets.UTF_8); byte[] value objectMapper.writeValueAsBytes(user); jedis.setex(key, 3600, value); } finally { jedis.close(); }这段代码看着还行但一旦项目里有几十个业务都要操作 Redis每个地方都要处理序列化和连接池资源代码会非常啰嗦而且很容易忘记释放连接。RedisTemplate 的价值就在这里它把这种“获取连接 → 序列化 → 执行命令 → 释放连接”的流程统一收口redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS)一行就完成了上面的逻辑。从工程角度讲RedisTemplate 还保证了异常类型的统一。底层无论使用 Jedis 还是 Lettuce抛出的底层连接异常都会被 Spring Data Redis 转换成DataAccessException体系这样业务代码里 catch 的时候不需要关心具体驱动是什么。1.2 Spring Boot 自动装配一个连接工厂到 Template 的过程在 Spring Boot 2.x 时代默认的 Redis 客户端是 Lettuce引入spring-boot-starter-data-redis之后框架会自动装配一个RedisConnectionFactory最常见的就是LettuceConnectionFactory。Spring Boot 的RedisAutoConfiguration会检测到这个连接工厂然后默认创建两个 BeanredisTemplate类型是RedisTemplateObject, Object默认使用 JDK 序列化。stringRedisTemplate类型是RedisTemplateString, Stringkey 和 value 默认都用 String 序列化。这两个 Bean 虽然默认存在但redisTemplate默认的 JDK 序列化在实际开发中基本都要重写后面第三部分我会详细说。如果你用的是 Spring Boot 3.x连接工厂变为了LettuceConnectionFactory的另一种形态但使用方式没有本质区别。部署环境不同Redis 的安装方式也会影响连接配置。有些人想在本机装 Redis注意官方其实没有 Windows 原生版本Windows 下比较省事的方式是用 WSL 跑 Linux 环境或者用 Docker 拉一个 redis 镜像提示直接docker run -d -p 6379:6379 --name redis redis:7-alpine是最快的本地 Redis 安装方式适合学习和开发环境。生产环境建议用云上托管的 Redis连接配置改为 TLS 或私有网络地址。连接工厂的核心配置就是 host、port、password、database、连接池参数。Lettuce 底层基于 Netty 实现默认是共享连接的方式但如果你配置了连接池它也会通过commons-pool2管理连接。配置文件里常见的一段是这样的spring: data: redis: host: 192.168.1.100 port: 6379 password: ${REDIS_PASSWORD} lettuce: pool: max-active: 32 max-idle: 8 min-idle: 0 max-wait: 3s注意Spring Boot 2.x 里配置前缀是spring.redis.*Spring Boot 3.x 改成了spring.data.redis.*很多人升级到 Spring Boot 3 后发现 Redis 连不上大概率就是配置前缀没改。1.3 RedisTemplate 的泛型设计与两个典型实例RedisTemplate 是个泛型类定义是RedisTemplateK, VK 和 V 分别代表 key 和 value 的类型。这个泛型设计意味着你在使用时可以针对不同业务定义不同类型的 template但大多数项目里我们只会配置两种RedisTemplateString, Objectkey 用字符串value 用 JSON 序列化后的对象适用于缓存复杂业务对象。RedisTemplateString, Stringkey 和 value 都用字符串像StringRedisTemplate就是这种类型适用于简单的缓存值、分布式锁、计数器。泛型还牵涉到序列化的问题。Java 泛型在运行时会被擦除RedisTemplate 底层的序列化器必须在运行时就确定不能依赖编译期的泛型参数。所以配置 RedisTemplate 时必须针对 key、value、hashKey、hashValue 分别指定序列化器否则就会用默认的 JDK 序列化。2. RedisTemplate 核心 API 实操覆盖典型 Redis 数据类型2.1 String 类型操作opsForValue 的常用姿势opsForValue()返回ValueOperationsK, V对应 Redis 的字符串类型这是最常用的操作接口。基本写法// 注入在 Spring 管理的类中直接注入 Autowired private StringRedisTemplate stringRedisTemplate; // 设置值同时指定过期时间 stringRedisTemplate.opsForValue().set(sms:code: phone, code, 5, TimeUnit.MINUTES); // 读取 String code stringRedisTemplate.opsForValue().get(sms:code: phone); // 删除 stringRedisTemplate.delete(sms:code: phone); // 自增 / 自减用于计数器 Long count stringRedisTemplate.opsForValue().increment(visit:count: articleId);这里面有一个非常容易被忽略的点setIfAbsent方法对应 Redis 的SETNX命令它在秒杀扣减库存、分布式锁场景下几乎是标配。// 只有 key 不存在时才设置成功返回 true Boolean locked stringRedisTemplate.opsForValue().setIfAbsent(lock:seckill: skuId, request-123, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 执行业务 } finally { stringRedisTemplate.delete(lock:seckill: skuId); } }这里有个坑setIfAbsent的返回值是Boolean不是基础类型boolean。直接拿它跟true比较可能会有拆箱空指针的问题因为如果 Redis 连接出错可能返回 null。保险的做法是Boolean.TRUE.equals(locked)。String 类型还会出现在很多“非人类可读”的场景里比如用 Bitmap 做用户签到。opsForValue().setBit可以操作字符串的位但日常业务中用得比较少这里不展开。2.2 Hash 类型操作对象字段的精确更新Hash 类型非常适合存储对象的部分字段比如用户信息中的昵称、头像、等级而不是把整个对象序列化成一个 JSON 字符串塞进 String 类型。这样当你只需要修改一个字段时用opsForHash的put就能直接更新字段而不需要读取整个 JSON 再重新序列化。Autowired private RedisTemplateString, Object redisTemplate; // 新增与更新字段 redisTemplate.opsForHash().put(user:info: userId, nickname, 老王); redisTemplate.opsForHash().put(user:info: userId, level, 99); // 获取单个字段 Object level redisTemplate.opsForHash().get(user:info: userId, level); // 获取所有字段 MapObject, Object infoMap redisTemplate.opsForHash().entries(user:info: userId); // 增量操作 redisTemplate.opsForHash().increment(user:info: userId, score, 10);Hash 相关的序列化坑是重灾区。默认情况下hashKey和hashValue如果没配置序列化器就会用 JDK 序列化这时候你去 Redis Insight 里看Hash 的 field 会是一串二进制乱码根本没法排查。后面第三部分我会给出一套完整的配置方案。用 Hash 还有一个好处是内存效率更高尤其是在缓存大量对象时比每个字段单独设置一个 String key 省了很多 Redis 内部的 key 管理开销。2.3 List、Set、ZSet 类型的工程化使用List 类型主要用来实现简单的消息队列和最近列表。比如我做过一个“用户操作日志”的需求只需要保存每个用户的最近 50 条操作记录用 List 就很合适// 左侧插入右侧截断保证只保留最近 50 条 String key user:ops: userId; redisTemplate.opsForList().leftPush(key, operationJson); redisTemplate.opsForList().trim(key, 0, 49);Set 类型适合做去重统计比如“今天登录的用户”。ZSet 则是我个人最喜欢的类型它天然支持排行榜。在真实业务里我做过“热销商品榜”“用户活跃榜”“文章热度榜”核心 API 如下// 商品销量1 redisTemplate.opsForZSet().incrementScore(rank:hot:sale, sku: skuId, 1); // 获取前10名带分数 SetZSetOperations.TypedTupleObject top10 redisTemplate.opsForZSet().reverseRangeWithScores(rank:hot:sale, 0, 9); // 获取某个商品的名次 Long rank redisTemplate.opsForZSet().reverseRank(rank:hot:sale, sku: skuId); // 总计多少个商品 Long size redisTemplate.opsForZSet().zCard(rank:hot:sale);ZSet 背后的实现是跳表加哈希表incrementScore的时间复杂度是 O(log n)即使集合里有几十万个成员性能也完全扛得住。但如果排行榜数据量达到千万级别建议定期做聚合和降档比如只保留前 1000 名避免 ZSet 无限膨胀。2.4 批量读写与 Pipeline 优化批量操作最容易踩的坑是“用 for 循环逐个 set”。如果你有 10000 条数据要写入 Redis每个 set 都是一次网络 round-trip耗时是批量操作的几十倍。Spring Data Redis 提供了executePipelined方法可以把多条命令一次性发给 Redis。ListObject results redisTemplate.executePipelined(new SessionCallbackObject() { Override public Object execute(RedisOperations operations) throws DataAccessException { for (OrderDO order : orderList) { operations.opsForValue().set(order: order.getId(), JSON.toJSONString(order)); } return null; } });这个写法里有几个细节需要说明Pipeline 期间不会立刻返回结果而是统一在executePipelined结束时返回。在 Pipeline 里用的应该是 RedisCallback 或者 SessionCallback而不是普通 Java 的 for 循环。如果 Pipeline 里的命令特别多对 Redis 服务端的内存会有压力建议分批处理比如每批 5000 条。注意Pipeline 不是事务它只是把命令批量发送中间如果某条命令失败不会回滚其他命令。想保证多个操作的原子性得用multi()事务或者 Lua 脚本。3. RedisTemplate 序列化机制最容易被忽视又最容易出事的一环3.1 RedisSerializer 家族四类核心序列化器对比RedisTemplate 里的 key、value、hashKey、hashValue 都有单独的序列化器接口是RedisSerializerT。常见的实现有序列化器特点适用场景StringRedisSerializer底层用 UTF-8 编码不转义对象key、hashKey 的首选JdkSerializationRedisSerializer默认方案Java 对象序列化为二进制格式不推荐跨语言或调试场景Jackson2JsonRedisSerializer把对象序列化为 JSON 字符串value 序列化为简单 JSONGenericJackson2JsonRedisSerializer序列化时自动带上class类型信息反序列化时能还原对象类型重点说下GenericJackson2JsonRedisSerializer和Jackson2JsonRedisSerializer的区别。普通Jackson2JsonRedisSerializer在反序列化时需要明确指定目标类型比如new Jackson2JsonRedisSerializer(User.class)。而GenericJackson2JsonRedisSerializer会往 JSON 里写入class字段反序列化时通过这个类型信息自动还原成 Java 对象。看起来更省事但代价是 JSON 字符串里多了类型信息而且它对泛型、内部类的兼容性偶尔有坑。从安全角度讲如果业务上不要求反序列化回原来的完整对象类型我建议直接用StringRedisSerializer存 JSON 字符串读取时用 JSON 工具手动转这样最可控也最容易排查问题。3.2 JDK 序列化的坑缓存里的 \xAC\xED 乱码Spring Boot 默认创建的RedisTemplateObject, Object使用的序列化器是JdkSerializationRedisSerializer它就是导致乱码的元凶。Java 原生的序列化输出以0xAC 0xED开头你去看 Redis 的 key就会看到类似\xAC\xED\x00\x05t\x00\x04user这样的乱码。这不是 Redis 崩了而是 RedisTemplate 把 Java 对象通过ObjectOutputStream序列化成字节流了。这个默认方案有三宗罪没法跨语言访问缓存数据。Java 序列化产物是二进制的别的服务用 Go、Python、Node.js 读这个缓存时根本解析不了。存储空间大。JDK 序列化产生的字节流包含大量类描述信息比同样内容用 JSON 序列化大好几倍。反序列化强依赖类结构。如果 Java 类的serialVersionUID变了反序列化直接抛异常缓存就废了。所以实践里几乎 99% 的项目都会重写 RedisTemplate 的序列化配置。3.3 一套经过验证的 RedisTemplate 序列化配置方案这里直接给一套我用了很久的配置类兼顾可读性、可维护性和对象反序列化Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // key 和 hashKey 都用 String 序列化保证 Redis 里可读 StringRedisSerializer stringSerializer new StringRedisSerializer(); // value 使用 GenericJackson2JsonRedisSerializer自动写入类型信息 GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashKeySerializer(stringSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }这里有三个细节说明一下afterPropertiesSet()必须调用这个方法会校验所有序列化器是否设置完整如果没有调用某些场景下会出现序列化器为 null 的异常。key和hashKey用 String 序列化是为了在 Redis 客户端里能看到原始 key。如果你用 Object 序列化 key调试成本很高。value用GenericJackson2JsonRedisSerializer这样即使你存储的是HashMap、对象、List反序列化时都能还原类型。配置好之后你往 Redis 里写入一个对象存进去的内容就是一段 JSONRedis Insight 里一眼就能看出数据内容和含义。3.4 StringRedisTemplate 和 RedisTemplate 混用的典型问题这个坑我在项目里见到太多次了代码里一会儿用StringRedisTemplate写一会儿用RedisTemplateString, Object读结果读出来是 null 或者反序列化报错。原因很简单StringRedisTemplate的 key 和 value 都用的 String 序列化而你没配置过RedisTemplate的话它用的是 JDK 序列化。同一个字符串 key经过两种不同序列化器处理后在 Redis 里根本不是同一个字节序列所以互相都读不到。解决办法是二选一统一使用自定义配置的RedisTemplateString, Object让它和StringRedisTemplate的 key 序列化方式一致。只使用StringRedisTemplate存 JSON 字符串读取后再手动转对象。我个人的习惯是如果项目规模不大优先用StringRedisTemplate所有缓存值统一存 JSON 字符串。因为这样最透明出了问题直接看 Redis 里的数据就能定位。只有明确需要存复杂对象并且对反序列化能力有要求时才使用自定义的RedisTemplate。4. 真实项目中的高频雷区与排查避坑指南4.1 泛型擦除导致的类型转换异常前面提过 Java 泛型会擦除RedisTemplate 反序列化后返回的是 Object业务代码里经常会习惯性强转User user (User) redisTemplate.opsForValue().get(user: userId);这段代码可能出现ClassCastException原因不一定是你存错了对象而可能是反序列化结果本身不是User类型。比如你用了Jackson2JsonRedisSerializer但没有指定泛型目标类型反序列化出来的是一个LinkedHashMap强转成 User 必然失败。解决办法有两种一种是使用GenericJackson2JsonRedisSerializer让 JSON 里带类型信息另一种是读取时用 JSON 工具手动转String json stringRedisTemplate.opsForValue().get(user: userId); User user JSON.parseObject(json, User.class);手动转的类型是你自己指定的最可控不会出现“读出来是 LinkedHashMap”的惊吓。4.2 RedisTemplate 里的事务与 execute 语义Spring 的Transactional注解并不会自动应用到 Redis 操作上。很多人以为在方法上加了Transactional方法里的 RedisTemplate 操作就会进入 Redis 事务这是错误的。RedisTemplate 的execute方法虽然接受事务回调但默认enableTransactionSupport是 false。想用 Redis 事务需要在一个SessionCallback里调用multi()和exec()ListObject results redisTemplate.execute(new SessionCallbackListObject() { Override SuppressWarnings(unchecked) public ListObject execute(RedisOperations operations) throws DataAccessException { operations.multi(); operations.opsForValue().set(key1, value1); operations.opsForValue().set(key2, value2); return operations.exec(); } });如果你必须和数据库事务保持同步需要开启setEnableTransactionSupport(true)这样 RedisTemplate 会与 Spring 事务同步但这么做要非常谨慎因为 Redis 事务本身不支持回滚一旦执行了exec()前面的命令已经生效就不会因为后续代码抛异常而回滚。我的实践经验是不要让 Redis 事务承担过于复杂的业务逻辑。Redis 的事务模型和数据库事务完全不同最多用在“多个 key 要同时写入”的简单场景。更推荐用 Lua 脚本保证原子性。4.3 连接池、超时与资源释放使用 RedisTemplate 时不需要手动释放连接这既是便利也是隐患。连接池参数设置不合理时会出现很多诡异的现象。常见的一个现象是“Redis 连接池耗尽”报错信息通常是Unable to connect to Redis或者RedisConnectionFailureException。排查时先看配置max-active设的是多少如果业务并发很高max-active8很容易不够用。max-wait是否设置如果不设置获取连接时可能无限等待请求线程被 hang 住。max-idle和min-idle是否合理在流量突增时如果 max-idle 太小频繁创建/销毁连接会导致延迟波动。Lettuce 还有一个特点它默认是异步多路复用连接虽然看起来连接数不多但如果配置了连接池实际行为会不一样。我曾经遇到过一个场景某个服务同时接了 Redis 和数据库高峰期 Redis 超时后连接池的等待线程把 Tomcat 线程池也打满了最终导致整个服务不可用。这个问题的根源不是 Redis 本身慢而是连接池的max-wait设置得太长。一个比较稳妥的配置是spring: data: redis: timeout: 3s lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 max-wait: 500ms别把timeout设得太长超过几秒的等待对大多数业务来说已经不能接受了。宁可快速失败然后走降级逻辑也别让线程全部阻塞住。4.4 缓存穿透、击穿、雪崩与 RedisTemplate 的应对这三个概念大家应该不陌生。穿透是查询一个不存在的 key每次请求都打到数据库击穿是某个热点 key 过期瞬间大量请求打爆数据库雪崩是一大批 key 同时过期导致整体流量涌向数据库。用 RedisTemplate 处理这三种问题核心实践有几个穿透对不存在的 key 也写入一个短暂的缓存空值比如空 JSON 字符串TTL 设置成 60 秒。注意如果缓存了空值业务读取时要能区分“空值”和“无数据”。Object cache opsForValue().get(key); if (cache null) { Object result dao.queryById(id); if (result null) { opsForValue().set(key, null, 60, TimeUnit.SECONDS); } else { opsForValue().set(key, JSON.toJSONString(result), 10, TimeUnit.MINUTES); } }击穿对热点 key 使用互斥锁只有一个线程能重建缓存其他线程短暂 sleep 后重试。RedisTemplate 实现互斥锁就是前面提到的setIfAbsent。雪崩给缓存设置 TTL 时加入随机值避免所有 key 在同一时间过期。比如基础 TTL 是 1 小时再加一个 0 到 300 秒的随机扰动。这里要提一个细节RedisTemplate 的set方法如果不指定 TTLkey 默认永久有效。很多初学的人写完缓存忘了设过期时间导致 Redis 内存持续膨胀最终服务 OOM。所以我在项目里定了一个规范所有的缓存写入必须显式指定过期时间。5. 完整实战用户信息缓存 实时排行榜 分布式锁5.1 用户信息缓存设计场景用户表经常被查询但用户信息变更频率不高。我们把用户信息缓存到 Redis采用 Hash 结构字段是昵称、头像、等级、积分。Service public class UserCacheService { Autowired private RedisTemplateString, Object redisTemplate; private static final String USER_KEY_PREFIX user:info:; public UserVO getUserById(Long userId) { String key USER_KEY_PREFIX userId; // 从缓存读取 MapObject, Object entries redisTemplate.opsForHash().entries(key); if (!entries.isEmpty()) { return mapToUserVO(entries); } // 查数据库这里省略 UserDTO user userDao.selectById(userId); if (user null) { // 缓存空值防止穿透 redisTemplate.opsForHash().put(key, empty, ); redisTemplate.expire(key, 1, TimeUnit.MINUTES); return null; } // 写入缓存 redisTemplate.opsForHash().put(key, nickname, user.getNickname()); redisTemplate.opsForHash().put(key, avatar, user.getAvatar()); redisTemplate.opsForHash().put(key, level, user.getLevel()); redisTemplate.opsForHash().put(key, score, user.getScore()); redisTemplate.expire(key, 30, TimeUnit.MINUTES); return user; } }这个设计的好处是修改某个字段不需要回写整个缓存对象也方便后面对接用户积分变动这种高频场景。如果缓存对象里有布尔值、数字、字符串用 GenericJackson2JsonRedisSerializer 反序列化时Redis 里存储的 JSON 会保留类型信息能避免“数字变成字符串”之类的意外。5.2 基于 ZSet 的实时排行榜排行榜需求里最核心的两个操作是“加分”和“查询名次”。用 RedisTemplate 的opsForZSet实现非常优雅// 加分接口 public void addScore(Long userId, double score) { redisTemplate.opsForZSet().incrementScore(rank:user:score, String.valueOf(userId), score); } // 查询前100名 public ListUserRankVO topN(int n) { SetZSetOperations.TypedTupleObject range redisTemplate.opsForZSet().reverseRangeWithScores(rank:user:score, 0, n - 1); ListUserRankVO list new ArrayList(); int rank 1; for (ZSetOperations.TypedTupleObject tuple : range) { UserRankVO vo new UserRankVO(); vo.setRank(rank); vo.setUserId(Long.valueOf((String) tuple.getValue())); vo.setScore(tuple.getScore()); list.add(vo); } return list; }实战中要注意ZSet 的 member 一定要稳定唯一。我用 userId 转成字符串这样即使 userId 换绑排行榜数据也不会错乱。如果积分有小数ZSet 的 score 是 double注意精度问题。特别大的积分数据建议用整数避免浮点误差。处理并发加分时incrementScore是原子操作不用担心两个请求把积分加丢。排行榜数据量变大后我还建议定时做一个“降维”把一周前的明细数据清理掉只保留汇总数据。否则 ZSet 里堆积上千万条 member虽然查询还是 O(log n)但内存成本会很高。5.3 RedisTemplate 实现一套可用的分布式锁分布式锁是 Redis 使用的高频场景RedisTemplate 让实现变得非常简单。public class RedisLock { private final RedisTemplateString, Object redisTemplate; public boolean tryLock(String lockKey, String requestId, long expireSeconds) { Boolean success redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, expireSeconds, TimeUnit.SECONDS); return Boolean.TRUE.equals(success); } public void unlock(String lockKey, String requestId) { // 使用 Lua 脚本保证判断和删除是原子操作 String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; DefaultRedisScriptLong script new DefaultRedisScript(luaScript, Long.class); redisTemplate.execute(script, Collections.singletonList(lockKey), requestId); } }几个关键点requestId必须是唯一的用来标识“谁持有这把锁”释放时只能删除自己持有的锁防止误删别人的锁。加锁时必须同时设置过期时间不能先setIfAbsent再expire否则如果中间进程崩溃锁就永远不释放。解锁用 Lua 脚本保证原子性。如果不用 Lua先get再delete之间存在并发窗口可能在锁已经过期、别人重新加锁后误删掉新锁。这个实现只适合单机 Redis 和简单的分布式场景。如果你的服务跨多个机房、要求高可用需要引入 Redisson 这类封装了看门狗与红锁逻辑的库但基础原理还是一样的SETNX 过期时间 原子释放。6. 常见问题速查表与排查思路为了方便排查我把平时遇到最多的 RedisTemplate 问题整理成一个速查表症状可能原因排查与解决KEY 在 Redis 里显示乱码key 使用了 JDK 序列化给 RedisTemplate 配置 String 序列化器同一个 key 写进去读出来为 null不同 RedisTemplate 实例序列化方式不一致统一使用一个 RedisTemplate或统一用 StringRedisTemplate反序列化报 ClassCastException泛型类型信息丢失用 GenericJackson2JsonRedisSerializer 或手动 JSON 转对象大量请求超时连接池耗尽或 Redis 负载过高检查连接池参数、Slow Log、大 key事务操作未生效未开启 enableTransactionSupport确认是否在 execute 里使用了 multi/exec缓存数据无限增长写入未设置 TTL所有缓存写入统一指定过期时间缓存穿透空值未缓存缓存空值并设置短过期时间分布式锁误删没有身份标识用 requestId Lua 脚本保证原子删除排查 RedisTemplate 问题时第一步永远是“先看 Redis 里到底存了什么”。用 Redis Insight 或者 redis-cli 直接查看 key 的 TTL、编码、字节序列能快速判断是不是序列化问题。第二步才是看代码确认是否混用了多个 Template 实例。Redis 服务端层面的问题也不要忽略。大 key、慢查询、内存淘汰策略都会让 RedisTemplate 的表现异常。比如缓存里存了一个特别大的 ListopsForList().range(0, -1)一次拉出几十 MB 数据网络传输和反序列化都会成为瓶颈。结尾做后端这几年我在 RedisTemplate 上栽过的跟头基本都写在这篇文章里了。最深的体会是RedisTemplate 的上手成本很低但真正的工程价值都在细节里——序列化配置、连接池参数、TTL 策略、事务边界。你在写opsForValue().set的时候心里最好能浮现它背后的 Redis 命令、序列化器、连接获取过程而不是把它当一个黑盒随便调。缓存相关的故障往往不是突发的大问题而是日积月累的坏味道堆积出来。如果你正在做团队的基础组件我建议把 RedisTemplate 的序列化配置、TTL 规范、key 命名规范固定下来写进项目的开发手册。这一步做扎实了后面能少踩无数个坑。