缓存扛住了 99% 的读,剩下 1% 的请求如果打穿到数据库,照样能把后端拖垮。穿透、击穿、雪崩这三个词经常被混着用,其实它们触发条件、破坏路径、处置手段完全不同。这篇把它们逐个拆开,再给一套可直接照着做的预热与保护方案。
一、先把三个概念分清,别再用错词
很多人把这三个词当成同义词在乱用,结果排查时方向就偏了。用一张表先把边界划清:
| 概念 | 触发条件 | 数据在不在库 | 请求特征 | 对后端的影响 |
|---|---|---|---|---|
| 缓存穿透 | 查询一个根本不存在的数据 | 不在 | 单个不存在的 Key 被高频查 | 缓存永远不命中,每次都落到数据库 |
| 缓存击穿 | 某个热点 Key 突然过期 | 在 | 同一个热点 Key 并发涌入 | 瞬时大量请求同时查同一行数据 |
| 缓存雪崩 | 大批 Key 同一时刻集中过期,或缓存实例整体不可用 | 在 | 全量或大范围 Key 同时失效 | 数据库瞬时承受数倍甚至数十倍流量 |
一句话记忆:
- 穿透是查不存在的东西——攻击者或脏参数导致,缓存层形同虚设;
- 击穿是一个热点 Key 过期——量不大但极集中,冲击的是"一行数据";
- 雪崩是过期时间撞车或整层挂掉——冲击面是"整个数据库"。
危害等级依次递进:穿透是慢性消耗,击穿是瞬时冲高,雪崩是系统性故障。
二、穿透:让缓存"守住"不存在的 Key
穿透的根因很明确——缓存对不存在的 Key 不缓存,于是每次请求都直接打库。典型场景是恶意刷不存在的商品 ID、用户 ID(比如 user:-1、随机字符串参数),或者业务上确实存在大量"查无此人"的合法请求。
处置手段一:缓存空对象
查库为空时,也往缓存里写一个特殊值(如 __NULL__),并设一个较短的过期时间(例如 60~300 秒)。
String value = redis.get(key);
if (value == null) {
// 缓存未命中,回源查库
value = db.query(key);
if (value == null) {
// 空结果也写缓存,TTL 设短,防止脏数据长期占用
redis.setex(key, 120, "__NULL__");
return null;
}
redis.setex(key, 3600, value);
return value;
}
if ("__NULL__".equals(value)) {
return null; // 命中空值标记,直接返回,不查库
}
要点:空值 TTL 一定要短。否则一旦数据库里后来真的插入了这条数据,缓存里还挂着空标记,会出现"数据明明写进去了却读不到"的假故障。
处置手段二:布隆过滤器前置拦截
把库中所有合法 Key 的指纹灌进布隆过滤器,请求先过一层过滤器,明显不存在的 Key 直接返回。优点是内存占用小、判断快;缺点是存在误判率(说"可能存在"时未必存在,但说"不存在"时一定不存在),且删除数据不方便。
处置手段三:参数校验兜底
最容易被忽略、也最划算的一层:在入口把明显非法的 ID 拦掉。id <= 0、超长字符串、非预期字符集的参数,在业务层直接拒绝,根本不用走到缓存。
三、击穿:给热点 Key 加一把互斥锁
击穿发生在单个热点 Key 过期的那一瞬间。比如首页 Banner、大促主推商品,平时 QPS 上万,一旦缓存过期,这一瞬间所有并发请求发现缓存没了,会同时去查同一行数据并回写——数据库瞬时压力可能是正常值的几十倍。
处置手段一:互斥锁 / 单飞(singleflight)
只允许一个请求回源,其余请求短暂等待后重新读缓存。
public String getWithMutex(String key) {
String value = redis.get(key);
if (value != null) return value;
String lockKey = "lock:" + key;
// SET NX 抢占回源资格,避免大量请求同时打库
if (redis.set(lockKey, "1", "NX", "EX", 10)) {
try {
value = redis.get(key); // 双重检查:可能已被别的线程写入
if (value != null) return value;
value = db.query(key);
if (value != null) {
redis.setex(key, 3600, value);
}
return value;
} finally {
redis.del(lockKey);
}
} else {
// 没抢到锁,短暂 sleep 后重试读缓存
try {
Thread.sleep(50); } catch (InterruptedException ignored) {
}
return redis.get(key);
}
}
处置手段二:逻辑过期(永不过期)
不设物理 TTL,而是在 value 里附带一个"逻辑过期时间"。读到数据时先判断逻辑时间是否到期,到期则异步触发一个线程去刷新,当前请求仍返回旧数据。这样任何时候都有值可读,不会出现"缓存真空期"。
处置手段三:热点 Key 提前续期
对可预知的热点(大促商品、活动页),用定时任务在 TTL 到期前主动刷新,把"过期"这件事从被动事件变成主动行为。
四、雪崩:从"过期时间"和"缓存层可用性"两头防
雪崩有两条独立成因,要分开治理。
成因一:大批 Key 同时过期
常见于系统刚上线时批量灌数据、或定时任务统一刷新缓存。全部 Key 的 TTL 都设为 1 小时,那么 1 小时后它们会一起失效。
处置:TTL 打散。 在基础过期时间上加一个随机扰动:
// 基础 1 小时,随机浮动 ±10 分钟,避免集中过期
int ttl = 3600 + ThreadLocalRandom.current().nextInt(0, 600) - 300;
redis.setex(key, Math.max(ttl, 60), value);
成因二:缓存实例整体不可用
实例宕机、网络抖动、主备切换期间,所有请求会瞬时涌向数据库。
处置:
- 高可用架构。云数据库 Tair(兼容 Redis)的标准架构本身是主备双节点并带自动主备切换,能在主节点异常时自动提升备节点;集群架构则每个分片都是主备多节点。架构层面的容灾是雪崩防护的地基,单节点架构官方明确不建议用于生产。
- 熔断与降级。给数据库访问加并发上限或信号量,超出后走降级逻辑(返回兜底数据或排队),宁可部分用户等一等,也不让数据库被打死。
- 本地缓存作二级缓冲。在应用进程内用 Caffeine/Guava 做一层短 TTL 的本地缓存,Redis 不可用时仍能挡住一部分读请求。
- 提前预热。系统重启、大促开始前,把热点数据主动加载进缓存,避免开放瞬间的冷启动冲击。
五、一套可落地的预热流程
预热不是"把数据全塞进 Redis"这么粗暴,按下面的顺序做更稳:
第一步:定位热点。 不要凭感觉猜。云数据库 Tair/Redis 控制台在 CloudDBA 下提供 Top Key 统计(实时显示每个分片各数据类型的前三大 Key 与热 Key,支持查看近 4 天历史),以及离线全量 Key 分析(基于 RDB 备份,给出 Key 的内存占用分布与过期时间)。这两个工具能把热点从"猜"变成"测"。
第二步:分层预热。 优先序:首页/活动页 > 购物车主推商品 > 用户高频画像,按预估 QPS 排序,先灌最热的。灌数据时统一走上面提到的 TTL 打散。
第三步:灰度放量。 预热完成后不要立刻全量放流量,先放小比例观察数据库的 QPS 回落情况,确认缓存命中率正常再逐步放大。
第四步:建监控回看。 配置 CPU 使用率、内存使用率、连接数、带宽的告警阈值。官方文档给出的参考值包括:内存使用率超过 70%、一小时内增长率超过 20% 触发告警。出现告警时按"定位热点 → 优化热 Key"的顺序处理,在影响业务之前解决。
六、选型与成本:缓存容量不是越大越好
云数据库 Tair(兼容 Redis)按开通的实例容量收费,而不是按实际存储的数据量收费。开一个 2 GB 实例、里面只放了 256 MB 数据,仍按 2 GB 计价。这意味着盲目开大规格会直接浪费预算。
- 架构选择:数据量少、压力可控、用作持久化存储 → 标准架构;数据量大、QPS 高、吞吐密集 → 集群架构;读多写少且读请求超过主节点上限 → 在标准或集群架构上开启智能读写分离,按需增加只读节点,且无需修改代码。
- 容量与性能参考:Redis 开源版标准架构内存上限约 64 GB、约 10 万 QPS;集群架构内存上限约 16,384 GB(64 GB × 256 分片),整体性能随分片数线性增长。
- 计费方式:按量付费灵活、适合突发或波动业务;包年包月单价更低、适合长期稳定业务;按量付费搭配资源包可进一步降低成本。变配不停机,业务增长时可随时调整。
具体价格与当前活动以官方页面实时展示为准,下单前建议先在控制台按预估容量和计费方式试算一次。
七、避坑清单(照着检查一遍)
- [ ] 查不到的数据也写缓存,但空值 TTL 必须短(建议 ≤ 300 秒)
- [ ] 热点 Key 回源必须加互斥锁,并在锁内做二次检查,避免重复回源
- [ ] 所有缓存 TTL 加随机扰动,禁止整批数据用同一个固定 TTL
- [ ] 生产环境不用单节点架构,选主备或集群,保证自动切换能力
- [ ] 数据库访问必须有并发上限或降级,不给雪崩留直通通道
- [ ] 大促/重启前主动预热,不要指望流量把缓存"自然填满"
- [ ] 定期用 Top Key 统计检查大 Key 与热 Key,别等报警才查
- [ ] 删除大 Key 优先用
UNLINK(异步释放),避免阻塞主线程;集群下用HSCAN+HDEL分批清理 - [ ] 容量按实际需求开通,按容量计价,不是开越大越好
把穿透、击穿、雪崩分开治理,再配上一套"定位热点 → 分层预热 → 灰度放量 → 监控回看"的流程,缓存这层才算真正稳住。缓存的价值不在于"有",而在于"每一次都命中且后端不被击穿"。如果你的业务正准备从自建 Redis 迁到托管实例,可以直接从云数据库 Tair(兼容 Redis)开始,先按小规格试跑、观察监控指标,再逐步调整架构和容量。