做高并发读多写少的业务(商品详情、活动配置、类目树都属于这一类),几乎一定会用缓存挡数据库压力。但"加了 Redis"不等于高枕无忧,缓存击穿、缓存穿透、缓存雪崩这三个名字相近的问题,成因和解法完全不同,很多人面试能背、线上却用混。本文先把三者彻底分清,再讲生产环境怎么组合落地。
一、一张表分清三个概念
| 问题 | 触发时刻 | 查询的数据 | 后果 | 核心解法 |
|---|---|---|---|---|
| 缓存击穿 | 某个热点 key 过期瞬间 | 数据库里存在 | 大量并发同时打到这一条记录 | 互斥重建 / 热点永不过期 |
| 缓存穿透 | 任意时刻持续请求 | 数据库里根本不存在 | 缓存永远拦不住,直打数据库 | 缓存空值 / 布隆过滤器 |
| 缓存雪崩 | 大批 key 同时过期或缓存宕机 | 大量存在的数据 | 成片请求同时压垮数据库 | 过期时间加随机 / 多级缓存 / 降级 |
一句话记忆:击穿是"一个热点刚好过期",穿透是"查的数据压根没有",雪崩是"一大批一起失效"。
二、缓存击穿:只放一个线程去重建
热点 key 在过期的那一瞬间,成百上千个请求同时发现缓存没了,又同时去查库、回写缓存,数据库瞬间被同一条 SQL 打满。解法是让重建过程互斥,只允许一个请求查库:
async function getHotData(key) {
let data = await redis.get(key);
if (data) return JSON.parse(data);
// 抢锁:抢到的去查库重建,没抢到的短暂等待后重读缓存
const locked = await redis.set('lock:' + key, 1, 'NX', 'PX', 3000);
if (locked) {
try {
data = await db.query(key); // 只有一个线程查库
await redis.set(key, JSON.stringify(data), 'EX', 600);
} finally {
await redis.del('lock:' + key);
}
} else {
await sleep(50);
data = await redis.get(key); // 等重建者回写后读缓存
}
return data && JSON.parse(data);
}
对超级热点(首页爆款、秒杀商品),更简单的做法是逻辑不过期:缓存本身不设 TTL,由后台任务异步刷新,用户线程永远只打缓存。
三、缓存穿透:让"不存在"也被挡住
攻击者或异常客户端反复查数据库里不存在的 id(比如商品 id 从 -1 一路扫下去),由于数据不存在,缓存永远写不进去,每次都落库。两种解法:
- 缓存空值:查库为空也写一个标记(如
null)并设较短 TTL,下次同样的查询直接被缓存挡住,简单直接; - 布隆过滤器:在缓存前再加一层,把所有合法 id 预先放进布隆过滤器,请求进来先判断"这个 id 可能存在吗",明确不存在的直接返回,连缓存都不查。
async function getProduct(id) {
if (!bloomFilter.maybeExists(id)) return null; // 一定不存在,直接挡掉
const cached = await redis.get('prod:' + id);
if (cached === 'NULL') return null; // 空值缓存命中
if (cached) return JSON.parse(cached);
const prod = await db.findProduct(id);
// 不存在也缓存,TTL 短一些,避免真实数据稍后创建后长时间查不到
await redis.set('prod:' + id, prod ? JSON.stringify(prod) : 'NULL', 'EX', prod ? 600 : 60);
return prod;
}
布隆过滤器有误判(可能把不存在判成可能存在)但不会漏判,正好适合做前置拦截;参数校验(如 id 必须为正)要在最外层先做,挡掉明显非法请求。
四、缓存雪崩:别让 key 们"约好一起过期"
雪崩有两个来源,分别应对:
- 大批 key 同时失效:给 TTL 加随机抖动,让失效时间打散。
EX = 基础时间 + random(0, 300秒),避免批量预热的缓存在同一刻集体过期; - 缓存节点整体宕机:靠多级缓存(本地缓存 + 分布式缓存)兜底、集群高可用,以及数据库侧的限流与降级,保证缓存全挂时数据库也不会被瞬时流量直接压垮。
此外要给缓存调用加超时和熔断,缓存慢的时候不能把请求线程全部拖死;数据库侧配置最大连接数和降级返回(如兜底静态数据),守住最后一道线。
五、踩坑清单
- 把穿透当击穿,给不存在的数据加互斥锁,纯属无效;
- 互斥重建忘记释放锁或没设过期,死锁卡住所有请求;
- 空值缓存 TTL 设太长,新创建的数据迟迟查不到;
- 布隆过滤器不随新增数据更新,误判越来越多;
- 批量预热用同一个 TTL,上线后整刻钟周期性抖动;
- 缓存不可用时无限重试,把数据库一起拖垮。
六、工程落地建议
实际系统里这三者往往要组合使用:入口先做参数校验和布隆过滤(防穿透),热点 key 用互斥重建或逻辑不过期(防击穿),全部 key 的 TTL 加随机抖动、配多级缓存和熔断降级(防雪崩),再配合缓存命中率和数据库慢查询监控,才能在大促流量下稳住。成型的商城系统通常已把这套缓存防护、限流降级封装在商品与详情链路里(如基于成型平台的商品缓存能力,如乔拓云商城),自研时重点把三种场景区分清楚、分别布防。
七、上线自检清单
- 热点 key 过期瞬间,查库 SQL 是否只执行一次;
- 反复查不存在的 id,数据库是否零压力;
- 缓存 TTL 是否带随机、不存在整刻同时失效;
- 缓存宕机时是否有本地缓存、限流和降级兜底;
- 空值缓存的 TTL 是否短于正常数据。
三个问题名字像,但本质分别是"热点过期、数据不存在、批量失效",先分清是哪一类,再对症下药,比堆砌方案重要得多。以上为个人工程实践总结,具体参数请结合自身流量压测确定。