缓存用好了是性能翻倍,用不好凌晨三点把你叫醒。
Redis 在大多数系统里扮演的角色是挡在数据库前面的一堵墙。正常情况下,请求打到 Redis,命中就走,数据库压力不大。但这堵墙有三个洞:它可能整堵墙同时塌,可能有人绕过墙直接锤数据库,也可能墙上最忙的那块砖碎了。
对应过来就是雪崩、穿透、击穿。一个一个说。
一、缓存雪崩:整堵墙同时塌了
什么是雪崩: 大量缓存在同一个时间点集中过期,所有请求瞬间穿透到数据库,数据库扛不住直接挂。
典型的触发场景:你批量导入了 10 万条商品数据,所有缓存都设了 24 小时过期。24 小时后,10 万个 key 在同一秒集体死亡。下一秒打进来的请求全部查库,数据库连接池瞬间打满。
怎么解决,三道防线:
// 防线一:过期时间加随机值,错开集体死亡
// 不要这样
redisTemplate.opsForValue().set(productKey, value, 24, TimeUnit.HOURS);
// 改成这样,随机加 1-6 小时
int randomHours = ThreadLocalRandom.current().nextInt(1, 7);
redisTemplate.opsForValue().set(productKey, value, 24 + randomHours, TimeUnit.HOURS);
// 防线二:永不过期 + 异步更新
// 缓存本身不设过期,由定时任务定期刷新
@Scheduled(fixedDelay = 300000) // 每5分钟
public void refreshHotProducts() {
List<Product> hotProducts = productService.getHotProducts();
for (Product p : hotProducts) {
String key = "product:" + p.getId();
redisTemplate.opsForValue().set(key, p, Duration.ofDays(7));
}
}
// 注意:这里设了7天兜底,防止定时任务挂了缓存永远不更新
// 防线三:多级缓存 + 限流兜底
// 如果 Redis 也挂了,本地缓存拦截一波
@Cacheable(value = "product", key = "#id", unless = "#result == null")
public Product getProduct(Long id) {
return productMapper.selectById(id);
}
// 数据库层加限流,即使被打穿也不至于雪崩到宕机
// Sentinel / RateLimiter 随便选一个,核心是保住数据库不挂
雪崩的核心矛盾不是数据过期,是所有东西同时过期。错开时间就消解了 80% 的风险。
二、缓存穿透:有人绕过墙直接锤数据库
什么是穿透: 查询一个不存在的数据------缓存里没有,数据库里也没有。正常逻辑会直接查库,然后发现是空的,不写缓存。下次同样的非法请求再来,继续查库。恶意攻击者可以构造一大堆不存在的 ID,把你的数据库活活打死。
// 漏洞代码
public Product getProduct(Long id) {
String key = "product:" + id;
Product p = (Product) redisTemplate.opsForValue().get(key);
if (p != null) {
return p;
}
p = productMapper.selectById(id);
if (p != null) {
redisTemplate.opsForValue().set(key, p, 24, TimeUnit.HOURS);
}
// BUG:p 是 null 的时候什么都不做,下次还查库
return p;
}
怎么修:
// 方案一:缓存空值,把「没有」也存起来
public Product getProduct(Long id) {
String key = "product:" + id;
Product p = (Product) redisTemplate.opsForValue().get(key);
if (p != null) {
// 判断是不是空值标记
if (p.getId() == -1L) return null;
return p;
}
p = productMapper.selectById(id);
if (p == null) {
// 缓存一个空对象,短过期,防止被恶意刷
Product empty = new Product();
empty.setId(-1L);
redisTemplate.opsForValue().set(key, empty, 5, TimeUnit.MINUTES);
return null;
}
redisTemplate.opsForValue().set(key, p, 24, TimeUnit.HOURS);
return p;
}
空值缓存的问题是如果有人刷几百万个不同的非法 ID,Redis 内存会被空对象占满。所以:
// 方案二:https://zhida.zhihu.com/search?content_id=278841174&content_type=Article&match_order=1&q=%E5%B8%83%E9%9A%86%E8%BF%87%E6%BB%A4%E5%99%A8&zhida_source=entity,事前拦截不存在的 key
// 用 Guava 或 Redisson 的布隆过滤器
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("product_filter");
bloomFilter.tryInit(1000000L, 0.01); // 100万容量,1%误判率
// 数据写入时同步加入过滤器
bloomFilter.add("product:" + id);
// 查询前先过过滤器
public Product getProduct(Long id) {
String key = "product:" + id;
if (!bloomFilter.contains(key)) {
return null; // 一定不存在,直接返回,不查库
}
// 剩下走正常缓存逻辑
// ...
}
布隆过滤器的代价:有 1% 误判率,说存在但实际不存在。多查一次数据库是能接受的。它的价值是拦住 99% 的恶意穿透请求。
推荐组合:布隆过滤器前置拦截 + 空值缓存兜底。布隆过滤器拦不住的那 1% 由空值缓存接住。
三、缓存击穿:墙上最忙的那块砖碎了
什么是击穿: 一个热点 key 过期了,这个 key 平时扛几万 QPS。过期瞬间,几万请求同时打向数据库。和雪崩的区别是:雪崩是一大片 key 同时过期,击穿是单个爆款 key 过期。
典型场景:秒杀商品的库存缓存、首页推荐位的配置缓存、大 V 的粉丝数缓存。
// 漏洞代码
public Product getHotProduct(Long id) {
String key = "product:" + id;
Product p = (Product) redisTemplate.opsForValue().get(key);
if (p == null) {
// 缓存过期了,直接查库
// 几千个线程同时走到这里,全部查库
p = productMapper.selectById(id);
redisTemplate.opsForValue().set(key, p, 10, TimeUnit.MINUTES);
}
return p;
}
怎么修:
// 方案一:https://zhida.zhihu.com/search?content_id=278841174&content_type=Article&match_order=1&q=%E4%BA%92%E6%96%A5%E9%94%81&zhida_source=entity,只让一个人去查库
public Product getHotProduct(Long id) {
String key = "product:" + id;
String lockKey = "lock:product:" + id;
Product p = (Product) redisTemplate.opsForValue().get(key);
if (p != null) return p;
// SET NX EX,抢锁,超时 10 秒防止死锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 抢到锁,二次检查
p = (Product) redisTemplate.opsForValue().get(key);
if (p != null) return p;
p = productMapper.selectById(id);
redisTemplate.opsForValue().set(key, p, 10, TimeUnit.MINUTES);
return p;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 没抢到锁,等 50ms 重试
try { Thread.sleep(50); } catch (InterruptedException ignored) {}
return getHotProduct(id); // 递归重试
}
}
// 方案二:https://zhida.zhihu.com/search?content_id=278841174&content_type=Article&match_order=1&q=%E9%80%BB%E8%BE%91%E8%BF%87%E6%9C%9F&zhida_source=entity,不删 key,只更新 value
// 适合容忍短期旧数据的场景
public Product getHotProductV2(Long id) {
String key = "product:" + id;
Product p = (Product) redisTemplate.opsForValue().get(key);
if (p == null) {
// 首次加载
p = productMapper.selectById(id);
redisTemplate.opsForValue().set(key, p, 10, TimeUnit.MINUTES);
return p;
}
// 判断是否逻辑过期(在 value 里塞一个过期时间戳)
if (p.getExpireTime() < System.currentTimeMillis()) {
// 异步更新,当前请求直接返回旧数据
threadPool.submit(() -> refreshCache(id));
}
return p; // 返回可能过期的旧数据,但不会击穿
}
逻辑过期方案的关键取舍:宁愿用户看到 30 秒前的旧数据,也别让他等 5 秒白屏。
区别速查表
| 类型 | 现象 | 本质 | 核心解法 |
|---|---|---|---|
| 雪崩 | 大量 key 同时过期 | 过期时间一致 | 随机 TTL + 永不过期异步刷新 |
| 穿透 | 查不存在的 key | 无缓存屏障 | 布隆过滤器 + 缓存空值 |
| 击穿 | 单个热点 key 过期 | 高并发查同一个 | 互斥锁 + 逻辑过期 |
最后
不管你用不用 Redis,这三类问题本质上都是同一件事:当缓存和数据库之间的假设破裂时,你的系统有没有兜底。
一个缓存 key 过期了你没有互斥锁,说明你假设了缓存永远能命中。缓存不存在你没有布隆过滤器,说明你假设了所有请求都是合法的。大量 key 同时过期你没有随机 TTL,说明你假设了过期时间是均匀分布的。
假设被打破的那一刻,就是你的系统开始雪崩的那一刻。