面试考察点
- 能否准确区分不存在数据、热点失效和大面积失效。
- 是否会组合布隆过滤器、互斥重建与随机过期。
- 是否考虑降级、限流和数据库保护。
核心答案
穿透是查询本就不存在的数据,缓存和数据库都被反复访问;击穿是单个热点 Key 失效后大量请求同时回源;雪崩是大量 Key 同时失效或缓存整体不可用,引发回源洪峰。三者流量形态不同,治理手段也不同。
穿透可做参数校验、缓存空值或布隆过滤;击穿可用互斥重建、逻辑过期和热点预热;雪崩要让过期时间离散化,并配合高可用、限流、熔断和降级。
方案细节
空值缓存必须设置短 TTL,避免无效数据长期占用。布隆过滤器可能误判存在但不会误判不存在,且新增数据要同步更新。互斥重建的锁要有超时、唯一标识和失败兜底。
三类问题的流量图景
| 问题 | 触发条件 | 回源范围 | 主要风险 | 优先手段 |
|---|---|---|---|---|
| 穿透 | 恶意或错误查询不存在 ID | 同一类无效请求 | 数据库被无效查询打穿 | 参数校验、布隆过滤、空值缓存 |
| 击穿 | 单个热点 Key 刚过期 | 一个热点数据 | 大量并发同时重建 | 请求合并、互斥、逻辑过期 |
| 雪崩 | 大量 Key 同时失效或 Redis 故障 | 大范围数据 | 数据库与应用整体过载 | TTL 打散、高可用、限流降级 |
先用监控确认是哪一种:若大量请求集中在不存在的 ID,是穿透;只有某热门商品在 TTL 到点后抖动,是击穿;全站命中率同时下降、回源与连接池激增,才接近雪崩。错误分类会导致错误治理,例如给穿透加分布式锁只会制造更多锁竞争。
穿透的分层防线
边缘参数校验 -> 业务鉴权/限速 -> 布隆过滤器 -> 空值缓存 -> 数据库
布隆过滤器保存“可能存在”的集合。查询结果为不存在时可以直接拒绝,结果为存在仍需查缓存和数据库,因为会有假阳性。过滤器容量、hash 数量和误判率需要基于预计元素量计算;新增、删除和重建时的更新策略必须明确,不能把它当永远正确的数据库镜像。
空值缓存适合数据确实不存在或短时间不会创建的场景:
User user = repository.find(id);
redis.setex(key(id), user == null ? NULL_MARKER : toJson(user),
user == null ? 60 : 3600 + randomJitter());
若“查询不存在后马上创建”是常见业务,空值 TTL 太长会造成读到假不存在,需要在创建成功时主动删除或更新缓存。
击穿的三种方案
互斥重建让第一个请求获取短锁回源,其他请求等待、短暂重试或返回降级;优点是数据库压力低,缺点是高峰请求会增加等待。请求合并可以在单实例把同 Key 的并发请求汇聚为一个 Future,减少锁和 Redis 往返,但跨实例仍需协作。
逻辑过期把“物理 TTL”与“业务新鲜度”分开:缓存一直保留,逻辑过期后一个后台任务刷新,其他请求暂时读旧值。它适合允许短暂陈旧的商品详情、配置等场景,但必须设置最大陈旧期限、刷新超时、失败告警和最终兜底,不能让旧数据无限存在。
雪崩下的容量保护
随机 TTL 只能分散正常到期,无法处理 Redis 集群整体故障或代码发布导致的批量删 Key。系统还需要:
- Redis 主从/Cluster 与跨故障域部署,明确 RTO/RPO。
- 本地只读缓存或静态降级页,减少完全失效时的回源。
- 网关与服务层限流,按数据库可承受 QPS 而不是入口 QPS 设置阈值。
- 数据库连接池有界、查询超时和熔断,避免请求无限堆积。
- 预热关键数据,并在发布、扩容、故障切换时演练命中率下降。
互斥重建的安全边界
锁值必须唯一,释放时通过 Lua 比较值后删除,TTL 要覆盖正常重建时间但不能无限长。若重建任务超时,后续请求可能接管;写入缓存时需用版本或时间判断,避免旧任务晚完成覆盖新数据。热点刷新失败时要区分“可读旧值”和“必须失败”,由业务新鲜度决定。
防护层次
应用本地缓存可降低 Redis 故障冲击,但要接受短暂陈旧。数据库连接池、请求队列和限流阈值应按数据库可承受能力设置,而不是按入口峰值设置。
常见误区
三类问题不能只靠“加分布式锁”统一解决。锁本身也可能成为热点;缓存高可用只能降低整体宕机概率,不能解决同一时刻大量业务 Key 到期。
高频追问与参考回答
追问:逻辑过期如何避免返回永久旧数据?
缓存值携带逻辑过期时间,过期后一个请求异步重建,其他请求暂时读旧值;同时要有最大陈旧时间、重建失败告警和主动兜底。
追问:布隆过滤器会不会漏掉真实数据?
正确构建时不会产生假阴性,但数据新增未同步、过滤器损坏或多版本切换错误会导致实际漏判,因此更新链路和重建策略同样重要。
追问:热点 Key 到期时为什么不让所有请求直接查库?
瞬时并发会把一个本来很轻的单行查询放大到数千次,挤占连接池和 CPU,还可能级联触发更多超时重试。应该合并重建工作而不是放大它。
追问:缓存雪崩时应该先扩数据库吗?
扩容可能帮助,但若回源请求无上限,新容量也会被打满。应先限流、降级、保护连接池并恢复缓存命中,再根据容量曲线扩展资源。
总结
先根据流量特征分类,再从命中防护、重建并发、过期离散和下游限流组成多层方案。
机制全景图
下面把「Redis 缓存穿透、击穿和雪崩怎么解决?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。
flowchart LR
A["请求查询缓存"]
A --> B["未命中或值过期"]
B --> C["控制回源并发"]
C --> D["数据库加载并回填"]
D --> E["设置随机 TTL 与降级"]
完整链路:从输入到结果
沿着「请求查询缓存 → 未命中或值过期 → 控制回源并发 → 数据库加载并回填 → 设置随机 TTL 与降级」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。
1. 请求查询缓存
穿透是查询不存在数据,击穿是热点瞬间失效,雪崩是大量 Key 或实例同时失效,三者治理目标不同。
2. 未命中或值过期
缓存查询要区分空值、逻辑过期和真实错误,不能把 Redis 超时当成缓存未命中直接放大回源。
3. 控制回源并发
互斥重建、singleflight 或限流把同一热点的回源合并,等待者可读旧值或快速失败。
4. 数据库加载并回填
数据库结果回填前要检查版本或使用一致性策略,避免慢旧请求覆盖新值。
5. 设置随机 TTL 与降级
TTL 加抖动、实例隔离和多级缓存降低相关性,熔断则保护数据库在缓存故障时不被打穿。
源码与实现定位
| 入口 | 阅读重点 |
|---|---|
| redis.call + Lua | 热点重建锁原子操作 |
| INFO keyspace/commandstats | 命中与回源证据 |
源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。
参数配置与可复现实验
if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', 3000) then
return 1
end
return 0
同时让 1000 请求命中不存在 Key、单热点过期和全量过期三种场景,分别量化数据库放大。
验证步骤与预期结果
1. 固定输入和基线
先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「回源放大」为主基线,记录值应满足「热点≈1个重建者」;同时保存 命中率按 Key 分布、回源并发,使后续变化能够回到同一时间轴比较。
2. 从实现入口确认路径
在「redis.call + Lua」确认请求确实进入「热点重建锁原子操作」对应的实现,再沿「INFO keyspace/commandstats」观察「命中与回源证据」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。
3. 注入本文特有的失败模式
优先复现「缓存超时全部直打数据库」,并把单一变量逐级放大,直到「回源放大」越过「接近请求数」。随后再分别验证「互斥锁无超时导致热点永久阻塞」和「慢重建把旧数据覆盖新值」,三类故障分开执行,避免多个变量同时变化而无法归因。
4. 执行止损和根因修复
第一轮只应用「热点 singleflight/互斥重建」,确认它能控制影响范围;第二轮应用「空值/布隆过滤器」,验证核心链路恢复;最后落实「缓存故障时限回源并降级」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。
5. 通过退出条件
实验只有同时满足三项才算通过:「回源放大」回到「热点≈1个重建者」、「空值命中」回到「攻击 Key 被拦截」、「TTL 分布」回到「均匀」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。
量化基线
| 指标 | 样例基线/口径 | 风险线 | 结论 |
|---|---|---|---|
| 回源放大 | 热点≈1个重建者 | 接近请求数 | 击穿 |
| 空值命中 | 攻击 Key 被拦截 | DB 仍高 | 穿透 |
| TTL 分布 | 均匀 | 同秒集中 | 雪崩 |
这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。
事故复盘:Redis 抖动一分钟拖垮数据库
客户端超时后所有请求直接查询数据库,连接池迅速满,缓存恢复后应用仍在排队。加入回源并发闸门、热点本地短缓存和降级数据后,缓存故障不再转化为数据库全量压力。
| 失败模式 | 首要证据 | 第一处置动作 |
|---|---|---|
| 缓存超时全部直打数据库 | 命中率按 Key 分布 | 热点 singleflight/互斥重建 |
| 互斥锁无超时导致热点永久阻塞 | 回源并发 | 空值/布隆过滤器 |
| 慢重建把旧数据覆盖新值 | 热点重建耗时 | 缓存故障时限回源并降级 |
发布与回滚检查点
- 发布前:确认「redis.call + Lua」对应实现和上述配置在目标版本仍然有效,并保存「回源放大」基线。
- 灰度中:同时观察 命中率按 Key 分布、回源并发、热点重建耗时;任一指标越过表中风险线,就停止继续扩量。
- 回滚时:先执行「热点 singleflight/互斥重建」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
- 发布后:至少覆盖一个完整峰值周期,确认「缓存超时全部直打数据库」没有再次出现,才关闭变更观察窗口。
方案对比与选型
| 方案 | 更适合的场景 | 主要收益 | 代价与边界 |
|---|---|---|---|
| 缓存空值 | 不存在查询可枚举且变化不频繁 | 实现简单 | 占内存且需短 TTL |
| 布隆过滤器 | Key 规模大、允许极低误判 | 拦截绝大多数穿透 | 删除和重建复杂 |
| 逻辑过期+异步重建 | 热点允许短暂旧值 | 避免击穿、延迟稳定 | 一致性和后台任务更复杂 |
选型至少带上 Key 数量、Value 大小、命令复杂度、热点分布和过期速率,并用上面的量化基线验证;未知数据应明确为待测假设。
设计边界与工程取舍
缓存治理的最终目标是保护数据源并给用户可解释结果;任何缓存故障路径都必须有明确的回源上限。
工程落地遵循:Redis 是高性能数据结构服务,不应被当成没有容量边界的黑盒。回答时直接引用「redis.call + Lua」、配置实验和事故数据,比复述固定模板更有说服力。