1. 点评项目里的缓存:为什么雪崩和击穿总是被放在一起说
1.1 一套标准的缓存代码,怎么就成了隐患
黑马点评这个项目,很多学后端的朋友都不陌生,模拟大众点评的商家查询加点评系统,技术栈就是 Spring Boot + MySQL + Redis。前面几篇技术汇总把项目架构、登录鉴权、缓存基本用法都梳理完了,这一篇专门啃缓存链路里最要命的两个场景——缓存雪崩和缓存击穿。
项目里查店铺的接口,核心逻辑非常简单:先查 Redis,命中就返回;没命中就查 MySQL,然后把数据写回 Redis,顺便设置一个 30 分钟的过期时间。这套 Cache Aside Pattern 是绝大多数后端项目缓存的标准写法,也是课程里最先教的东西。但正是这个"统一设置 30 分钟过期"的细节,在特定流量下会变成雷。第一个雷:如果大量 key 在同一时间点过期,比如系统预热时把所有店铺数据一次性写入缓存,统一设置 30 分钟,那么 30 分钟后所有 key 一起失效,这时所有请求全部落到 MySQL。第二个雷:如果某个店铺是明星店铺,查询量占了全站一半,它的缓存 key 刚好在高峰期过期,那么这一半的流量会在同一瞬间压向数据库。
这两个场景在并发量低的时候完全暴露不出来。开发环境一台机器、几个测试账号,哪怕缓存全部失效,MySQL 也就多扛几十个请求。但项目一旦上线,尤其是点评类这种高并发读多写少的业务,流量一来就是几千上万的 QPS。MySQL 能扛的连接数有限,一旦连接池被打满,整个接口就卡死,接着线程池排队,Web 容器跟着阻塞,最后是全站不可用。所以这两个问题不是"理论上的风险",而是真实上线后最先撞上的墙。
1.2 雪崩、击穿、穿透:三兄弟先分清楚
在往下读之前,先花一分钟把这三个概念理清楚。很多人面试或者写简历的时候,都容易把这几个词说串,其实它们的触发场景和解决思路差异很大。
缓存穿透,是查询一个根本不存在的数据。因为缓存里没有,数据库里也没有,所以每次请求都会打到数据库。最典型的场景是恶意脚本循环请求一个不存在的店铺 ID,比如 id = -1 或者一个超大的随机数字。解决方案也直接:空值缓存(查不到数据也写一个空值进缓存,短 TTL),或者布隆过滤器挡在缓存前面。
缓存击穿,是查询一个非常热点的 key,而这个 key 刚好在某一刻过期。一瞬间大量请求发现缓存没命中,全部涌向数据库。关键词是"一个 key"。
缓存雪崩,是大量 key 在同一时间段内集体过期,或者 Redis 实例本身宕机。关键词是"一批 key"。
击穿可以理解为雪崩的一个特殊子集,一个是单点过期,一个是群体过期,本质上都是缓存失效导致流量直达数据库,只是规模和场景不同。这也就解释了为什么黑马点评的课程把雪崩和击穿放在同一篇讲——它们的解决方案高度重合,互斥锁和逻辑过期这两招,对两个问题都有效。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存雪崩:一批 key 同时过期后的连锁反应
2.1 从一次批量更新看雪崩的完整链路
假设黑马点评上线后,运营在后台做了一次店铺数据批量更新,更新完直接把所有店铺信息写进了 Redis,统一设置 30 分钟过期,写入时间点是 14:00。到了 14:30,这批 key 同时失效。
此时恰好是下午的访问高峰,用户端的请求照旧先查 Redis,结果 key 全部不在,于是每个请求都去查 MySQL。MySQL 的连接池默认也就二三十个连接,平时缓存命中率 95% 以上时,数据库 QPS 只有几百,现在突然变成几千甚至上万。连接池被占满后,新请求只能等待获取连接,数据库的 CPU 和磁盘 IO 同时飙升,SQL 查询越来越慢,慢查询又把连接占得更久——这是一个恶性循环,最终数据库直接拒绝服务。
更麻烦的是,雪崩会向上游传导。数据库响应变慢,Tomcat 的线程池很快耗尽,后面的请求全部排队,从接口层开始响应时间飙升。如果网关层还有超时重试机制,重试流量会进一步加剧数据库压力。这也是为什么很多人说,缓存雪崩本质上是"系统性的连锁故障",而不是单纯某一个接口的问题。处理它的核心思路,就是破坏这个连锁条件:要么让 key 不同时失效,要么让失效后的重建流量不直接压向数据库。
2.2 第一道防线:TTL 随机化,打散集体失效
理解了雪崩的成因,第一个解法就很自然了:既然大家是一起过期的,那就让它们不要一起过期。给每个 key 的过期时间加一个随机偏移量,把"集体失效"打散成"渐次失效"。
在黑马点评项目里,把原来的固定 TTL 改成这样:
java复制// 原写法:固定 30 分钟
stringRedisTemplate.opsForValue().set(key, json, 30, TimeUnit.MINUTES);
// 改造后:30 分钟 + 0~10 分钟随机偏移
int ttl = 30 * 60 + new Random().nextInt(10 * 60);
stringRedisTemplate.opsForValue().set(key, json, ttl, TimeUnit.SECONDS);
这段代码的逻辑很简单:每个 key 的实际过期时间分布在 30~40 分钟之间,即使它们是同一时间写入的,也不会在同一个秒级窗口内集体失效。数据库承受的压力从"一瞬间几千 QPS"变成了"分散在 10 分钟内的几百 QPS",完全在可接受范围内。
TTL 随机化是成本最低的方案,改动只有一行,适合作为通用的兜底手段。但要注意,它只能降低雪崩的概率和冲击烈度,不能彻底消除。如果 Redis 直接宕机,再随机也没用,所有 key 还是访问不到。所以它通常是雪崩防线里的第一层,而不是唯一一层。
提示:随机偏移量的范围不要太小。偏移 1~2 分钟意义不大,大数据量下还是可能大量集中在某个时间点;但也不要太大,否则一些数据早该过期了还占着缓存空间,反而增加内存压力。5~10 分钟的随机窗口在大多数业务里是合理的。
2.3 第二道防线:互斥锁,让数据库同一时刻只接一个重建请求
TTL 随机化解决的是"大批 key 一起失效"的概率问题,但如果失效的这一刻已经来了,怎么处理?核心思路是:当缓存 miss 时,不要让所有请求都去查数据库,而是只让一个请求去查,其他请求等它查完,然后直接拿缓存结果。
这个"只放一个请求进去"的手段就是互斥锁。在 Redis 里最简单的实现就是 SETNX 命令——谁用 SETNX 设置成功,谁就拿到锁,去执行数据库查询和缓存重建;没拿到锁的线程,休眠一小会儿再重试。
在黑马点评项目里,互斥锁的完整实现后面会专门拆解,这里先理解它的核心价值:它把"缓存重建"这个操作从并行变成串行,数据库在同一时刻最多只承受一个查询请求,其他请求全部在等待中。对于单个热点 key 的击穿场景,这是最彻底的解法。
互斥锁有代价:拿到锁的线程如果查库慢,其他线程会一直阻塞等待,接口的响应时间会上升。另外分布式锁本身要注意防死锁,锁必须设置过期时间,否则持有锁的线程挂了,锁永远不释放,整个接口就永久阻塞了。这两点后面都会展开讲。
2.4 第三道防线:逻辑过期,用旧数据换可用性
逻辑过期是黑马点评课程里非常巧妙的一个方案,它和前面两种思路完全不一样。前面两种都是"让缓存不失效"或者"失效后重建",逻辑过期则是"让缓存永远不失效"。
具体做法是:写入缓存时不设置物理 TTL,而是把过期时间作为业务字段,放在 value 里面。比如 Redis 里存的是一个 JSON,里面包含店铺数据和一个 expireTime 字段。查询时先取出这个 JSON,判断 expireTime 是否已过:
- 没过期:直接返回数据,这是最理想的情况。
- 已过期:不删除缓存,而是尝试获取互斥锁。拿到锁的线程异步去查数据库、重建缓存;拿不到锁的线程也不等待,直接把当前这个"过期但还存在"的旧数据返回给用户。
这个方案的精妙之处在于,缓存永远有数据,永远不会出现"缓存 miss 后所有请求涌向数据库"的情况。最多只有拿到锁的那一个线程去查数据库,其他线程瞬间就能拿到响应——哪怕数据是旧的。它用"短暂的数据不一致"换来了"更高的可用性和更低的响应延迟"。
我在实际项目里对逻辑过期的评价是:它是击穿场景下体验最好的方案,尤其适合对实时性要求不高的热点数据,比如店铺信息、商品详情这种允许分钟级延迟的内容。但如果业务对数据一致性非常敏感,比如库存、余额,逻辑过期就不合适,宁可让请求等一等也要拿最新数据。
3. 缓存击穿:热点 key 过期的那一瞬间
3.1 击穿和雪崩的本质区别
缓存击穿在代码层面和雪崩几乎一模一样,都是缓存 miss 后去查数据库,但两者的数据特征完全不同。击穿只有一个 key,但这个 key 的访问量极高。
黑马点评里最典型的案例就是"头部店铺"。点评类业务天然有头部效应,某家网红店可能承担了全站 30% 以上的查询流量。当这个店铺的缓存 key 到点过期,一瞬间几千个请求同时发现缓存 miss,全部去 MySQL 查同一条记录。
MySQL 对这种"同一条记录的并发查询"其实很尴尬。虽然 InnoDB 的行锁能保证数据一致性,但大量线程同时执行同一条 SELECT 时,每个查询都得走完整的 SQL 执行链路,buffer pool 的锁竞争、磁盘 IO 都会激增。最终表现就是:数据库连接数被打满,接口大面积超时。所以击穿虽然只涉及一个 key,造成的后果和雪崩一样严重。
3.2 互斥锁方案:setnx 抢锁、等待重试、double check
击穿的互斥锁方案,完整的实现逻辑可以拆成五个步骤:
- 查缓存,命中直接返回。
- 查缓存未命中,尝试用 SETNX 获取锁(锁 key 用业务 id 拼接,value 可以随便填,必须设置过期时间)。
- 获取锁失败,说明有其他线程正在重建缓存,休眠 50ms 后重试,回到第 1 步。
- 获取锁成功,再次查询缓存(double check,防止上一个线程已经重建完了,自己重复查库)。
- 缓存依然没有,查数据库,写缓存,释放锁。
其中第 4 步的 double check 是很多初学者容易漏掉的。如果不做二次检查,两个线程先后拿到锁,每个线程都查一次数据库,虽然比所有线程都查好,但锁的意义就打了折扣。做完 double check,才能保证数据库同一时刻只被查一次。
下面是黑马点评课程里互斥锁方案的完整代码,我加了详细注释:
java复制public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1. 先查缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, Shop.class);
}
// 判断是否为空值(穿透保护)
if (shopJson != null) {
return null;
}
// 2. 尝试获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 3. 没拿到锁,休眠后重试(递归)
if (!isLock) {
Thread.sleep(50);
return queryWithMutex(id);
}
// 4. double check,二次查询缓存
shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, Shop.class);
}
// 5. 查数据库
shop = shopService.getById(id);
// 模拟缓存重建耗时
Thread.sleep(200);
if (shop == null) {
// 数据库没有,写空值缓存,防止穿透
stringRedisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES);
return null;
}
// 6. 写缓存,TTL 随机化兜底
int ttl = 30 * 60 + new Random().nextInt(10 * 60);
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), ttl, TimeUnit.SECONDS);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
return shop;
}
private boolean tryLock(String key) {
// SETNX + 过期时间,原子性由 Redis 保证
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
几个细节值得单独说。
锁的过期时间设置为 10 秒。这个 10 秒必须大于缓存重建的最大耗时。如果重建逻辑里查库加写缓存需要 3 秒,锁 10 秒是安全的;但如果重建很慢,超过 10 秒锁自动释放,其他线程就会拿到锁重复重建,互斥的效果会减弱。课程里用 Thread.sleep(200) 模拟重建耗时,实际项目中要把锁过期时间设成重建耗时的 3~5 倍。
重试机制的写法也有讲究。我见过有人把重试写成 for 循环,循环次数写死,一旦缓存重建特别慢,循环跑完了还是没拿到锁,就直接返回 null。递归加 sleep 的方式没有循环次数限制,只要能拿到锁就继续,但要注意控制 sleep 的粒度。50ms 是比较常用的值,太小会空转消耗 CPU,太大则接口响应时间明显变长。另外递归深度如果太大,要注意方法栈的压力,一般这个场景下重试几次就能拿到锁,栈不会被压爆。
3.3 逻辑过期方案:把时间戳藏进 value 里
击穿的逻辑过期方案和雪崩里的逻辑过期是同一套思路,代码层面有几个细节值得单独展开。
首先需要定义一个包装类 RedisData,把业务数据和过期时间包在一起:
java复制@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
预热时就按这个结构写入缓存,不设置物理 TTL:
java复制public void saveShop2Redis(Long id, Long expireSeconds) throws InterruptedException {
Shop shop = shopService.getById(id);
// 模拟缓存重建的耗时场景
Thread.sleep(200);
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
查询时的核心逻辑:
java复制public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
String json = stringRedisTemplate.opsForValue().get(key);
// 缓存未命中。逻辑过期方案依赖缓存预热,正常情况下热点 key 一定存在
if (StrUtil.isBlank(json)) {
return null;
}
// 反序列化,拿到过期时间和业务数据
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 1. 逻辑时间还没到,直接返回
if (expireTime.isAfter(LocalDateTime.now())) {
return shop;
}
// 2. 逻辑过期,尝试获取锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
if (isLock) {
// 3. 拿到锁,开启独立线程异步重建缓存
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
}
// 4. 没拿到锁或重建已完成,都先返回旧数据
return shop;
}
逻辑过期方案里有一个隐藏设计:拿到锁之后的重建是异步的。为什么必须异步?因为如果同步重建,拿到锁的线程需要等数据库查询加写缓存完成才能返回,接口 RT 会明显增加;而异步重建可以让当前请求立即返回旧数据,用户的感知几乎无变化。代价是线程池的引入。如果重建任务太多,线程池队列会被占满,所以必须控制线程池大小和队列容量,并做好异常兜底。稍后我会专门讲这个坑。
