Spring Boot + Redis 缓存一致性实战:穿透、击穿、雪崩一次讲清 📅 2026/7/3 5:40:40 给查询接口加上Cacheable很简单困难的是并发更新、热点 Key 失效和异常流量。本文从 Cache-Aside 模式出发使用 Spring Boot 与 Redis 实现商品缓存并逐步解决缓存一致性、穿透、击穿和雪崩问题。本文示例基于Spring Boot 3.5.x Java 21 Spring Data Redis。一、先理解 Cache-AsideCache-Aside旁路缓存不要求缓存主动同步数据库而是由应用维护两者关系。查询流程读取缓存 ──命中── 返回结果 │ 未命中 ↓ 查询数据库 → 写入缓存 → 返回结果更新流程通常是更新数据库 → 删除缓存为什么不是“更新数据库后更新缓存”因为缓存对象可能由多张表或复杂规则计算而来而且并发更新缓存容易发生旧值覆盖新值。删除缓存更简单下一次查询会从数据库读取最新值并重建缓存。缓存只能提升读取效率数据库仍是真实数据源。不要把 Redis 中的数据视为唯一事实。二、创建项目与 Redis 配置引入依赖dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-web/artifactId/dependencydependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-data-redis/artifactId/dependencydependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-data-jpa/artifactId/dependencydependencygroupIdcom.mysql/groupIdartifactIdmysql-connector-j/artifactIdscoperuntime/scope/dependency配置连接spring:data:redis:host:localhostport:6379timeout:2sdatasource:url:jdbc:mysql://localhost:3306/shopusername:rootpassword:root本文使用StringRedisTemplate显式展示缓存流程。生产项目也可以使用 Spring Cache但必须理解注解背后的读写行为。三、实现最基本的商品查询缓存定义返回对象publicrecordProductView(Longid,Stringname,BigDecimalprice){}实现 Cache-AsideServiceRequiredArgsConstructorpublicclassProductService{privatestaticfinalStringKEY_PREFIXproduct:;privatefinalStringRedisTemplateredisTemplate;privatefinalProductRepositoryrepository;privatefinalObjectMapperobjectMapper;publicProductViewfindById(Longid){StringkeyKEY_PREFIXid;StringcachedredisTemplate.opsForValue().get(key);if(cached!null){returnread(cached);}Productproductrepository.findById(id).orElseThrow(()-newProductNotFoundException(id));ProductViewviewtoView(product);redisTemplate.opsForValue().set(key,write(view),Duration.ofMinutes(30));returnview;}}这个版本能工作但它还无法抵抗恶意查询和突发并发。四、缓存穿透查询不存在的数据如果请求不断查询数据库中不存在的 ID每次都会绕过缓存并访问数据库这就是缓存穿透。常见治理方式包括参数校验拒绝明显非法的 ID对不存在的数据缓存短期空值数据量很大时在入口增加布隆过滤器对接口实施限流和身份校验。空值缓存示例privatestaticfinalStringNULL_VALUE__NULL__;publicProductViewfindById(Longid){if(idnull||id0){thrownewIllegalArgumentException(商品 ID 非法);}StringkeyKEY_PREFIXid;StringcachedredisTemplate.opsForValue().get(key);if(NULL_VALUE.equals(cached)){thrownewProductNotFoundException(id);}if(cached!null){returnread(cached);}returnrepository.findById(id).map(product-{ProductViewviewtoView(product);redisTemplate.opsForValue().set(key,write(view),Duration.ofMinutes(30));returnview;}).orElseGet(()-{redisTemplate.opsForValue().set(key,NULL_VALUE,Duration.ofMinutes(2));thrownewProductNotFoundException(id);});}空值 TTL 应明显短于正常数据避免新建商品后长时间不可见。空值缓存不能代替限流否则攻击者仍可制造大量不同 Key 消耗 Redis 内存。五、缓存击穿热点 Key 瞬间失效某个热点商品缓存到期时大量并发请求可能同时查询数据库这就是缓存击穿也叫缓存风暴或 Stampede。一种直接的方案是只允许一个请求重建缓存其他请求短暂等待publicProductViewfindWithMutex(Longid){StringkeyKEY_PREFIXid;StringcachedredisTemplate.opsForValue().get(key);if(cached!null){returnparseCached(id,cached);}StringlockKeylock:product:id;StringtokenUUID.randomUUID().toString();BooleanlockedredisTemplate.opsForValue().setIfAbsent(lockKey,token,Duration.ofSeconds(10));if(!Boolean.TRUE.equals(locked)){sleepBriefly();returnfindWithMutex(id);// 示例代码生产中必须限制重试次数}try{// 获得锁后必须二次检查可能已有其他请求完成重建cachedredisTemplate.opsForValue().get(key);if(cached!null){returnparseCached(id,cached);}returnloadAndCache(id,key);}finally{unlockSafely(lockKey,token);}}释放锁不能直接执行DEL否则锁过期并被其他线程重新获取后旧线程可能误删新锁。应通过 Lua 脚本比较 token 后原子删除privatestaticfinalDefaultRedisScriptLongUNLOCK_SCRIPTnewDefaultRedisScript( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) end return 0 ,Long.class);privatevoidunlockSafely(Stringkey,Stringtoken){redisTemplate.execute(UNLOCK_SCRIPT,List.of(key),token);}锁必须设置过期时间并限制等待次数。若重建耗时可能超过锁 TTL需要续期机制或成熟的分布式锁实现。六、缓存雪崩大量 Key 同时过期缓存雪崩是大量 Key 在相近时间失效导致请求集中进入数据库。最常见诱因是批量写入时使用完全相同的 TTL。可在基础 TTL 上增加随机抖动privateDurationrandomTtl(){longjitterThreadLocalRandom.current().nextLong(0,600);returnDuration.ofSeconds(1_800jitter);}还应组合以下措施热点数据采用更长 TTL 或逻辑过期Redis 高可用部署并设置合理内存淘汰策略数据库入口限流、熔断和降级启动预热分批进行不要同时写入全部热点 Key监控命中率、过期数量、数据库连接池和慢查询。随机 TTL 只能分散自然过期时间无法解决 Redis 整体故障因此数据库保护不可省略。七、更新时如何尽量保证一致性推荐的基本顺序是“先更新数据库再删除缓存”TransactionalpublicvoidupdatePrice(Longid,BigDecimalprice){Productproductrepository.findById(id).orElseThrow(()-newProductNotFoundException(id));product.changePrice(price);repository.save(product);redisTemplate.delete(KEY_PREFIXid);}但这里存在一个事务陷阱数据库事务可能尚未提交缓存却已经被删除此时并发查询可能读到数据库旧值并重新写入缓存。更稳妥的做法是在事务提交后删除缓存TransactionalpublicvoidupdatePrice(Longid,BigDecimalprice){Productproductrepository.findById(id).orElseThrow(()-newProductNotFoundException(id));product.changePrice(price);TransactionSynchronizationManager.registerSynchronization(newTransactionSynchronization(){OverridepublicvoidafterCommit(){redisTemplate.delete(KEY_PREFIXid);}});}这仍不是严格的原子事务数据库提交后进程可能在删缓存前崩溃。对一致性要求较高的系统可以使用事务消息、Outbox 表或订阅数据库变更日志进行可靠失效并通过 TTL 提供最终兜底。所谓“延迟双删”是在更新前后删除并延迟再次删除。它能缩小部分并发窗口但延迟时间难以准确设定也不能保证第二次删除必然执行因此不能把它当成强一致性方案。八、热点数据可以考虑逻辑过期对于不能接受缓存失效瞬间阻塞的热点接口可以把逻辑过期时间与数据一起存储publicrecordCacheEntryT(Tdata,InstantexpireAt){}读取时即使逻辑过期也先返回旧值同时只让一个后台任务重建缓存。其取舍是允许短时间旧数据换取稳定延迟因此适合商品详情、榜单等最终一致场景不适合余额、库存扣减等强一致业务。选择策略时应先确认业务容忍度场景推荐策略普通查询TTL Cache-Aside不存在的数据短期空值 参数校验热点且必须较新互斥重建热点且可短暂陈旧逻辑过期 异步重建强一致数据直接查权威数据源或重新设计边界九、测试不能只验证“能查到数据”至少应覆盖以下测试首次查询访问数据库第二次命中缓存不存在的 ID 只在首次访问数据库更新事务提交后缓存被删除一百个并发请求访问过期热点 Key数据库回源次数受控Redis 超时或不可用时接口按设计降级缓存中的坏数据不会导致无限报错。压测时重点观察缓存命中率、Redis P95/P99、数据库 QPS、连接池等待时间、缓存重建次数和接口错误率。命中率高不代表设计正确如果热点 Key 的重建仍能打满数据库风险依然存在。十、总结Redis 缓存设计的核心不是选择一个注解而是明确数据权威、并发窗口和失败策略。Cache-Aside 适合大多数读多写少的业务更新时优先采用“提交数据库后删除缓存”并用 TTL 兜底最终一致性。面对异常流量缓存穿透要靠空值、校验和限流缓存击穿要控制热点重建并发缓存雪崩要打散 TTL 并保护数据库。真正可靠的方案还必须覆盖 Redis 故障、事务提交窗口和可观测性而不是只验证正常路径。