Redis 缓存穿透、击穿、雪崩:从一次凌晨报警说起
一个真实的凌晨故事
凌晨 3 点,手机疯狂震动——数据库 CPU 飙到 100%,接口 P99 从 50ms 暴涨到 5s。打开监控一看:Redis 命中率从 99% 骤降到 15%,所有请求像潮水一样涌向 MySQL。
排查后发现:运营同学上线了一个新活动页,URL 里带了一个数据库不存在的商品 ID。这个 ID 在 Redis 里找不到,在 MySQL 里也找不到,于是每次请求都穿透到数据库。 10 万 QPS 的流量,MySQL 直接被打满。
这就是经典的缓存穿透——而它只是 Redis 三大缓存危机中最好理解的一个。
一、缓存穿透:查不到的数据,永远穿透
是什么
请求查询的数据既不在缓存中,也不在数据库中,每次请求都会直达数据库,缓存形同虚设。
为什么危险
正常请求: Client → Redis(命中) → 返回 ✅ 耗时 < 1ms
穿透请求: Client → Redis(未命中) → MySQL(未命中) → 返回 ❌ 耗时 > 10ms
单个穿透请求不可怕,可怕的是规模化穿透:
- 恶意攻击者构造大量不存在的 key(如
user:-1、order:999999999) - 业务 bug 产生大量无效查询
- 热点数据被批量删除后,大量请求同时回源
怎么办:三种方案对比
方案 1:缓存空值
// 伪代码:查询时缓存空结果
public Object getUser(String userId) {
String cacheKey = "user:" + userId;
String value = redis.get(cacheKey);
// 命中缓存(包括空值标记)
if (value != null) {
return "NULL_MARKER".equals(value) ? null : deserialize(value);
}
// 未命中,查数据库
Object result = mysql.query("SELECT * FROM user WHERE id = ?", userId);
if (result == null) {
// 缓存空值,设置较短过期时间
redis.set(cacheKey, "NULL_MARKER", 60); // 60秒过期
} else {
redis.set(cacheKey, serialize(result), 3600);
}
return result;
}
优点:实现简单,效果直接
缺点:占用 Redis 内存;同一 key 短时间大量穿透时仍会回源;攻击者变换 key 时无效
方案 2:布隆过滤器
flowchart LR
A[请求] --> B{布隆过滤器判断}
B -->|可能存在| C[查询 Redis]
B -->|一定不存在| D[直接返回]
C -->|命中| E[返回数据]
C -->|未命中| F[查询 MySQL]
F --> G[回填 Redis]
// 伪代码:基于布隆过滤器的防护
public Object getUser(String userId) {
// 第一层:布隆过滤器快速判断
if (!bloomFilter.mightContain("user:" + userId)) {
return null; // 一定不存在,直接返回
}
// 第二层:查询缓存
String value = redis.get("user:" + userId);
if (value != null) {
return deserialize(value);
}
// 第三层:查询数据库
return queryAndCache(userId);
}
优点:内存占用极小(1亿 key 约 120MB);拦截效率高
缺点:存在误判率(可接受,约 0.01%);不支持删除;需要预热数据
方案 3:请求参数校验
# 伪代码:入口层拦截非法参数
def get_user(user_id: str):
# 合法性校验:ID 必须为正整数且在合理范围
if not user_id.isdigit() or int(user_id) <= 0:
raise ValueError("Invalid user ID")
if int(user_id) > MAX_USER_ID:
raise ValueError("User ID out of range")
return cache_or_db_query(user_id)
优点:零成本,从源头拦截
缺点:只能防御已知规则的非法请求
方案选择
| 场景 | 推荐方案 |
|---|---|
| 少量固定 key 穿透 | 缓存空值 |
| 大规模数据防穿透 | 布隆过滤器 |
| 参数明显非法 | 请求校验 |
| 生产环境 | 三者组合使用 |
二、缓存击穿:热点的坍塌
是什么
一个极度热点的 key 在过期的瞬间,大量并发请求同时回源,压垮数据库。
与穿透的区别:数据是存在的,只是过期的那个瞬间被大量请求同时击穿。
为什么危险
sequenceDiagram
participant C1 as 请求1
participant C2 as 请求2
participant C3 as 请求3
participant R as Redis
participant M as MySQL
Note over R: 热点key过期
C1->>R: GET hot_key → miss
C2->>R: GET hot_key → miss
C3->>R: GET hot_key → miss
C1->>M: SELECT ... (回源)
C2->>M: SELECT ... (重复回源)
C3->>M: SELECT ... (重复回源)
Note over M: 同一时刻大量相同SQL
典型场景:秒杀商品信息、热搜词条、明星微博——单个 key 承载数万 QPS。
怎么办
方案 1:互斥锁
// 伪代码:只允许一个线程回源
public Object getWithLock(String key) {
Object value = redis.get(key);
if (value != null) {
return value;
}
String lockKey = "lock:" + key;
// 尝试获取分布式锁(SETNX)
if (redis.setnx(lockKey, "1", 10)) { // 10秒超时
try {
// 双重检查
value = redis.get(key);
if (value != null) return value;
// 回源查询
value = mysql.query(key);
redis.set(key, value, 3600);
return value;
} finally {
redis.del(lockKey);
}
} else {
// 未获取锁,短暂等待后重试
sleep(50);
return getWithLock(key);
}
}
优点:只回源一次,保护数据库
缺点:增加延迟(等待锁的请求会阻塞);实现复杂,需处理锁超时、死锁
方案 2:逻辑过期(不设 TTL)
// 伪代码:逻辑过期方案
public Object getWithLogicalExpire(String key) {
CacheData data = redis.get(key);
if (data == null) {
return null; // 真正不存在
}
if (!data.isExpired()) {
return data.getValue(); // 未过期,直接返回
}
// 已过期,尝试获取锁异步更新
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1", 10)) {
// 获取锁,异步更新
asyncExecute(() -> {
Object newValue = mysql.query(key);
redis.set(key, new CacheData(newValue, newExpiry), 0); // 永不过期
redis.del(lockKey);
});
}
// 无论是否获取锁,先返回旧数据
return data.getValue();
}
优点:高可用,永远返回数据;无阻塞等待
缺点:可能返回过期数据;需额外存储过期字段
方案 3:热点 key 永不过期 + 主动更新
1. 核心热点 key 不设 TTL
2. 数据变更时,通过 Binlog / 消息队列 主动更新 Redis
3. 兜底:定时任务巡检,发现过期数据主动刷新
优点:最稳妥,彻底消除击穿风险
缺点:维护成本高;需保障数据同步链路可靠性
方案选择
| 场景 | 推荐方案 |
|---|---|
| 强一致性要求 | 互斥锁 |
| 可接受短暂不一致 | 逻辑过期 |
| 超级热点 key | 永不过期 + 主动更新 |
三、缓存雪崩:大面积的崩溃
是什么
大量 key 同一时刻集中过期,或 Redis 实例整体宕机,导致请求全部涌向数据库。
与击穿的区别:击穿是一个热点 key,雪崩是大量 key 同时失效。
为什么危险
缓存穿透:针扎一个洞 → 单点穿透
缓存击穿:锤砸一个坑 → 热点坍塌
缓存雪崩:整个屋顶塌了 → 系统级故障
真实案例:某电商大促前,运维批量导入了 10 万个商品缓存,过期时间统一设为 2 小时。2 小时后,10 万个 key 同时过期,数据库瞬间被打挂。
怎么办
方案 1:过期时间加随机抖动
// 伪代码:过期时间 = 基础时间 + 随机偏移
int baseExpire = 3600; // 1小时
int randomJitter = random(0, 300); // 0~5分钟随机
redis.set(key, value, baseExpire + randomJitter);
一行代码解决 80% 的雪崩问题。
方案 2:Redis 高可用
flowchart TB
subgraph "Redis Cluster"
M[Master-1] --- S1[Slave-1]
M2[Master-2] --- S2[Slave-2]
M3[Master-3] --- S3[Slave-3]
end
subgraph "Sentinel 监控"
SE1[Sentinel-1]
SE2[Sentinel-2]
SE3[Sentinel-3]
end
SE1 -.-> M
SE2 -.-> M2
SE3 -.-> M3
S1 -.->|故障自动切换| M
- Redis Sentinel:主从自动故障转移
- Redis Cluster:数据分片 + 高可用
- 多可用区部署:跨机房容灾
方案 3:多级缓存
// 伪代码:L1 本地缓存 + L2 Redis + L3 MySQL
public Object getWithMultiLevel(String key) {
// L1: 本地缓存(Caffeine / Guava Cache)
Object value = localCache.get(key);
if (value != null) return value;
// L2: Redis
value = redis.get(key);
if (value != null) {
localCache.put(key, value);
return value;
}
// L3: MySQL(限流保护)
value = rateLimitedQuery(key);
if (value != null) {
redis.set(key, value, expireWithJitter());
localCache.put(key, value);
}
return value;
}
方案 4:兜底降级
- 数据库侧:连接池限流 + 熔断(Hystrix / Sentinel)
- 业务侧:返回兜底数据 / 静态页面
- 监控侧:缓存命中率低于阈值时自动告警
方案选择
| 层面 | 推荐方案 |
|---|---|
| 预防 | 过期时间加随机抖动 ⭐ |
| 架构 | Redis Cluster 高可用 |
| 防护 | 多级缓存 + 限流降级 |
| 生产环境 | 四者组合 |
回到那个凌晨的故事
最后,那次事故是怎么解决的?
- 紧急止血:网关层对不存在的商品 ID 返回默认值(方案 3:参数校验)
- 根因修复:上线布隆过滤器,预热全量商品 ID(方案 2)
- 兜底保护:缓存空值,过期时间 60 秒(方案 1)
- 长效改进:所有缓存过期时间加随机抖动,防止雪崩
从凌晨 3 点报警到早上 6 点,三个人没合眼。但这次事故也让团队重新审视了缓存防护体系——穿透、击穿、雪崩,不是三个独立的问题,而是同一根链条上的三个薄弱环节。
思考:缓存的本质
缓存的核心矛盾是数据一致性 vs 可用性:
- 缓存空值牺牲一致性,换取穿透防护
- 逻辑过期牺牲强一致,换取击穿防护
- 随机过期牺牲精确控制,换取雪崩防护
这些 trade-off 的本质,和 CAP 定理一脉相承——在分布式系统中,你无法同时拥有强一致性、高可用性和分区容错性。 缓存策略的选择,就是在特定业务场景下,找到三者之间的最优平衡点。
所以下次设计缓存方案时,不要问"用哪种方案",而要问:我的业务,能容忍多大程度的不一致?
这个问题想清楚了,方案自然就出来了。