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            # 错误率阈值

总结

缓存击穿防护的核心原则:

  1. 不阻塞请求:逻辑过期保证请求立即返回
  2. 后台重建:异步刷新,不影响用户
  3. 分布式锁:保证只有一个线程刷新
  4. 热点探测:提前预警,预防性更新
  5. 限流兜底:数据库压力过大时快速失败

记住:缓存击穿不是技术问题,是架构问题。通过逻辑过期+后台刷新的组合拳,可以让系统在热点 key 过期时依然稳稳当当。


源码获取

文章已同步至小程序博客栏目,需要源码的请关注小程序博客。

公众号:服务端技术精选

小程序码:


标题:SpringBoot + 缓存击穿/惊群效应防护:热点 Key 过期瞬间打垮 DB?逻辑过期+后台刷新!
作者:jiangyi
地址:http://jiangyi.space/articles/2026/05/21/1779116727856.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消