想象你经营一家书店:门口是“热门展示台”——那是缓存;书店深处是“仓库”——那是数据库。顾客来问书,你先看展示台,没有再去仓库翻。正常情况下,展示台覆盖了 80% 的常见问题,仓库管理员很轻松。可一旦出现三种“坏客人”:有人专门问根本不存在的书(缓存穿透)、某本爆款书刚被撤下就涌来几百人要买(缓存击穿)、或者全店书籍约定同一分钟集体下架(缓存雪崩)——仓库管理员就会瞬间被汹涌的查书请求淹没,最终连正常业务都瘫痪。 好消息是,书店有一套完整的应对手册:门口放一本“总目录”(布隆过滤器),拦截不存在的书;柜台设“排队闸机”(互斥锁),避免爆款书空窗期被挤爆;书里夹“内容提示条”(逻辑过期),让读者永远拿得到书;下架时间加上“随机抖动”(随机过期),让集体失效变成错峰失效。这篇文章,就是带你把这套书店安保手册读透,再亲手把它写成代码。

岗位关联

缓存三连击是后端高并发面试的必考题,也是生产事故的高发区。掌握成因辨析与布隆过滤器、互斥锁、逻辑过期、随机过期等方案,是你从“会写 CRUD”迈向“能扛流量”的关键一步。

目录
  1. 可视化演示
  2. 穿透成因与危害
  3. 击穿成因与危害
  4. 雪崩成因与危害
  5. 布隆过滤器
  6. 互斥锁方案
  7. 逻辑过期方案
  8. 随机过期策略
  9. 防护体系落地
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

交互展示缓存穿透、缓存击穿和缓存雪崩三种高并发故障的成因与防护方案,理解缓存防护体系设计。

本节配有交互动效(进入视口后自动加载,约 3KB)。

缓存穿透:查询不存在的数据 请求绕过缓存直达数据库 布隆过滤器拦截无效查询 Bloom Filter 缓存空值防止重复穿透

穿透成因与危害

周六早晨,书店门口排着长队。一个顾客挤到前台,张口就问:“请问有《哈利波特前传:巨怪起源》吗?”店员小李查了展示台——没有;又跑到仓库翻了半天——还是没有。刚喘口气,第二个顾客又来问同一本书,小李只好再跑一趟仓库。如果这时候有个捣蛋鬼拿着脚本,每秒换一个根本不存在的书名来问,小李这一天就别干别的了,全在仓库里翻空气。

这个场景映射到系统里就是“缓存穿透”:客户端请求一个缓存和数据库里都不存在的 key。正常流程是“先查缓存,不中再查数据库”,可当 key 根本不存在时,缓存永远不可能命中,每一次请求都会穿透缓存层直达数据库。单个请求还好,一旦被恶意攻击者利用——比如用不存在的用户 id 批量刷接口——数据库的 QPS 就会被无效流量打满。

设系统入口总流量为 QPS_all,穿透发生时缓存命中率为 0,全部流量落到数据库:

DBQPS=QPSall\displaystyle \text{DB}_{\text{QPS}} = \text{QPS}_{\text{all}}

1. 入口总流量 QPS_all:系统每秒钟接收到的所有请求数量,是压力的源头。

QPSall\displaystyle \text{QPS}_{\text{all}}

2. 缓存命中率归零:因为 key 不存在,缓存永远无法命中,形同虚设。

HitRate=0\displaystyle \text{HitRate} = 0

3. 数据库负载公式:所有流量直接落到数据库,DB_QPS 等于入口 QPS。

DBQPS=QPSall\displaystyle \text{DB}_{\text{QPS}} = \text{QPS}_{\text{all}}

所以,对付这样的坏客人,书店光靠展示台是没用的——压根没有这书,怎么摆?必须在大门口就把他拦住:要么让他查“总目录”(布隆过滤器),要么在柜台登记“空查询记录”(空值缓存)。这些办法,我们在后面的章节一个个装上去。

击穿成因与危害

早上九点,书店刚开门,三十个读者同时冲到前台,点名要买《Redis 从入门到放弃》。这本书昨天明明摆在展示台最中央,可店员为了换新书,刚好在这一刻把它撤了下来——旧书还没入库,新书还没上架,展示台上空空如也。三十个人齐刷刷掉头冲向仓库,仓库管理员老赵被围得水泄不通。

这就是缓存击穿:一个真实存在、流量极高的热点 key,恰好在某一瞬间过期失效,导致大量并发请求同时发现缓存没有数据,全部回源数据库。它与穿透的关键区别在于:击穿的 key 是“本来就该有”的热门数据,只是短暂的“空窗期”撞上了高并发;而穿透的 key 是“压根不存在”的数据。

设热点 key 过期瞬间有 N_t 个并发请求同时压过来,叠加正常流量,数据库瞬时压力为:

DBQPS(t+)=Nt+QPSnormal\displaystyle \text{DB}_{\text{QPS}}(t^+) = N_t + \text{QPS}_{\text{normal}}

1. 失效时刻 t:热点 key 的 TTL 到期的那一瞬间,缓存中该 key 被删除。

t=TTLkey\displaystyle t = \text{TTL}_{\text{key}}

2. 并发请求数 N_t:恰好在 t 之后同时到达该 key 的请求数量,是尖峰的来源。

Nt=count(requests in window)\displaystyle N_t = \text{count}(\text{requests in window})

3. 数据库瞬时压力:所有并发请求同时落入数据库,QPS 出现脉冲式尖峰。

DBQPS(t+)=Nt+QPSnormal\displaystyle \text{DB}_{\text{QPS}}(t^+) = N_t + \text{QPS}_{\text{normal}}

还好老赵经验丰富——他喊了一声:“第一个人跟我进仓库找书,其他人在门口排队!”这就是互斥锁的思路,我们后面就学习这套“排队放行”的武功。

雪崩成因与危害

书店有个“整点换书”的规矩:每天早上十点,把展示台上所有的书一次性撤下,换上今日推荐。问题来了:十点一到,五十位读者同时发现自己想看的书不见了,呼啦啦全涌进仓库。老赵刚应付完一本畅销书,又要面对五十个人同时查书。仓库门口挤成了早高峰地铁,连进出的路都被堵死了,其他正常采购也全部瘫痪。

这就是缓存雪崩:大量 key 在同一时间窗口集体失效,比如缓存服务重启、或者所有 key 设置了相同的过期时间(比如统一 30 分钟),导致这些 key 的请求几乎同时回源数据库。和击穿不同,击穿是“一根针扎破气球”,雪崩是“整面墙塌下来”——数据库面对的是成倍放大的流量,往往瞬间被打垮,进而引发依赖它的所有服务级联宕机。

设同一时刻失效的 key 集合大小为 K,第 i 个 key 的访问流量为 Q_i,则数据库承受的总压力为:

DBQPS=∑i=1KQi\displaystyle \text{DB}_{\text{QPS}} = \sum_{i=1}^{K} Q_i

1. 失效 key 集合 K:在同一时间窗口内过期的 key 总数,数量越大雪崩越猛。

K=∣{key:TTLkey=t0}∣\displaystyle K = |\{\text{key} : \text{TTL}_{\text{key}} = t_0\}|

2. 单 key 流量 Q_i:第 i 个 key 在缓存失效后带来的回源请求量。

Qi=QPSquery(keyi)\displaystyle Q_i = \text{QPS}_{\text{query}}(key_i)

3. 总 DB 压力:所有失效 key 的流量叠加,构成数据库的瞬间总负载。

DBQPS=∑i=1KQi\displaystyle \text{DB}_{\text{QPS}} = \sum_{i=1}^{K} Q_i

所以聪明的店长取消了“整点换书”,改成每本书的下架时间都错开,还让每本书自带不同的“失效小纸条”。这就是随机过期和逻辑过期的由来——雪崩不是运气不好,而是设计上埋了雷。

布隆过滤器

为了治住那些问“不存在之书”的坏客人,店长在门口放了一本巨大的《本书店收录书目总册》。每一位客人来问书,先翻总册:册子里没有这本书,直接告诉客人“本店不收”,连仓库都不用去;册子里说有这本书,再去展示台和仓库找。这册子翻得飞快,因为它只有薄薄几页索引,不存书的内容——它只回答一个问题:“这本书存不存在?”

这个“总册”就是布隆过滤器(Bloom Filter):一个很节省内存的概率型数据结构,底层是一个位数组和 k 个哈希函数。它只回答“key 是否可能存在”,不存任何数据本身。如果布隆过滤器说“不存在”,那必然不存在;如果它说“可能存在”,则有一定误判概率——因为不同 key 的哈希位可能互相覆盖。

布隆过滤器的核心操作:插入时把 k 个哈希位置置 1,查询时看 k 个位置是否全为 1。误判率 P 与位数组长度 m、已插入元素个数 n、哈希函数个数 k 的关系如下:

P≈(1−e−nkm)k\displaystyle P \approx \left(1 - e^{-\frac{nk}{m}}\right)^k

1. 位数组长度 m:底层比特数组的总位数,决定过滤器能容纳多少“痕迹”。

m=bits count\displaystyle m = \text{bits count}

2. 插入元素数 n:已经加入过滤器的 key 个数,n 越大位数组中 1 越密集,碰撞越多。

n=inserted keys\displaystyle n = \text{inserted keys}

3. 哈希函数个数 k:每个 key 映射到 k 个位置,k 越大单个 key 占的位越多,但碰撞也越复杂。

k=hash functions\displaystyle k = \text{hash functions}

4. 误判率 P:位数组中 1 的密度与 k 共同决定“可能不存在却说存在”的概率。

P≈(1−e−nkm)k\displaystyle P \approx \left(1 - e^{-\frac{nk}{m}}\right)^k

有了这本总册,坏客人再来问《巨怪起源》,店员翻册子翻个一两秒就能回答“没有这本书”,连仓库的门都不用开。布隆过滤器不能告诉你书在哪儿,但能高效地告诉你“这里肯定没有”——这正是防穿透最需要的“第一道闸门”。

互斥锁方案

面对《Redis 从入门到放弃》被抢空的瞬间,老赵吼了一嗓子:“小李,你一个人进仓库找书!其余人,在柜台前排队等着!”小李翻了一分钟找到书,摆回展示台,排队的人一看展示台上有了,直接拿走。从头到尾,进仓库查书的只有小李一个人——这就是互斥锁的精髓:用一次回源,换来全场等待。

互斥锁方案是解决缓存击穿的经典手段。它的思想是:当热点 key 失效后,只允许第一个请求去数据库回源重建缓存,其他请求在短暂等待后重读缓存。在 Redis 中,最常用的实现是 SETNX(set if not exists):key 不存在时才能设置成功,返回值 1 表示“锁到手”。

设锁的 key 为 lock_key,抢锁与回源流程可以表达为:

acquired=SETNX(lockkey,1)\displaystyle \text{acquired} = \text{SETNX}(\text{lock}_{key}, 1)

1. SETNX 抢锁:第一个请求执行 SETNX 时锁 key 不存在,设置成功返回 1,获得回源资格。

acquired=SETNX(lockkey,1)\displaystyle \text{acquired} = \text{SETNX}(\text{lock}_{key}, 1)

2. 回源重建:拿到锁的请求去数据库查数据并写回缓存,再释放锁。

acquired=1⇒load from DB, write cache\displaystyle \text{acquired} = 1 \Rightarrow \text{load from DB, write cache}

3. 等待重试:未拿到锁的请求 sleep 一小段时间(如 50ms)后重试读缓存,此时缓存已被重建。

acquired=0⇒sleep + retry cache\displaystyle \text{acquired} = 0 \Rightarrow \text{sleep + retry cache}

补充一个关键细节:锁必须设置过期时间(如 500ms),否则拿到锁的请求中途崩溃,锁永远不释放,后续请求全部排队到死——这就是死锁。典型实现是 SET lock_key 1 EX 5 NX。

互斥锁就像老赵的“排队放行”:让一个跑腿的人进仓库,其他人在门口等结果,仓库门口永远不会被挤爆。但它也有短板——拿不到锁的请求要等,如果回源很慢,等待者会积压。于是店长又想出了更丝滑的办法:书先别撤,夹个纸条就行。

逻辑过期方案

老赵后来琢磨出一个妙招:《Redis 从入门到放弃》这本书干脆永远摆在展示台上,不撤了。但每本书里夹一张小纸条,写着“本册内容为 6 月版,可能不是最新”。读者来了,直接拿起书就走,高兴极了;营业员呢,则趁客流不大的时候去仓库把新版拿来,悄悄换掉旧书。读者永远买得到书,仓库也永远不挤。

这就是逻辑过期方案:Redis 中不设置物理 TTL,key 永久存在;但 value 中封装一个逻辑过期时间 expireAt。请求读到数据时,如果发现“当前时间 > expireAt”,并不立即丢弃数据,而是先返回旧数据给调用方(保证可用性),同时启动一个异步任务去数据库刷新缓存。由于 key 永不过期,永远不会出现“缓存中查不到”进而集体回源的场面。

value 的结构与过期判定如下:

value=(data,expireAt),TTLredis=∞\displaystyle value = (\text{data}, \text{expireAt}), \quad \text{TTL}_{\text{redis}} = \infty

1. 数据与过期时间封装:每个 value 不再是最朴素的业务数据,而是“数据 + 过期时刻”的结构体。

value=(data,expireAt)\displaystyle value = (\text{data}, \text{expireAt})

2. Redis 不设 TTL:key 永不过期,缓存层面永远“查得到”,物理上杜绝了空窗期。

TTLredis=∞\displaystyle \text{TTL}_{\text{redis}} = \infty

3. 逻辑过期判定:读取时比较当前时间与 expireAt,发现过期则异步刷新。

now>expireAt⇒async refresh\displaystyle \text{now} > \text{expireAt} \Rightarrow \text{async refresh}

这里要注意两个坑:一是异步刷新必须加互斥锁(可以复用 SETNX),防止几千个请求同时触发刷新;二是这个方案会有一小段“旧数据”窗口,属于最终一致性,对一致性要求极高的场景要慎用。

逻辑过期把“瞬间的集体恐慌”变成了“无声的暗中替换”——读者体验始终在线,数据库压力也被平摊到空闲时刻。不过,这个方案主要针对“单个热点 key”;如果整店的书都在同一分钟过期,再多的纸条也救不了。这时候就需要“错峰下架”的随机过期了。

随机过期策略

店长终于明白了“整点换书”的荒谬,于是定下新规矩:每本书的下架时间各不相同,而且在下架前五分钟,再拿个骰子随机加减几分钟。书 A 九点三十二分下架,书 B 九点四十七分下架,书 C 十点零九分下架……再也没有一分钟内几十本书同时消失的恐怖时刻,仓库门口恢复了往日的秩序。

随机过期策略是应对缓存雪崩最直接的手段,也是线上最常用的一条基线。做法很简单:给缓存 key 的 TTL 添加一个随机扰动,让原本“整齐划一”的过期时间变得错落有致。比如原本所有 key 都是 30 分钟过期,现在改成“30 分钟 + 0~300 秒随机数”,每个 key 的失效时刻就散开了,数据库承受的压力也从“一堵墙”变成了“一阵小雨”。

设基础过期时间为 TTL_base,随机扰动为 rand(0, Δ),则最终写入 Redis 的 TTL 为:

TTL′=TTLbase+rand(0,Δ)\displaystyle \text{TTL}' = \text{TTL}_{\text{base}} + \text{rand}(0, \Delta)

1. 基础过期时间 TTL_base:业务约定的统一缓存时长,例如 1800 秒。

TTLbase=1800\displaystyle \text{TTL}_{\text{base}} = 1800

2. 随机扰动 rand(0, Δ):在 0 到 Δ 秒之间均匀取一个随机数,Δ 一般取 TTL 的 10%~20%。

Δ=300 s\displaystyle \Delta = 300 \text{ s}

3. 最终 TTL':每个 key 的过期时间各不相同,形成错峰失效的分布。

TTL′=TTLbase+rand(0,Δ)\displaystyle \text{TTL}' = \text{TTL}_{\text{base}} + \text{rand}(0, \Delta)

对于“Redis 宕机”导致的雪崩,随机过期无能为力,需要借助多级缓存:本地进程内缓存(如 Caffeine)优先于 Redis 返回数据,即使 Redis 挂了,还有本地缓存顶住第一波,给 Redis 恢复争取时间。

随机过期就像给每本书的“下架闹钟”加了抖动,几十个闹钟再也不在同一秒响起。它是防雪崩的第一道防线,简单、高效、零依赖——如果你的系统还没有做任何缓存防护,请从这一条开始。

防护体系落地

书店经历了三连击之后,店长终于拍板:把所有的安保手段整合成一套《书店安全手册》。门口放总目录(布隆过滤器),拦住问不存在之书的坏客人;柜台设排队闸机(互斥锁),避免热门书下架时仓库被挤爆;畅销书夹内容纸条(逻辑过期),读者永远买得到书;下架时间全部错峰(随机过期),杜绝集体涌入。此外,柜台还装了限流闸机,客人太多时先在外面排队,不能让仓库管理员累倒。

一个高可用的缓存防护体系,从来不是靠单一方案,而是“分层设防、逐级兜底”。请求进系统后依次穿过布隆过滤器、本地缓存、Redis 缓存、数据库,每一层都拦截掉一部分流量,真正落到数据库的只是极少数的“漏网之鱼”。同时,限流降级熔断作为“最后的安全带”:一旦数据库压力仍然过大,立刻丢弃部分非核心请求,保证核心链路不瘫痪。

完整的请求链路与落到数据库的流量估算为:

DBQPS≈QPSall×(1−Pbloom)×Pmiss×Pbreak\displaystyle \text{DB}_{\text{QPS}} \approx \text{QPS}_{\text{all}} \times (1 - P_{\text{bloom}}) \times P_{\text{miss}} \times P_{\text{break}}

1. 布隆拦截率 P_bloom:布隆过滤器挡掉非法 key 的比例,通常能拦下 99% 以上的穿透攻击。

Pbloom≈0.99\displaystyle P_{\text{bloom}} \approx 0.99

2. 缓存未命中率 P_miss:经本地缓存与 Redis 之后仍未命中的请求比例,正常情况在 10% 以下。

Pmiss≈0.1\displaystyle P_{\text{miss}} \approx 0.1

3. 降级放行率 P_break:限流熔断后允许进入数据库的请求比例,极端情况下可以拉到很低。

Pbreak∈[0,1]\displaystyle P_{\text{break}} \in [0, 1]

4. 最终 DB 负载:各层拦截逐级相乘,数据库承受的流量被压缩到总量的极小比例。

DBQPS≈QPSall×(1−Pbloom)×Pmiss×Pbreak\displaystyle \text{DB}_{\text{QPS}} \approx \text{QPS}_{\text{all}} \times (1 - P_{\text{bloom}}) \times P_{\text{miss}} \times P_{\text{break}}

落地的实操建议:先给所有 key 的 TTL 加随机数(成本最低),再为空值缓存加一层短 TTL 兜底;热点 key 用互斥锁或逻辑过期;在入口层引入布隆过滤器拦截非法请求;最后上线限流降级熔断,并定期压测验证。

故事的最后,书店再也没发生过“仓库被挤爆”的事故。坏客人被总目录挡在门外,热门书有排队闸机和内容纸条,全店书籍错峰下架,限流闸机又兜住了最后的风险。你看到的每一道防线,落地到代码里就是布隆过滤器、SETNX、逻辑过期和随机 TTL——这套组合拳,就是后端工程师给数据库穿上的“防弹衣”。

AI 应用场景

场景一:AI 推荐服务遭遇恶意爬虫

阶段:数据读取层 / 在线推理前置缓存

解决的问题:推荐系统对外暴露的“猜你喜欢”接口,每天被爬虫用不存在的用户 id 或 item id 狂刷。传统做法是每次请求都查数据库确认,数据库 QPS 被无效流量打满,真实用户反而得不到响应。引入布隆过滤器后,过滤器把不存在的 id 全部拦截在缓存层之前,数据库只服务真实用户。

具体案例:某资讯推荐 App 的推荐接口被批量脚本刷“收藏状态”,导致 MySQL 慢查询飙升。开发在 Redis 前加了一层布隆过滤器,非法 id 的查询直接返回空,数据库 QPS 从 6 万降到 4000,接口 TP99 从 800ms 降到 120ms。

场景二:大模型推理网关的热点缓存击穿

阶段:推理服务前置缓存(KV Cache / Token Embedding 缓存)

解决的问题:大模型推理网关对 prompt 做 embedding 缓存,当某个热门 prompt 模板的缓存恰好过期,瞬间涌来的请求全部回源到 embedding 服务,导致推理队列雪崩。用互斥锁保证只有一个请求回源重建 embedding 缓存,其余请求短暂等待后直接命中新缓存;对一致性要求不那么高的数据,也可以用逻辑过期“先返回旧值、异步刷新”。

具体案例:某 LLM 应用平台的 prompt 前缀缓存 key 平均 5 分钟过期一次,过期瞬间网关超时率飙升到 30%。改用互斥锁 + 逻辑过期后,超时率下降到 0.2%,缓存命中率稳定在 85% 以上。

场景三:大促活动期间的全链路缓存雪崩防护

阶段:高并发流量治理 / 多级缓存架构

解决的问题:618 大促零点,运营统一配置的营销配置缓存同时过期,所有促销信息请求瞬间打向数据库。通过随机过期错峰 + 本地缓存兜底 + 限流降级,把数据库从“生死边缘”拉了回来。

具体案例:某电商平台对全部营销 key 增加 0~600 秒随机抖动,并在服务本地引入 Caffeine 缓存,Redis 因网络抖动短暂不可用时,本地缓存仍能扛住 5 分钟流量,数据库零故障。

Python 演示

python67 行
import randomimport threadingimport timefrom concurrent.futures import ThreadPoolExecutor  class BloomFilter:    def __init__(self, size=1024, k=3):        self.bits = [0] * size        self.size = size        self.k = k     def _hash(self, s, seed):        h = 0        for c in str(s):            h = (h * 31 + ord(c) + seed * 131) % self.size        return h     def add(self, key):        for i in range(self.k):            self.bits[self._hash(key, i)] = 1     def might_contain(self, key):        return all(self.bits[self._hash(key, i)] for i in range(self.k))  class GuardedCache:    def __init__(self):        self.cache = {}        self.bloom = BloomFilter()        self.db_count = 0        self.mutex = threading.Lock()        for k in ["hot", "normal"]:            self.bloom.add(k)     def get(self, key):        if key in self.cache:            return self.cache[key]        if not self.bloom.might_contain(key):            return None        with self.mutex:            if key in self.cache:                return self.cache[key]            self.db_count += 1            time.sleep(0.01)            value = "value:" + key            self.cache[key] = value            return value  def worker(cs, key):    return cs.get(key)  def main():    cs = GuardedCache()    with ThreadPoolExecutor(max_workers=20) as pool:        futures = [pool.submit(worker, cs, "hot") for _ in range(100)]        for f in futures:            f.result()    print("100 concurrent requests, DB queries:", cs.db_count)    print("Ghost query result:", cs.get("ghost"))    print("DB queries after ghost:", cs.db_count)  if __name__ == "__main__":    main()
目的:用 100 个并发线程同时请求热点 key “hot“,演示互斥锁如何让数据库只被查询一次;再用不存在的 ”ghost” key 演示布隆过滤器拦截穿透。
  1. for k in ["hot", "normal"]: self.bloom.add(k)预热布隆过滤器,让合法 key 能通过“总目录”检查。
  2. if not self.bloom.might_contain(key): return None布隆说“不存在”就直接返回,连数据库的门都不敲。
  3. with self.mutex:互斥锁,保证同时只有一个线程能回源数据库,其他人排队等结果。
  4. if key in self.cache: return self.cache[key]双检:拿到锁后发现缓存已被别人重建,直接返回,不再重复查库。
  5. self.db_count += 1记录真实打到数据库的次数,运行结果会告诉你它只有 1。

常见误区

误区缓存穿透和缓存击穿不都是“缓存没数据”吗?感觉是一回事,不需要区分。

正解两者的病灶完全不同。穿透是“数据根本不存在”,缓存永远不可能命中,属于“白跑的查询”;击穿是“数据存在但刚好过期”,属于“空窗期撞上高并发”。应对方向也因此不同:穿透靠“前置拦截”(布隆过滤器、空值缓存),击穿靠“回源保护”(互斥锁、逻辑过期)。分不清就容易用错方案:用互斥锁去防穿透,拦不住层出不穷的不存在 key;用布隆过滤器去防击穿,挡不住合法热点 key 的集体回源。

为何常见:两者表象都是“缓存 miss 后打到 DB”,初学者看到同一个现象,就容易忽略背后的成因差异。

误区布隆过滤器能判断 key 是否存在,那我把所有数据都放进去,不就不用缓存和数据库了?

正解布隆过滤器只回答“可能存在 / 必不存在”,不存储数据本身。它能拦截不存在的 key,但对“存在的 key”不提供任何值,请求命中后仍然需要去缓存或数据库取数据。而且它有误判率:可能把不存在的 key 判成存在(多查一次 DB 而已),但绝不会把存在的 key 判成不存在(不会漏判)。它的定位是“准入闸门”,不是“数据仓库”。

为何常见:布隆过滤器“查存在性”的能力看起来很像缓存,容易让人误以为它可以当缓存用,其实它连数据都不存。

误区我给热点 key 加了 SETNX 互斥锁,击穿肯定就不会发生了。

正解互斥锁能防击穿,但至少有三个坑:第一,锁必须带过期时间,否则持锁线程崩溃会死锁;第二,拿不到锁的请求要“短暂等待 + 重试”,如果回源很慢,等待请求会积压成新的流量高峰;第三,锁的粒度要 key 级,不能全局一把锁,否则一个冷 key 回源会拖慢所有热点。此外,互斥锁只防“单 key 击穿”,对“大量 key 雪崩”无能为力。

为何常见:“加了锁就安全”是直觉反应,忽略了分布式锁的边界条件(锁超时、重试风暴、粒度)才是真正的考验。

误区既然要错峰,那我给每个 key 随便写个不同的过期时间,比如 1 分钟、5 分钟、10 分钟,这不就是随机过期吗?

正解随机过期的正确姿势是在一个统一基础 TTL 上加小范围随机扰动,如 1800 + rand(0, 300)。这样既保持缓存整体时效可控(大部分数据在 30 分钟左右淘汰),又能让失效时刻错开。如果各 key 的 TTL 差异过大,会出现“有的数据太旧、有的数据频繁失效”的双重问题,反而影响缓存命中率。

为何常见:字面上理解“随机”二字,忽略了工程上“基础值 + 抖动”才是既安全又高效的折中。

误区雪崩不就是 Redis 里的 key 全部失效吗?只要 Redis 不挂,就不会雪崩。

正解雪崩的本质是“大面积 key 同时失效导致数据库过载”,Redis 宕机只是触发方式之一。更常见的触发是“缓存预热时所有 key 设置了相同的过期时间”——Redis 好好的,数据库照样被打垮。而且数据库被打垮后,依赖它的其他服务会连锁超时、堆积,最终引发整个调用链的级联故障。所以防雪崩要同时在“过期错峰、本地缓存、限流熔断”三层布局,而不是只盯 Redis。

为何常见:名字里带“缓存”,容易让人把问题归因于缓存组件,忽略了“流量瞬间转移到下游”才是灾难的根源。

面试题

Q1. 缓存穿透的本质是什么?

单选题 · easy

  • A.热点 key 过期瞬间大量请求压垮数据库
  • B.查询缓存和数据库都不存在的 key← 正确答案
  • C.大量 key 同时过期导致数据库压力剧增
  • D.Redis 实例宕机导致全部请求落入数据库

答案:B。缓存穿透是请求一个缓存和数据库中都不存在的 key,每次请求都穿透缓存直达数据库。选项 A 描述的是缓存击穿,C 描述的是缓存雪崩,D 是 Redis 宕机触发的雪崩场景,三者都不是穿透的本质。

Q2. 布隆过滤器判断 key 不存在的置信度如何?

单选题 · mid

  • A.100% 确定不存在,绝不会出现漏判
  • B.100% 确定存在,绝不会出现误判
  • C.返回不存在则必不存在,返回存在则可能误判← 正确答案
  • D.判断结果与哈希函数个数完全无关

答案:C。布隆过滤器的核心特性是:若返回“不存在”,则该 key 必然不存在;若返回“存在”,则只是“可能存在”,有一定误判概率。选项 A 混淆了“误判”与“漏判”的概念,B 把特性说反了,D 忽略了哈希函数个数是影响误判率的关键参数。

Q3. 缓存穿透与击穿的本质区别是什么?

单选题 · mid

  • A.穿透是合法 key 过期,击穿是非法 key 攻击
  • B.穿透查询的数据不存在,击穿是热点 key 失效瞬间← 正确答案
  • C.穿透只发生在读请求,击穿只发生在写请求
  • D.穿透攻击数据库,击穿攻击 Redis 服务本身

答案:B。穿透是查询“根本不存在”的数据,缓存永远无法命中;击穿是“存在但刚过期”的热点 key 在空窗期遭遇高并发。选项 A 把两者的成因说反了,C 与读写方向无关,D 错误——两者都是流量落到数据库,而不是攻击 Redis 本身。

Q4. 布隆过滤器使用 k 个哈希函数的主要作用是什么?

单选题 · mid

  • A.将 key 映射到位数组 k 个位置以降低碰撞概率← 正确答案
  • B.为每个 key 存储 k 份数据副本以便容灾
  • C.加速哈希运算从而减少查询延迟
  • D.将过滤器的误判率精确降为零

答案:A。k 个哈希函数把 key 映射到位数组中的 k 个位置,多个位置共同标记,能显著降低不同 key 的碰撞概率。B 是数据存储的职责,布隆过滤器不存数据;C 哈希函数越多运算反而越慢;D 误判率可以趋近于零但无法精确为零。

Q5. SETNX 互斥锁必须设置过期时间的原因是什么?

单选题 · easy

  • A.防止多个线程同时获取锁而引发死锁
  • B.防止持锁请求崩溃后锁永远无法释放← 正确答案
  • C.加快 Redis 内存回收并减少空间占用
  • D.保证所有线程都能轮流获取到锁

答案:B。若不设置过期时间,持锁线程在回源数据库时崩溃,lock key 将永远存在,其他请求永远拿不到锁,系统陷入“饿死”状态。A 中互斥锁本身就是防止并发,不设置过期不会引发“同时拿锁”;C 与内存回收无关;D 不是过期时间的作用。

Q6. 逻辑过期方案中,发现 value 逻辑过期后首先应该做什么?

单选题 · hard

  • A.立即丢弃旧数据并同步等待数据库返回
  • B.先返回旧数据保证可用,再异步重建缓存← 正确答案
  • C.直接删除缓存并让下一次请求回源
  • D.将请求重定向到备用数据库实例

答案:B。逻辑过期的核心思想是“先服务再刷新”:发现过期后先把旧数据返回给调用方保证可用性,同时异步去数据库刷新缓存。选项 A 违背了逻辑过期消除等待的初衷,C 会让下一次请求重新经历击穿,D 属于灾难恢复方案而非逻辑过期。

Q7. 给缓存 key 设置随机过期时间的直接目的是什么?

单选题 · easy

  • A.减少缓存占用内存的空间总量
  • B.避免大量 key 同时失效冲击数据库← 正确答案
  • C.提高热点 key 的缓存命中率
  • D.让 Redis 内存淘汰策略更加公平

答案:B。随机扰动让 key 的失效时刻错峰分布,从根源上避免“集体过期”引发的雪崩。A 中随机数与内存占用无关;C 命中率主要取决于容量和访问模式;D 与 Redis 的 LRU/LFU 淘汰策略没有任何关系。

Q8. 哪些情况容易引发缓存雪崩?(多选)

多选题 · mid

  • A.大量 key 设置了相同的过期时间← 正确答案
  • B.Redis 集群在高峰期突发宕机← 正确答案
  • C.数据库连接池参数配置过小
  • D.缓存预热时未加随机化延迟← 正确答案

答案:ABD。雪崩的本质是大面积 key 同时失效导致数据库过载。A 让大量 key 在同一秒过期,D 让预热的 key 在启动后同一时刻集体失效,都是典型诱因;B 是 Redis 宕机导致的流量瞬间转移,也属于雪崩。C 是数据库自身的参数问题,不会“引发”雪崩,只会让雪崩更严重。

Q9. 下列属于缓存穿透防护手段的有?(多选)

多选题 · mid

  • A.对空结果也缓存并设置较短过期时间← 正确答案
  • B.用布隆过滤器拦截不存在的非法 key← 正确答案
  • C.给热点 key 加分布式互斥锁
  • D.为所有 key 增加随机过期时间

答案:AB。空值缓存把“查不到”的结果也缓存起来,布隆过滤器在入口拦截不存在的 key,两者都是为“不存在的数据”兜底,属于穿透防护。C 互斥锁是防击穿的手段,D 随机过期是防雪崩的手段,它们解决的不是同一类问题。

Q10. 关于布隆过滤器误判率的说法正确的是?(多选)

多选题 · hard

  • A.位数组长度增大时误判率逐步降低← 正确答案
  • B.插入元素数量增多时误判率逐步上升← 正确答案
  • C.哈希函数数量越多误判率必然越低
  • D.误判率可以估算但理论上无法降为零← 正确答案

答案:ABD。由误判率公式 P ≈ (1 - e^(-nk/m))^k 可知:m 增大时误判率降低,n 增大时误判率升高,因此 A、B 正确。C 错误——k 存在最优值,超过最优值后位数组快速变满,误判率反而上升。D 正确,哈希碰撞无法完全消除,误判率只能趋近于零。