一、结论先给
三个问题名字像,成因和解法完全不同,先分清:
| 问题 | 触发条件 | 后果 | 核心解法 |
|---|---|---|---|
| 穿透 | 查询根本不存在的数据(如 id=-1、恶意随机 id) | 每次都打穿到数据库 | 布隆过滤器 + 缓存空值 |
| 击穿 | 某个热点 key 正好过期,瞬间几万请求同时回源 | 数据库瞬时被打爆 | 互斥锁 / 逻辑过期 |
| 雪崩 | 大批 key 同一时刻集体过期,或 Redis 整体宕机 | 数据库压力骤增甚至宕机 | TTL 随机化 + 多级缓存 + 高可用 |
一句话记:穿透是"查了个不存在的",击穿是"一个热的没了",雪崩是"一片都没了"。
👉 排查时先看数据库慢日志和 QPS 曲线,用 日志分析工具 统计错误高发时段,能快速定位是哪一个。
二、缓存穿透
2.1 成因
// 恶意请求或爬虫:id 是随机数,数据库里根本没有
GET /user?id=-1
GET /user?id=999999999
缓存查不到 → 查数据库也查不到 → 不会写进缓存 → 下次又打穿。攻击者用几万个随机 id 循环请求,数据库直接被打满。
2.2 解法一:缓存空值(最简单,首选)
public User getUser(Long id) {
String key = "user:" + id;
String json = redis.get(key);
if (json != null) {
return "".equals(json) ? null : parse(json); // 空串表示"确认不存在"
}
User u = db.queryById(id);
if (u == null) {
redis.setex(key, 120, ""); // ⭐ 空值也缓存,TTL 短一些(2-5 分钟)
return null;
}
redis.setex(key, 3600, toJson(u));
return u;
}
要点:
- 空值 TTL 要短(2-5 分钟),避免真有数据写入后长时间查不到;
- 空值用特殊标记(
""或"__NULL__"),别用null(很多客户端无法区分"缓存里是 null"和"缓存没命中")。
2.3 解法二:布隆过滤器(数据量极大时用)
布隆过滤器:一个 bit 数组 + 多个 hash 函数。判定"不存在"是 100% 准确的,判定"存在"可能误判(误判率可配置)。
# Redis 4.0+ 通过模块支持(RedisBloom)
BF.RESERVE user_filter 0.001 10000000 # 误判率 0.1%,预计 1000 万元素
BF.ADD user_filter 10001
BF.EXISTS user_filter 10001 # 1 可能存在
BF.EXISTS user_filter -1 # 0 一定不存在 ⭐
BF.MADD user_filter 1 2 3 4
BF.MEXISTS user_filter 1 2 -1
Java 用 Redisson:
RBloomFilter<Long> bf = redisson.getBloomFilter("user_filter");
bf.tryInit(10_000_000L, 0.001); // 预计元素数、误判率
// 数据写入 DB 时同步加进过滤器
bf.add(user.getId());
// 查询时先过过滤器
if (!bf.contains(id)) return null; // 一定不存在,直接返回
布隆过滤器的局限:不支持删除(计数布隆/Cuckoo Filter 可以),需要初始化时把所有合法 id 灌进去,数据新增时要同步 add。
2.4 解法三:接口层校验
// 最容易被忽略但最有效的一招
if (id == null || id <= 0) return Result.paramsError();
// 分页参数上限、id 格式校验、频率限制(同一 IP 每分钟最多 N 次)
性价比排序:参数校验 > 缓存空值 > 布隆过滤器。大多数业务做到前两条就够了。
三、缓存击穿
3.1 成因
某个超级热点 key(比如首页爆款商品、顶流主播信息)在某一秒过期,此时有 10 万并发请求进来,全部发现缓存没有,全部去查数据库。
3.2 解法一:互斥锁(一致性优先)
public User getHotUser(Long id) {
String key = "user:" + id;
String json = redis.get(key);
if (json != null) return parse(json);
String lockKey = "lock:user:" + id;
// SETNX + 过期时间,原子实现(Redis 2.6.12+ 支持 NX EX 组合)
Boolean ok = redis.set(lockKey, "1", "NX", "EX", 10);
if (Boolean.TRUE.equals(ok)) {
try {
User u = db.queryById(id); // 只有一个线程回源
redis.setex(key, 3600, toJson(u));
return u;
} finally {
redis.del(lockKey);
}
} else {
Thread.sleep(50); // 没拿到锁,稍等重试
return getHotUser(id); // 递归重试(注意加次数上限)
}
}
要点:
- 锁必须设过期时间,否则持有锁的线程挂了就死锁;
- 释放锁要校验是自己加的(防误删别人的锁),生产建议直接用 Redisson 的
RLock; - 没拿到锁的线程睡一下再重试,不要死循环空转。
3.3 解法二:逻辑过期(性能优先)
物理上永不过期,把过期时间存进 value,谁发现过期了谁去异步刷新,其他线程先返回旧值。
@Data
class CacheItem {
private String data; // 业务 JSON
private long expireAt; // 逻辑过期时间戳
}
public User getHotUser(Long id) {
String key = "user:" + id;
CacheItem item = redis.get(key);
if (item == null) return null; // 预热过就不该为 null
if (item.getExpireAt() > System.currentTimeMillis()) {
return parse(item.getData()); // 未过期,直接返回
}
// 已逻辑过期:抢锁后开新线程刷新,自己先返回旧数据
if (Boolean.TRUE.equals(redis.set("lock:user:" + id, "1", "NX", "EX", 10))) {
executor.submit(() -> {
try {
User u = db.queryById(id);
redis.set(key, new CacheItem(toJson(u), now + 3600_000));
} finally {
redis.del("lock:user:" + id);
}
});
}
return parse(item.getData()); // ⭐ 立刻返回旧值,不阻塞
}
| 方案 | 一致性 | 性能 | 适用 |
|---|---|---|---|
| 互斥锁 | 强(返回的一定是新数据) | 有等待 | 金融、库存 |
| 逻辑过期 | 弱(可能短暂返回旧值) | 无等待,最优 | 首页、详情页、资讯 |
热点 key 还需要预热:大促前把爆款商品提前写进缓存,别让用户来触发第一次回源。
四、缓存雪崩
4.1 成因
- 大批 key 设了相同的 TTL(比如凌晨批量预热,全部 1 小时过期);
- Redis 实例整体宕机,所有请求瞬间落到数据库。
4.2 解法
// 1. TTL 加随机抖动 ⭐最简单有效
int base = 3600;
int ttl = base + new Random().nextInt(600); // 3600-4200 秒随机
redis.setex(key, ttl, value);
| 手段 | 做法 |
|---|---|
| TTL 随机化 | 基础 TTL + 随机 0-10% 偏移 |
| 多级缓存 | 本地缓存(Caffeine/Guava)+ Redis,Redis 挂了还有本地 |
| Redis 高可用 | 主从 + 哨兵,或 Redis Cluster,避免单点 |
| 熔断降级 | 数据库压力过大时返回兜底数据/排队页,保护 DB |
| 限流 | 网关层限流,超过阈值直接拒绝部分请求 |
| 数据预热 | 低峰期提前加载,且分散 TTL |
4.3 多级缓存示例(Caffeine + Redis)
@PostConstruct
public void init() {
localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(60)) // 本地只存 1 分钟,容忍短暂不一致
.build();
}
public User getUser(Long id) {
User u = localCache.getIfPresent(id); // L1 本地
if (u != null) return u;
String json = redis.get("user:" + id); // L2 Redis
if (json != null) {
u = parse(json);
localCache.put(id, u);
return u;
}
u = db.queryById(id); // L3 DB
if (u != null) {
redis.setex("user:" + id, 3600 + random(600), toJson(u));
localCache.put(id, u);
}
return u;
}
本地缓存的过期时间要短(几十秒到几分钟),否则多实例之间数据不一致会很严重。
五、缓存一致性:更新时是先删缓存还是先更新数据库
这是个经典问题,结论取决于你能接受什么:
| 方案 | 做法 | 风险 |
|---|---|---|
| 先删缓存,再更新 DB(推荐) | DEL cache → UPDATE db |
并发下可能把旧值写回缓存(概率较低) |
| 先更新 DB,再删缓存 | UPDATE db → DEL cache |
删缓存失败则长期不一致(可用 MQ 重试兜底) |
| 更新 DB 同时更新缓存 | 双写 | 并发写会导致脏数据,不推荐 |
业界通行做法是"先更新数据库,再删除缓存",并加两道保险:
@Transactional
public void updateUser(User u) {
db.update(u);
// 1. 删缓存
redis.del("user:" + u.getId());
// 2. 发 MQ,消费端再删一次(失败自动重试)→ 双删保障
mq.send("cache-invalidate", "user:" + u.getId());
// 3. 可选:延迟双删,sleep 几百毫秒后再删一次
}
另外给所有缓存 key 设 TTL,即使删除失败,过期后也会自愈。这是最朴素的兜底。
六、排查清单
当你发现数据库压力异常时,按顺序查:
# 1. Redis 还活着吗、命中率多少
redis-cli -a pwd INFO stats | grep -E "keyspace_hits|keyspace_misses"
# 命中率 = hits / (hits + misses),低于 90% 要警惕
# 2. 有没有大量 key 同时过期
redis-cli -a pwd INFO keyspace # 看 db0 的 expires 数量
# 3. 慢命令
redis-cli -a pwd SLOWLOG GET 20
# 4. 大 key / 热 key
redis-cli -a pwd --bigkeys
redis-cli -a pwd --hotkeys
# 5. 内存是否到上限触发淘汰
redis-cli -a pwd INFO memory | grep -E "used_memory_human|maxmemory_human|maxmemory_policy"
命中率突然下降 + 数据库 QPS 暴涨 = 典型的击穿或雪崩;命中率长期很低 = 缓存设计有问题(key 设计不合理或穿透)。
七、常见误区
- 三个问题混为一谈:穿透是"不存在",击穿是"一个热的没了",雪崩是"一片没了",解法不同。
- 空值缓存 TTL 设太长:真数据插进来了还查不到,业务异常。
- 用
SETNX加锁不加过期时间:线程挂了锁永远不释放,该 key 永久瘫痪。 - 互斥锁递归重试不设上限:会栈溢出,应该设最多重试 3 次。
- 以为加了 Redis 就不用管数据库容量:缓存一挂,数据库必须扛得住全量流量(通常扛不住,所以要熔断限流)。
- 所有 key 用相同 TTL:这就是给雪崩铺路,必须加随机。
八、几条纪律
- 所有缓存 key 设 TTL 且加随机抖动,基础值上下浮动 5-10%。
- 接口层做参数校验和限流,把明显非法的请求挡在缓存之前。
- 热点 key 用互斥锁或逻辑过期,并提前预热。
- Redis 必须高可用(哨兵/集群),且数据库侧要有熔断降级兜底。
- 更新数据采用"更新 DB + 删缓存 + MQ 补偿删",不双写缓存。
- 监控三件事:缓存命中率、Redis 内存水位、数据库 QPS,三者一起看才能发现问题。
九、延伸阅读
👉 相关在线工具:日志分析 · Linux 命令速查 · Docker 命令速查,免登录、纯前端。