很多后端同学初学 Redis 时,最先接触的就是用它做缓存加速。面试时八股文背得滚瓜烂熟——缓存雪崩、击穿、穿透,三个名字分得清清楚楚,但一问到"你们线上怎么防的""万一真打穿了你怎么排查",场面往往就安静下来了。这篇就把这三个坑从原理到实战一次性说透,不绕弯子,直接看代码、看配置、看排查思路。如果你正在负责缓存治理相关的工作,或者准备面试中关于 Redis 分布式锁、缓存一致性、高并发场景下的缓存设计,这篇文章都值得你花二十分钟认真看完。
1. 三个"坑"名字像,但死法完全不同
先别急着上解决方案,一定要先把三个问题区分开,不然很容易出现"用布隆过滤器防雪崩"这种牛头不对马嘴的操作。我见过不止一个团队,出了故障之后连问题的性质都没定清楚,就开始改代码,最后改了一晚上,第二天流量一来又炸了。
1.1 一句话定义,先建立整体印象
缓存穿透:查询一个根本不存在的数据。比如查一个不存在的用户 ID,缓存里没有,数据库里也没有,于是每次请求都直接打到数据库。这种请求如果量一大,DB 直接给你干趴下。
缓存击穿:查询一个热点 key,这个 key 在某一瞬间刚好过期了。比如某个明星的八卦详情页,平时缓存扛住 99% 的流量,结果缓存刚好失效的那一秒,海量请求同时穿透到数据库,数据库瞬间压力爆表。
缓存雪崩:大量 key 在同一时间段内集中失效。比如你给所有缓存设置了统一的过期时间——"凌晨两点统一过期",那两点一过,一大波请求集体穿透,数据库直接被一波流带走。还有一种变体:Redis 服务本身宕机了,所有请求直接落到 DB,这也可以归到雪崩的范畴。
1.2 用体检报告的方式理解三者的核心差异
如果你觉得上面文字不够直观,可以按体检指标的方式来记:
| 维度 | 穿透 | 击穿 | 雪崩 |
|---|---|---|---|
| 数据是否存在 | 不存在 | 存在但刚好过期 | 存在但集体过期 |
| 作用范围 | 单key/单类数据 | 单个热点key | 大量key/全局 |
| 核心原因 | 恶意攻击或脏数据 | 热点数据过期时间点集中 | 过期时间设置不合理/Redis宕机 |
| 危害程度 | 中(取决请求量) | 高(热点请求峰值极高) | 极高(大面积DB被打爆) |
| 解决思路 | 布隆过滤器/空值缓存 | 互斥锁/逻辑过期 | 过期时间抖动/多级缓存/高可用 |
拿医院挂号来类比就更好理解了。穿透是你挂了一个根本不存在的科室号,分诊台没有这个科室的记录,只能每次都跑去问院长;击穿是某个超级专家号刚好放出来的那一秒,几千人同时抢;雪崩是全院所有科室的号都在同一秒放出来,挂号系统当场挤爆。三个问题的处境完全不同,应对方案自然也不一样,下面逐个拆开讲。
2. 缓存穿透实战:布隆过滤器不是银弹,要配合空值缓存一起吃
穿透这个问题,因为涉及"数据本身不存在",很多人的第一反应是——"那我在数据库查不到的时候就缓存一个空值呗"。思路是对的,但这只是第一层防御,实际生产中光靠空值缓存还不够。
2.1 穿透是怎么发生的?两类典型触发场景
第一类是恶意攻击或爬虫。攻击者发现你的接口是/user/{id}这样的结构,就故意拿一些不存在的超大 ID 或者负数 ID 来刷。比如 ID 是自增主键,他拿 -1、0、99999999 这样的值来遍历。这些数据在缓存里没有,数据库里也查不到,请求直接打到 DB,DB 的连接数很快被打满。
第二类是业务自身的脏数据或误调用。比如前端页面某个状态下会传一个空的 skuId,或者上游服务在数据未初始化时就调用了详情接口。这些在正常业务中虽然量不大,但一旦某个上游逻辑出了 bug,产生了循环调用,QPS 瞬间就能上来。
2.2 第一道防线:缓存空值,但要设置短过期
缓存空值这个方案,核心点在于——空值也要过期,但不能太久。
// 伪代码:查询商品详情 public Product getProduct(Long productId) { // 1. 先查缓存 Object cacheValue = redis.get("product:" + productId); if (cacheValue != null) { // 2. 如果缓存中的值是空标记,直接返回 null if (EMPTY_MARK.equals(cacheValue)) { return null; } return (Product) cacheValue; } // 3. 缓存未命中,查数据库 Product product = productMapper.selectByPrimaryKey(productId); if (product == null) { // 4. 数据库也没有——缓存一个空标记,过期时间设短一些,比如3-5分钟 redis.set("product:" + productId, EMPTY_MARK, 3 * 60 * 1000); return null; } // 5. 数据库查到了,写入缓存并返回 redis.set("product:" + productId, product, 30 * 60 * 1000); return product; }这里有个细节很多人注意不到:空值标记要和正常值的过期时间区分开。正常商品可能缓存 30 分钟,但空值缓存只需要 3-5 分钟。因为"某个 ID 的商品不存在"这个状态是会变化的——比如运营人员后来补录了这个商品的信息,如果你空值缓存一个小时,用户在这一个小时之内永远看不到新上架的商品。这就是我多次强调过的"经验值":空值缓存时间不能太长,否则就变成了数据不一致的新坑。
2.3 第二道防线:布隆过滤器的实际工程用法
如果只是靠空值缓存,遇到恶意攻击时依然有问题——攻击者可以每次都用不同的随机 ID 来刷,你的 Redis 里会塞满各种无意义的空 key,内存白白被消耗。所以更前置的一道防线是布隆过滤器。
布隆过滤器的核心思想是:用一个很省内存的位图结构,预判一个 key "一定不存在"还是"可能存在"。它说"不存在"就一定不存在,说"存在"则可能误判——但误判率可以控制在可接受的范围。
实际工程项目中,通常这样做:
// 项目启动时,将数据库中的商品ID全部加载到布隆过滤器 @Component public class ProductBloomFilterInitializer implements ApplicationRunner { @Autowired private RedisTemplate<String, String> redisTemplate; private static final String BLOOM_KEY = "bloom:product"; @Override public void run(ApplicationArguments args) { // 从数据库查出所有上架商品ID List<Long> allProductIds = productMapper.selectAllIds(); for (Long id : allProductIds) { // 通过Redisson的RBloomFilter,或者自己用bitmap实现 bloomFilter.add(id.toString()); } } } // 查询前先判断 public Product getProductV2(Long productId) { // 布隆过滤器判断ID不存在,直接返回,拦截穿透请求 if (!bloomFilter.contains(productId.toString())) { return null; } // 后续逻辑与之前一致... }布隆过滤器需要解决的最大工程问题,不是实现,而是数据初始化和与数据库的一致性。你商品上新了,过滤器里没有这个 ID,怎么办?两种常见方案:
- 商品创建时同步向布隆过滤器中添加该 ID(最终一致性,可以接受短暂延迟)。
- 定时任务每隔几分钟把最新 ID 全量重建一次布隆过滤器(适合数据量可控的场景)。
布隆过滤器本身存放在 Redis 里面(Redisson 的实现是基于 bitmap),所以对内存的占用极低。比如 1 亿个 ID,误判率设置 1%,大概只需要 114 MB 左右的内存,完全可接受。
2.4 我的建议:两层一起上
如果你的系统里所有数据都有唯一 ID 且都有 DB 兜底查询,我建议两层同时启用:布隆过滤器挡掉绝大多数恶意请求,空值缓存兜底处理"过滤器误判 + 少量漏网之鱼"。我也见过有些团队只在过滤器层做了拦截,结果 DB 里偶尔确实会新增一条 ID 刚好被误判的数据,在过滤器刷新之前就出现了线上数据查询不到的问题。这种问题的背景虽然不常见,但一旦出现,排查起来非常浪费时间,所以在设计期就把空值缓存一起做了,才是经验丰富的做法。
3. 缓存击穿:热点 key 过期的那一秒,互斥锁怎么选
击穿问题比穿透复杂在一点——它不是由于数据不存在,而是正好卡在"数据存在但缓存刚过期"的那个时间窗口。有些热点 key 的 QPS 是几万甚至几十万,数据库根本扛不住这个量级的直连。所以击穿本质上是一个并发竞态问题。
3.1 为什么"过期时间到了"不是一个瞬间,而是一个窗口
理解击穿,先要理解缓存的"重建时间"。当 key 过期后,第一个请求发现缓存 miss,于是去查 DB,把结果写回缓存。这个过程中的每一毫秒,其他请求也来了,它们同样发现缓存 miss,同样会去查 DB。关键在于这个 key 被重建需要多长时间——如果你的 DB 查询很慢,比如 200ms,那这 200ms 内涌入的请求全部打到 DB 上。性能差的数据库在这一波冲击下,连接数直接耗尽。
3.2 方案一:互斥锁(业界最常用的经典方案)
互斥锁的核心思路是:只允许一个请求去查 DB 并重建缓存,其他请求要么等待,要么直接返回旧值。
public Product getProductWithLock(Long productId) { // 1. 先查缓存 Product product = redis.get("hot:product:" + productId); if (product != null) { return product; } // 2. 缓存未命中,尝试获取分布式锁 String lockKey = "lock:hot:product:" + productId; String requestId = UUID.randomUUID().toString(); boolean locked = redis.setIfAbsent(lockKey, requestId, 3, TimeUnit.SECONDS); if (locked) { try { // 3. 拿到锁之后再次查缓存(双重检测,防止上一个线程已经重建了) product = redis.get("hot:product:" + productId); if (product != null) { return product; } // 4. 查DB product = productMapper.selectByPrimaryKey(productId); // 5. 回写缓存(热点数据可以考虑不设置过期时间,或设置较长) redis.set("hot:product:" + productId, product, 60 * 60 * 1000); return product; } finally { // 6. 释放锁——注意要判断是不是自己的锁 String lockValue = redis.get(lockKey); if (requestId.equals(lockValue)) { redis.delete(lockKey); } } } else { // 7. 没有抢到锁,说明其他线程正在重建缓存 // 可以短暂sleep后重试,或者直接返回旧值 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getProductWithLock(productId); // 重试(注意递归深度的控制) } }这里有几个容易被忽略的细节,都是我实战中踩过的坑:
- 锁必须设置过期时间,不然线程持锁期间宕机,锁永远不会释放,会变成一个分布式死锁。一般设置 3-5 秒,视 DB 查询耗时而定。
- 删除锁时要校验是不是自己加的锁。如果 A 线程加的锁过期了,B 线程又加上了锁,A 线程执行完直接删除锁,就会把 B 的锁删掉。所以删除前要判断 value 是否一致,这也是网上说的 Redis 分布式锁的经典坑。
- 自旋重试的次数要控制,不要无限递归。我习惯用一个计数器或者 for 循环最多重试 3-5 次,如果还是拿不到缓存,就返回一个降级结果(比如默认商品信息或者空对象),而不是死等。
3.3 方案二:逻辑过期(适合热点 key 极其珍贵、要求响应极快的场景)
互斥锁方案有个小缺点——抢不到锁的线程会阻塞等待,如果热点 key 的 DB 查询要几百毫秒,那这些线程的响应时间也会跟着变长。在某些对响应时间极度敏感的场景,可以使用"逻辑过期"方案。
逻辑过期方案的本质是:缓存数据不设置 TTL,永不过期,而是在 value 中额外存一个逻辑过期时间。每次检查发现逻辑过期时,立刻返回旧值(此时数据是脏的但可用),同时异步线程去 DB 加载最新数据并更新缓存。
public Product getProductWithLogicalExpire(Long productId) { // 1. 从缓存读取数据,缓存本身永不过期 CacheItem<Product> cacheItem = redis.get("hot:product:" + productId); if (cacheItem == null) { // 2. 缓存里完全没有数据(比如刚上线),需要走DB加载 return loadFromDbAndSetCache(productId); } // 3. 逻辑过期时间判断 if (cacheItem.getExpireTime() > System.currentTimeMillis()) { // 4. 未过期,直接返回 return cacheItem.getData(); } // 5. 已逻辑过期,先返回旧数据 // 6. 同时尝试获取互斥锁,异步更新缓存 String lockKey = "lock:hot:product:" + productId; boolean locked = redis.setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); if (locked) { // 异步线程池中更新缓存 threadPoolExecutor.execute(() -> { try { Product latest = productMapper.selectByPrimaryKey(productId); CacheItem<Product> newItem = CacheItem.of(latest, System.currentTimeMillis() + 60 * 60 * 1000); redis.set("hot:product:" + productId, newItem); } finally { redis.delete(lockKey); } }); } // 7. 先返回旧的逻辑过期数据 return cacheItem.getData(); }这个方案最关键的地方是:数据短暂不一致是可接受的。如果业务上不允许读到过期数据,那就老老实实用互斥锁方案。逻辑过期方案适合的内容包括:资讯详情、活动配置、排行榜榜单等允许短暂延迟的数据。比如排行榜,用户看到的数据差一分钟其实无伤大雅。
3.4 方案三:热点 key 永不过期 + 定时主动刷新
除了上面两种,我见过一些大厂的高并发核心链路用的是"物理上不设置过期时间 + 定时任务/消息主动更新缓存"的方式。这种方案等于把"过期删除"换成了"主动更新"——缓存永远在,只是有一个后台任务定期去 DB 拉取最新值刷进去。
它的优势很明显:完全不存在击穿窗口,因为 key 永不失效。缺点是需要保证 DB 更新后能及时同步到缓存。一般通过监听数据库 binlog 变更或者 RPC 调用后发送消息来触发刷新,属于缓存一致性部分的内容,这里不展开。
我个人的经验是:真正常年占据流量 Top10 的热点 key,就应该用"永不过期 + 主动刷新";那些偶尔成为热点的 key,才用互斥锁来防击穿。两种方案配合使用,比我见过有些人试图用一套方案解决所有问题要好得多。
4. 缓存雪崩:大量 key 同时失效或者 Redis 挂了,拿什么兜底
雪崩的杀伤力是三大坑里最大的,因为它不是单点问题,而是全盘崩溃的连锁反应。典型场景:峰值流量时某业务方把缓存设置的过期时间全部设成了"整点过期",比如 10:00:00 过期。10 点一过,所有 key 同时失效,所有请求同时穿透到数据库,数据库压力瞬间飙满,连接超时,紧接着出现大量报错,这就是线上事故。
4.1 过期时间雪崩:给 TTL 加一个随机抖动
最简单的做法,就是在你设置 key 的过期时间时,不要用固定值,而是加一个随机数。
// 不要这样:所有缓存统一60分钟过期 redis.set(key, value, 60 * 60 * 1000); // 应该这样:基础过期时间 + 随机抖动 int baseExpire = 60 * 60 * 1000; // 1小时 int randomExpire = ThreadLocalRandom.current().nextInt(600 * 1000); // 0-10分钟随机 redis.set(key, value, baseExpire + randomExpire);这样每个 key 的过期时间分散在 60-70 分钟之间,就不会出现"整点集体过期"的情况。这个思路很简单,我却见过很多团队因为"偷懒"或者"觉得无所谓"而没有做,最后在线上栽了跟头。做这件事的成本极低,收益却很直接——我建议所有缓存过期时间设置的地方,都默认加上随机抖动。
4.2 Redis 服务不可用:多级缓存和限流降级
如果雪崩的原因不是 key 集体过期,而是 Redis 本身挂了,那么你再怎么调整 TTL 都没用。这种情况下要做的是层次化的防护:
第一层:本地缓存(JVM 级别的 Cache)。比如使用 Caffeine 或 Guava Cache,在 Redis 之外再加一层应用本地的短暂缓存。即使 Redis 挂了,请求还可以命中本地缓存,不直接打到 DB。本地缓存的时间一般很短,比如 30-60 秒。
第二层:限流和降级。如果 Redis 挂了且本地缓存也未命中,你就必须接受一个现实——系统当前的容量撑不住所有流量,那就主动丢弃一部分请求,保留大部分核心请求可用。比如 Sentinel 或 Hystrix 限流,超过阈值的请求直接快速失败,返回提示"系统繁忙,请稍后再试",而不让请求穿透到 DB。
// 加入本地缓存兜底后的查询链路 public Product getProductWithMultiLevelCache(Long productId) { // L1: 本地缓存 Product localCacheProduct = localCache.getIfPresent(productId); if (localCacheProduct != null) { return localCacheProduct; } // L2: Redis 缓存(这里要做异常捕获,Redis超时不能拖垮主链路) try { Product redisProduct = redis.get("product:" + productId); if (redisProduct != null) { localCache.put(productId, redisProduct); // 回填本地缓存 return redisProduct; } } catch (Exception e) { // Redis异常时记录日志,继续走下层逻辑,不要在这里抛出 log.error("Redis查询异常", e); } // L3: DB(这里需要配合限流组件,防止DB被打垮) Product product = productMapper.selectByPrimaryKey(productId); if (product != null) { redis.set("product:" + productId, product, 30 * 60 * 1000 + randomExpire); localCache.put(productId, product); } return product; }这里最关键的一个编程习惯是:Redis 的访问不能影响主链路的可用性。很多初学者不加 try-catch,Redis 网络抖动一下,整个接口都报错,那就违背了缓存的初衷——缓存是"加速"和"保护"用的,不能成为新的故障点。在做缓存治理时,我用过一个"三原则":
- Redis 读失败,必须降级到 DB,不能直接抛异常。
- DB 也失败才允许报错。
- Redis 超时时间要短,不能因为 Redis 慢导致接口响应变慢。
第三层:Redis 自身的高可用。这就是另一个话题了,包括 Redis 哨兵模式、Redis 集群模式。单机 Redis 挂了就切主从,或者用 Redis Cluster 做分片,把故障影响面限制在可控范围内。
4.3 预防雪崩的一个进阶思路:key 预热
很多雪崩的根源在于"缓存里根本没有数据,或者数据失效后重建成本太高"。对于秒杀、大促、活动开场这类场景,可以做缓存预热:在流量到达之前,把即将用到的热点数据提前加载到 Redis 中,避免在流量高峰的瞬间去数据库拉取。
预热不是光写代码就行,还要考虑这些细节:
- 预热哪些 key?一般通过历史流量分析找出 Top N 的热点数据,或者运营提报名单。
- 预热的过期时间怎么设?应该按活动的预期结束时间设置,保证整个活动期间 key 不失效。
- 如果预热过程中 Redis 已经有旧值怎么办?要设置 force 标志,避免预热数据被旧缓存挡住。
有些团队用离线任务每天凌晨把次日的热点商品统一预热到 Redis 里,配合过期时间随机化,我实测下来可以很大程度缓解白天高峰期的缓存压力。
5. 真实线上排查:Redis 慢查询和缓存失效的定位套路
讲了这么多原理和方案,如果你只是记住了概念但不会排查,遇到线上问题还是会被动。这里分享一下我个人在排查缓存故障时的常用套路。
5.1 先看是"全部挂"还是"部分挂"
收到告警后,不要急着去看代码,先看监控大盘。如果业务 Redis 的 QPS 和 DB 的 QPS 同时飙升,说明大量请求穿透到 DB 了。这时候要看:
- 是所有接口都慢了,还是某一个接口慢了?
- 如果是所有接口,优先怀疑 Redis 服务不可用(比如网络抖动、主从切换、内存满了触发淘汰策略)。
- 如果是某一个接口慢,就要从该接口使用的 key 入手,排查那个 key 是否被集中访问、是否刚好集中过期。
5.2 用 Redis 的 keyspace 分析和慢查询日志定位异常 key
Redis 本身就提供了很多排查工具。SLOWLOG GET可以查看慢查询命令,MONITOR命令可以看到实时命令流(生产环境慎用,会影响性能)。定位到具体 key 后,用TTL key看剩余过期时间,用OBJECT ENCODING看 value 的编码类型。
另外,Redis 4.0 以后有了MEMORY USAGE key命令,可以查看一个 key 占用的内存。排查缓存穿透时很有用——如果发现大量 key 的 value 都是极小值(比如空值标记),那基本可以确定有人在刷穿透请求。
5.3 排障之后必须做复盘:修复五步法
每次缓存事故处理完之后,我都会按照一个标准化的五步来做复盘:
- 故障时间线和影响范围(何时的流量高峰、多少请求受损)。
- 根因分析(穿透、击穿还是雪崩,具体原因是什么)。
- 临时止血手段(限流、切流量、扩容、人工预热等)。
- 长期修复方案(防穿透的布隆过滤器、防击穿的互斥锁、防雪崩的随机过期等)。
- 监控预警完善(在 Redis 的命中率、DB 的 QPS 上建立合适的告警阈值)。
缺少第 2 步的复盘就是在走过场。很多团队修复完以后只是说"加了随机过期时间",但完全没搞清楚为什么当初会统一过期——是代码里写死了,还是配置中心没有更新?不堵住源头,几个月后同样的问题还会换个马甲找上门。
6. 缓存治理不只是"三防",要做整体的系统设计
如果只盯着穿透、击穿、雪崩这三个坑,容易陷入"头痛医头"的局面。实际业务中,缓存问题往往和缓存一致性、缓存更新策略、序列化方式、内存淘汰策略纠缠在一起。你要是只看一个点,修了 A 处的 bug,B 处又开始出问题。
6.1 缓存更新要有一条"主线"
一个 key 的数据在 DB 发生变化后,Redis 里的旧值怎么办?我见过有些系统同时存在三种更新方式:修改 DB 后删除缓存、修改 DB 后更新缓存、定时任务全量刷新缓存。三种方式互相打架,最后数据一致性一塌糊涂。
我的建议是确定一条主线:如果是强一致要求不高的读多写少场景,用"先更新 DB,再删除缓存"(Cache Aside Pattern);如果允许短暂延迟,用"监听 DB binlog 同步刷新缓存";如果需要同一时刻多个 key 保持一致,才考虑分布式事务或者基于版本号的乐观更新方案。
6.2 缓存监控指标要建全
谈缓存治理,没有监控就是"瞎治理"。值得关注的几个核心指标:
| 指标 | 说明 | 建议的告警场景 |
|---|---|---|
| Redis命中率 | 命中次数 / 总请求次数 | 命中率骤降超过10%时告警 |
| Redis慢查询 | 超过阈值的命令数量与耗时 | 平均耗时上升50%时告警 |
| DB QPS | 数据库的每秒请求数 | 突然翻倍时告警(可能发生穿透) |
| Redis内存 | 已用内存与最大内存比例 | 超过80%时告警,防止触发LRU淘汰 |
| key过期数量 | 每秒过期的key数量 | 出现明显尖峰时告警(可能雪崩) |
其中命中率是最容易被忽视的。有些人只盯着接口延迟,接口延迟不涨就不管缓存。但如果你发现某接口的 Redis 命中率从 95% 掉到了 60%,说明有大量坏 key 在被查询——这往往是穿透的前兆。提前发现,比等 DB 被打爆再处理要舒服得多。
6.3 序列化方式的选择:最容易忽略的一环
在做缓存治理时,很多问题其实不是"三防"能解决的,而是序列化方式选错了。Redis 的 value 类型如果使用 JDK 默认序列化,存储的是二进制对象,可读性差、占用空间大、跨语言不兼容。我推荐用 JSON 字符串存储可读性要求高的数据,用 Protobuf 等二进制序列化存储性能要求高的数据。
另外要注意不同的 RedisTemplate 序列化器会导致同一个 key 在不同客户端下"看不到"。例如用 StringRedisTemplate 写入的 key,用 RedisTemplate(JDK 序列化)去读就会获取不到。这类问题排查起来非常隐蔽,光看日志很难发现,很容易被误判为缓存穿透——其实是 key 的序列化前缀不一致。解决方法是统一配置一套序列化方案,并且在项目的缓存工具类中强制使用,而不是每个开发同学自己写一套 RedisTemplate。
7. 一个完整的实战代码:整合三防方案的工具类设计
前面原理和套路都讲了,我来写一个我在生产环境中整合三防思路的缓存工具类骨架。这个工具类不依赖具体的业务,但你可以直接拉到项目里改一改就能用。
@Component public class CacheGuardService<T> { @Autowired private RedisTemplate<String, String> redisTemplate; /** * 缓存查询入口(带穿透和击穿防护) * * @param key 缓存key * @param expireSeconds 过期时间(秒) * @param randomExpireRange 随机过期时间范围(秒),用于防雪崩 * @param bloomFilter 布隆过滤器(可为空,空则不启用穿透校验) * @param loader DB加载逻辑 */ public T queryWithGuard(String key, long expireSeconds, long randomExpireRange, Object bloomFilter, Supplier<T> loader) throws Exception { // 1. 穿透防护:布隆过滤器预判 if (bloomFilter != null && !bloomFilterContains(bloomFilter, key)) { return null; } // 2. 查缓存 String json = redisTemplate.opsForValue().get(key); if (StringUtils.isNotBlank(json)) { return JsonUtils.parseObject(json); } // 3. 缓存未命中,加分布式锁防止击穿 String lockKey = "lock:" + key; String requestId = UUID.randomUUID().toString(); boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 3, TimeUnit.SECONDS); if (locked) { try { // 双重检测 json = redisTemplate.opsForValue().get(key); if (StringUtils.isNotBlank(json)) { return JsonUtils.parseObject(json); } // 查DB T data = loader.get(); if (data == null) { // 空值缓存,防穿透兜底(短过期) redisTemplate.opsForValue().set(key, "", 3 * 60, TimeUnit.SECONDS); return null; } // 写入缓存,过期时间加随机抖动 long expire = expireSeconds + ThreadLocalRandom.current().nextLong(randomExpireRange); redisTemplate.opsForValue().set(key, JsonUtils.toJson(data), expire, TimeUnit.SECONDS); return data; } finally { // 释放锁(校验value) String lockValue = redisTemplate.opsForValue().get(lockKey); if (requestId.equals(lockValue)) { redisTemplate.delete(lockKey); } } } else { // 未获锁,短暂等待后重试 Thread.sleep(20); return queryWithGuard(key, expireSeconds, randomExpireRange, bloomFilter, loader); } } // 这里还可以扩展:加本地缓存(Caffeine)、异步刷新逻辑等 }这个工具类的设计思路是"一入口、多防线":查询全部走这个入口,内部自动屏蔽穿透、击穿、雪崩三类问题。当你在项目里看到某个 key 的缓存代码重复写了三遍而且每遍逻辑都不一样,那就是该收拢的时候了——把规则统一收敛到一个工具类,是缓存治理从"会写"到"会管"的标志。
8. 踩过这几轮坑之后,我对缓存设计的几点体会
最后聊一点心得。
我见过太多团队在缓存的设计上"过于乐观"——以为加了 Redis 就万事大吉,QPS 随便上,完全不去想缓存失效时系统会怎样。直到线上因为一条热点新闻、一次集中过期、一个爬虫脚本把 DB 打挂之后,才开始认真做防护。做缓存跟做灾备其实很像:不是看正常时跑得多快,而是看故障时能扛多久、能怎么恢复。
我也见过把方案设计得过于复杂的团队——为了防止击穿,搞了双层锁、多级缓存、消息队列异步刷新,代码写了几百行,最后运行半年发现那个 key 根本没成为过热点,白白增加了维护成本。缓存防护也应该分级:普通 key 不做特殊防护,只用随机过期防雪崩;热点 key 单独标记,做互斥锁或主动刷新;核心链路的数据配合本地缓存兜底。把有限的精力投入到真正有风险的位置上。
如果你现在正准备重构缓存层,我的建议是先从监控和代码收敛开始:把 Redis 访问统一收口到工具类,把缓存 key 的规范定下来,把 TTL 的随机抖动加上,再把命中率、DB QPS 的告警配好。做好这几件事,哪怕穿透、击穿、雪崩的方案还没有完全落地,你的系统也已经比大多数裸奔的缓存架构稳健多了。