欢迎光临
我们一直在努力

缓存击穿问题及解决思路

一、前言:一个热门商品,为何让数据库瞬间崩溃?

你是否遇到过这些现象?

  • 某款秒杀商品上线,数据库 CPU 瞬间飙到 100%
  • 监控显示大量 SELECT * FROM product WHERE id = 1001 请求
  • 缓存命中率从 99% 骤降到 0%,服务响应超时

根本原因:缓存击穿(Cache Breakdown)!

缓存击穿是高并发场景下的“隐形杀手”,看似普通,却能瞬间压垮数据库。本文将带你彻底理解其原理,并掌握三种工业级解决方案,尤其详解分布式锁互斥重建的最佳实践。


二、什么是缓存击穿?

缓存击穿:指某个热点 key 在缓存中过期的瞬间,大量并发请求同时发现缓存失效,全部打到数据库,造成 DB 瞬时压力激增。

🌰 经典场景:

  • 商品详情页(如 iPhone 16)被万人同时访问
  • 缓存 TTL = 5 分钟,恰好在高峰期过期
  • 10,000 个请求同时查 DB → 连接池耗尽

正常流程:
请求 → 缓存(命中)→ 返回 ✅

缓存击穿:
t=0s:缓存过期
t=0.001s:10,000 请求同时发现 miss
t=0.002s:10,000 条 SQL 打到 DB ❌

⚠️ 与缓存穿透、雪崩的区别:

问题范围原因特点
缓存穿透 不存在的 key 恶意查询无效 ID 请求永远不命中
✅ 缓存击穿 单个热点 key 高并发 + 缓存过期 瞬时 DB 压力
缓存雪崩 大量 key 集体过期 / Redis 宕机 全局性崩溃

三、解决方案一:永不过期(慎用)

原理:

对极核心热点数据,设置永不过期(TTL = -1),由后台线程异步更新。

实现方式:

// 启动定时任务,每 5 分钟刷新一次
@Scheduled(fixedDelay = 300_000)
public void refreshHotProduct() {
Product product = productMapper.selectById(1001);
redis.set("product:1001", JSON.toJSONString(product));
}

✅ 优点:

  • 彻底避免击穿
  • 读性能极致

⚠️ 缺点:

  • 数据可能陈旧(最长 5 分钟延迟)
  • 仅适用于变更频率极低的热点数据(如首页 Banner、热门排行榜)

📌 不推荐用于商品价格、库存等实时性要求高的场景


四、解决方案二:互斥重建(Mutex Rebuild)—— 推荐方案!

原理:

当缓存失效时,只允许一个线程去查数据库并重建缓存,其他线程等待或重试。

核心步骤:

  • 线程 A 发现缓存 miss
  • A 尝试获取分布式锁
  • A 成功 → 查 DB → 写缓存 → 释放锁
  • 线程 B/C/D 等待锁释放后,直接读新缓存
  • 代码实现(Java + Redisson):

    @Autowired
    private RedissonClient redisson;

    public Product getProduct(Long id) {
    String key = "product:" + id;

    // 1. 先查缓存
    String json = redis.get(key);
    if (json != null) {
    return JSON.parseObject(json, Product.class);
    }

    // 2. 缓存 miss,尝试获取分布式锁
    String lockKey = "lock:product:" + id;
    RLock lock = redisson.getLock(lockKey);

    try {
    boolean isLocked = lock.tryLock(1, 3, TimeUnit.SECONDS);
    if (!isLocked) {
    // 未获取到锁,短暂等待后重试(或返回旧数据)
    Thread.sleep(50);
    return getProduct(id); // 递归重试
    }

    // 3. 双重检查:可能其他线程已重建
    json = redis.get(key);
    if (json != null) {
    return JSON.parseObject(json, Product.class);
    }

    // 4. 查数据库并写缓存
    Product product = productMapper.selectById(id);
    if (product != null) {
    redis.setex(key, 300, JSON.toJSONString(product)); // 5分钟
    }
    return product;

    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw new RuntimeException(e);
    } finally {
    lock.unlock(); // 释放锁
    }
    }

    ✅ 优点:

    • 保证只有一个线程查 DB
    • 数据强一致
    • 适用于所有热点 key

    ⚠️ 注意事项:

    • 必须做双重检查(Double Check),防止锁竞争后重复查 DB
    • 锁超时时间要合理(避免死锁)
    • 失败时要有 fallback 策略(如重试、返回默认值)

    五、解决方案三:逻辑过期(Logical Expire)

    原理:

    缓存中不设物理过期时间,而是存储一个“逻辑过期时间”字段。后台异步更新,读请求发现过期则触发重建(但不阻塞)。

    数据结构示例:

    {
    "data": { "id": 1001, "name": "iPhone 16" },
    "expireTime": 1712345678 // 时间戳
    }

    流程:

    • 请求读取缓存
    • 若 expireTime < now,则异步启动重建任务
    • 当前请求仍返回旧数据(容忍短暂不一致)

    适用场景:

    • 对实时性要求不高,但可用性要求极高的系统
    • 如新闻、文章、非交易类数据

    💡 优势:读请求永不阻塞,用户体验流畅


    六、三大方案对比总结

    方案一致性性能实现难度适用场景
    永不过期 弱(有延迟) ⭐⭐⭐⭐⭐ 静态热点数据
    ✅ 互斥重建 ⭐⭐⭐ ⭐⭐⭐ 通用推荐(商品、订单等)
    逻辑过期 最终一致 ⭐⭐⭐⭐ ⭐⭐⭐⭐ 高可用优先场景

    📌 生产环境首选:互斥重建 + 分布式锁


    七、避坑指南:常见错误实践

    ❌ 错误 1:只加本地锁(synchronized)

    后果:多实例部署时,每个 JVM 各自加锁,DB 仍被打爆 正解:必须使用分布式锁(Redis、ZooKeeper)

    ❌ 错误 2:忘记双重检查

    后果:多个线程拿到锁后都查 DB 正解:加锁后再次检查缓存是否存在

    ❌ 错误 3:锁没有设置超时

    风险:线程异常退出,锁永远不释放 → 死锁 正解:tryLock 必须带超时时间


    八、结语

    感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!

    赞(0)
    未经允许不得转载:171主机测评 » 缓存击穿问题及解决思路
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址