缓存这个东西我一开始以为是加个注解就完事的简单活直到在线上被教育过好几次才明白Spring Boot集成Redis缓存的门道远比想象中多。配置层面的坑、序列化的坑、数据一致性的坑、热key的坑每一个都可能让系统在流量高峰时给你来一次惊喜。这篇文章不聊教科书式的概念就说说我实际项目里怎么一步步把Spring Boot和Redis缓存从能用调到好用的包括那些让我加班到深夜的坑和对应的解决办法。如果你正准备在Spring Boot项目里引入Redis缓存或者已经在用了但总觉得哪里不对劲这篇文章应该能帮你少走不少弯路。1. 项目接入前的关键决策为什么最终选了Redis而不是本地缓存很多项目一开始最自然的想法是先把数据放Map里缓存一下不就行了我也这么干过刚开始确实爽接口响应时间直接从800ms降到20ms团队都快鼓掌了。但等服务一拆多实例问题就来了——每个实例的Map各存各的用户在前端刷新两次请求落到不同机器上数据就对不上了。这时候才意识到本地缓存做不了分布式这个前提。1.1 本地缓存和分布式缓存的边界先理清楚什么时候该用本地缓存Caffeine、Guava什么时候该上Redis。判断标准就三条数据量级如果缓存数据在百MB以内单机够用业务也没有跨实例共享的需求Caffeine完全够。一致性要求如果多个实例必须读到同一份数据比如活动配置、用户登录态必须用Redis。成本控制Redis是独立资源需要考虑运维成本、内存成本。本地缓存零成本。实际项目里最合理的方案往往是两级缓存——本地Caffeine做一级Redis做二级。先用本地挡掉一部分高频读请求本地没有再去查RedisRedis没有再查数据库。这样可以减少Redis的网络IO压力也能降低数据不一致的概率。但我建议初学阶段先别搞这么复杂老老实实用Redis单级缓存把基础打稳了再玩高级的。1.2 Spring Boot引入Redis的最小可用配置依赖只需要引入Spring Data Redis的starter即可不需要额外引入Jedis之类的东西Spring Boot 2.x默认用的是Lettuce客户端。我遇到过不少同事手动引入Jedis导致版本冲突的情况其实没必要。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency配置文件里最关键的是连接参数。很多人只配host和port就跑了线上连接超时才发现问题。我一般在启动阶段就会把基础的连接参数配全避免后续排查问题时连当前配置的是什么都要翻半天。spring: redis: host: 127.0.0.1 port: 6379 password: yourpassword timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 cache: type: redis这里spring.cache.typeredis很关键如果你不确定自己配置的是什么类型Redis Desktop Manager连接上之后看到一堆乱码序列化的key基本可以判断是没配置序列化器或缓存类型不对。这一段先有个整体印象具体的坑下一节展开。2. 配置与初始化最容易埋雷的三个地方2.1 连接池参数不是越大越好连接池参数这玩意看着像随便填的实际上是性能和稳定性之间的走钢丝。max-active设得太小高并发下连接不够用请求会排队设得太大Redis服务端连接数暴增内存被白白吃掉反倒拖垮性能。我的经验值是单实例Redismax-active设16到32就够了。如果你的服务是多个实例同时连同一个Redis要把所有实例的连接数加起来算。比如你有10个实例每个实例max-active设32那Redis要承受320个并发连接虽然Redis默认能扛1万连接但每个连接都要占内存和CPU没必要浪费。还有timeout参数默认是2秒我觉得太短了。网络抖动的时候2秒超时会导致大量请求直接报错。我一般是设置3秒到5秒宁可让请求慢一点也不能一抖就全挂。2.2 序列化器配置为什么默认的JDK序列化是隐形炸弹如果你不加任何配置直接用RedisTemplateSpring Boot默认用的是JdkSerializationRedisSerializer。它能用但有两个致命问题存进Redis的数据是二进制乱码用Redis Desktop Manager打开根本看不出来存的是什么运维巡检和问题排查会非常痛苦。JDK序列化带了完整的类结构信息数据体积膨胀得厉害同样一份用户对象JSON存可能200字节JDK序列化可能要500字节。缓存数据量一大内存成本直接翻倍。我见过一个生产事故就是这样缓存数据量从1GB涨到3GBRedis内存告警最后发现是因为序列化器没配全是JDK序列化存进去的大体积数据。排查了整整一个下午最后改了序列化器再等缓存自然过期内存就降下来了。推荐的做法是自建一个RedisTemplate配置类用StringRedisSerializer做key的序列化器用GenericJackson2JsonRedisSerializer做value的序列化器。这样key是人类可读的字符串value是JSON既方便排查又节省空间。Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(connectionFactory); StringRedisSerializer stringSerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }注意GenericJackson2JsonRedisSerializer在反序列化时会自动保存类信息class字段所以从Redis里取出来直接可以转成对应的Java对象。但如果你改了类的包名或字段名老数据会反序列化失败这种情况宁可让缓存自然过期也不要半夜起来改代码。2.3 缓存key设计命名空间是你的第一道防线key的设计直接影响缓存的可维护性和排查效率。我踩过的坑是早期项目里直接用业务主键做key比如123456后来多个业务共用同一个Redis就分不清这个key到底是哪个业务的。现在我的固定规范是采用冒号分隔的命名空间业务名:实体名:主键例如user:detail:10001、order:list:uuid-xxx、goods:stock:8899。这样做有几个好处第一Redis Desktop Manager里会按冒号自动分组key列表看起来一目了然第二用SCAN命令按前缀批量处理的时候很方便第三可以在Redis的Keyspace Notifications里精确监听某个业务前缀的变化实现精准失效。要注意key不要设计得太长。Redis的key是字符串长度越长占用的内存越多而且比较key的哈希值也要耗费CPU。我之前看到有人把整个查询条件JSON塞进key里比如order:list:{status:1,page:1,size:10}这种key不仅长而且还因为JSON中字段顺序不同产生不同的key导致缓存命中率直线下降。3. 注解缓存实战Cacheable没那么简单Spring Cache的注解封装确实方便一行Cacheable就能给方法加缓存。但你要是以为这就完事了后面会有一堆意想不到的行为等着你。这一节我专门讲讲注解缓存的几个关键选择。3.1 key生成策略与SpEL表达式Cacheable如果不指定keySpring会默认用SimpleKeyGenerator生成key。这个生成器的逻辑是方法参数为空就生成SimpleKey.EMPTY参数有多个就拼接成一个SimpleKey对象然后调用它的hashCode()和toString()来生成key。问题来了——两个不同方法如果参数相同生成的key是一样的就会出现缓存互相覆盖。举个我实际遇到的例子Cacheable(value user, key methodName) public User getByMobile(String mobile) { ... } Cacheable(value user, key methodName) public User getById(Long id) { ... }两个方法都用了同样的key结果用户A通过手机号查询后用户B通过ID查询时直接命中了用户A的缓存返回了错误的数据。这个问题排查了很久最后把key改为SpEL表达式#root.methodName : #mobile和#root.methodName : #id才解决。我现在的经验是所有Cacheable的key必须包含方法名或业务场景标识不能只用参数对象。常用的SpEL表达式有这些表达式含义示例#root.methodName方法名getUserById#root.targetClass目标类名UserServiceImpl#参数名任意参数#userId#p0/#a0第一个参数#p0#root.args所有参数数组#root.args[0]3.2 缓存过期时间与CacheConfig的坑Spring Cache的注解层面上Cacheable并没有直接提供expire属性。你想给不同的缓存设置不同的过期时间不能在一个注解里简单搞定。默认情况下所有缓存都是永不过期这在生产环境无异于埋雷——一旦数据变更缓存永远展示旧值。我尝试过几种方案最终推荐这个用RedisCacheManager手动配置每个缓存区域的TTL。Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration defaultConfig RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); MapString, RedisCacheConfiguration configMap new HashMap(); configMap.put(user:detail, defaultConfig.entryTtl(Duration.ofMinutes(30))); configMap.put(goods:detail, defaultConfig.entryTtl(Duration.ofHours(2))); configMap.put(order:list, defaultConfig.entryTtl(Duration.ofMinutes(5))); return RedisCacheManager.builder(factory) .cacheDefaults(defaultConfig) .withInitialCacheConfigurations(configMap) .build(); }这样Cacheable(value user:detail)就会自动用30分钟的TTLCacheable(value goods:detail)用2小时TTL。每个业务域自己决定过期策略互不干扰。3.3 缓存穿透在注解场景下的处理数据库查不到数据时返回nullCacheable默认不会缓存null值。这带来的问题是如果某个ID不存在每次查询都会打到数据库遇到恶意请求大量不存在的ID就能把数据库拖垮。这就是缓存穿透最简单的解决方法是缓存空值。不过在Spring Cache注解里你需要特意开启缓存空值的功能同时用一个较短的空值TTL。Cacheable(value user:detail, key #id, unless #result null) public User getUserById(Long id) { return userMapper.selectById(id); }看到这里你可能觉得奇怪unless #result null明明是拒绝缓存null值这和缓存空值不是矛盾的吗是的Spring Cache注解本身就不支持缓存null值且设置短TTL这种需求除非你自定义实现。所以如果业务上经常有查不到的场景我建议直接用RedisTemplate手动处理把null也写进去但TTL设置成60秒左右这样用户查询一次空数据后一分钟内再来查询就不会打到数据库了。4. 缓存穿透、击穿、雪崩线上事故模拟与逐层防御这三个问题是Redis缓存的经典面试题也是线上故障的重灾区。我这部分配合实际案例来讲保证你看完知道怎么处理。4.1 缓存穿透空值缓存与布隆过滤器的取舍前面说了最简单有效的办法是空值缓存。不过空值缓存有个隐患如果攻击者随机生成大量不存在的key那Redis里会被塞满无意义的空缓存占用大量内存TTL到期前这些垃圾key还会反复出现。所以对于高并发场景我推荐用布隆过滤器做更前置的拦截。布隆过滤器的原理是用多个哈希函数把数据映射到一个bitmap上。判断一个key是否存在时只要有一个bit位为0就说明这个key肯定不存在所有的bit位都是1只能说可能存在——存在一定的误判率但不会漏判。这正好适合拦截垃圾ID查询的场景。实际代码里用Redisson封装好的RBloomFilter就行不用自己实现PostConstruct public void initBloomFilter() { RBloomFilterLong bloomFilter redissonClient.getBloomFilter(userFilter); bloomFilter.tryInit(1000000L, 0.03); // 预计100万条数据误判率3% ListLong userIds userMapper.selectAllIds(); userIds.forEach(bloomFilter::add); } public User getUserById(Long id) { RBloomFilterLong bloomFilter redissonClient.getBloomFilter(userFilter); if (!bloomFilter.contains(id)) { return null; // 一定不存在直接返回不查缓存不查库 } return cacheService.getUserById(id); }布隆过滤器的缺点是无法删除数据bit位被多个key共享删一个key不能把bit位归零否则影响其他key的判断。所以它适合存量数据相对稳定的场景。如果业务数据频繁删增建议定期重建布隆过滤器或者改用空值缓存。4.2 缓存击穿互斥锁与逻辑过期的选择缓存击穿和一个热点key有关某个热点key在某一瞬间过期偏偏此时有大量请求同时来查它缓存里没有所有请求全部打到数据库。数据库瞬间压力爆炸。我在一个秒杀活动接口上遇到过这个问题。活动商品详情是做成了缓存TTL是30分钟结果过期的那一刻正好是流量高峰数据库QPS瞬间飙到几千数据库CPU报警。解决方案不外乎两种互斥锁和逻辑过期。互斥锁的思路是当缓存不存在时不是所有请求都去查数据库而是先尝试获取一个Redis分布式锁。拿到锁的请求去查数据库并回填缓存没拿到锁的请求自旋等待再从缓存里取。这样同时只有一个请求打数据库。关键代码示意public Goods getGoodsById(Long id) { // 先查缓存 String cacheKey goods:detail: id; Goods goods redisTemplate.opsForValue().get(cacheKey); if (goods ! null) return goods; // 缓存未命中尝试获取锁 String lockKey lock:goods:detail: id; RLock lock redissonClient.getLock(lockKey); try { boolean isLocked lock.tryLock(2, TimeUnit.SECONDS); if (!isLocked) { Thread.sleep(100); return getGoodsById(id); // 递归重试 } // 拿到锁后再次检查缓存防止重复查询 goods redisTemplate.opsForValue().get(cacheKey); if (goods ! null) return goods; goods goodsMapper.selectById(id); if (goods ! null) { redisTemplate.opsForValue().set(cacheKey, goods, 30, TimeUnit.MINUTES); } return goods; } finally { lock.unlock(); } }这里用Redisson的RLock是因为Redisson实现的锁有看门狗自动续期机制默认leaseTime是30秒如果业务执行时间超过30秒会自动续期有效防止拿到了锁但业务挂了锁永远不释放的问题。如果用自己实现的SETNX命令一定要记得设置过期时间并且要处理好误删锁的问题。4.3 缓存雪崩过期时间打散策略雪崩的意思是大量key在同一时间过期然后大量请求同时穿透到数据库。典型场景是一次性导入了很多数据设置了相同的TTL比如都设了30分钟那么30分钟后它们会一起过期。搞个随机化就行了但实现上还是有细节的。给每个key的TTL加上一个随机偏移量让过期时间自然错开。public void setWithRandomTtl(String key, Object value, long baseTtl) { long randomTtl baseTtl ThreadLocalRandom.current().nextLong(0, 300); redisTemplate.opsForValue().set(key, value, randomTtl, TimeUnit.SECONDS); }除了随机化TTL还可以考虑多级缓存。前面提到本地缓存Redis的二级缓存结构在雪崩场景下本地缓存可以兜底一部分请求减少直接打到数据库的压力。另外如果是在集群部署下尽量保证不同实例的缓存过期时间也存在一定差异分散数据库的压力。5. 缓存一致性与分布式锁最难的一关缓存时间设得再合理数据一变缓存里的旧数据就是脏数据。缓存和数据库的一致性问题是缓存架构里最难的一关也是线上排查最多的问题来源。5.1 缓存与数据库不一致的根因先明确一点只要缓存存在就一定有短暂的不一致窗口。我们的目标是让不一致窗口尽量短或者让它在业务上可接受。最常见的缓存更新策略是Cache Aside Pattern旁路缓存操作流程是读缓存命中直接返回。未命中查数据库回填缓存。写数据时先更新数据库再删除缓存。那先删缓存再更新数据库和先更新数据库再删缓存有什么区别这个我想了很久实际测试过一次才彻底理清楚先删缓存再更新DB线程A删缓存还没更新DB线程B来查询发现没缓存就去查DB然后回填的是旧数据。等线程A更新完DB缓存已经过期写不进去了。结果缓存里一直是旧数据直到下一次过期。这是大坑。先更新DB再删缓存线程A更新完DB还没删缓存线程B来读拿到旧值。但线程A马上就会删掉这个旧缓存下一次读取就是新值了。不一致窗口很小。所以推荐先更新DB再删缓存的方案。但删除缓存也可能失败比如Redis临时性故障这时候缓存里就一直是旧值。解决办法是加一个重试机制删除失败后把key丢进消息队列异步重试删除。5.2 分布式锁Redisson方案在缓存击穿和缓存一致性场景里都提到分布式锁单独拎出来说说。Spring没有提供开箱即用的分布式锁最简单的是用Redis的SETNX命令但自己实现容易踩坑比如忘记给锁设置过期时间一个线程挂了锁永远不释放其他线程全部被卡死。设置过期时间但业务执行太久锁提前释放另一个线程进入临界区两个线程同时更新数据锁的作用失效。锁误删线程A执行完了释放锁的时候恰好线程B已经拿到了同一个锁A把B的锁删掉了。我用Redisson就是为了规避这些坑Redisson的RLock内部通过Lua脚本保证了加锁和过期时间的原子性、释放锁时的所有权校验还有看门狗自动续期。实际用起来很简单Autowired private RedissonClient redissonClient; public void payOrder(String orderId) { RLock lock redissonClient.getLock(lock:pay: orderId); boolean isLocked lock.tryLock(3, TimeUnit.SECONDS); if (!isLocked) { throw new BusinessException(操作过于频繁请稍后重试); } try { // 业务逻辑 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }注意tryLock的第一个参数是等待时间不是锁的持有时间。它表示最多等3秒获取不到锁就放弃。锁的持有时间可以传给tryLock的第二个参数也可以不传用默认的看门狗续期时间30秒。5.3 延迟双删看起来简单细节不少「先更新数据库再删缓存」方案在大并发下其实还有一个隐患。我举个例子线程A更新数据库把值从1改成2然后删缓存。但是因为网络抖动删除缓存的操作发生了延迟。在延迟期间线程B来读缓存缓存还是旧值1于是回填缓存为1旧值。等线程A的删除操作执行完缓存还是旧值但实际上数据库已经是2了。这就是不一致。延迟双删就是为了解决这个窗口期问题第一次删除缓存后等待一段时间再删一次缓存。public void updateUser(User user) { String cacheKey user:detail: user.getId(); // 第一次删除 redisTemplate.delete(cacheKey); // 更新数据库 userMapper.updateById(user); // 延迟第二次删除给读旧值写缓存的操作一个完成的窗口 scheduleDelete(cacheKey, 500, TimeUnit.MILLISECONDS); }scheduleDelete可以用ThreadPoolExecutor配合ScheduledExecutorService实现。要估算的等待时间通常是读请求回填缓存的最长时间一般看业务情况几百毫秒到一秒比较常见。设得太短旧值缓存可能还没被回填完第二次删除没效果设得太长就白白增加了不一致的时间窗口。延迟双删不是银弹在极端并发下还是有残留的不一致问题只是把概率降低了几个数量级。如果你的业务对一致性要求极高比如支付对账我建议直接放弃缓存或者引入Canal监听数据库binlog异步删除缓存做到秒级一致。6. 上线后的监控与治理别等出了事故才看Redis很多项目缓存上线之后就不管了直到线上出了问题才想起来看一眼Redis。但Redis缓存这东西运行状态如果不监控它出问题的方式会非常隐蔽。我这部分分享几个最实用的监控和治理手段。6.1 怎么确认缓存真的生效了第一个问题怎么知道缓存到底有没有在起作用最简单粗暴的方法是用redis-cli看key的数量和内存使用量。更准确的做法是用Spring Boot Actuator暴露的缓存指标。在pom.xml里加上dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency配置management: endpoints: web: exposure: include: health, info, metrics然后访问/actuator/metrics/cache.gets就能看到缓存的命中次数、未命中次数计算命中率命中次数 / (命中次数 未命中次数)。我一般会用Grafana把这些指标做成看板每天扫一眼。缓存命中率如果长期低于60%说明key的设计或者TTL配置有问题命中率低到这种程度还不如不加缓存白白增加维护成本。6.2 大key和热key的排查大key是指单个key的value特别大比如几MB甚至几十MB的集合。大key会拖慢Redis的读写性能删除时甚至可能阻塞整个Redis实例。我用redis-cli --bigkeys来扫描redis-cli --bigkeys这个命令会遍历整个实例统计每个类型里最大的key。发现大key后如果这个key是Hash或者Set可以考虑拆分成多个小key或者换用SCAN批量读取如果是普通的字符串大value大概率是序列化方案没优化好可以试试压缩之后再存。热key是指某个key的访问频率极高比如秒杀商品、大V的用户信息。热key会导致单个Redis分片的CPU飙升、带宽打满影响整个集群。判断方法是用redis-cli --hotkeys需要开启LFU策略更简单的做法是在代码里加一层本地缓存做兜底把热key的请求拦在最前面减少Redis的压力。6.3 淘汰策略与容量规划Redis内存是有限的如果写缓存的速度大于过期速度内存迟早会被占满。当内存不够时Redis会按照配置的淘汰策略清理key。Spring Boot项目里我推荐用这两个策略策略命令适用场景allkeys-lrumaxmemory-policy allkeys-lru所有key都允许被淘汰适合缓存类业务volatile-ttlmaxmemory-policy volatile-ttl优先淘汰即将过期的key适合希望尽量保留未设置过期时间的核心数据场景注意如果你的缓存key大部分都没有设置TTL那就别用volatile开头的策略不然内存满了之后Redis会尝试淘汰所有设置了TTL的key而那些没有TTL的key永远占着位置相当于策略失效了。容量规划上我的经验是监控Redis内存使用率的日增长曲线。缓存类业务的内存通常是锯齿形波动如果趋势是持续上升且偶尔逼近上限说明有key没设过期时间赶紧排查。一般在Redis配置里设置maxmemory让它自己在内存满了之后触发淘汰策略比OOM之后重启要稳得多。最后聊聊我的实操体会做了一年多的Spring Boot Redis缓存下来最大的感受是缓存不是写上去就完事的它是一个持续演进的过程。最开始可以先把连接、序列化、基本TTL配好让缓存跑起来。接着逐步根据监控数据调整key设计、TTL粒度、淘汰策略再为高频读场景设计锁和一致性方案。踩过的坑越多反而越明白为什么网上老说缓存是系统性能优化的最后一块拼图——因为它牵涉的细节太多任何一个环节没考虑周全都会在流量高峰的时候暴露出来。现在每次在代码里加一个新的缓存点我都会先问自己三个问题这个数据会变吗变了之后我能不能及时让缓存失效如果缓存挂了我的数据库扛得住吗想明白这三个问题缓存架构一般不会出大问题。这套方法分享给你希望对你有用。