SpringBoot + 缓存击穿/惊群效应防护:热点 Key 过期瞬间打垮 DB?逻辑过期+后台刷新!
做缓存系统的同学肯定都遇到过这个问题:某个热点 key 突然过期了,结果瞬间大量请求直接打到数据库上,导致数据库被打爆,服务雪崩。
我之前就遇到过这样一个案例:电商系统的商品详情页,某个人气商品正在秒杀,结果缓存刚好过期了。瞬间几万并发请求全部穿透到数据库,数据库 CPU 直接飙升到 100%,整个系统瘫痪了十几分钟。
这就是经典的"缓存击穿"问题,也叫"惊群效应"。今天我们就来聊聊如何防护这种问题。
缓存击穿的常见场景
1. 热点数据过期
热点数据特点:
- 访问频率极高(每秒数万次)
- 数据量大(商品信息、用户信息等)
- 时效性要求高(需要定期更新)
问题:
当热点数据过期瞬间,所有请求同时发现缓存失效
所有请求同时去查询数据库
数据库无法承受瞬间压力,直接崩溃
2. 缓存击穿 vs 缓存穿透 vs 缓存雪崩
缓存三连击对比:
┌─────────────┬────────────────────────────────┬─────────────────────┐
│ 类型 │ 描述 │ 区别 │
├─────────────┼────────────────────────────────┼─────────────────────┤
│ 缓存击穿 │ 热点 key 过期,瞬间大量请求打 DB │ 单个 key 的问题 │
│ 缓存穿透 │ 查询不存在的数据,始终穿透到 DB │ key 不存在的问题 │
│ 缓存雪崩 │ 大量 key 同时过期,打垮数据库 │ 多个 key 的问题 │
└─────────────┴────────────────────────────────┴─────────────────────┘
3. 惊群效应示意
没有防护的情况:
时刻 0: 缓存中有数据,10000 QPS 全部命中缓存
↓
时刻 1: 缓存过期,数据失效
↓
时刻 2: 10000 个请求同时发现缓存失效
↓
时刻 3: 10000 个请求同时去查数据库
↓
时刻 4: 数据库被打爆,响应超时,系统雪崩
常见解决方案
1. 互斥锁方案
原理:只允许一个请求去查数据库,其他请求等待
优点:简单直接,数据一致性好
缺点:大量请求会排队等待,可能影响响应时间
2. 永不过期方案
原理:给缓存设置很长的过期时间,人工更新
优点:彻底解决击穿问题
缺点:数据一致性问题,需要配合主动更新
3. 逻辑过期方案(推荐)
原理:缓存不过期,但数据中包含逻辑过期时间
发现逻辑过期时,后台异步刷新缓存
优点:不阻塞请求,性能好
缺点:存在短暂的数据不一致窗口
终极方案:逻辑过期 + 后台刷新
我们的方案采用"双重保护"策略:
┌─────────────────────────────────────────────────────────────────┐
│ 缓存击穿防护整体方案 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 第一层:逻辑过期(避免阻塞) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ • 缓存数据中包含逻辑过期时间字段 │ │
│ │ • 发现逻辑过期时立即返回旧数据 │ │
│ │ • 不阻塞请求,保证响应速度 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 第二层:后台异步刷新(重建缓存) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ • 后台线程检测到逻辑过期后立即刷新 │ │
│ │ • 使用分布式锁保证只有一个线程刷新 │ │
│ │ • 刷新期间其他请求继续返回旧数据 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 第三层:热点探测(提前预警) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ • 实时统计 key 的访问频率 │ │
│ │ • 发现热点 key 即将过期时提前刷新 │ │
│ │ • 预防性更新,避免击穿发生 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 第四层:限流熔断(兜底保护) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ • 数据库压力过大时触发限流 │ │
│ │ • 快速失败,拒绝部分请求 │ │
│ │ • 保护数据库不被打垮 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
核心组件设计
1. 逻辑过期缓存数据
缓存中存储的是包含元数据的数据结构:
@Data
public class LogicalCacheData<T> {
private T data; // 实际数据
private long logicalExpire; // 逻辑过期时间(时间戳)
private long version; // 版本号
}
// 存入缓存
LogicalCacheData<Product> cacheData = new LogicalCacheData<>();
cacheData.setData(product);
cacheData.setLogicalExpire(System.currentTimeMillis() + 30 * 1000); // 30秒后逻辑过期
cacheData.setVersion(1L);
redis.set(key, cacheData);
2. 缓存查询拦截器
核心逻辑:
function get_data(key):
# 1. 先查缓存
cache_data = redis.get(key)
if cache_data is null:
# 缓存不存在,走数据库
return fetch_from_db_and_cache(key)
# 2. 检查是否逻辑过期
if is_logically_expired(cache_data):
# 3. 开启后台刷新
async_refresh(key)
# 4. 返回旧数据(不阻塞)
return cache_data.data
# 5. 正常返回
return cache_data.data
function is_logically_expired(cache_data):
return System.current_time_millis() > cache_data.logical_expire
3. 后台刷新服务
核心逻辑:
class BackgroundRefreshService:
def __init__(self):
self.refresh_lock = RedisDistributedLock()
self.refresh_thread = Thread(target=self.refresh_loop)
def async_refresh(self, key):
# 尝试获取分布式锁
if self.refresh_lock.try_lock(key, timeout=10):
# 只有一个线程执行刷新
Thread(target=self.do_refresh, args=(key,)).start()
def do_refresh(self, key):
try:
# 从数据库查询最新数据
new_data = db.query(key)
# 更新缓存
cache_data = LogicalCacheData()
cache_data.data = new_data
cache_data.logical_expire = now() + 30 * 1000
cache_data.version += 1
redis.set(key, cache_data)
finally:
self.refresh_lock.unlock(key)
4. 热点探测服务
提前发现即将过期的热点 key,进行预防性更新:
核心逻辑:
class HotspotDetector:
def __init__(self):
self.access_counter = RedisSortedSet()
self.threshold = 10000 # 访问频率阈值
def record_access(self, key):
# 增加访问计数
self.access_counter.increment(key)
def detect_hotspot_keys(self):
# 找出高访问频率的 key
hot_keys = self.access_counter.get_top_n(100)
for key in hot_keys:
cache_data = redis.get(key)
if cache_data and self.is_about_to_expire(cache_data):
# 提前刷新
self.refresh_service.async_refresh(key)
def is_about_to_expire(self, cache_data):
# 距离逻辑过期时间小于5秒
return (cache_data.logical_expire - now()) < 5000
5. 分布式锁管理
保证只有一个线程执行缓存刷新:
核心逻辑:
class RedisDistributedLock:
def __init__(self):
self.redis = RedisClient()
self.lock_prefix = "refresh_lock:"
def try_lock(self, key, timeout=10):
lock_key = self.lock_prefix + key
# 使用 SET NX EX 实现分布式锁
return self.redis.set(lock_key, "1", nx=True, ex=timeout)
def unlock(self, key):
lock_key = self.lock_prefix + key
self.redis.delete(lock_key)
完整工作流程
逻辑过期 + 后台刷新完整流程:
┌─────────────────────────────────────────────────────────────────┐
│ 请求处理流程 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 请求到来 │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 1. 查询缓存 │ │
│ │ - 命中缓存 │ │
│ │ - 检查逻辑过期时间 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 2. 未逻辑过期 │ │
│ │ - 直接返回缓存数据 │ │
│ │ - 记录访问统计 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 3. 已逻辑过期 │ │
│ │ - 记录访问统计 │ │
│ │ - 开启后台刷新协程 │ │
│ │ - 立即返回旧数据(不阻塞) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 4. 后台刷新线程 │ │
│ │ - 尝试获取分布式锁 │ │
│ │ - 获取锁成功:从 DB 加载数据 │ │
│ │ - 更新缓存,延长逻辑过期时间 │ │
│ │ - 释放锁 │ │
│ │ - 获取锁失败:跳过(其他线程正在刷新) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 5. 热点探测线程(定期执行) │ │
│ │ - 统计热点 key │ │
│ │ - 发现即将过期的热点 key │ │
│ │ - 预防性刷新 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
与其他方案对比
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 互斥锁 | 只允许一个请求查 DB | 简单,数据一致 | 请求排队,RT 增加 | 低并发场景 |
| 永不过期 | 人工维护缓存 | 彻底解决击穿 | 数据不一致 | 对一致性要求低 |
| 逻辑过期+后台刷新 | 不阻塞,后台重建 | 不阻塞,RT 稳定 | 短暂不一致 | 高并发热点数据 |
| 限流熔断 | 拒绝部分请求 | 保护 DB | 用户体验差 | 兜底保护 |
性能对比
假设场景:10000 QPS,热点 key 过期
┌─────────────┬──────────────┬──────────────┬──────────────┐
│ 方案 │ 数据库 QPS │ 平均 RT │ 系统稳定性 │
├─────────────┼──────────────┼──────────────┼──────────────┤
│ 无防护 │ 10000 │ >5000ms │ ❌ 雪崩 │
│ 互斥锁 │ 1 │ 100ms │ ✅ 但排队 │
│ 逻辑过期+后台│ 1-2 │ 5ms │ ✅ 推荐 │
└─────────────┴──────────────┴──────────────┴──────────────┘
配置建议
# 缓存击穿防护配置
cache:
logical-expire:
enabled: true
default-ttl-seconds: 3600 # 物理过期时间
logical-ttl-seconds: 30 # 逻辑过期时间
background-refresh:
enabled: true
thread-pool-size: 10 # 后台刷新线程数
lock-timeout-seconds: 10 # 分布式锁超时时间
hotspot-detect:
enabled: true
check-interval-seconds: 10 # 热点检测间隔
threshold: 10000 # 热点 key 阈值
pre-refresh-before-seconds: 5 # 提前多少秒预热
circuit-breaker:
enabled: true
db-max-qps: 100 # 数据库最大 QPS
error-threshold: 50 # 错误率阈值
总结
缓存击穿防护的核心原则:
- 不阻塞请求:逻辑过期保证请求立即返回
- 后台重建:异步刷新,不影响用户
- 分布式锁:保证只有一个线程刷新
- 热点探测:提前预警,预防性更新
- 限流兜底:数据库压力过大时快速失败
记住:缓存击穿不是技术问题,是架构问题。通过逻辑过期+后台刷新的组合拳,可以让系统在热点 key 过期时依然稳稳当当。
源码获取
文章已同步至小程序博客栏目,需要源码的请关注小程序博客。
公众号:服务端技术精选
小程序码:
标题:SpringBoot + 缓存击穿/惊群效应防护:热点 Key 过期瞬间打垮 DB?逻辑过期+后台刷新!
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/05/21/1779116727856.html
公众号:服务端技术精选
评论
0 评论