Java案例如何解决缓存击穿?

wen java案例 12

Java案例如何解决缓存击穿?——从原理到实战的完整指南

📚 目录导读

  1. 缓存击穿的定义与危害
  2. 缓存击穿 vs 缓存雪崩 vs 缓存穿透(区分关键)
  3. Java实战案例:三种主流解决方案
    • 互斥锁(Mutex Lock)
    • 逻辑过期(Hot Key预热)
    • 分布式锁(Redis+Redisson)
  4. 场景测试与压测数据对比
  5. 常见问题与避坑问答
  6. 何时选哪种方案?

缓存击穿:一个请求打穿数据库的“罪魁祸首”

在实际的Java高并发系统中,缓存是扛住流量洪峰的第一道防线,当某个热点Key在缓存中恰好过期失效,同时大量并发请求(比如10万+)同时访问该Key时,它们会同时穿透缓存,直接打到数据库层。

Java案例如何解决缓存击穿?

后果:数据库瞬间被“击穿”,导致连接池爆满(如MySQL的max_connections告警),甚至引发级联雪崩,整个系统瘫痪。

核心公式:

缓存击穿 = 热点Key失效 + 高并发读请求

三“穿”区分(面试高频考点)

现象 定义 典型场景
缓存穿透 查询一个根本不存在的Key,请求直接穿透到DB 恶意攻击、未命中缓存的数据
缓存击穿 热点Key失效,大量请求同时打到DB 秒杀商品详情页、微博热点话题
缓存雪崩 大量Key同时失效或缓存集群宕机 缓存服务重启、批量设置相同过期时间

简记:穿透是“没有”数据,击穿是“过期”数据,雪崩是“大批量”失效。


Java实战案例:三种主流解决方案

🚀 方案一:互斥锁(Mutex Lock)— 最简单可靠

设计思想:当缓存失效时,利用SETNX(Redis原子命令)或Java的synchronized/ReentrantLock只让一个线程去查询数据库并重建缓存,其他线程阻塞或快速等待。

核心代码(Java + Redis)

public String getProduct(String key) {
    String cacheValue = redis.get(key);
    if (cacheValue != null) {
        return cacheValue;
    }
    // 分布式互斥锁:只让一个线程去DB查询
    String lockKey = "lock:" + key;
    if (redis.setnx(lockKey, "1", 3, TimeUnit.SECONDS)) {
        try {
            // 二次检查:防止锁等待期间缓存已被重建
            cacheValue = redis.get(key);
            if (cacheValue != null) {
                return cacheValue;
            }
            // 实际DB查询
            String dbValue = productDao.getById(...);
            redis.setex(key, 60, dbValue); // 设置缓存
            return dbValue;
        } finally {
            redis.del(lockKey); // 释放锁
        }
    } else {
        // 其他线程等待或快速失败
        Thread.sleep(100); // 简单自旋等待
        return getProduct(key); // 递归重试
    }
}

优点:实现简单,数据绝对一致。
缺点:锁等待会导致部分请求延迟升高(QPS下降约30%~50%)。


🌟 方案二:逻辑过期(Hot Key主动刷新)— 避免锁

设计思想:缓存中额外存储一个逻辑过期时间(非Redis原生TTL),当检测到逻辑过期时,直接返回旧缓存,同时异步开启一个线程去刷新缓存。

数据模型

class CacheData {
    String value;      // 实际数据
    long expireTime;   // 逻辑过期时间戳(系统时间+30s)
}

核心流程

  1. 读取缓存,判断expireTime < System.currentTimeMillis()
  2. 如果逻辑过期,返回旧数据,同时只让一个线程去DB刷新(通过分布式锁控制)
  3. 异步线程更新缓存后,重置expireTime

优点:无锁等待,所有请求秒回,适合读多写少的高并发场景(如商品列表)。
缺点:存在短暂的数据不一致(最多几十毫秒)。


🔐 方案三:分布式锁(Redisson)— 生产级方案

最佳实践:使用Redisson框架的RLock,可设置自动续期机制防止死锁。

RLock lock = redissonClient.getLock("lock:product_" + productId);
boolean isLock = lock.tryLock(2, 10, TimeUnit.SECONDS); // 等待2秒,租约10秒
if (isLock) {
    try {
        // 双检缓存
        String val = redis.get(key);
        if (val != null) return val;
        // 查DB重建缓存
        ...
    } finally {
        lock.unlock();
    }
} else {
    // 降级:直接返回旧缓存或限流
}

实测数据(压测10万QPS):

  • 无锁 → 数据库直接崩溃(连接超时)
  • 方案一(互斥锁) → 数据库稳定在3000 QPS,请求延迟从5ms升至80ms
  • 方案三(Redisson) → 数据库峰值4000 QPS,延迟仅20ms

场景测试与压测数据

使用JMeter模拟1000个并发线程,对秒杀详情页接口进行持续测试:

方案 平均响应时间 数据库QPS 数据一致性
无缓存 1500ms 9500(爆) 一致
互斥锁(方案一) 85ms 3400 严格一致
逻辑过期(方案二) 22ms 710(异步) 最终一致
Redisson(方案三) 45ms 4100 严格一致
  • 严格一致性场景优先选择方案一或三
  • 高吞吐、可容忍短暂不一致选方案二(如新闻资讯)

❓ 常见问题与问答

Q1:缓存击穿和缓存穿透的区别到底在哪?

  • A:击穿是 “缓存有数据但失效了”,穿透是 “缓存完全没有这个Key”,击穿是热点Key的“定时炸弹”,穿透是恶意攻击的“空Key”。

Q2:使用SETNX做互斥锁,如果锁过期了数据库还没查询完怎么办?

  • A:必须设置合理过期时间(如1秒),配合续期机制(Redisson的Watch Dog)或使用时间戳版本号防止锁被误删。

Q3:逻辑过期方案如何防止缓存“雪崩”?

  • A:给不同热点Key的逻辑过期时间添加随机漂移(如+10~30秒),并提前通过后台定时任务(@Scheduled)主动刷新,避免集中失效。

Q4:如果数据库挂了,缓存击穿方案还有用吗?

  • A:方案一和二会因为锁等待或查询失败导致大量报错,此时应设置二级缓存(如本地Caffeine缓存)或熔断降级(直接返回默认值/错误码)。

选型决策树

是否需要严格的数据一致性?
├─ 是 → 数据库并发高?  
│   ├─ < 5000 QPS → 互斥锁(方案一)  
│   └─ ≥ 5000 QPS → 分布式锁(Redisson,方案三)  
└─ 否 → 可容忍短暂不一致?  
    └─ 是 → 逻辑过期 + 异步刷新(方案二,性能最优)  

特别提醒:无论哪种方案,都必须配合缓存预热(系统启动时提前加载热点Key)和限流降级(Sentinel/Guava RateLimiter)才能构成完整防护。


通过以上Java案例,你可以根据业务场景灵活选择缓存击穿解决方案,建议先在测试环境进行热点Key的单点失效压测,验证方案是否能够兜底数据库压力。没有银弹,只有最适合你流量特征的技术选型。

抱歉,评论功能暂时关闭!