六月十三号上午,MySQL 被 3 万 QPS 打挂了
那天早上 9:07,我还在地铁上,DBA 的电话就打过来了:"你们的库 CPU 100%,连接数打满,赶紧看看是不是缓存挂了。"
到公司一看监控,数据库 QPS 从平时的 800 飙到 31000,CPU 100%,连接数 500 打满,大量请求超时。而 Redis 的命中率从 99.2% 掉到了 12%。
先说结论:这是一次典型的缓存雪崩,而且我们项目里三种缓存问题全都占齐了。
先把三个概念分清楚
这三个词我以前一直混淆,出事之后才真正搞明白它们的区别。核心是问自己两个问题:失效的是一个 key 还是一批 key?这个 key 到底存不存在?
| 问题 | 失效范围 | key 是否存在 | 典型场景 |
|---|---|---|---|
| 缓存穿透 | 单个 key | 根本不存在(DB 里也没有) | 恶意攻击、爬虫刷不存在的 ID |
| 缓存击穿 | 单个 key | 存在,是热点 key | 爆款商品、首页 banner 到期 |
| 缓存雪崩 | 大批 key | 都存在 | 预热时设置相同 TTL、Redis 宕机 |
我们的事故是怎么发生的
先看下出事的代码,一个很标准的缓存查询模板:
@Service
public class GoodsService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final int TTL = 3600; // 统一 1 小时
public Goods getById(Long goodsId) {
String key = "goods:" + goodsId;
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, Goods.class);
}
Goods goods = goodsMapper.selectById(goodsId);
if (goods != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(goods), TTL, TimeUnit.SECONDS);
}
return goods;
}
}
看着没啥问题对吧?它同时埋了三颗雷。
雷一:所有 key 的 TTL 都是 3600 秒
前一天晚上运营做了个全量商品预热,脚本在 8:50 开始跑,把 4 万多个商品一次性写入缓存,TTL 全是 3600 秒。9:50 这些 key 会在同一秒集体失效——而 9:07 那次是因为预热脚本跑了两次,第一次那批在 9:07 前后到期。
4 万个 key 同时失效,瞬时几万个请求全部穿透到 MySQL。这就是雪崩。
雷二:数据库查不到的数据不写缓存
if (goods != null) 这个判断让所有不存在的商品 ID 每次都打到数据库。有人拿脚本刷 /goods?id=-1、?id=99999999,一秒钟几千次,全部落在 MySQL 上。这就是穿透。
我们在日志里确实看到了大量 id=0 和负数 ID 的请求,IP 集中在几个段,很明显是扫描器。
雷三:热点商品失效后并发重建
活动主推的那个商品(id=88213),key 失效后有 2700 个并发请求同时发现缓存为空,然后 2700 个线程同时去查数据库、同时写缓存。数据库里那条 SQL 被执行了 2700 次。这就是击穿。
解决方案一:随机 TTL 解决雪崩
最简单的一招,给固定 TTL 加上随机偏移,让 key 的过期时间分散开:
private static final int BASE_TTL = 3600;
private static final int RANDOM_TTL = 600; // 0~10 分钟的随机量
private int randomTtl() {
return BASE_TTL + ThreadLocalRandom.current().nextInt(RANDOM_TTL);
}
// 写入时
redisTemplate.opsForValue().set(key, json, randomTtl(), TimeUnit.SECONDS);
4 万个 key 的过期时间被摊到 1 小时到 1 小时 10 分钟这个区间里,同时失效的量直接降到原来的 1/6。上线之后我盯了一周,缓存命中率的曲线从"悬崖式下跌"变成了平缓的小波动,最低点也有 96.3%。
另外还有一个思路:永不过期 + 后台更新。缓存不设 TTL,由定时任务或者消息队列在数据变更时主动更新。这样彻底没有集中失效的问题,但需要一个可靠的更新机制,还要处理"更新失败后缓存一直是脏的"的风险。我们核心商品数据后来改成了这个方案,配合 Canal 监听 binlog 触发更新。
解决方案二:空值缓存 + 布隆过滤器解决穿透
空值缓存(我们线上用的)
数据库查不到也写进缓存,值是个特殊标记,TTL 设短一点:
private static final String EMPTY_MARK = "__NULL__";
private static final int EMPTY_TTL = 120; // 空值缓存 2 分钟
public Goods getById(Long goodsId) {
String key = "goods:" + goodsId;
String json = redisTemplate.opsForValue().get(key);
if (EMPTY_MARK.equals(json)) {
return null;
}
if (json != null) {
return JSON.parseObject(json, Goods.class);
}
Goods goods = goodsMapper.selectById(goodsId);
if (goods == null) {
// 查不到也缓存,防止穿透
redisTemplate.opsForValue().set(key, EMPTY_MARK, EMPTY_TTL, TimeUnit.SECONDS);
return null;
}
redisTemplate.opsForValue().set(key, JSON.toJSONString(goods), randomTtl(), TimeUnit.SECONDS);
return goods;
}
加完之后那波扫描器的请求全部被挡在缓存层,数据库 QPS 立刻降了 4000 多。
两个注意点:一是空值的 TTL 要短,我们设的 2 分钟。太长的话,万一这个 ID 后来真有数据了,要等很久才能生效;二是要在数据新增时主动删掉空值缓存,我们是在商品上架的 Service 里加了删除逻辑。
布隆过滤器(备用方案,我最后没上)
如果攻击的 ID 是完全随机、无穷无尽的(比如每次都是不同的负数),空值缓存会撑爆 Redis——每个不存在的 ID 都占一个 key。这种场景该用布隆过滤器。
原理:把所有合法的商品 ID 提前存进一个位数组,查询前先过一遍过滤器,判断"这个 ID 一定不存在"还是"可能存在"。
// 我们使用 Google Guava 23.0 的布隆过滤器
@Service
public class GoodsBloomFilter implements InitializingBean {
private BloomFilter<Long> filter;
@PostConstruct
public void init() {
List<Long> allIds = goodsMapper.selectAllIds(); // 4 万多条
filter = BloomFilter.create(
Funnels.longFunnel(),
allIds.size(),
0.01); // 误判率 1%
for (Long id : allIds) {
filter.put(id);
}
}
public boolean mightExist(Long id) {
return filter.mightContain(id);
}
}
// 查询入口
public Goods getById(Long goodsId) {
if (!goodsBloomFilter.mightExist(goodsId)) {
return null; // 一定不存在,直接返回
}
// ... 走正常缓存流程
}
布隆过滤器的代价是有误判率:它说"不存在"就一定不存在,但说"存在"可能其实是误判(1% 的概率)。误判的请求会继续走缓存和数据库,属于可接受范围。
我们最后没上,因为 Guava 的布隆过滤器是单机内存版,多实例部署时每个 JVM 都要加载一份,4 万条数据占内存不大(约 48KB)但更新很麻烦——新增商品时得让所有实例都更新自己的过滤器。要用的话应该用 Redis 的 BitMap 自己实现一个分布式版本,或者用 Redis 4.0 的模块机制。考虑到我们当时的场景空值缓存已经够用了,就没再折腾。
解决方案三:互斥锁解决击穿
热点 key 失效时,只让一个线程去重建缓存,其他线程等一会儿再读。用 Redis 的 SETNX 实现分布式锁:
public Goods getByIdWithMutex(Long goodsId) {
String key = "goods:" + goodsId;
String json = redisTemplate.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, Goods.class);
}
String lockKey = "lock:goods:" + goodsId;
String lockValue = UUID.randomUUID().toString();
try {
// SET key value NX PX 10000,原子操作
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 拿到锁,查库重建
Goods goods = goodsMapper.selectById(goodsId);
redisTemplate.opsForValue().set(key, JSON.toJSONString(goods),
randomTtl(), TimeUnit.SECONDS);
return goods;
} else {
// 没拿到锁,等 50ms 后重试读缓存
Thread.sleep(50);
return getByIdWithMutex(goodsId);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
// 只删除自己加的锁,防止误删别人的
if (lockValue.equals(redisTemplate.opsForValue().get(lockKey))) {
redisTemplate.delete(lockKey);
}
}
}
几个要点:
- setIfAbsent + 过期时间必须是原子操作。 Spring Data Redis 2.0 的
setIfAbsent(key, value, timeout, unit)会翻译成SET key value NX PX ttl一条命令。老版本(1.x)没有这个重载,得先 setnx 再 expire,中间宕机就会产生死锁。 - 释放锁要校验 value。 如果线程 A 执行慢了,锁超时自动释放,线程 B 拿到锁,这时 A 执行完直接 del,就把 B 的锁删掉了。用 UUID 做 value 对比能避免这个。
- 锁一定要有过期时间。 不然持有锁的线程挂了,这个 key 就永远锁死了。
上线后我又压测了一次那个热点商品:缓存失效瞬间 2700 个并发请求,只有 1 个去查了数据库,其余的在 50ms 内拿到了重建后的数据。数据库那条 SQL 的执行次数从 2700 次降到 1 次。
还有一层:Redis 自己挂了怎么办
上面这些都是在"Redis 可用"的前提下。如果 Redis 整体宕机,所有请求还是会全打到数据库,这是最严重的雪崩。
我们能做的:
- Redis 高可用。 我们用的是 Redis 3.2 主从 + Sentinel 哨兵,主节点挂了哨兵会自动切主。切换期间有几十秒不可用,所以应用层还得有兜底。
- 本地缓存兜底。 用 Guava Cache 做一个短TTL(比如 60 秒)的一级缓存,Redis 挂了先读本地。数据一致性会差一些,但总比数据库被打挂强:
private final Cache<Long, Goods> localCache = CacheBuilder.newBuilder()
.maximumSize(10000)
.expireAfterWrite(60, TimeUnit.SECONDS)
.build();
public Goods getById(Long goodsId) {
try {
// 先查 Redis
...
} catch (RedisConnectionFailureException e) {
log.error("Redis 不可用,降级到本地缓存", e);
return localCache.getIfPresent(goodsId);
}
}
- 限流。 用 Guava 的 RateLimiter 或者自己在网关层做,给数据库留一个它能承受的上限。我们给商品查询接口设了单机 2000 QPS 的令牌桶,超出的直接返回"系统繁忙"。
事故后的数据
| 指标 | 事故时 | 修复后 |
|---|---|---|
| 数据库峰值 QPS | 31000 | 890 |
| Redis 命中率最低点 | 12% | 96.3% |
| 热点 key 失效时 DB 查询次数 | 2700 | 1 |
| 商品详情 P99 耗时 | 超时(>3s) | 43ms |
恢复时间是 9:07 到 9:41,34 分钟。事后复盘时我最大的感受是:这三段代码每一行我写的时候都觉得"没毛病",单看都是对的,合在一起却能在某个时刻同时引爆。缓存的坑不在于某个 API 不会用,而在于你得时刻假设缓存会失效、Redis 会挂、请求会是恶意的。