凌晨两点的数据库告警
先讲个我亲历的事故。凌晨两点,钉钉炸了:商品详情库 CPU 100%,连接池耗尽,整个 App 的商品页全挂。我揉着眼睛打开慢查询,看到的画面至今难忘——几乎全部查询长一个样:WHERE id = -1、WHERE id = 99999999999,id 是随机生成的、根本不存在的数字。
我当时的第一反应是懵的:我们不是上了缓存吗?反应过来才明白,缓存挡得住「查得到的数据」,挡不住「根本不存在的数据」。这就是缓存三大经典问题里最阴险的一个——缓存穿透。
为什么「不存在」最致命
回顾一下正常的缓存读流程:先查缓存,未命中查库,把结果回填缓存。注意这个流程有个隐含假设——查得到。只要数据在库里存在,第一次 miss 之后缓存就有了,后续请求都被缓存消化。
但穿透请求查的是库里也没有的数据。查不到就没有东西可回填,缓存永远是空的,于是每一个这样的请求都会原封不动地砸到数据库。正常用户访问这种请求占比极低,无所谓;但攻击者只要写个循环用随机 id 扫你的接口,每秒几万个必然 miss 的请求就能把数据库打死。
和另外两个经典问题区分一下,很多人容易混:
- 穿透:数据在缓存和库里都不存在,缓存永远无法生效(本篇);
- 击穿:数据存在,但热点 key 过期的一瞬间大量请求集中打库(第 5 篇);
- 雪崩:大批 key同一时间集体失效或 Redis 整体宕机(第 6 篇)。
记住这个判断口径:穿透是「查无此数据」,击穿是「过期那一秒」,雪崩是「集体失效」。三者的解法完全不同,混了概念就布错了防线。
防线一:参数校验,把低级攻击挡在门外
第一道防线成本最低、见效最快:在应用入口把明显非法的参数直接拦掉。id 小于等于零的、格式不合法的、明显超出业务取值范围的,直接返回 400,连 Redis 都不用碰。
凌晨那次事故里,如果我们在 Controller 层加一行 id <= 0 则拒绝 的校验,一半流量当场就没了。很多攻击脚本是「盲扫」,专挑最廉价的非法值下手,参数校验挡住的恰恰是这一批。
但它只防低级攻击——攻击者换成「真实格式但不存在」的 id(比如从 1 递增扫到 10 亿),参数校验就无能为力了。所以它必做,但远远不够。
防线二:空值缓存,让 miss 也有代价
第二道防线的思路是釜底抽薪:既然穿透的根因是「查不到就没法回填」,那查不到就回填一个「空」的标记。下次同样的 id 再来,先看到标记,直接返回,不再碰库。
public Article getArticle(Long id) {
String key = "article:detail:" + id;
String emptyMark = "article:empty:" + id;
// 命中空值标记:这 id 确认不存在,直接返回
if (Boolean.TRUE.equals(redisTemplate.hasKey(emptyMark))) {
return null;
}
Article article = redisTemplate.opsForValue().get(key);
if (article != null) {
return article;
}
article = articleMapper.selectById(id);
if (article == null) {
// 库里也没有:写一个 60 秒的空值标记,下次不再打库
redisTemplate.opsForValue().set(emptyMark, "1", Duration.ofSeconds(60));
return null;
}
redisTemplate.opsForValue().set(key, article, Duration.ofMinutes(10));
return article;
}两个工程细节。第一,空值标记的 TTL 必须短(30~90 秒):一来防止数据后来被创建导致长时间读到「假不存在」,二来防止攻击者用海量随机 id 把你的 Redis 塞满空标记——空值缓存是拿少量内存换数据库安全,内存成本要有上限。第二,如果业务上有「创建数据」的入口,创建成功后记得主动删掉对应的空值标记,否则新数据要等 TTL 过期才能被查到。
防线三:布隆过滤器,把不存在挡在缓存之前
空值缓存有个软肋:它只能防「重复来问的 id」,攻击者换一批新 id 又能打穿一轮。布隆过滤器(Bloom Filter)则从另一个方向解决问题——在请求进入缓存之前,先回答「这个 id 存在吗」。
原理一句话:一个很长的位数组加多个哈希函数。写入时把 id 哈希到位数组上置 1;查询时看对应位置是不是全 1。它说「不存在」就一定不存在,说「存在」可能是误判——这个单向保证恰好完美匹配穿透场景:我们要拦的就是「一定不存在」的请求,误判的那一小撮「假存在」放过去也只是走正常 miss 回源,无害。
Redisson 提供了开箱即用的分布式布隆过滤器:
// 启动时预热:把全量合法 id 灌入布隆过滤器
RBloomFilter<Long> filter = redissonClient.getBloomFilter("article:id:filter");
filter.tryInit(10000000L, 0.01); // 预期 1000 万条,误判率 1%
articleMapper.selectAllIds().forEach(filter::add);
// 查询前先过过滤器
public Article getArticle(Long id) {
if (!filter.contains(id)) {
return null; // 说不存在就一定不存在,直接拦下
}
return doGetArticle(id); // 正常走缓存和库
}三个使用要点。其一,预期容量和误判率要提前定好:容量超了误判率会飙升,实际条数超过 tryInit 的预期值就必须重建。其二,布隆过滤器不支持删除:数据下架后过滤器里仍然「存在」,只能容忍误判或定期重建(定时任务全量重灌一遍)。其三,数据新增时要同步 add,漏一条就多一个永远查不到的「假穿透」。
防线四:限流熔断,最后的护城河
前三道防线都是「让非法请求查不到东西」,第四道则回答最坏情况:万一有漏网的,怎么保证数据库不死。
两个手段。一是单维度限流:统计同一 IP 或用户在时间窗内的未命中次数,超阈值就临时拉黑:
// 同一 IP 60 秒内未命中超过 100 次,判定为恶意扫描
public boolean isAbusive(String ip) {
String key = "penalty:miss:" + ip;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, Duration.ofSeconds(60));
}
return count > 100;
}二是熔断:给「回源查库」这个动作加熔断器(Sentinel、Resilience4j 都行),未命中导致 DB 响应时间飙升时自动熔断降级,宁可短时间返回兜底数据,也不让数据库被压垮。穿透攻击打的是缓存,真正的靶子是数据库——护城河要修在数据库门口。
四道防线怎么组合
| 防线 | 拦截位置 | 成本 | 适用场景 |
|---|---|---|---|
| 参数校验 | 应用入口 | 极低 | 必做,挡住低级攻击 |
| 空值缓存 | 缓存层 | 低(短 TTL 空标记) | id 零散、无法预知全集 |
| 布隆过滤器 | 缓存之前 | 中(需预热与维护) | id 全集稳定、读多写少 |
| 限流熔断 | 应用与库之间 | 中 | 兜底,防最坏情况 |
我的推荐组合:参数校验必做;id 全集可枚举(商品、文章这类)就上布隆过滤器;全集没法枚举(用户生成的 id、复合查询)就靠空值缓存顶;限流熔断作为标配兜底。四道防线不是互斥的,是纵深——攻击者要打穿数据库,得连过四关。
小结
- 穿透的本质:查缓存和库里都不存在的数据,缓存永远 miss,每次都打库——攻击者的最爱;
- 四道防线:参数校验(拦低级)→ 布隆过滤器(拦「一定不存在」)→ 空值缓存(拦重复 miss)→ 限流熔断(保 DB 不死);
- 两个细节:空值标记 TTL 要短且创建数据时要清标记;布隆过滤器不支持删除、容量要预估。
下一篇讲经典三问的第二问——缓存击穿:一个千万 QPS 的热点 key 过期那一秒,会发生什么?互斥锁和逻辑过期两种方案怎么选?下篇见。
咖啡凉了,记得趁热喝。
评论 (0)