凌晨两点十三分,我被电话叫醒。商城主页的品类楼层像被推倒的积木一样一片空白,监控大屏上数据库的QPS曲线从平时的800直线飙升到9000,Redis连接数打满,订单服务超时率冲上30%。后来排查结果让我哭笑不得:根本不是代码发布或者数据库故障,就是一次日常的定时任务批量重建缓存,撞上了活动的开场秒杀。三个词——缓存穿透、缓存击穿、缓存雪崩——一次全占,教科书都不敢这么演。
如果你也是后端开发,这几个词大概率在面试题里背过无数次,但真正遇到的时候,能立刻判断出“当前是哪种问题”,并把手里的方案选对、参数调好、代码落稳的人,并不多。这篇文章我不打算做概念堆砌,而是把这三种故障形态的成因、判断方法、代码级别的解决方案,以及我踩过的那些坑,一次性讲透。
1. 三者到底怎么区分,别再看图鉴式科普了
很多文章喜欢用“缓存雪崩是大量key同时过期、击穿是单个热点key过期、穿透是查了一个不存在的key”这种定义式描述,听起来很清晰,但落地的时候你会发现,它们经常混在一起出现,就像前面说的那次事故。所以我习惯用两个维度去区分:请求目标是不是真实存在,以及失效的范围是大还是小。
1.1 穿透:打到了一个不存在的key
缓存穿透,本质上是请求在“缓存层”和“存储层”都找不到答案。比如一个用户ID=-100的请求、一个商品ID=0的请求、或者是爬虫随机拼的ID,Redis查不到,数据库也没有这条记录。于是每次请求都穿过缓存,直接打到数据库上。
这里有个执行力很强的歪门邪道思路:既然缓存里没有,那就别让数据库也白挨打。在缓存和数据库之间加一道“校验层”,或者把不可能存在的结果也缓存起来,是穿透场景的核心解法。具体的代码和方案在第二章细讲。
1.2 击穿:单个热点key的“死而复生”瞬间
缓存击穿是三者里最“阴”的。它针对的是一个被高并发访问的热点key,比如首页的推荐位配置、秒杀商品的库存key。这个key平时能扛住99%的流量,但缓存刚好在失效的那一瞬间,同时来了几千个请求。它们发现缓存miss了,于是一起去数据库重建缓存,数据库瞬间被冲垮。
这个名字特别形象:一个key就像一面墙,墙倒的那一秒,所有压力瞬时穿透。它和穿透的根本区别在于——数据本身是真实存在的,只是缓存恰好过期了。
1.3 雪崩:大批key一起失效引发连锁反应
缓存雪崩,就是击穿的“群殴版”。大量的key在同一时间段集体失效,比如定时任务批量更新缓存,把同一批数据全部写进缓存并设置了相同的过期时间;或者Redis实例本身的物理故障导致缓存层全部不可用。
我遇到过一个非常经典的雪崩场景:运营人员把所有活动商品统一设置了“当日23:59:59过期”,结果零点刚过,所有商品缓存同时失效,系统直接被压垮。本质上,雪崩的核心在于失效时间的同步性,以及缓存层整体可用性的丧失。
1.4 一张表看懂三者的本质区别
| 场景 | 请求的数据是否真实存在 | 影响范围 | 主要矛盾 |
|---|---|---|---|
| 缓存穿透 | 不存在 | 单次请求打穿缓存 | 缓存与数据库都无法命中 |
| 缓存击穿 | 存在 | 单个热点key失效期间 | 热点key重建的高并发冲突 |
| 缓存雪崩 | 存在 | 大量key同时失效/缓存层不可用 | 失效时间的同步性与缓存整体可用性 |
这段描述帮助我在线上排查的第一时间建立判断框架:先看DB慢查询日志里有没有大量“查不到数据”的请求,有就是穿透;再看Redis的键空间统计里是否有某个key的访问频率异常高且正好miss,有就是击穿;如果观测到Redis连接异常或者大量key同时消失,那就是雪崩。
2. 缓存穿透实战解法:双保险比单单一个方案更可靠
穿透的解法市面上就两种主流:返回空值缓存和布隆过滤器拦截。很多资料把它们作为“二选一”的选项,但我的实际操作经验是:两者各守一道关卡,组合使用才最稳妥。
2.1 空值缓存:简单粗暴但要注意TTL
当一个查询在数据库里没查到数据,我不急着让它直接返回null,而是把这个“null”也写进Redis,设置一个较短的过期时间。这样后续同样的无效请求,就能命中缓存,不再打到数据库。
// 查询用户信息时处理穿透 public UserInfo getUserInfo(Long userId) { String cacheKey = "user:info:" + userId; // 1. 先查缓存 String value = redis.get(cacheKey); if (value != null) { // 2. 缓存中存了空值标记,直接返回null if (value.equals(EMPTY_MARK)) { return null; } return JSON.parseObject(value, UserInfo.class); } // 3. 缓存未命中,查数据库 UserInfo userInfo = userMapper.selectById(userId); if (userInfo == null) { // 4. 数据库查不到,缓存一个空值,TTL设置为3分钟 redis.set(cacheKey, EMPTY_MARK, 180); return null; } // 5. 正常数据缓存10分钟 redis.set(cacheKey, JSON.toJSONString(userInfo), 600); return userInfo; }这里有几个实际操作中的关键细节:
- 空值的TTL绝对不能长。空值缓存的意义是挡住短期内的恶意请求或数据缺失,但一旦数据真的被创建了,空值缓存还挡着就会造成数据不一致。我一般把空值TTL设置在60-180秒之间,最长不超过5分钟。
- 要区分“真正的空”和“业务无关的默认值”。比如默认头像的URL、默认昵称,这些本身是有效数据,不应该走空值标记逻辑。
- 空值缓存会消耗Redis内存,如果恶意请求构造的随机ID过多,比如几百万个不同的无效ID,空值缓存方案会撑爆内存。这时候就必须依靠第二道防线。
2.2 布隆过滤器:拦截非法ID的“准入名单”
布隆过滤器的原理,本质上是一个位数组配合多个哈希函数。它用极小的空间代价,判断一个元素是否“有可能存在于集合中”。注意,是“有可能”而不是“一定”。它会误判“可能存在”某个元素,但绝对不会漏判——如果一个ID判定为“不存在”,那它就一定不在数据库中。
我常用的方案,是项目启动后把数据库里所有合法的用户ID、商品ID,也就是会走查询逻辑的ID,全部加载进布隆过滤器。之后每次查询前先过过滤器,如果过滤器说“不存在”,直接返回null,根本不会去查Redis和DB。
// 使用Redisson的RBloomFilter实现 @Bean public RBloomFilter<Long> userIdBloomFilter(RedissonClient redissonClient) { RBloomFilter<Long> filter = redissonClient.getBloomFilter("bloom:user:ids"); filter.tryInit(1000000L, 0.01); // 预期元素数量100万,误判率1% return filter; } // 查询时的拦截逻辑 public UserInfo getUserInfoWithBloomFilter(Long userId) { if (!userBloomFilter.contains(userId)) { // 布隆过滤器判定不存在,直接返回,拦截穿透 return null; } // 命中过滤器,走正常缓存查询流程 return doGetUserInfo(userId); }2.3 布隆过滤器参数设计:误判率和空间的计算
这里必须提一下参数设置的算法,很多人直接用默认值,用得不明不白的。
布隆过滤器的核心参数有三个:预期元素数量n、误判率p、位数组长度m和哈希函数个数k。它们之间存在这样的关系:
- 位数组长度 m = - (n * ln p) / (ln 2)^2
- 哈希函数个数 k = (m / n) * ln 2
以100万ID、1%误判率为例:
- m = - (1000000 * ln 0.01) / (0.693)^2 ≈ 958万位,约1.14MB
- k = (9580000 / 1000000) * 0.693 ≈ 7
也就是说,只要1.14MB的内存,就能容纳100万个ID,且误判率控制在1%以内。这里有个工程上的经验判断:误判率不要追求极致的0.01%,因为内存占用会指数级上升,1%-5%的误判率在绝大多数业务场景下完全够用——毕竟被误判放行的请求,后续还有空值缓存兜底。
如果你使用的是Guava的BloomFilter,它内部已经封装好了最优参数计算,只需要传入预期数量和误判率即可,但了解底层算法有助于你在面试或排查问题时快速判断瓶颈。
2.4 为什么不建议“只靠缓存空值”或“只靠布隆过滤器”
只靠布隆过滤器的问题是:它只能拦截数据库里“确定不存在”的数据,但数据是动态的。如果今天用户注册了一个新ID,它在布隆过滤器里是不存在的,如果不做实时更新,这个新用户会被拦截,无法查询到自己的数据。
只靠空值缓存的问题在前面也提过:恶意构造随机ID时,内存会爆炸。
所以我的实现方案是:布隆过滤器做第一道拦截,挡住绝大多数恶意无效请求;空值缓存做第二道拦截,兜住那些“数据库里确实没有但布隆过滤器误判放行”的请求。两层一起,才能把穿透率降到极致。
提示:布隆过滤器不支持删除操作。如果你有删除ID的需求,比如注销用户、下架商品,是需要重建过滤器或使用布谷鸟过滤器(支持删除)的。不要为了这个功能自己实现,调Redisson或者Guava的现成实现最稳。
3. 缓存击穿实战解法:互斥锁与逻辑过期怎么选
击穿的关键矛盾是“热点key失效瞬间的高并发重建”,思路也很直接:要么让同一时间只有一个线程去重建缓存,要么让旧数据在key失效后的短暂时间内继续对外服务。
市面上的成熟方案有四种,但我在生产环境实际用过、并且推荐的是下面两种:互斥锁(Mutex Lock)和逻辑过期(Logical Expiry)。
3.1 方案一:互斥锁重建缓存,只允许一个线程访问DB
核心思路是:发现缓存miss后,不直接查数据库,而是先尝试获取一把分布式锁。拿到锁的线程去查数据库并重建缓存,其他线程短暂自旋重试,等待第一个线程把缓存重建好。
public String getHotData(String cacheKey) { String value = redis.get(cacheKey); if (value != null) { return value; } // 缓存不存在,尝试获取互斥锁 String lockKey = "lock:" + cacheKey; // SET NX EX:只有key不存在时才能设置成功 if (redis.set(lockKey, "1", "NX", "EX", 5)) { try { // 拿到锁,查数据库并重建缓存 String dbValue = queryFromDb(); redis.set(cacheKey, dbValue, 600); return dbValue; } finally { // 释放锁 redis.delete(lockKey); } } else { // 没拿到锁的线程,短暂休眠后重试 Thread.sleep(50); return getHotData(cacheKey); // 递归重试,或使用循环重试 } }这段代码的关键点,也是我实际踩过坑的地方:
- 锁的过期时间要短,我一般设置5秒。如果DB查询时间较长,锁到期自动释放,可能导致并发问题。更稳妥的做法是使用Redisson的RLock实现“看门狗”自动续期,避免锁过期但线程还没执行完的情况。
- 递归重试有栈溢出风险,高频场景下建议用for循环重试,最多重试3-5次,超过就返回兜底数据或直接抛异常。
- 重建缓存时,一定要用DB的最新数据,不要基于旧缓存做修改——因为锁只保证DB查询这步不并发,但无法保证数据源自身的一致性。
3.2 方案二:逻辑过期,用旧数据撑住新流量
逻辑过期,是不设置Redis物理过期时间,而是在value里加一个字段记录“业务过期时间”。当读取时发现逻辑时间已过期,不是立刻删除key,而是返回旧值给用户,同时异步发起一个重建线程更新缓存。
public String getHotDataByLogicalExpire(String key) { String value = redis.get(key); if (value == null) { // 缓存不存在(可能是Redis重启),直接查DB并返回 return queryFromDbAndSetCache(key); } // 反序列化,读取逻辑过期时间 CacheObject cacheData = JSON.parseObject(value, CacheObject.class); if (cacheData.getExpireTime() > System.currentTimeMillis()) { // 未过期,直接返回 return cacheData.getData(); } // 已过期,尝试获取锁,异步重建 String lockKey = "lock:" + key; if (redis.set(lockKey, "1", "NX", "EX", 5)) { try { // 获取锁后,再检查一次逻辑时间 // 防止上一个重建线程刚更新完,本线程又重建一次 Thread thread = new Thread(() -> { String newValue = queryFromDb(); // 设置新的缓存值,逻辑过期时间为当前时间+600秒 redis.set(key, newCacheObject(newValue, 600)); redis.delete(lockKey); }); thread.start(); } finally { // 释放锁 redis.delete(lockKey); } } // 返回已过期的旧数据 return cacheData.getData(); }这个方案的优点是:读取操作永远不阻塞,即使缓存过期了,用户拿到的还是旧数据,体验几乎无感知。我主推它来应对秒杀场景下的热点商品——因为秒杀场景对“返回旧库存数据”的容忍度比“超时无响应”高得多。
但它有一个代价:容易出现短期数据不一致,因为用户可能读取到过期的数据几秒甚至十几秒。所以,对数据一致性极高的业务要慎重,对读多写少且一致性容忍度较高的热点数据则非常合适。
3.3 两种方案选型表格对比
| 维度 | 互斥锁重建 | 逻辑过期 |
|---|---|---|
| 用户体验 | 缓存重建期间,部分请求可能等待重试 | 始终能拿到数据(旧值) |
| 数据一致性 | 高,读到的一定是重建后的新数据 | 低,可能读到过期数据 |
| 对DB的压力 | 只放行一个线程,压力最小 | 高并发时DB可能短暂承压 |
| 实现复杂度 | 中等,需要处理锁的获取、释放、重试 | 相对复杂,需要维护逻辑过期时间和异步更新 |
| 适用场景 | 数据一致性要求高,DB响应快 | 读多写少,对短时旧数据容忍度高 |
3.4 一个容易被忽略的坑:锁的粒度
锁的粒度设计很重要。很多人直接对整个业务接口加锁,导致所有key的缓存重建都串行化,性能断崖式下降。正确的做法是按key粒度加锁,也就是每个热点数据的cacheKey对应一把独立的lockKey。这样某个商品重建缓存时,其他商品的查询完全不受影响。
我在生产环境踩过这个坑后总结出来的规则是:锁的粒度越细越好,锁的过期时间要远小于业务可接受的等待时间。
4. 缓存雪崩实战解法:时间随机化与高可用架构双管齐下
雪崩的成因可以分两类:一是大量key同时过期,二是Redis实例不可用。两种情况的解决思路完全不同。
4.1 针对“大量key同一时间点过期”
最朴素也最见效的做法,就是给过期时间加一个随机偏移量。
我记得当年为活动任务批量写缓存的时候,业务方原本要求“所有商品缓存统一在十分钟后过期”。我直接在代码里把统一过期时间改成了“基础过期时间 + 随机数”,这样就避免了一大波缓存同步失效。
// 设置缓存时,在原始过期时间基础上加上随机偏移 int baseExpire = 600; // 10分钟 int randomExpire = baseExpire + new Random().nextInt(300); // 加上0~5分钟随机数 redis.setex(cacheKey, randomExpire, value);别看这个改动小,实际效果却出奇的好。如果一万个key都在同一秒失效,那么加了随机偏移后,它们会在5分钟的时间窗口内分散失效,DB压力从“瞬时峰值1万QPS”平摊成“持续平稳的几百QPS”。
另一个实践是避免设置相同的逻辑过期时间点。比如定时任务更新缓存时,很多工程师习惯为所有key设置“当天23:59:59过期”,这就人为制造了第二天的雪崩时刻。我一般会让运营数据的过期时间基于“创建时间+业务有效时长+随机偏移”的逻辑,而不是统一对齐到某个自然时间点。
4.2 针对“Redis整体不可用”
当Redis集群宕机、网络分区或连接池耗尽时,所有请求都会绕过缓存直接打向DB,这种雪崩更致命。我的方案是构建一个“缓存不可用时的降级链路”:
public String getDataWithFallback(String key) { try { String value = redis.get(key); if (value != null) { return value; } } catch (Exception e) { // Redis超时或者连接异常,记录告警,继续走降级逻辑 log.error("Redis异常,降级", e); } // 本地缓存兜底(Caffeine/Guava Cache) String localValue = localCache.get(key); if (localValue != null) { return localValue; } // 本地缓存也没有,查DB并更新本地缓存 String dbValue = queryFromDb(); localCache.put(key, dbValue); return dbValue; }这个降级链路的精髓在于多级缓存兜底:Redis挂了,本地缓存顶上;本地缓存也没有,DB还能扛一阵。配合限流组件(比如Sentinel或Hystrix),把超出系统承载能力的请求直接快速失败,返回提示或降级数据,不让无休止的请求把DB打挂。
4.3 Redis高可用的基础设施配置
降级是兜底手段,高可用才是主动防御。我强烈建议在主生产环境使用Redis Sentinel(哨兵模式),在数据量超过单机内存承载能力时使用Redis Cluster(集群模式)。
哨兵模式至少部署一主两从三哨兵,主节点故障时自动完成故障转移,业务方无感知。Cluster模式则通过数据分片把key分散到多个主节点,任何一个分片挂掉,其他分片依然提供服务。另外由于Cluster模式下每个节点默认只负责部分slot,单节点宕机导致的缓存不可用范围会被限制在1/总节点数的规模内。
这里有个我实际遇到过的问题:配置了哨兵,但客户端没有配置多节点地址。有些框架的Redis客户端只配了一个主节点地址,主节点故障时它依然连老的地址,导致连接失败。正确做法是配置多个哨兵地址,让客户端通过哨兵动态感知当前主节点。
4.4 预防雪崩的几道闸门
生产环境中,我的雪崩防御体系是分层的:
- 网络层:对可疑IP进行限流,拦截恶意并发请求。
- 网关层:接入层配置总并发控制,防止瞬时流量冲击。
- 缓存层:过期时间加随机偏移;核心数据用逻辑过期;Redis高可用架构。
- 应用层:互斥锁重建热点key;多级本地缓存兜底;限流熔断器。
这套闸门体系在多次大促场景中验证过,即使Redis真的出现问题,数据库也不会直接被压垮。
5. 线上故障排查:一夜之间怎么从监控入手定位问题
理论讲得再多,不会排查等于零。我复盘一下自己是怎么在十分钟内定位到那场“三问题齐爆”的事故的。
5.1 看监控指标找线索
排查的第一步永远是看监控大盘,重点看四个指标:
| 指标 | 穿透的特征 | 击穿的慢特征 | 雪崩的特征 |
|---|---|---|---|
| Redis命中率 | 短期不变或缓慢下降 | 某个key刷新瞬间短暂下降后恢复 | 整体断崖式暴跌后持续低位 |
| DB QPS | 超高且持续 | 单个时间点尖峰 | 高峰持续数秒甚至数分钟 |
| 接口响应时间 | 未命中时响应缓慢 | 高并发下部分请求超时 | 大面积超时甚至服务熔断 |
| Redis连接数 | 正常 | 正常或略高 | 打满或异常 |
那天的监控是Redis命中率一秒内从95%降到60%,同时DB QPS暴增。这两个信号同时出现,我第一反应是雪崩——因为击穿一般是命中率跌下去又快速涨回来,穿透也不可能让命中率波动这么大。
5.2 快慢日志定位具体key
确认是雪崩方向之后,通过Redis慢查询日志和DB慢查询日志,很快锁定了命中率暴跌区间内访问量最大的几个key——全部集中在活动商品品类上。再结合Redis-keyspace-notifications检测键失效事件,可以看到这些key几乎在同一秒内触发了expire,时间和那次定时任务吻合。
定位到一批key同时过期是问题根源之后,马上把那条定时任务脚本里的“写缓存设置统一过期时间”逻辑揪了出来,现场修改成带随机偏移的版本。因为Redis本身没挂,所以降级链路没用上,但如果你观察到的是Redis连接抛异常,就要立刻激活降级代码,先把流量从Redis切到本地缓存或限流。
5.3 一次实战模拟:用JMeter复现击穿
建议你在测试环境提前演练一次。我之前用JMeter做过一次击穿压测:
- 先把某个热点key删除,模拟缓存失效。
- 然后开启200个并发线程,循环请求这个key。
- 观察监控:DB QPS瞬间冲到200,而缓存重建完成后降回0。
- 接着应用互斥锁方案,重复上述步骤,发现DB QPS会降到1-2,其他190多个请求都在等待重试。
做过一次这种对比压测,你就会对击穿的危害和方案效果有切身体感,不至于上线后才手忙脚乱。
5.4 线上排查的三大铁律
我总结了几条排查时的操作纪律:
- 绝对不要在线上直接删除生产key来测试击穿,除非你已经确认业务低峰期且方案已上。
- 通过Redis的monitor命令观察实时请求模式,注意只筛选关键的访问量大的key,否则日志量会大到崩溃。
- 先保命再定位,如果DB眼看要被打挂,优先执行限流熔断,先把流量挡住,再慢慢查根因。不要试图在系统挂掉时还保持“完整取证”。
6. 综合落地实践:一套方案同时防住三种问题
前面分析了各自的解法,但生产中这三种问题往往交替出现。我推荐一套组合拳,你可以直接参考落地。
6.1 整体架构分流图
- 所有查询请求先经过布隆过滤器,不存在的数据直接返回null,这是第一道防线。
- 通过过滤器的请求进入Redis查询,命中直接返回;未命中判断是否热点key。
- 热点key走逻辑过期或互斥锁方案,普通key走空值缓存方案。
- 如果Redis异常,启动本地缓存降级和DB限流兜底。
这套链路在代码层面是一个标准的多级缓存Query流程:
public String queryProductDetail(Long productId) { // --- 第一层:布隆过滤器拦截 --- if (!productBloomFilter.contains(productId)) { return null; } // --- 第二层:Redis查询 --- String cacheKey = "product:detail:" + productId; String value = redis.get(cacheKey); if (value != null) { return value; } // --- 第三层:本地缓存兜底 --- value = localCache.get(cacheKey); if (value != null) { return value; } // --- 第四层:互斥锁重建,DB查询 --- String ret = getHotDataByMutex(cacheKey); return ret; }6.2 参数配置的心得总结
我把自己常用的参数整理成一个速查建议表,方便你copy设置时参考:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 空值缓存TTL | 60-180秒 | 太短挡不住穿透,太长容易数据不一致 |
| 布隆过滤器误判率 | 1%-5% | 越低越耗内存,1%是均衡点 |
| 互斥锁过期时间 | 3-5秒 | 要小于DB平均查询时间+重建缓存时间 |
| 互斥锁重试间隔 | 50-200ms | 太短会空转消耗CPU,太长影响请求响应时间 |
| 逻辑过期预留时间 | 基础TTL的20%-30% | 保证异步重建有足够时间窗口 |
| 缓存随机偏移 | 基础过期时间的10%-50% | 太大导致缓存不生效,太小无法错峰 |
| 本地缓存容量 | 1000-5000个key | 按业务实际热点key数评估 |
6.3 定时任务批量写缓存,一个必改的坏习惯
最后单独提一个高频雷区:定时任务里用统一的setex批量写缓存。
比如一个跑批系统每天凌晨把全量商品信息刷进Redis,很多工程师图简单,直接for循环set key,所有key的过期时间一模一样。一旦某个定时任务延迟到业务高峰前才执行完,就相当于给业务埋了一颗定时炸弹——统一过期时间到的那一刻,雪崩就来了。
我的标准做法是:跑批写缓存时,按每批或每个key随机分散过期时间;如果业务确实需要统一在某个时间点失效,那就再加一层懒加载重建,即任务先把数据更新至DB,Cache由读取时逐步重建,而不是依靠定时任务统一刷入。
7. 写在最后:这不想再被半夜叫醒的话
那场凌晨三点的故障之后,我把缓存相关的所有代码做了一次全面审查,不仅修了跑批和过期时间,还补上了空值缓存和布隆过滤器两道防线。后来类似的并发场景又来过好几次,但监控上的DB曲线始终很平稳,再没有人半夜打电话叫醒我。
我的体会是,缓存这三个问题真正难的地方不在“理解概念”,而在于“把方案落到代码里时,能不能提前想到边界条件”。锁过期了怎么办、空值会不会撑爆内存、布隆过滤器误判了后续靠谁兜底、Redis挂了业务能不能降级——这些问题每一个都是实操里能碰到的事情。
如果你现在正准备重构自己系统的缓存层,我建议顺序是:先给Redis加高可用,再给热点key做好互斥锁或逻辑过期,然后在入口加上布隆过滤器和空值缓存,最后定期做一次压测,模拟一种或多种故障同时发生的情况。不要等到监控报警才去思考方案,那会儿你和我一样,只能手忙脚乱看日志了。
这套组合拳运行稳定之后,你会发现“缓存雪崩、缓存击穿、缓存穿透”这几个词,在咨询记录里会渐渐消失。它们最终沉淀为你对系统容错能力的一种直觉,而不是面试答题时的三个名词。