连载中 4/16

缓存穿透:恶意 ID 打穿 MySQL 的四道防线

2026-05-16 · 9277 阅读 · 21 评论 · 287 赞

凌晨两点的数据库告警

先讲个我亲历的事故。凌晨两点,钉钉炸了:商品详情库 CPU 100%,连接池耗尽,整个 App 的商品页全挂。我揉着眼睛打开慢查询,看到的画面至今难忘——几乎全部查询长一个样:WHERE id = -1WHERE 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、复合查询)就靠空值缓存顶;限流熔断作为标配兜底。四道防线不是互斥的,是纵深——攻击者要打穿数据库,得连过四关。

小结

  1. 穿透的本质:查缓存和库里都不存在的数据,缓存永远 miss,每次都打库——攻击者的最爱;
  2. 四道防线:参数校验(拦低级)→ 布隆过滤器(拦「一定不存在」)→ 空值缓存(拦重复 miss)→ 限流熔断(保 DB 不死);
  3. 两个细节:空值标记 TTL 要短且创建数据时要清标记;布隆过滤器不支持删除、容量要预估。

下一篇讲经典三问的第二问——缓存击穿:一个千万 QPS 的热点 key 过期那一秒,会发生什么?互斥锁和逻辑过期两种方案怎么选?下篇见。

咖啡凉了,记得趁热喝。

503

10 年全栈工程师 · 503咖啡馆主理人

#Redis#缓存穿透#布隆过滤器#高可用

评论 (0)

相关推荐

连载中 12/20

排查四件套:jstack、jmap、jstat、jcmd 的实战分工

jstack 看线程在干什么,jmap 看堆里装了什么,jstat 看运行时在变什么,jcmd 是统一入口。四把刀各管一段,配合着用没有查不动的现场。

#jstack#jmap#jstat#jcmd#排查工具
2026-09-16 · 4 阅读 · 0 评论 · 0 赞
连载中 11/20

GC 日志:把回收过程翻译成人话

一行 GC 日志里塞着七种信息:谁触发的、收了哪、停了多久、活了哪些。加上 -Xlog 配置,再加上日志分析工具,GC 不再是只能盯监控曲线的黑盒。

#GC日志#Xlog#日志分析#GC监控#Full GC排查
2026-09-16 · 13 阅读 · 0 评论 · 0 赞
连载中 10/20

ZGC:亚毫秒停顿是怎么炼成的

百 G 大堆停顿不到一毫秒,靠的是把搬家全部挪到并发阶段——着色指针让引用自带状态,读屏障让搬运中的对象依然可访问。代价是吞吐与内存,收益是停顿与堆大小解耦。

#ZGC#着色指针#读屏障#亚毫秒停顿#分代ZGC
2026-09-15 · 8 阅读 · 0 评论 · 0 赞