
1. 客户端选型为什么一条 SET 命令有这么多讲究很多人对 Redis 的第一次接触都是redis-cli敲几个命令set一下get一下感觉这玩意简单得不像话。但真正把 Redis 接进业务代码之后问题就全冒出来了连接数莫名其妙打满、存进去的中文变成一堆带\xac\xed前缀的乱码、明明设了过期时间却越来越多 key、线上突然报Could not get a resource from the pool。这些问题九成都不在 Redis 服务端而在客户端这一层。这篇就聊Jedis 客户端和Spring Data Redis 客户端这两条最主流的技术路线把连接池参数怎么算、序列化为什么乱码、模板类怎么配、缓存注解怎么用得靠谱一条条拆开讲。先说清楚这两者到底是什么关系这是新手最容易混淆的地方。Jedis是一个直连 Redis 的 Java 客户端它做的事情非常薄把 Redis 的 RESP 协议封装成 Java 方法jedis.set(k, v)背后基本就是一次 socket 写入加一次读取几乎没有中间层。它简单、直接、性能好但线程不安全一个 Jedis 实例不能跨线程共享所以生产环境必须配连接池。而Spring Data Redis是 Spring 生态里的一个抽象层它不直接实现协议而是依赖底层的连接工厂——可以是 Jedis也可以是 Lettuce。在它之上提供了RedisTemplate这种模板类、序列化器机制、Cacheable注解缓存、RedisCacheManager等一整套东西把 Redis 的各种数据结构包装成了 Spring 风格的 API。理解了这个分层后面的很多坑就都有解释了。RedisTemplate存进去的东西redis-cli看不到是序列化器的问题RedisTemplate报连接超时可能是底下 Jedis 连接池配错了或者 Lettuce 的共享连接被阻塞了用注解缓存发现 key 的前缀和自己预期不一样那是RedisCacheManager的前缀规则在起作用。抽象层带来的是开发效率代价是你必须知道它在底下替你做了什么否则出问题时你在上层代码里怎么找都找不到原因。这篇内容适合三类人刚把 Redis 接进项目、还在被序列化乱码困扰的开发者已经用了RedisTemplate但只会opsForValue().set()这一招、想搞清楚连接池和超时配置的工程师以及准备在项目里做技术选型、需要判断 Jedis 和 Lettuce 到底该选哪个的技术负责人。全程都会给可直接抄的配置和代码参数为什么这么定也会算给你看。2. Jedis把连接管理这件事做到位就赢了一半2.1 从最短的可用代码说起以及它为什么不能上生产最朴素的 Jedis 用法长这样Jedis jedis new Jedis(127.0.0.1, 6379); jedis.set(user:1:name, 张三); String name jedis.get(user:1:name); jedis.close();这段代码在本地跑没问题放到生产环境就是个定时炸弹。原因有两层。第一层是每次new Jedis()都是一次完整的 TCP 三次握手如果开了认证还要多一次 AUTH 往返加了SELECT库还要再来一次。一次本地连接可能只要零点几毫秒跨机房可能就是好几毫秒而 Redis 单条命令的处理时间通常在微秒级——也就是说你把 90% 以上的时间花在了建立连接上而不是执行命令。第二层更致命jedis.close()一旦因为异常没被执行到这个连接就永久泄漏了。Jedis 实例持有的是真实的 socket不关闭就是文件句柄占用攒到一定数量服务端maxclients撑满或者本机 fd 耗尽整个服务直接拒绝服务。所以生产环境的标准姿势只有一个连接池 try-with-resources。GenericObjectPoolConfigConnection poolConfig new JedisPoolConfig(); poolConfig.setMaxTotal(64); poolConfig.setMaxIdle(32); poolConfig.setMinIdle(8); poolConfig.setMaxWait(Duration.ofMillis(1000)); poolConfig.setTestOnBorrow(false); poolConfig.setTestWhileIdle(true); poolConfig.setTimeBetweenEvictionRuns(Duration.ofSeconds(30)); JedisPool jedisPool new JedisPool(poolConfig, 127.0.0.1, 6379, 2000, password, 0); try (Jedis jedis jedisPool.getResource()) { jedis.set(user:1:name, 张三); }try-with-resources保证了无论中间抛什么异常close()都会被调用而池化后的close()实际语义是归还给池不是真的断开。这一点很多人不知道以为每次close都要重连所以宁可手动管理——恰恰相反池化之后close是极轻量的操作。注意Jedis 4.x 之后 API 有变化setMaxWaitMillis(long)被标记为过时推荐用setMaxWait(Duration)JedisPoolConfig的泛型参数变成了Connection。如果你手上的项目还是 Jedis 2.x/3.x编译报错大概率就是这个原因别急着怀疑 Spring 版本冲突。另外 Jedis 5.x 推出了JedisPooled它内部自己管池写法更接近RedisTemplate但团队里如果有大量存量代码不建议为了尝鲜去改。2.2 连接池参数那几个数字到底该怎么算连接池配错是线上事故的高发区。配小了高峰期线程全在等连接接口 RT 飙升配大了Redis 服务端连接数暴涨触发maxclients限制新连接直接被拒。所以这几个参数不能拍脑袋得有依据。先看核心参数的作用和推荐算法参数含义配置建议配错的后果maxTotal池内最大连接数按峰值并发 × 1.5~2 倍冗余估算再除以应用实例数校验太小则线程排队等连接太大则打满服务端maxclientsmaxIdle最大空闲连接数取maxTotal的 50%~75%太小导致连接频繁创建销毁太大会话长期空占资源minIdle最小空闲连接数取maxTotal的 10%~25%或等于日常并发量太小导致流量突增时有冷启动延迟maxWait借连接的最长等待时间500ms~2000ms必须小于接口超时时间太长会把线程池拖死太长也说明池子配小了testOnBorrow借出时是否 PING 校验生产建议false开启后每次借连接多一次往返QPS 高时损耗明显testWhileIdle空闲时是否校验建议true不开启则网络闪断后的死连接会被借出去业务报错timeBetweenEvictionRuns空闲连接检测周期30s~60s太长则死连接清理不及时maxTotal的估算有个参考公式理论并发连接数 峰值 QPS × 单条命令平均耗时(秒) maxTotal ≈ 理论并发 × 冗余系数(1.5~2) 且不超过 Redis 服务端 maxclients / 应用实例数举个具体例子。某服务的峰值是 3000 QPS 打在 Redis 上单条命令平均 RT 0.5ms那么理论并发 3000 × 0.0005 1.5 个连接——听起来 2 个就够了。但实际业务里一定有慢命令比如KEYS、大 key 的HGETALL、复杂的ZRANGE也有网络抖动还会有批量操作占着连接不放所以按 10 倍甚至 20 倍冗余取到 30~50 是合理的。再检查服务端Redis 默认maxclients是 10000如果这个服务部署了 20 个实例那 20 × 64 1280只占了 12.8%还有大量业务共用同一个 Redis 的话就要把所有客户端的maxTotal加起来做总量评估。实操心得minIdle这个参数被严重低估了。我见过有团队maxTotal200、minIdle0结果每次流量波峰到来时前几秒全在疯狂建连接接口 P99 直接翻倍。把minIdle设成日常并发量的水平相当于给系统预留了一批热连接波峰到来时直接可用这点成本非常划算。2.3 三处超时漏配任何一处都可能拖死线程Jedis 相关的超时其实有三个层次很多人只配了第一个第一处是连接超时connectTimeout在构造JedisPool时传入控制 TCP 握手的最长等待。设太短网络稍微抖一下就建连失败设太长Redis 挂掉时你的线程会卡在那里。一般 1000ms~2000ms 比较稳妥。第二处是读写超时soTimeout同样在构造时传入。它控制命令发出后等待响应的时间。这里有个坑如果你在 Jedis 上执行了BLPOP这类阻塞命令soTimeout必须大于阻塞时间否则会抛JedisConnectionException: Read timed out。这也是为什么不建议在共享连接池上跑阻塞命令它会长期占用一个连接池子很快就不够用了。第三处是池等待超时maxWait就是上面表格里那个。它和业务线程的关系最直接池子里的连接被借光了新来的线程就在这等着等超过maxWait就抛Could not get a resource from the pool。这个异常一旦在日志里刷起来说明池子已经严重不够用了要么扩maxTotal要么去查是不是有连接泄漏。三者的关系可以这么理解maxWait是排队时间connectTimeout是新建连接的时间soTimeout是执行命令的时间。一次完整的借连接并执行命令耗时上限粗略等于maxWait connectTimeout soTimeout这个总和不能超过上游接口的超时阈值否则上游早就超时返回了你这边还在傻等。2.4 Pipeline、事务、Lua三种批量手段的区别Jedis 提供了三种把多条命令打包发送的方式用途完全不同用错了会出问题。Pipeline解决的是网络往返次数RTT的问题。假如你要连续执行 100 次SET普通写法的 100 次 RTT 累积起来可能几十毫秒用 pipeline 打包成一次发送耗时能压到 1~2ms。写法是try (Jedis jedis jedisPool.getResource()) { Pipeline pipeline jedis.pipelined(); for (int i 0; i 100; i) { pipeline.set(k: i, v: i); } ListObject results pipeline.syncAndReturnAll(); }关键在于pipeline 不是原子的。它只是把命令攒起来一次性发出去服务端还是一条条执行中间完全可以插入其他客户端的命令。而且管道里的命令会先在客户端缓冲区堆积一次塞十万条进去内存会先炸。批处理时建议按 500~1000 条分批。事务MULTI/EXEC保证的是原子性但要注意 Redis 的事务和数据库事务不是一回事——它没有回滚。如果EXEC之前某条命令语法错误整个事务会被拒绝如果语法都对但运行时出错比如对 String 类型执行LPUSH只有那一条失败其他照常执行。而且WATCH实现的乐观锁在并发高时重试率会很高。Lua 脚本才是真正意义上的原子执行 逻辑可控。服务端把整段脚本当一个整体执行中间不会插入别的命令。适合做判断条件 执行操作的组合逻辑比如分布式锁释放、库存扣减、限流计数String lua if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; Object result jedis.eval(lua, Collections.singletonList(lock:order:1), token-abc);注意eval每次都要把脚本文本传过去脚本大了带宽就浪费而且首次执行要编译。生产上应该用SCRIPT LOAD预加载拿到 SHA1之后用EVALSHA调用。Jedis 的jedis.scriptLoad(lua)和jedis.evalsha(sha, keys, args)就是干这个的。另外脚本里不要写死 key 名一律用KEYS[]和ARGV[]传参否则集群模式下会因为路由问题报CROSSSLOT错误。3. Spring Data Redis抽象层的收益与必须知道的代价3.1 RedisTemplate 和 StringRedisTemplate 到底是什么关系StringRedisTemplate就是RedisTemplateString, String的一个特化子类它把 key 和 value 的序列化器都设成了StringRedisSerializer。之所以需要它是因为RedisTemplate的默认序列化器是JdkSerializationRedisSerializer——用 Java 原生序列化存进去的 key 会带上一串二进制前缀你在redis-cli里看到的是一堆乱码KEYS user:*根本匹配不到。这个默认值坑了无数人而且是代码能跑、数据不对的隐蔽型问题。两者的选择标准很简单如果你的数据都是字符串包括你自己序列化好的 JSON 字符串用StringRedisTemplate省心如果要存对象、并且希望模板类帮你处理序列化用自定义的RedisTemplateString, Object但必须显式指定序列化器。3.2 序列化器选型乱码、class 和类型丢失的三角难题序列化器是 Spring Data Redis 里最容易出问题的地方先看几种主流方案的对比序列化器key 可读性value 可读性是否带类型信息典型问题JdkSerializationRedisSerializer差二进制差是跨语言不可读字节数大版本不兼容StringRedisSerializer好好否只能处理 String存不了对象Jackson2JsonRedisSerializerT好好否反序列化需要明确目标类泛型丢失GenericJackson2JsonRedisSerializer好好是class字段多写一个字段历史数据改动类名会反序列化失败OxmSerializer/Protostuff等差差视实现需额外依赖调试不直观实践中最常用的组合是key 和 hashKey 用StringRedisSerializervalue 和 hashValue 用GenericJackson2JsonRedisSerializer。这样redis-cli里能直接看到可读的 key 和 JSON 值排查问题非常方便。但GenericJackson2JsonRedisSerializer有两个必须提前知道的坑。第一个坑是class字段。为了让反序列化时能还原成原始类型它会在 JSON 里写入一个class: com.example.User这样的属性。这意味着两件事一是数据体积变大了一点二是如果你后来重命名或移动了这个类历史缓存反序列化会直接抛ClassNotFoundException。所以用这种方式缓存的对象类命名和包路径要当成接口契约对待不能随便改。第二个坑是时间类型。Jackson 默认不认识LocalDateTime要么报Java 8 date/time type not supported by default要么给你序列化成一串数字。解决办法是给ObjectMapper注册JavaTimeModule并关掉时间戳输出ObjectMapper om new ObjectMapper(); om.registerModule(new JavaTimeModule()); om.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); om.activateDefaultTyping( LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY ); GenericJackson2JsonRedisSerializer serializer new GenericJackson2JsonRedisSerializer(om);实操心得activateDefaultTyping里的LaissezFaireSubTypeValidator是放开所有类型的意思配合NON_FINAL基本上能覆盖绝大多数业务对象。但如果你的缓存数据来源不受控比如缓存了外部传入的 JSON这种配置存在反序列化风险更稳妥的做法是给需要缓存的类加白名单或者干脆统一用Jackson2JsonRedisSerializerUser并明确指定类型把泛型丢失的问题交给调用方处理。3.3 连接工厂Jedis 和 Lettuce 该选谁Spring Data Redis 支持两种底层实现选型上有一张对照表维度JedisLettuce连接模型每个线程独占一个连接连接池基于 Netty多线程共享一个连接线程安全实例不安全需池化连接本身线程安全资源开销连接数约等于并发数少量连接即可支撑高并发阻塞命令支持会占用池中连接影响其他请求有独立的阻塞连接影响较小集群/哨兵支持需额外封装原生支持自动重连和拓扑刷新生态成熟度老牌稳定代码直观现代默认Spring Boot 2.x 起默认使用Spring Boot 2.x 之后spring-boot-starter-data-redis默认引入的就是 Lettuce。如果你的团队习惯了 Jedis 的连接池调参方式也可以显式切换只要把lettuce-core排除、引入jedis即可。切换之后配置项的前缀也要跟着变因为两者的池化参数是分开的# Lettuce 的池化配置 spring: data: redis: host: 127.0.0.1 port: 6379 password: your-password database: 0 timeout: 2000ms lettuce: pool: max-active: 64 max-idle: 32 min-idle: 8 max-wait: 1000ms # 换成 Jedis 之后 spring: data: redis: jedis: pool: max-active: 64 max-idle: 32 min-idle: 8 max-wait: 1000ms注意Spring Boot 3.x 里配置前缀从spring.redis.*改成了spring.data.redis.*。从 2.x 升级上来时如果发现配置不生效、连的还是localhost:6379八成就是这个前缀没改。这个错误极其隐蔽——启动不报错跑起来才发现读不到数据还以为是缓存穿透。3.4 一个能直接用的 RedisTemplate 配置类把上面的东西捏在一起就是一份可以直接复制进项目的配置Configuration EnableCaching public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer new GenericJackson2JsonRedisSerializer(buildObjectMapper()); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } private ObjectMapper buildObjectMapper() { ObjectMapper om new ObjectMapper(); om.registerModule(new JavaTimeModule()); om.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); om.activateDefaultTyping( LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY); return om; } }template.afterPropertiesSet()这行很多人会漏。它负责在容器初始化时做一次校验把默认序列化器补齐。少了它某些场景下hashKey和hashValue会回退到 JDK 序列化于是你又一次看到了乱码。4. 完整实操搭一套能复用的缓存读写层4.1 数据结构操作实战别只会 opsForValueRedisTemplate提供了opsForValue、opsForHash、opsForList、opsForSet、opsForZSet五套操作入口对应 Redis 的五种基本结构。新手最常见的问题是什么场景该用哪个这里按实际用途对一下业务场景推荐结构关键操作注意事项单值缓存、计数器Stringset/get/increment计数器别用get再set并发下会丢数对象缓存、字段更新Hashhset/hget/hgetall单 key 字段别太多超过数百个考虑拆分简单消息队列Listlpushbrpop阻塞弹出要用独立连接别占用业务连接排行榜、延时队列ZSetzadd/zrangeByScore分数相同的元素按字典序排业务上要能接受去重、标签、点赞Setsadd/sismember集合元素极多时smembers慎用会阻塞以 Hash 存对象为例Autowired private RedisTemplateString, Object redisTemplate; public void saveUserHash(User user) { String key user:hash: user.getId(); MapString, Object map new HashMap(); map.put(name, user.getName()); map.put(age, user.getAge()); map.put(updatedAt, user.getUpdatedAt()); redisTemplate.opsForHash().putAll(key, map); redisTemplate.expire(key, Duration.ofHours(2)); }这里快照整个对象、用 Hash 存的好处是可以只更新某个字段而不重写整条记录。用HGET取年龄、HSET改名字都不需要把整个对象读出来改完再写回去。缺点是字段多了之后读写都变重而且 Redis 的 Hash 内部有两种编码listpack和hashtable元素数量或单个值超过阈值就会转换内存占用会明显上涨。所以那种几万个字段的超大 Hash应该考虑拆成多个 key。4.2 注解缓存Cacheable 背后的三件事Cacheable用起来很爽加个注解就完事但有三件事必须搞清楚否则要么缓存不生效要么数据错了没人发现。第一件事是 key 的生成规则。默认的 key 生成器会把方法参数拼起来做 key可读性一般。建议在注解里显式指定Service public class UserService { Cacheable(value user, key #id, unless #result null) public User getById(Long id) { return userMapper.selectById(id); } CacheEvict(value user, key #user.id) public void update(User user) { userMapper.updateById(user); } }unless #result null这一句非常关键。不加的话方法返回 null 时也会写入缓存这时候缓存里存的是空值和没有缓存是两回事——如果你另外还配了空值缓存防穿透两套逻辑混在一起就容易出问题。RedisCacheConfiguration里还有个disableCachingNullValues()可以让 null 结果干脆不写入配合unless用更稳妥。第二件事是 TTL 的精细化管理。全站一个过期时间是不合理的用户基本信息可以缓存久一点商品库存这种强一致要求高的必须短。用RedisCacheManager按 cache 名分配置Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration defaultConfig RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .prefixCacheNameWith(cache:) .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); MapString, RedisCacheConfiguration configMap new HashMap(); configMap.put(user, defaultConfig.entryTtl(Duration.ofHours(2))); configMap.put(stock, defaultConfig.entryTtl(Duration.ofSeconds(30))); configMap.put(hotList, defaultConfig.entryTtl(Duration.ofMinutes(5))); return RedisCacheManager.builder(factory) .cacheDefaults(defaultConfig) .withInitialCacheConfigurations(configMap) .transactionAware() .build(); }prefixCacheNameWith(cache:)是 Spring Data Redis 3.x 的写法2.x 是computePrefixWith(name - name ::)升级时这里会编译不过。加上统一前缀的好处是redis-cli里一眼能看出哪些 key 是注解缓存生成的运维清理时不容易误删业务自己写的 key。第三件事是缓存注解和事务的关系。CacheEvict默认在方法执行完才触发如果方法内部抛异常缓存不会清——这时候数据库已经回滚了缓存里还是老数据其实是对得上的没问题。但如果在事务提交前清了缓存而事务最终回滚就会出现缓存没了、数据库还是旧值的短暂窗口下一次请求重新加载进来又是旧值问题不大。真正危险的是CachePut和事务混用时的执行顺序这块建议用transactionAware()让缓存操作跟着事务走避免出现中间状态。4.3 分布式锁用 SET NX PX 加 Lua 释放用RedisTemplate做分布式锁核心就两个动作加锁用SET key value NX PX ttl释放用 Lua 脚本比对 value 再删。public boolean tryLock(String key, String token, long ttlMillis) { Boolean ok redisTemplate.opsForValue() .setIfAbsent(key, token, Duration.ofMillis(ttlMillis)); return Boolean.TRUE.equals(ok); } private static final String UNLOCK_LUA if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; public boolean unlock(String key, String token) { DefaultRedisScriptLong script new DefaultRedisScript(UNLOCK_LUA, Long.class); Long result redisTemplate.execute(script, Collections.singletonList(key), token); return result ! null result 1L; }为什么释放必须用 Lua因为比对 value和删除 key是两个命令中间有间隙。假设线程 A 拿的锁已经超时自动释放了线程 B 又拿到了同一把锁这时候 A 执行删除如果不比对 value 就会把 B 的锁删掉即便比对了如果比对和删除不是原子的在这个间隙里 B 的锁也可能被误删。Lua 脚本在服务端整体执行把这两步合成一个原子操作问题就解决了。注意这套实现只适合防重复这类弱一致场景比如防止用户重复提交。它无法解决锁超时后业务还没执行完的问题需要看门狗续期也不支持可重入。真要做严肃的分布式协同直接用 Redisson 的RLock它内置了续期、可重入、公平锁等一堆能力自己造轮子的成本远超想象。5. 常见问题与排查实录5.1 连接相关的四类故障怎么快速定位连接类问题的表现形式很像但根因差别很大用这张表可以对号入座报错/现象可能原因排查动作处理方式Could not get a resource from the pool连接池耗尽打印池的getNumActive、getNumWaiters查连接泄漏合理上调maxTotal缩短maxWait让问题暴露更快JedisConnectionException: Read timed out命令执行超时看 Redis 慢日志SLOWLOG GET找出慢命令避免大 key 操作上调读写超时Connection refused连接被拒看服务端maxclients和客户端连接数降低单实例maxTotal或调整服务端上限隔一段时间报一次连接异常重启就好网络闪断后池里有死连接检查testWhileIdle是否为 true开启空闲检测缩短检测周期排查连接池问题的第一步永远是把池的实时状态打出来。Spring Boot 环境下可以通过LettuceConnectionFactory或者注入JedisPool拿到统计数据做成一个健康检查端点出问题时一眼就能看到当前活跃连接数和等待线程数。这比在日志里翻半天异常堆栈高效得多。5.2 序列化问题的典型症状与修复路径序列化问题有个很讨厌的特点代码不报错但数据不对。几个高频症状症状一redis-cli里 key 全是乱码KEYS user:*匹配不到。这个是用了默认的JdkSerializationRedisSerializer或者RedisTemplate的 key 序列化器忘了设置。修复方法就是显式指定StringRedisSerializer然后注意——旧数据得清理掉因为序列化方式变了老 key 的编码和新代码对不上会变成一堆读不到的垃圾。症状二反序列化报ClassCastException或LinkedHashMap cannot be cast to User。典型原因是读取时用了GenericJackson2JsonRedisSerializer但写入时没有类型信息或者两个模板用不同的序列化器操作同一个 key。检查一下项目里是不是同时存在多个RedisTemplate的 Bean导致注入的和你以为的不是同一个。症状三class找不到类。改过包名、重构过类历史缓存全炸。修复方法是临时把GenericJackson2JsonRedisSerializer换成宽容模式或者干脆清掉这批缓存重新加载。实操心得序列化方案一旦上线就是数据格式契约改起来成本很高。所以项目初期就该定好而且最好在配置文件里写清楚注释。我见过一个项目前后换了三次序列化器Redis 里同时存在三种格式的数据最后一狠心全库清了缓存靠数据库扛了一个小时才恢复——这个教训值得记。5.3 大 key 和热 key客户端视角看不出来的隐患客户端能做的排查有限但有两个指标必须盯单次响应字节数和单条命令耗时。大 key 的判定标准业内一般参考String 类型超过 10KBHash/List/Set/ZSet 元素数量超过 5000或者总大小超过 1MB。这类 key 的问题在于一次HGETALL可能产生几毫秒的阻塞足以让同一时刻其他请求排队。而且网络传输时间也长客户端侧表现出来就是偶尔慢一下。排查手段是在客户端加埋点记录每次 Redis 调用的 key 和耗时超过阈值比如 5ms就打日志。另一种方法是用redis-cli --bigkeys扫描但这会遍历全库生产环境要在低峰期做或者直接用副本。热 key 则是指某个 key 的 QPS 远超其他。比如秒杀场景里的商品库存 key可能占掉整个实例 80% 的流量。客户端的应对办法是本地缓存 随机过期把这个 key 的值缓存在 JVM 里几百毫秒把压力从 Redis 转移到本地内存。虽然会损失一点点一致性但对库存这种允许短暂误差的场景是可以接受的。5.4 排查用的几个笨办法往往最有效线上出问题的时候最快的手段往往不是复杂的监控而是几个朴素的动作。第一用redis-cli --latency看服务端响应是否稳定。如果这里就抖说明问题在服务端别在客户端代码里找了。第二MONITOR命令慎用但SLOWLOG一定要看。MONITOR会把所有命令都打出来压力大的实例上执行它可能直接拖垮服务。SLOWLOG GET 100只返回超过阈值的慢命令代价小得多。第三把客户端的连接数和请求量对上。如果 QPS 只有 100连接数却常年 200那几乎可以断定有连接泄漏。查泄漏的方法很简单在借连接和还连接的地方打上调用栈跑几分钟之后看哪些栈只借不还。第四缓存穿透/雪崩的迹象要能认出来。表现为某一刻 Redis 的 QPS 突然暴涨到平时好几倍同时数据库压力也上去了。这种情况要么是大量 key 同时过期过期时间加了随机抖动就能缓解要么是有请求在反复查不存在的 key用空值缓存或者布隆过滤器挡一下。6. 几个我踩过之后才真正记住的点关于客户端这块有几个认知是吃了亏才建立的分享出来省得别人再走一遍。别把RedisTemplate当成线程安全的万能工具。它本身是线程安全的因为底层 Lettuce 连接是共享的但它上面绑着的序列化器、RedisCacheManager的配置都是有状态的。我在一个项目里见过两个Configuration类各自定义了RedisTemplateBean后加载的覆盖了前一个导致一部分业务的 key 前缀突然变了缓存命中率直接掉了一半。同一个类型只允许一个 Bean需要多个就在名字上区分并显式Qualifier注入这是硬规矩。连接池参数不是越调越大越好。有一段时间线上频繁报池耗尽团队的第一反应是把maxTotal从 64 调到 200问题确实缓解了但两周后又犯了。后来才发现真正的根因是一处try/finally里漏了close的分支每次异常都泄漏一个连接。池子耗尽几乎从来不是池子太小而是有地方没还先查泄漏再谈扩容。注解缓存上线前一定要手动验证一次 key 长什么样。自己写个脚本调一次接口然后去redis-cli里KEYS *看看实际生成的 key 是什么格式、TTL 对不对、value 是不是期望的 JSON。这一步花两分钟能避免上线后一整天的数据对不上。我现在的习惯是任何缓存相关的改动本地必须手工验一遍实际存储形态绝不只看单元测试通过。版本升级时优先看配置前缀和序列化器。从 Spring Boot 2.x 升 3.x从 Jedis 3.x 升 5.x最容易踩的都是这两处而不是 API 本身。配置不生效不报错、序列化变了不报错这两种静默失败最能耗时间。升级前把配置文件里的spring.redis.*全部改一遍、把序列化器相关的类检查一遍能省掉大半的排查工作。