这是 Beta 探索课程,内容结构、实验步骤和示例可能会继续调整。
缓存击穿
那次惊魂时刻
周三上午 10 点,流量高峰期。
一切看似正常:
- API 响应时间:50ms
- 缓存命中率:98%
- 外部 API 调用成功率:99%
突然:
Inbox
From: 监控系统
To: 运维值班
Time: 周三 10:00
Subject: [P1] Weather API 异常告警
- 警告:API 响应时间飙升到 8 秒
- 警告:外部 API 调用失败率飙升
- 警告:请求超时率 60%
系统差点崩溃。事后分析日志,我发现了一个致命问题。
什么是缓存击穿?
缓存击穿流程
热点 Key 形成
北京 50,000 次/天
上海 40,000 次/天
深圳 30,000 次/天
击穿发生过程
T0
热点 key 过期 weather:北京 缓存失效
T1
100 个并发请求同时到达 全部访问同一个 key
T2
缓存未命中 Redis 中无此数据
T3
100 个请求同时访问外部 API 触发 API 限流
100%
T4
系统崩溃 API 超时,错误率飙升
正常情况 vs 击穿时刻
正常情况
请求
→ 缓存命中
→ 返回 (50ms)
缓存命中率: 98%
击穿时刻
100 请求
→ 缓存过期
→ 外部 API
API 限流状态: 已触发
缓存击穿是指:
- 某个热点 key(被频繁访问的数据)突然过期
- 大量并发请求同时访问这个 key
- 所有请求都穿透到外部 API
- 外部 API 瞬间压力激增,可能触发限流
关键特征:
- 只有一个 key 过期(不是大面积)
- 但这个 key 非常”热”(访问量巨大)
- 并发请求同时击穿缓存层
击穿 vs 穿透
这两个概念很容易混淆,我们来对比一下:
| 对比项 | 缓存穿透 | 缓存击穿 |
|---|---|---|
| 攻击目标 | 不存在的数据 | 存在的热点数据 |
| key 特征 | 大量不同的无效 key | 单个有效的热点 key |
| 触发原因 | 恶意攻击/非法请求 | 热点 key 自然过期 |
| 数据存在性 | 缓存和外部 API 都没有 | 外部 API 有,缓存刚好过期 |
| 请求特点 | 持续的恶意请求 | 并发量大的正常请求 |
简单记忆:
- 穿透 = 穿透空对象(数据不存在)
- 击穿 = 击穿热点 key(数据存在但缓存过期)
解决方案一:互斥锁
请求 1
请求 2
请求 3
...
请求 N
互斥锁竞争 (SETNX)
请求 1
获得锁
查缓存 (无) → 查数据库 → 写缓存
释放锁
请求 2
等待锁
sleep(100ms) → 重试→查缓存 (命中!)
请求 3
等待锁
sleep(100ms) → 重试→查缓存 (命中!)
数据库受到保护 只有一个请求访问数据库
后续请求快速响应 直接从缓存读取数据
核心思路:
- 只让一个请求去调用外部 API
- 其他请求等待
- 等第一个请求把数据写入缓存后,大家都从缓存读
优点:
- 保证只有一个请求调用外部 API
- 完全解决击穿问题
- 实现相对简单
缺点:
- 需要处理锁的获取和释放
- 如果持锁请求失败,需要超时机制
- 性能略有损耗(等待时间)
解决方案二:逻辑过期
写入缓存 数据 + 逻辑过期时间
{ "data": {...}, "expire_at": "2025-03-15T15:00:00" }
用户请求到达 查询缓存数据
检查逻辑过期时间
未过期
直接返回数据
响应快 (50ms)
已过期
立即返回旧数据
用户无感知 后台异步更新
不阻塞用户请求 后台异步更新流程
1 启动异步线程
→2 获取更新锁
→3 查数据库
→4 更新缓存
用户零等待 总是立即返回数据
可能返回旧数据 短暂不一致窗口
优点:
- 用户永远能读到数据(不会等待)
- 完全避免外部 API 压力
- 适合对实时性要求不高的场景
缺点:
- 可能返回过期数据(一致性弱)
- 实现复杂(需要异步线程)
- 需要处理并发更新问题
方案对比
| 对比项 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 实现难度 | ⭐⭐ 中等 | ⭐⭐⭐⭐ 复杂 |
| 数据一致性 | 强一致 | 弱一致(可能返回旧数据) |
| 响应时间 | 需要等待(~100ms) | 无需等待(立即返回) |
| 外部 API 压力 | 完全保护 | 完全保护 |
| 适用场景 | 大多数场景 | 高并发、可接受短暂不一致 |
效果验证
实施互斥锁方案后的系统指标:
| 指标 | 无防护 | 互斥锁方案 | 逻辑过期方案 |
|---|---|---|---|
| 外部 API 峰值调用量 | 10000 次/秒 | 100 次/秒 | 50 次/秒 |
| 平均响应时间 | 2000ms | 150ms | 50ms |
| 最大响应时间 | 8000ms | 300ms | 100ms |
| 请求超时率 | 60% | 0.1% | 0% |
| 数据一致性 | 强一致 | 强一致 | 弱一致 |
结论:
- 互斥锁方案:适合大多数场景,一致性好
- 逻辑过期方案:适合超高并发、可接受短暂不一致的场景
当前技术架构
练习
练习 1
缓存击穿和缓存穿透的区别是什么?
参考答案 (3 个标签)
缓存击穿 缓存穿透 概念辨析
答案:
| 对比项 | 缓存穿透 | 缓存击穿 |
|---|---|---|
| 攻击目标 | 不存在的数据 | 存在的热点数据 |
| key 数量 | 大量不同的 key | 单个热点 key |
| 数据存在性 | 缓存和外部 API 都没有 | 外部 API 有,缓存刚好过期 |
| 触发原因 | 恶意攻击/非法请求 | 热点 key 自然过期 |
| 并发特征 | 持续的恶意请求 | 高并发的正常请求 |
简单记忆:
- 穿透 = 数据不存在,请求穿透空对象
- 击穿 = 热点 key 过期,请求击穿缓存层
练习 2
互斥锁方案中,为什么获取锁后要”双重检查”缓存?
参考答案 (3 个标签)
缓存击穿 互斥锁 双重检查
答案:
双重检查的目的是避免重复调用外部 API。
场景说明:
时间线:
T1: 请求 A 获取锁成功
T2: 请求 B 获取锁失败,进入等待
T3: 请求 A 调用外部 API 并写入缓存,释放锁
T4: 请求 B 获得锁(因为 A 释放了)
T5: 请求 B 再次调用外部 API ← 这是浪费的!双重检查流程:
设计流程
练习 2
- 步骤 1:按本节策略读取、写入缓存并处理过期或失效
- 步骤 2:校验身份、密钥或权限
- 步骤 3:识别请求 key、缓存命中状态和回源数据对象
- 步骤 4:根据命中率、数据新鲜度和回源压力调整策略
关注点:命中率、回源压力、TTL 和异常 key 处理。
效果:
- 避免在等待锁期间,其他请求已经写入了数据
- 减少不必要的外部 API 调用
练习 3
请设计一个带有缓存击穿防护的商品查询流程,使用互斥刷新方案。
参考答案 (3 个标签)
缓存击穿 互斥锁 方案设计
参考答案:
设计流程
练习 3
- 步骤 1:写入缓存值、空值标记或热点保护状态
- 步骤 2:按命中、未命中和异常 key 处理读取与回源
- 步骤 3:按本节策略读取、写入缓存并处理过期或失效
- 步骤 4:校验身份、密钥或权限
关注点:命中率、回源压力、TTL 和异常 key 处理。
关键点:
- 互斥锁保护外部 API 调用
- 双重检查避免重复调用
- 空值缓存防止穿透
- 等待重试机制处理锁竞争
- finally 块确保锁释放