一个只缓存查得到的数据的接口,被人用大量不存在的 ID 反复查询,缓存形同虚设、请求全砸到数据库上,差点把库打垮:一次缓存穿透的深度复盘

商品详情接口前面挡着 Redis,平时 DB 压力很小,某天 DB QPS 突然暴涨几十倍、缓存命中率却跌到 0。抓参数一看,请求查的全是不存在的随机 ID。根因是 cache-aside 只缓存查得到的非空结果:查不存在的 ID 时缓存没有、DB 也查不到、null 又不写缓存,于是每次都必然穿透直达 DB,被海量不存在 ID 攻击就把库打垮——这就是缓存穿透。本文讲透穿透与击穿、雪崩的区别,给出缓存空值(短过期)、布隆过滤器、参数校验+限流的正解,梳理缓存常见坑,最后落到'缓存是有边界的优化不是万能屏障、威胁来自没设防的异常路径、用防御性思维主动堵盲区'的认知。

一个只缓存"查得到的数据"的接口,被人用大量不存在的 ID 反复查询,缓存形同虚设、请求全砸到数据库上,差点把库打垮:一次缓存穿透的深度复盘

那次数据库告警来得很蹊跷:我们一个商品详情接口,前面挡着 Redis 缓存,平时数据库的查询压力很小。可某天下午,数据库 QPS 突然暴涨了几十倍,慢查询、连接堆积,差点被打垮。我看了一下流量,请求量确实涨了,但诡异的是,我们的缓存命中率几乎跌到了 0——明明有缓存,怎么请求全打到数据库上去了?我抓了一批请求的参数看,头皮发麻:这些请求查的商品 ID,全是一些根本不存在的、看起来像是随机生成的 ID(比如 id=-1id=99999999)。我顺着这条线索,才终于明白这是一次典型的"缓存穿透",后背发凉:我们的缓存逻辑是最常见的 cache-aside:先查 Redis,命中就返回;没命中就查数据库,然后把查到的结果写进 Redis。这套逻辑有一个致命的盲区:它只缓存"查得到的数据"。当请求查一个数据库里根本不存在的 ID 时,Redis 里必然没有(因为它不存在、从没被缓存过)、数据库里也查不到(返回 null)、于是这个 null 结果不会被写进缓存(我们只缓存非空结果)。结果就是:每一次查不存在的 ID,都会必然地穿透缓存、直达数据库。而对方(可能是恶意攻击、也可能是爬虫/异常调用)用海量的、不存在的 ID 反复请求,这些请求全部绕过了缓存、像潮水一样砸到数据库上——缓存形同虚设,数据库直接被打到崩溃边缘。这篇就把这次"缓存穿透"的坑,从头到尾复盘一遍。

故障现场:只缓存非空结果的 cache-aside

问题代码,是一个最经典、却留了穿透盲区的缓存逻辑:

// ✗ 出问题的代码: cache-aside, 但只缓存"查得到的"结果
public Product getProduct(Long id) {
    String key = "product:" + id;
    String cached = redis.get(key);
    if (cached != null) {
        return deserialize(cached);        // 缓存命中
    }
    Product p = db.findById(id);           // 缓存没命中, 查数据库
    if (p != null) {                       // ✗ 只有查到了, 才写缓存
        redis.set(key, serialize(p), 3600);
    }
    // ✗ 如果 p == null(ID不存在), 【什么都不缓存】→ 下次查这个ID, 又会穿透到DB!
    return p;
}

// 缓存穿透是怎么发生的:
// - 攻击者/爬虫 用大量【不存在的ID】(id=-1, id=随机大数...)反复请求;
// - 每个不存在的ID: Redis里没有(从没缓存过) → 查DB → DB也查不到(null) → 不写缓存;
// - → 每次请求都【必然穿透缓存、直达数据库】, 缓存完全挡不住;
// - → 海量这种请求, 全砸到DB上 → DB QPS暴涨、被打垮。

// 三个相关但不同的"缓存失效"问题, 别搞混:
// - 缓存穿透(本文): 查【根本不存在】的数据, 缓存永远挡不住, 每次穿到DB;
// - 缓存击穿: 某个【热点key过期】的瞬间, 大量并发同时穿到DB去重建;
// - 缓存雪崩: 【大量key同时过期】(或Redis宕机), 瞬间大量请求一起砸到DB。

// 关键: cache-aside只缓存非空结果, 留下了"查不存在的数据每次都穿透"的盲区;
//       被海量不存在的ID攻击时, 缓存形同虚设, DB被直接打垮——这就是缓存穿透。

第一次想通这个"穿透"时,我又懊恼又警醒:"我加了缓存,以为数据库就安全了,结果攻击者用不存在的 ID 就轻松绕过了我所有的缓存。"这个坑最阴险的地方在于:专门攻击缓存的盲区——正常请求(查存在的数据)都被缓存挡住了,可"查不存在的数据"这一类请求,缓存设计上就挡不住;而这个盲区极易被恶意利用:攻击者只要不停地拿不存在的 ID 来打,就能让你的缓存完全失效、把压力全导向数据库平时根本不会发生(正常用户不会去查一堆不存在的 ID),一旦被针对性利用就是致命的。下面就来拆解,缓存穿透为什么发生、该怎么防。

第一件事:搞懂缓存穿透,以及它和击穿、雪崩的区别

我认真梳理了缓存的几种失效问题,才彻底理解穿透的成因和防御。

缓存穿透 / 击穿 / 雪崩 的区别

【核心: 穿透=查"不存在的数据", 缓存永远挡不住; 击穿="热点key过期瞬间"并发穿透; 雪崩="大量key同时失效"】

1. 缓存穿透(cache penetration)——本文:
   - 查询的是【数据库里根本不存在】的数据;
   - 缓存里必然没有(它不存在、没被缓存)、DB也查不到、null又不缓存;
   - → 每次查这种数据都【必然穿透缓存、直达DB】, 缓存完全挡不住;
   - 危害: 被海量不存在的ID攻击, DB被打垮; 是一种"打穿缓存防线"的攻击/异常。

2. 缓存击穿(cache breakdown):
   - 某个【热点key】(被大量访问的)恰好【过期】的那一瞬间;
   - 大量并发请求同时发现缓存没了, 【同时】穿到DB去重建这一个key;
   - → 瞬间大量请求砸DB(就为重建一个key); 是"单个热点key过期"引发的并发问题。

3. 缓存雪崩(cache avalanche):
   - 【大量key在同一时刻集体过期】(如都设了相同的过期时间), 或Redis整个宕机;
   - → 瞬间大量请求一起穿到DB; 是"大面积缓存同时失效"引发的。

4. 三者的本质区别:
   - 穿透: 数据【不存在】, 缓存本就没法缓存它 → 防御要"缓存空值/拦截不存在的请求";
   - 击穿: 数据【存在但单个热点key过期】 → 防御要"重建时加锁/热点不过期";
   - 雪崩: 【大量key同时失效】 → 防御要"过期时间打散/缓存高可用/限流降级"。

类比: 缓存像一道挡在DB前的"门卫", 只放行/拦截它"认识的人(缓存过的数据)";
   - 穿透: 来的全是"门卫名册上根本没有的人(不存在的数据)", 门卫只能一个个放进去问DB;
   - 击穿: 一个"大名人(热点)"的通行证刚好到期, 一堆人同时涌进去找DB确认;
   - 雪崩: 一大批人的通行证同时到期, 全涌进去找DB。

一句话: 穿透是查不存在的数据、缓存永远挡不住(防御:缓存空值/布隆过滤器); 击穿是热点key过期瞬间
   并发穿透(防御:加锁重建/不过期); 雪崩是大量key同时失效(防御:过期打散/高可用/限流)。

这套区分,是整个坑的根。缓存穿透(本文):查的是数据库里根本不存在的数据,缓存必然没有、DB 也查不到、null 又不缓存,于是每次都必然穿透直达 DB、缓存完全挡不住,被海量不存在的 ID 攻击 DB 就垮。缓存击穿:某个热点 key 恰好过期的瞬间,大量并发同时穿到 DB 去重建这一个 key。缓存雪崩:大量 key 同一时刻集体过期(或 Redis 宕机),瞬间大量请求一起砸 DB。本质区别:穿透是数据"不存在"(防御:缓存空值/拦截)、击穿是"单个热点 key 过期"(防御:加锁重建/不过期)、雪崩是"大量 key 同时失效"(防御:过期打散/高可用/限流)就像缓存是 DB 前的门卫只认识缓存过的人:穿透来的全是名册上没有的人(每个都得问 DB)、击穿是名人的通行证刚到期(一堆人涌进去)、雪崩是一大批通行证同时到期一句话:穿透是查不存在的数据、缓存永远挡不住(防御:缓存空值/布隆过滤器);击穿是热点 key 过期瞬间并发穿透(防御:加锁重建/不过期);雪崩是大量 key 同时失效(防御:过期打散/高可用/限流)。

第二件事:正解——缓存空值、布隆过滤器拦截、参数校验

搞懂了原理,正解就清晰了:给"查不到"的结果也缓存一个空值(设短过期)、用布隆过滤器提前拦截不存在的 key、做好参数校验和限流;多管齐下挡住穿透

// ====== 正解一(最简单有效): 缓存空值 ======
public Product getProduct(Long id) {
    String key = "product:" + id;
    String cached = redis.get(key);
    if (cached != null) {
        // ★ 命中, 但要区分"真数据"和"空值标记"
        return "NULL".equals(cached) ? null : deserialize(cached);
    }
    Product p = db.findById(id);
    if (p != null) {
        redis.set(key, serialize(p), 3600);          // 真数据缓存1小时
    } else {
        // ★ 关键: 查不到, 也缓存一个"空值标记", 设【短】过期(如60秒)
        redis.set(key, "NULL", 60);
        // → 下次再查这个不存在的ID, 直接从缓存返回"NULL", 不再穿透到DB!
        //   设短过期: 万一这个ID之后真的有了数据, 60秒后空值过期会重新查到。
    }
    return p;
}
// → 缓存空值: 让"不存在的数据"也被缓存挡住; 用短过期避免缓存太多无用空值/数据滞后。
# ====== 正解二: 用布隆过滤器(Bloom Filter)提前拦截不存在的key ======
# - 布隆过滤器: 一个空间高效的概率数据结构, 能快速判断"一个元素【一定不存在】"或"【可能存在】";
# - 把所有【真实存在的ID】预先放进布隆过滤器;
# - 查询时先问布隆过滤器: 这个ID可能存在吗?
#   - 答"一定不存在" → 直接返回null, 【根本不查缓存和DB】→ 挡住穿透;
#   - 答"可能存在" → 再走正常的缓存/DB流程。
# - 特点: 有极小的"误判为存在"概率(但绝不会把"存在的"误判为"不存在"), 空间省、速度快。
# - 适合: ID集合相对稳定、量大的场景(如海量商品/用户)。

# ====== 正解三: 参数校验 + 限流 ======
# - 参数校验: 明显非法的ID(负数、超长、格式不对)直接拒绝, 不进入查询流程;
# - 限流: 对单IP/单用户的请求频率限流, 缓解恶意刷不存在ID的攻击;
# - 异常监控: 缓存命中率突然暴跌、查不到的比例突然飙升 → 告警(可能正被穿透攻击)。

# ====== 选型 ======
# - 通用、简单: 缓存空值(短过期)——大多数场景够用, 首选;
# - 不存在的ID种类极多(空值会缓存很多): 布隆过滤器;
# - 配合: 参数校验拦明显非法的 + 限流防恶意刷 + 监控命中率异常。

# 核心: 防缓存穿透——缓存空值(给查不到的也缓存空标记+短过期)、布隆过滤器拦不存在的key、
#   参数校验拒非法、限流防恶意、监控命中率; 让"不存在的数据"也能被挡在DB之外。

修复的核心,是"让'查不到'这件事本身也被缓存/拦截住,别每次都问 DB"正解一(最简单有效):缓存空值——查不到时也缓存一个"空值标记"(设过期如 60 秒),下次查这个不存在的 ID 直接从缓存返回、不再穿透;短过期是为了避免缓存太多无用空值、且万一之后真有数据能较快重查正解二:布隆过滤器——把所有真实存在的 ID 预放进去,查询先问它"可能存在吗",答"一定不存在"就直接返回、根本不查 DB(适合 ID 量大且相对稳定的场景)正解三:参数校验+限流+监控(拒明显非法 ID、对单 IP 限流防恶意刷、命中率暴跌告警)。归根结底:防缓存穿透——缓存空值(查不到的也缓存空标记+短过期)、布隆过滤器拦不存在的 key、参数校验拒非法、限流防恶意、监控命中率;让"不存在的数据"也能被挡在 DB 之外。

第三件事:缓存使用的其他常见坑

排查后我把缓存使用相关的其他常见坑也系统梳理了一遍。

缓存使用的其他常见坑

# 1. 缓存穿透(本文): 查不存在的数据全穿到DB。→ 缓存空值/布隆过滤器/校验限流。

# 2. 缓存击穿: 热点key过期瞬间并发穿透。→ 重建时加互斥锁/逻辑过期/热点key不过期。

# 3. 缓存雪崩: 大量key同时过期/Redis宕。→ 过期时间加随机打散/Redis高可用/限流降级。

# 4. 缓存与DB不一致: 更新DB和缓存的时序问题导致脏数据。→ cache-aside更新DB后删缓存+过期兜底。

# 5. 缓存没设过期: 缓存只增不过期, 内存涨满/数据长期陈旧。→ 都要设合理过期(同524的"只增"教训)。

# 6. 大key/热key: 单个超大value/单key被打爆。→ 拆分大key/热key多副本/本地缓存。

# 7. 缓存了不该缓存的: 缓存了强一致要求的数据(如余额), 导致读到旧值。→ 强一致数据慎缓存。

# 8. 序列化/反序列化问题: 缓存的对象结构变了, 反序列化失败。→ 注意缓存数据的版本兼容。

# 共同根源: 缓存是"用'数据相对稳定'换性能"的旁路; 它有它的边界(挡不住不存在的、会过期、会不一致),
#   把它当成"万能的、永远挡在DB前的铜墙铁壁", 忽视它的各种失效场景, 就会被穿透/击穿/雪崩。

# 核心: 理解缓存的各种失效场景(穿透/击穿/雪崩/不一致); 针对性防御(空值/锁/打散/删缓存);
#   缓存都要设过期、注意大热key和一致性; 把缓存当"有边界的优化"而非"绝对可靠的屏障"。

排查让我把缓存的其他坑也梳理清了。一、缓存穿透(本文)。二、缓存击穿(热点 key 过期,加锁重建)。三、缓存雪崩(过期打散/高可用)。四、缓存与 DB 不一致(更新 DB 后删缓存+过期兜底)。五、缓存没设过期六、大 key/热 key七、缓存了强一致数据八、序列化版本问题它们的共同根源是:缓存是"用'数据相对稳定'换性能"的旁路;它有边界(挡不住不存在的、会过期、会不一致),把它当"万能的铜墙铁壁"、忽视它的各种失效场景,就会被穿透/击穿/雪崩核心是:理解缓存的各种失效场景;针对性防御(空值/锁/打散/删缓存);缓存都要设过期、注意大热 key 和一致性;把缓存当"有边界的优化"而非"绝对可靠的屏障"下面这张图,是这次缓存穿透坑的成因与解法:

第四件事:缓存穿透/击穿/雪崩对比表

这次踩坑后,我把缓存的三种典型失效问题对比成一张表,彻底分清。

问题 触发原因 主要防御
缓存穿透(本文) 查根本不存在的数据 缓存空值 / 布隆过滤器
缓存击穿 单个热点key过期瞬间并发 重建加互斥锁 / 热点不过期 / 逻辑过期
缓存雪崩 大量key同时过期/Redis宕 过期时间打散 / Redis高可用 / 限流降级
缓存不一致 更新DB与缓存的时序 更新DB后删缓存 + 过期兜底

这张表把缓存的失效问题钉清了。核心是:缓存的几种失效问题名字像、易混淆,但成因和防御截然不同——穿透是"数据不存在"(缓存空值)、击穿是"单个热点过期"(加锁重建)、雪崩是"大量同时失效"(过期打散);搞混了就会"用错防御手段"(比如对穿透用加锁重建是没用的, 因为数据压根不存在)它给我的最大启发是:这几个问题虽然表现都是"请求穿到了 DB", 但"为什么穿过去"的根因不同, 对应的解法也就完全不同——正确解决问题的前提, 是精确地区分"看起来相似"的几种情况、找到各自真正的根因;"现象相似"不代表"根因相同", 不能用一套方案套所有看起来像的问题这其实是一种诊断的功力:面对一类"表象相似"的问题(都是 DB 压力大), 要能进一步细分、辨别出它具体是哪一种(是查不存在的?还是热点过期?还是大面积过期?), 因为不同的子类型, 对应不同的根因和药方;"精准诊断、对症下药", 而非"看到 DB 压力大就笼统地加缓存/扩容"——能区分相似问题的细微差别, 是从"会处理问题"到"精准解决问题"的进阶精确区分穿透/击穿/雪崩、对相似现象辨别根因对症下药——是这个坑带给我的诊断认知。

第五件事:缓存作为"旁路优化"的边界

这次让我重新认识了缓存的定位:它是优化,不是万能屏障。我把它能做和不能做的整理成表。

认识 说明
缓存是优化, 不是必需 它挂了, 系统应能退化到直接查DB(虽慢但能用)
缓存挡不住不存在的数据 穿透的根源(本文)
缓存会过期/失效 击穿、雪崩的根源
缓存可能与DB不一致 它是数据的副本, 有滞后
DB才是最终防线 缓存挡不住时, 压力都回到DB; DB自己也要扛得住/限流

这张表道出了缓存的真实定位。核心是:缓存本质是一个"旁路的、尽力而为的性能优化",而不是"绝对可靠、永远挡在 DB 前的屏障"——它会被穿透、会过期、会失效、会和 DB 不一致、甚至会整个挂掉;一旦缓存这道"软防线"失效,所有压力都会回落到 DB 这道"最终防线"它给我的深刻启发是:设计系统时,不能把希望全寄托在缓存上、假设它永远有效——要假设"缓存随时可能失效/被绕过",并保证缓存失效时,后面的 DB(及整个系统)依然扛得住、不会被打垮;"给最终防线(DB)也做好保护(限流、降级、连接池保护)",而不是"以为有了缓存 DB 就高枕无忧"这其实是一种纵深防御的架构思维:不要依赖单一的一道防线(缓存),而要层层设防、每一层都假设前一层可能失效——缓存挡一部分、布隆过滤器/限流挡一部分、DB 自身也要能限流降级保护自己;"不把鸡蛋放一个篮子、每层都为'前一层失效'做准备",才能让系统在某一层被突破时依然不崩——这和'不能只靠 catch 兜底、要从源头设防'是同一种纵深防御的智慧认清缓存是有边界的旁路优化而非万能屏障、给最终防线 DB 也做好保护——是这个穿透坑带给我的架构认知。

第六件事:给接口加缓存时,我现在的检查习惯

现在每当我给一个查询接口加缓存,我都会按这张图把"缓存防护"过一遍:

这张图的精髓,是"加缓存先想穿透/击穿/雪崩三种失效,并给 DB 兜底"对外/ID 可猜就防穿透(缓存空值+校验+布隆)、有热点就防击穿(加锁重建)、大量 key 会同时过期就防雪崩(过期打散),最后给 DB 限流降级兜底+监控命中率这套习惯,让我从"加个缓存就以为万事大吉"变成了"加缓存先想它的几种失效和 DB 兜底"——核心始终是:缓存是有边界的优化,加它要同时防穿透/击穿/雪崩,并保证缓存失效时 DB 扛得住。

我立下的几条规矩

这场"缓存穿透、DB 被打垮"的事故,换来了我做缓存时,刻进骨子里的几条铁律:

  1. cache-aside 只缓存非空结果,留了穿透盲区。查不存在的数据每次都穿到 DB。
  2. 防穿透:缓存空值(短过期)。查不到也缓存个空标记,挡住重复穿透。
  3. ID 量大用布隆过滤器。不存在的 ID 直接拦掉,根本不查 DB。
  4. 分清穿透/击穿/雪崩,对症下药。成因不同、防御手段完全不同。
  5. 缓存都要设合理过期,过期时间打散防雪崩。别大量 key 同时失效。
  6. 缓存是有边界的优化,不是万能屏障。它会被绕过、会失效、会不一致。
  7. 给 DB 这道最终防线也做保护。限流降级,假设缓存随时可能失效。

写在最后

回头看,这场由"缓存只缓存查得到的数据"引发的、被不存在的 ID 打穿的事故,真正教给我的,远不止"缓存空值、用布隆过滤器"这一个技巧。它让我对"设计防护时,我们总在防'正常的、预期内的'情况,而真正的威胁,往往来自'我们没设想过的、异常的'情况",有了一次刻骨的体会。我栽跟头,根源在于我设计缓存时,脑子里想的全是"正常的请求":用户来查存在的商品,缓存命中、皆大欢喜——我的缓存逻辑,是完全围绕"查得到数据"这个正常路径设计的,把它优化得很好。可我从没认真想过"如果有人专门来查查不到的数据呢?"——这个"异常的、甚至恶意的"路径,恰恰是我的缓存设计完全没有覆盖的盲区;而攻击者,正是精准地瞄准了这个我没设防的盲区下手。我把精力都花在了"优化正常情况"上,却在"防御异常情况"上门户大开这让我领悟到一个关于"防护设计"的深刻认知:一个系统的健壮性和安全性,往往不取决于它把"正常情况"处理得多好,而取决于它对"异常、边界、恶意"情况的防御有多周全——"正常路径"是我们自然会去设计和测试的(因为那是功能);而"异常路径"(空输入、超大输入、不存在的数据、恶意构造、并发、故障)是极易被忽视、却恰恰是威胁所在;"攻击者和故障, 从不走你铺好的正常路, 它们专找你没设防的角落"这给了我一种防御性设计的自觉:设计任何对外的、有压力的系统时,要主动地、刻意地去想"异常和恶意的情况"——"如果输入是空的/超大的/不存在的/非法的呢?如果有人故意反复打它呢?如果并发/故障呢?";"站在'攻击者/故障'的视角审视自己的系统、主动寻找并堵上盲区",而不是只把"正常情况"跑通就以为安全——这种"防御性、对抗性的思维",是构建健壮系统的关键一环认清威胁来自没设防的异常路径、用防御性对抗性思维主动堵盲区——这,是我用一次缓存穿透的事故,换来的、关于架构、也关于如何设计真正健壮系统的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次给接口加缓存时,多问一句"有人查不存在的数据怎么办",顺手加上缓存空值,那我对着那个被打穿的数据库排查的这段时间,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

一个直接把大模型回答当权威答案展示给用户的功能,因为模型一本正经地编造了一个不存在的政策条款,把用户彻底带偏:一次 LLM 幻觉的深度复盘

2026-6-2 18:09:28

技术教程

一个用 DateTime 在前后端和数据库之间传时间的系统,因为 DateTime 不带时区信息,把时间整整搞偏了 8 个小时:一次 C# 时区处理的深度复盘

2026-6-2 18:20:36

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索