Redis 缓存穿透、击穿、雪崩与一致性策略
三个词经常被混用,根因和修法完全不同。空值缓存与布隆过滤器、互斥锁与逻辑过期、TTL 抖动与本地兜底,以及四种一致性策略的取舍。
「加个 Redis 缓存」是性能优化的万能答案,但缓存引入的问题比它解决的问题更隐蔽:数据库被穿透打挂、热点 key 失效瞬间压死 DB、缓存和数据库数据不一致导致用户看到旧数据。
这篇文章把缓存的三类失效问题(穿透、击穿、雪崩)和一致性策略分开讲清楚,每类给出可落地的代码。
一、先分清三个词
这三个词经常被混用,但根因和修法完全不同:
| 问题 | 现象 | 根因 | 影响面 |
|---|---|---|---|
| 穿透 | 查一个不存在的数据,每次都打到 DB | 缓存里没有,DB 里也没有 | 特定恶意请求,量大 |
| 击穿 | 单个热点 key 失效瞬间,大量请求打 DB | 热点 key 过期 | 单个 key,瞬间高峰 |
| 雪崩 | 大量 key 同时失效,或 Redis 挂了 | 过期时间集中 / 实例故障 | 全站 |
关键区别:穿透是「数据本来不存在」,击穿是「数据存在但缓存刚好没了」,雪崩是「大面积同时没了」。
二、缓存穿透:查不存在的数据
典型攻击场景:用 id = -1 或者随机 ID 高频请求,缓存里没有 → 查 DB → DB 也没有 → 不写缓存 → 下次继续查 DB。每一万个请求就是一万次 DB 查询。
方案 A:缓存空值
public User getUser(Long id) {
String key = "user:" + id;
String cached = redis.get(key);
if (cached != null) {
return EMPTY_MARKER.equals(cached) ? null : JSON.parseObject(cached, User.class);
}
User user = userMapper.selectById(id);
if (user == null) {
// 缓存空值,过期时间短一些(60 秒)
redis.setex(key, 60, EMPTY_MARKER);
return null;
}
redis.setex(key, 1800, JSON.toJSONString(user));
return user;
}
为什么空值的过期时间要短? 因为数据可能后来被创建了。空值缓存 30 分钟,意味着新注册的用户 30 分钟内查不到自己。60 秒是个折中。
缺点:如果攻击者用大量不同的随机 ID,空值缓存本身就成了一种内存攻击——每个不存在的 ID 都占一个 key。所以要配合方案 B。
方案 B:布隆过滤器前置拦截
@Component
public class UserBloomFilter {
private RBloomFilter<Long> filter;
@PostConstruct
public void init() {
filter = redisson.getBloomFilter("user:bloom");
filter.tryInit(10_000_000L, 0.01); // 预期 1000 万,误判率 1%
// 预热:把已有的所有 ID 灌进去
userMapper.selectAllIds().forEach(filter::add);
}
public boolean mightExist(Long id) {
return filter.contains(id);
}
public void add(Long id) {
filter.add(id);
}
}
查询前先过布隆:
public User getUser(Long id) {
if (!bloomFilter.mightExist(id)) {
return null; // 一定不存在,直接返回
}
// ... 走缓存 + DB
}
布隆过滤器的特性必须记住:
- 说「不存在」→ 一定不存在(无假阴性)
- 说「存在」→ 可能存在(有假阳性)
- 不支持删除。用户被删了,ID 还在过滤器里,会继续放行到 DB
误判率决定内存占用。1000 万元素、1% 误判,约 12 MB;0.1% 误判,约 18 MB。这个开销通常可以接受。
新增数据时要同步 add,否则新用户会被判定为「不存在」。放在事务提交后(TransactionSynchronization.afterCommit),避免回滚后过滤器里有脏数据。
方案 C:参数校验(最便宜)
if (id == null || id <= 0 || id > MAX_VALID_ID) {
throw new BizException("参数非法");
}
大部分穿透攻击用的是负数、0、超大数。一个 id > 0 的判断就能挡掉一半。
三、缓存击穿:热点 key 失效
一个首页 Banner 的缓存 key,QPS 2000,TTL 30 分钟。第 30 分钟那一刻,key 失效,2000 个请求同时发现缓存没了,同时去查 DB——DB 瞬间被打穿。
注意:这个 key 是热点,所以哪怕只失效 100ms,也是 200 个并发查询压到 DB。
方案 A:互斥锁(保证只有一个线程回源)
public String getBanner(String key) {
String cached = redis.get(key);
if (cached != null) return cached;
// 只有抢到锁的线程去查 DB
String lockKey = "lock:" + key;
boolean locked = redis.set(lockKey, "1", SetParams.setParams().nx().ex(10));
if (!locked) {
// 没抢到锁,短暂等待后重试读缓存
try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return getBanner(key); // 递归重试,实际要加最大重试次数
}
try {
// 双重检查,防止等锁期间别人已经写入
cached = redis.get(key);
if (cached != null) return cached;
String data = loadFromDb(key);
redis.setex(key, 1800, data);
return data;
} finally {
redis.del(lockKey); // 注意:必须校验是自己的锁
}
}
两个细节:
finally 里直接 del 有风险。 如果查 DB 超时(超过 10 秒锁自动过期),此时另一个线程拿了锁,你的 del 会删掉别人的锁。正确做法是锁的 value 存唯一标识,删除前比对:
String token = UUID.randomUUID().toString();
redis.set(lockKey, token, SetParams.setParams().nx().ex(10));
// ...
// Lua 保证原子性
String lua = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
redis.eval(lua, ScriptOutputType.INTEGER, new String[]{lockKey}, token);
用 Redisson 更省事,它的 RLock 自带看门狗续期和可重入。
递归重试要有上限。 上面的写法如果 DB 一直超时,会无限递归。加一个重试计数器,超过 3 次返回降级数据。
方案 B:逻辑过期(不设 TTL,异步刷新)
// 缓存结构:{"data": {...}, "expireAt": 1730000000000}
public String getBannerLogicExpire(String key) {
String cached = redis.get(key);
if (cached == null) {
return loadAndSet(key); // 首次加载
}
CacheWrapper w = JSON.parseObject(cached, CacheWrapper.class);
if (System.currentTimeMillis() < w.getExpireAt()) {
return w.getData(); // 未过期,直接返回
}
// 已逻辑过期:先返回旧数据,同时异步刷新
boolean locked = redis.set("lock:" + key, "1", SetParams.setParams().nx().ex(10));
if (locked) {
executor.execute(() -> {
try {
String fresh = loadFromDb(key);
redis.set(key, wrap(fresh), 86400); // 物理 TTL 设很长
} finally {
redis.del("lock:" + key);
}
});
}
return w.getData(); // 立即返回旧数据,不阻塞
}
优点:响应永远不阻塞,用户看到的可能是几十秒前的数据。 适用: 商品详情、首页 Banner、排行榜——能容忍短暂陈旧。 不适用: 库存、余额、订单状态——不能有脏读。
方案 A vs B 怎么选
| 互斥锁 | 逻辑过期 | |
|---|---|---|
| 一致性 | 强(回源后是最新数据) | 弱(有一段窗口返回旧值) |
| 响应时间 | 抢锁失败的请求要等 | 恒定快速 |
| 实现复杂度 | 低 | 中(要异步线程池) |
| 适用 | 交易相关 | 展示类 |
四、缓存雪崩:大面积失效
三种成因:
1. 过期时间集中。 凌晨批量预热 1000 个 key,都设 3600 秒,一小时后集体失效。
修法:加随机抖动。
int ttl = 1800 + ThreadLocalRandom.current().nextInt(300); // 1800 ± 300 秒
redis.setex(key, ttl, value);
2. Redis 实例故障。 单节点挂了,全部请求打 DB。
修法:
- 主从 + 哨兵(或 Cluster)保证高可用
- 本地缓存做二级兜底(Caffeine)
- 熔断降级:Redis 不可用时直接返回降级数据,不要让流量涌向 DB
public String get(String key) {
try {
String v = redis.get(key);
if (v != null) return v;
} catch (Exception e) {
log.warn("Redis 不可用,走本地缓存", e);
String local = localCache.getIfPresent(key);
if (local != null) return local;
throw new DegradeException("服务繁忙"); // 快速失败
}
// ...
}
3. 冷启动。 服务重启后缓存是空的,所有请求都回源。
修法:启动预热。 用 ApplicationRunner 把热点数据先加载进缓存:
@Component
public class CacheWarmer implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
List<Long> hotIds = statMapper.topProductIds(500);
hotIds.parallelStream().forEach(id -> {
String data = loadProduct(id);
redis.setex("product:" + id, 3600 + random(300), data);
});
log.info("缓存预热完成,{} 个 key", hotIds.size());
}
}
五、一致性:先删缓存还是先更新 DB
这是面试高频题,也是实操最容易出错的地方。
四种策略对比
| 策略 | 步骤 | 问题 |
|---|---|---|
| 先删缓存,再更新 DB | del → update | 并发下会读到旧值并写回缓存 |
| 先更新 DB,再删缓存 | update → del | 概率极低的不一致(Cache Aside 推荐) |
| 先更新 DB,再更新缓存 | update → set | 并发写入顺序错乱,缓存永久脏 |
| 延迟双删 | del → update → sleep → del | 缓解但仍不严格 |
为什么「先更新 DB,再删缓存」是最优解
时序分析:
线程 A:update DB (id=1, v=2) ← t1
线程 B:read cache ← t2,缓存未命中
线程 B:read DB → v=2 ← t3(此时 A 已提交)
线程 A:del cache ← t4
线程 B:set cache = 2 ← t5
最终缓存是 v=2,正确。
极端情况:
线程 A:update DB ← t1
线程 B:read cache 未命中 ← t2
线程 A:del cache ← t3
线程 B:read DB → v=1(旧值,如果 B 的读在 A 提交前快照)← t4
线程 B:set cache = 1 ← t5 ← 缓存变脏
这个窗口要求 B 的读在 A 提交之前开始、写入缓存在 A 删除之后,概率极低(需要 B 的读跨越 A 的整个事务)。而且这个脏数据会一直存在直到 TTL 过期,所以 TTL 不能设太长。
关键结论:任何 Cache Aside 策略都不是强一致的,必须靠 TTL 兜底。 别指望用「延迟双删」做到强一致——sleep 多久合适没有任何理论依据,只是碰运气。
强一致场景怎么做
如果是余额、库存这类不能有任何脏读的场景,正确做法是:
方案 A:不用缓存,直接查 DB。 最可靠。用索引和读写分离优化性能。
方案 B:缓存只做「降低 DB 压力」,关键判断走 DB。
// 展示用缓存
public BigDecimal getBalanceDisplay(Long uid) { return cacheOrDb(uid); }
// 扣款必须走 DB + 行锁
@Transactional
public void deduct(Long uid, BigDecimal amount) {
accountMapper.deductWithCheck(uid, amount); // UPDATE ... WHERE balance >= ?
}
方案 C:订阅 binlog 异步失效(最终一致 + 可靠)
用 Canal / Debezium 订阅 MySQL binlog,数据变更时发消息,消费者删缓存:
MySQL → binlog → Canal → Kafka → 消费者 → del cache
优点是解耦且不漏——只要 binlog 落了,缓存一定会被删。缺点是延迟(通常 100ms~1s)。
六、本地缓存 + Redis 的两级架构
单靠 Redis,一次网络往返 0.5~2ms。超高 QPS 下这仍是瓶颈。
加一层 Caffeine 本地缓存:
public class TwoLevelCache {
private final Cache<String, String> local = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(30, TimeUnit.SECONDS) // 本地 TTL 短,保证及时性
.recordStats()
.build();
public String get(String key) {
// 1. 本地
String v = local.getIfPresent(key);
if (v != null) return v;
// 2. Redis
v = redis.get(key);
if (v != null) {
local.put(key, v);
return v;
}
// 3. DB
v = loadFromDb(key);
if (v != null) {
redis.setex(key, 1800, v);
local.put(key, v);
}
return v;
}
}
本地缓存的最大问题是多实例不一致。 3 个 Pod 各有一份本地缓存,更新数据后要通知所有实例失效。
轻量方案用 Redis Pub/Sub 广播失效:
// 更新后广播
redis.publish("cache:invalidate", key);
// 每个实例订阅
redis.subscribe("cache:invalidate", (ch, msg) -> local.invalidate(msg));
Pub/Sub 不保证送达(没有 ack)。要可靠就用 Redis Stream 或者接受本地 TTL 兜底(30 秒内的不一致)。
七、几个容易忽略的细节
1. key 设计要用冒号分层,但别太深
user:1001:profile ✓
shop:88:order:20261105:list ✓
user_profile_1001 ✗(无法按前缀批量操作)
a:b:c:d:e:f:g:h ✗(不好读,也占内存)
2. 大 key 是定时炸弹
一个 key 存了 10 万元素的 List,LRANGE 会阻塞 Redis 主线程几百毫秒。检查方法:
redis-cli --bigkeys
redis-cli --memkeys # Redis 6+
判断标准:String 超过 10 KB、集合元素超过 5000 个,就该拆分。
3. 热 key 会打爆单个分片
Cluster 模式下,一个 10 万 QPS 的 key 全落在同一个分片。检测:
redis-cli --hotkeys # 需要 maxmemory-policy 是 lfu*
处理:本地缓存挡掉 90%,或者把 key 拆成 hotkey:1..10 分散到不同分片。
4. TTL 一定要设
// 危险:永不过期
redis.set(key, value);
// 安全:总有过期时间
redis.setex(key, ttlWithJitter(1800), value);
不设 TTL 的缓存迟早会因为业务变更变成脏数据,而且永远无法自愈。Redis 的 maxmemory-policy 也要设成 allkeys-lru,否则内存满了会拒绝写入。
小结
三件事对应三个动作:
| 问题 | 核心动作 |
|---|---|
| 穿透 | 空值缓存(短 TTL)+ 布隆过滤器 + 参数校验 |
| 击穿 | 互斥锁(交易类)或逻辑过期(展示类) |
| 雪崩 | TTL 随机抖动 + 高可用 + 本地缓存兜底 + 启动预热 |
一致性上记住一句话:缓存不是数据库,做不到强一致。 用「更新 DB 后删缓存 + 合理 TTL」做最终一致,把强一致的要求留给 DB 本身。真要缓存强一致,成本会远超收益。
正在加载评论…