缓存三大劫攻防战:穿透、击穿、雪崩的Java实战防御体系(二)

第二部分:缓存击穿------热点key过期引发的"DB瞬间高压"

缓存击穿的本质是"某个热点key(高并发访问)突然过期",导致大量请求在同一时间穿透缓存,集中冲击DB,形成"瞬间高压"。

案例3:电商秒杀的"库存超卖"惊魂

故障现场

某电商平台"618"秒杀活动中,一款限量1000台的手机采用"Redis缓存+MySQL"架构:

  • 缓存key:seckill:stock:1001(存储库存数量),过期时间1小时;
  • 流程:查询缓存→未命中则查DB→扣减库存→更新缓存。
  • 故障:活动开始1小时后,缓存key恰好过期,此时2000+用户同时刷新页面,缓存未命中,所有请求直达MySQL查询库存。MySQL因瞬间高并发(2000QPS)出现锁等待,库存更新延迟,最终超卖50台。
根因解剖
  1. 热点key(seckill:stock:1001)过期瞬间,2000+并发请求穿透至MySQL;
  2. MySQL查询库存时加行锁(SELECT stock FROM seckill WHERE item_id=1001 FOR UPDATE),并发请求排队等待,导致库存更新延迟;
  3. 前端未做防重放处理,用户多次刷新加剧并发。
三重防御方案落地
方案1:热点数据"逻辑永不过期"

核心逻辑 :缓存不设置物理过期时间,而是在value中嵌入"逻辑过期时间"。当逻辑过期时,不直接删除缓存,而是通过后台线程异步更新,当前请求仍返回旧数据。
优势:彻底避免过期瞬间的并发穿透。

实战代码

java 复制代码
@Service
public class SeckillStockService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private SeckillMapper seckillMapper;
    // 线程池:处理缓存异步更新
    private final ExecutorService updatePool = new ThreadPoolExecutor(
        5, 10, 60, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(100),
        new ThreadPoolExecutor.CallerRunsPolicy()
    );

    // 缓存数据模型(含逻辑过期时间)
    @Data
    static class StockCache {
        private Integer stock; // 库存数量
        private long expireTime; // 逻辑过期时间(毫秒)
    }

    /**
     * 查询秒杀库存(逻辑永不过期)
     */
    public Integer getStock(Long itemId) {
        String cacheKey = "seckill:stock:" + itemId;
        // 1. 查询缓存
        String cacheVal = redisTemplate.opsForValue().get(cacheKey);
        if (cacheVal == null) {
            // 2. 缓存未命中(首次加载):加锁查询DB并初始化
            return loadStockWithLock(itemId, cacheKey);
        }

        // 3. 解析缓存数据
        StockCache cache = JSON.parseObject(cacheVal, StockCache.class);
        // 4. 逻辑未过期:直接返回
        if (System.currentTimeMillis() < cache.getExpireTime()) {
            return cache.getStock();
        }

        // 5. 逻辑已过期:异步更新缓存,当前请求返回旧数据
        updatePool.submit(() -> refreshStockCache(itemId, cacheKey));
        return cache.getStock();
    }

    // 加锁加载库存(防止缓存击穿)
    private Integer loadStockWithLock(Long itemId, String cacheKey) {
        // 使用Redisson分布式锁
        RLock lock = redissonClient.getLock("lock:seckill:stock:" + itemId);
        try {
            // 最多等待100ms,持有锁5秒
            if (lock.tryLock(100, 5000, TimeUnit.MILLISECONDS)) {
                // 双重检查:防止重复加载
                String cacheVal = redisTemplate.opsForValue().get(cacheKey);
                if (cacheVal != null) {
                    return JSON.parseObject(cacheVal, StockCache.class).getStock();
                }
                // 查询DB并初始化缓存(逻辑过期1小时)
                Integer stock = seckillMapper.selectStock(itemId);
                StockCache cache = new StockCache();
                cache.setStock(stock);
                cache.setExpireTime(System.currentTimeMillis() + 3600 * 1000);
                redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(cache));
                return stock;
            } else {
                // 获取锁失败:返回DB查询结果(兜底)
                return seckillMapper.selectStock(itemId);
            }
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    // 刷新缓存(异步执行)
    private void refreshStockCache(Long itemId, String cacheKey) {
        RLock lock = redissonClient.getLock("lock:seckill:stock:" + itemId);
        try {
            // 加锁防止并发更新
            if (lock.tryLock(100, 5000, TimeUnit.MILLISECONDS)) {
                Integer newStock = seckillMapper.selectStock(itemId);
                StockCache cache = new StockCache();
                cache.setStock(newStock);
                cache.setExpireTime(System.currentTimeMillis() + 3600 * 1000);
                redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(cache));
            }
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

时序图

复制代码
正常请求(未过期):
[用户] → 查缓存 → 命中(未过期)→ 返回结果

过期请求(异步更新):
[用户] → 查缓存 → 命中(已过期)→ 返回旧数据
                                   ↓
                            异步线程更新缓存(加锁)

实战效果:缓存过期时无请求穿透至DB,MySQL查询量稳定在50QPS以内,超卖问题彻底解决。

方案2:分布式锁"串行化"查询

核心逻辑 :热点key过期时,通过分布式锁保证只有一个线程能查询DB并更新缓存,其他线程等待重试。
适用场景:数据实时性要求高,无法接受旧数据。

实战代码(Redisson实现)

java 复制代码
@Service
public class HotItemService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private RedissonClient redissonClient;
    @Autowired
    private ItemMapper itemMapper;

    /**
     * 查询热点商品详情(分布式锁防击穿)
     */
    public ItemDTO getHotItem(Long itemId) {
        String cacheKey = "item:hot:" + itemId;
        // 1. 查询缓存
        String cacheVal = redisTemplate.opsForValue().get(cacheKey);
        if (cacheVal != null) {
            return JSON.parseObject(cacheVal, ItemDTO.class);
        }

        // 2. 缓存未命中:加分布式锁
        RLock lock = redissonClient.getLock("lock:item:hot:" + itemId);
        try {
            // 最多等待500ms,持有锁3秒
            if (lock.tryLock(500, 3000, TimeUnit.MILLISECONDS)) {
                // 双重检查:防止锁等待期间已更新缓存
                cacheVal = redisTemplate.opsForValue().get(cacheKey);
                if (cacheVal != null) {
                    return JSON.parseObject(cacheVal, ItemDTO.class);
                }
                // 3. 查询DB并更新缓存(设置过期时间30分钟)
                ItemDTO item = itemMapper.selectById(itemId);
                redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(item), 30, TimeUnit.MINUTES);
                return item;
            } else {
                // 4. 获取锁失败:重试(最多3次)
                for (int i = 0; i < 3; i++) {
                    Thread.sleep(50); // 短暂等待
                    cacheVal = redisTemplate.opsForValue().get(cacheKey);
                    if (cacheVal != null) {
                        return JSON.parseObject(cacheVal, ItemDTO.class);
                    }
                }
                // 重试失败:返回DB结果(兜底)
                return itemMapper.selectById(itemId);
            }
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

实战效果:热点key过期时,仅1个线程查询DB,其他线程从缓存获取,MySQL峰值QPS从2000降至5,接口响应时间从500ms降至50ms。

方案3:熔断降级(极端情况保护)

核心逻辑:当DB压力过大时,通过熔断组件(如Resilience4j)临时返回缓存旧值或默认值,避免DB被压垮。

实战代码(Resilience4j配置)

java 复制代码
@Configuration
public class CircuitBreakerConfig {
    @Bean
    public CircuitBreakerRegistry circuitBreakerRegistry() {
        CircuitBreakerConfig config = CircuitBreakerConfig.custom()
                .failureRateThreshold(50) // 失败率超50%触发熔断
                .waitDurationInOpenState(Duration.ofSeconds(10)) // 熔断10秒
                .permittedNumberOfCallsInHalfOpenState(5) // 半开状态允许5次调用
                .slidingWindowSize(100) // 滑动窗口大小100
                .build();
        return CircuitBreakerRegistry.of(config);
    }
}

@Service
public class ItemService {
    @Autowired
    private CircuitBreakerRegistry circuitBreakerRegistry;
    @Autowired
    private ItemMapper itemMapper;
    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 带熔断的DB查询(兜底方案)
     */
    public ItemDTO queryFromDBWithFallback(Long itemId) {
        CircuitBreaker breaker = circuitBreakerRegistry.circuitBreaker("itemDBQuery");
        // 包装DB查询方法,配置熔断降级
        return Try.ofSupplier(CircuitBreaker.decorateSupplier(breaker, () -> 
            itemMapper.selectById(itemId)
        )).recover(Exception.class, e -> {
            log.warn("DB查询熔断,使用缓存旧值,itemId={}", itemId, e);
            // 熔断时返回缓存旧值(即使过期)
            String oldVal = redisTemplate.opsForValue().get("item:hot:" + itemId);
            return oldVal != null ? JSON.parseObject(oldVal, ItemDTO.class) : buildDefaultItem(itemId);
        }).get();
    }

    // 构建默认商品(极端降级)
    private ItemDTO buildDefaultItem(Long itemId) {
        ItemDTO defaultItem = new ItemDTO();
        defaultItem.setId(itemId);
        defaultItem.setName("商品信息加载中");
        return defaultItem;
    }
}

实战效果:DB压力过大时自动熔断,返回缓存旧值,接口成功率保持99.9%,无服务雪崩。

击穿防御总结

方案 适用场景 优点 缺点 实施成本
逻辑永不过期 实时性要求不高 无并发穿透,性能好 可能返回旧数据
分布式锁 实时性要求高 数据一致,实现简单 锁竞争可能导致延迟
熔断降级 极端流量保护 兜底保障,防止DB雪崩 影响用户体验
相关推荐
小bo波12 小时前
Java Swing 图形用户界面实验 —— 从算术练习到游戏开发的完整实践
java·课程设计·gui·游戏开发·扫雷·swing
咖啡八杯14 小时前
GoF设计模式——备忘录模式
java·后端·spring·设计模式
SamDeepThinking1 天前
裁掉那个差程序员后,给你看团队里高手的代码:这个习惯,希望你有
java·后端·程序员
朕瞧着你甚好1 天前
技术雷达 & Java 集成评估报告 — Apache Tika 3.3.1
java·ai编程
MacroZheng1 天前
短短几天,暴涨2.8万Star!又一款编程神器开源!
java·人工智能·后端
SamDeepThinking1 天前
函数式编程:用BiFunction消除多类型分支的代码重复
java·后端·面试
Flittly2 天前
【AgentScope Java新手村系列】(16)从RAG到多路检索
java·spring boot·spring
小兔崽子去哪了2 天前
Java 生成二维码解决方案
java·后端
人活一口气2 天前
从JVM调优到MCP协议:Java全栈技术体系深度总结与企业级架构实践
java·spring boot
NE_STOP2 天前
Vibe Coding -- 完整项目案例实操
java