学习日报 20250929|缓存击穿及其解决方案

缓存击穿是指热点 key 在缓存过期的瞬间,大量并发请求直接穿透到数据库 ,导致 DB 压力骤增的问题(例如秒杀活动中某一优惠券的缓存突然过期)。针对该问题,核心解决方案是在缓存失效时,控制对 DB 的并发请求量,常用方案如下:

方案 1:互斥锁(分布式锁)

原理 :缓存失效时,只有一个线程能获取锁并查询 DB,其他线程等待重试,避免大量请求直击 DB。适用场景:并发量高、热点数据更新不频繁的场景(如优惠券秒杀)。

代码示例(基于 Redis 分布式锁)
java 复制代码
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;

@Service
public class CouponSeckillService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final CouponDao couponDao; // 数据库访问层
    // 分布式锁前缀
    private static final String LOCK_PREFIX = "lock:coupon:";
    // 锁超时时间(避免死锁,需大于DB查询耗时)
    private static final long LOCK_EXPIRE = 5000; // 5秒
    // 重试间隔(单位:毫秒)
    private static final long RETRY_INTERVAL = 100;

    public CouponSeckillService(RedisTemplate<String, Object> redisTemplate, CouponDao couponDao) {
        this.redisTemplate = redisTemplate;
        this.couponDao = couponDao;
    }

    /**
     * 查询秒杀优惠券信息(解决缓存击穿)
     */
    public Coupon getSeckillCoupon(Long couponId) {
        String cacheKey = "coupon:seckill:" + couponId;
        
        // 1. 先查缓存
        Coupon coupon = (Coupon) redisTemplate.opsForValue().get(cacheKey);
        if (coupon != null) {
            return coupon; // 缓存命中,直接返回
        }

        // 2. 缓存失效,尝试获取分布式锁
        String lockKey = LOCK_PREFIX + couponId;
        boolean locked = false;
        try {
            // 尝试获取锁(setIfAbsent:原子操作,避免并发问题)
            locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_EXPIRE, TimeUnit.MILLISECONDS);
            
            if (locked) {
                // 3. 成功获取锁,查询DB
                coupon = couponDao.queryById(couponId);
                if (coupon != null) {
                    // 4. 从DB查到数据,回写缓存(设置随机过期时间,避免再次同时失效)
                    int expireMinutes = 30 + (int) (Math.random() * 25); // 30-55分钟随机
                    redisTemplate.opsForValue().set(cacheKey, coupon, expireMinutes, TimeUnit.MINUTES);
                } else {
                    // 5. DB中不存在,设置空值缓存(短期过期,避免缓存穿透)
                    redisTemplate.opsForValue().set(cacheKey, null, 5, TimeUnit.MINUTES);
                }
                return coupon;
            } else {
                // 6. 未获取到锁,休眠后重试(控制并发)
                Thread.sleep(RETRY_INTERVAL);
                return getSeckillCoupon(couponId); // 递归重试
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        } finally {
            // 7. 释放锁(确保锁是当前线程持有,避免误删)
            if (locked) {
                String unlockScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
                redisTemplate.execute(
                    new DefaultRedisScript<>(unlockScript, Integer.class),
                    Collections.singletonList(lockKey),
                    "1" // 与加锁时的value一致
                );
            }
        }
    }
}

方案 2:热点数据永不过期 + 异步更新

原理

  • 缓存不设置过期时间(逻辑上永不过期),避免因过期导致的击穿。
  • 后台启动定时任务,定期从 DB 更新缓存数据,保证数据一致性。

适用场景:热点数据实时性要求不高(如优惠券基本信息,非库存)。

代码示例
java 复制代码
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.concurrent.TimeUnit;

@Service
public class HotCouponService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final CouponDao couponDao;
    // 热点优惠券缓存key前缀(不设置过期时间)
    private static final String HOT_COUPON_KEY = "coupon:hot:";

    public HotCouponService(RedisTemplate<String, Object> redisTemplate, CouponDao couponDao) {
        this.redisTemplate = redisTemplate;
        this.couponDao = couponDao;
    }

    /**
     * 查询热点优惠券(缓存永不过期)
     */
    public Coupon getHotCoupon(Long couponId) {
        String cacheKey = HOT_COUPON_KEY + couponId;
        Coupon coupon = (Coupon) redisTemplate.opsForValue().get(cacheKey);
        if (coupon == null) {
            // 缓存未命中(首次加载),直接查DB并写入缓存(无过期时间)
            coupon = couponDao.queryById(couponId);
            if (coupon != null) {
                redisTemplate.opsForValue().set(cacheKey, coupon); // 不设置过期时间
            }
        }
        return coupon;
    }

    /**
     * 定时任务:每30分钟更新热点优惠券缓存(异步更新,避免穿透)
     */
    @Scheduled(fixedRate = 30 * 60 * 1000) // 30分钟执行一次
    public void refreshHotCouponCache() {
        // 查询所有热点优惠券ID(可从配置或DB获取)
        List<Long> hotCouponIds = couponDao.queryHotCouponIds();
        for (Long id : hotCouponIds) {
            Coupon latestCoupon = couponDao.queryById(id);
            if (latestCoupon != null) {
                redisTemplate.opsForValue().set(HOT_COUPON_KEY + id, latestCoupon);
            }
        }
    }
}

方案 3:熔断降级(临时返回默认值)

原理 :缓存失效时,通过熔断机制直接返回默认值(如 "活动太火爆,请稍后再试"),不查询 DB,保护数据库。适用场景:非核心流程、允许临时返回降级结果的场景。

代码示例(基于 Guava RateLimiter 简单降级)
java 复制代码
import com.google.common.util.concurrent.RateLimiter;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;

@Service
public class CouponDegradeService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final CouponDao couponDao;
    // 限流工具(控制DB查询QPS)
    private final RateLimiter rateLimiter = RateLimiter.create(10); // 允许每秒10次DB查询
    private static final String COUPON_KEY = "coupon:info:";

    public CouponDegradeService(RedisTemplate<String, Object> redisTemplate, CouponDao couponDao) {
        this.redisTemplate = redisTemplate;
        this.couponDao = couponDao;
    }

    /**
     * 查询优惠券(熔断降级策略)
     */
    public Coupon getCouponWithDegrade(Long couponId) {
        String cacheKey = COUPON_KEY + couponId;
        Coupon coupon = (Coupon) redisTemplate.opsForValue().get(cacheKey);
        
        if (coupon != null) {
            return coupon; // 缓存命中
        }

        // 缓存失效,尝试获取令牌(控制DB访问)
        if (rateLimiter.tryAcquire()) {
            // 获得令牌,查询DB并更新缓存
            coupon = couponDao.queryById(couponId);
            if (coupon != null) {
                redisTemplate.opsForValue().set(cacheKey, coupon, 30, TimeUnit.MINUTES);
            }
            return coupon;
        } else {
            // 未获得令牌,返回降级结果
            return new Coupon() {{
                setId(couponId);
                setMessage("活动太火爆,请稍后再试"); // 降级提示
            }};
        }
    }
}

方案对比与选择

方案 优点 缺点 适用场景
分布式锁 数据一致性高,适用范围广 加锁 / 解锁有性能损耗,可能有死锁风险 高并发、数据实时性要求高
永不过期 + 异步更新 无锁竞争,性能好 数据可能有延迟,需维护定时任务 实时性要求低的热点数据
熔断降级 实现简单,保护 DB 效果好 用户体验可能受影响(返回降级结果) 非核心流程、允许临时降级

实际业务中,可结合场景组合使用(例如:分布式锁 + 随机过期时间,既防击穿也防雪崩)

相关推荐
Albert Edison8 小时前
【Redis】Centos7.9 安装 Redis 5 教程
数据库·redis·缓存
Steadfast_GG9 小时前
Redis中的通用命令
redis·缓存
小二·9 小时前
Redis 内存溢出(OOM)排查与恢复实战
数据库·redis·bootstrap
pqk6V6Vep9 小时前
Redis 分布式锁进阶第一篇讲解
数据库·redis·分布式
giaz14n9X9 小时前
Redis 分布式锁进阶第六十一篇
数据库·redis·分布式
JAVA面经实录91712 小时前
Redis 知识体系(完整版)
java·redis·nosql数据库·nosql
颜笑晏晏13 小时前
长输入短输出场景下的 SGLang 推理性能实测前缀缓存、PD 分离配比与参数调优
缓存·推理优化·sglang·ai infra·pd分离
ManageEngine卓豪13 小时前
数据库可观测性:MySQL与Redis监控核心监控指标与全栈运维解决方案
数据库·redis·mysql·数据库性能·数据库监控
真实的菜13 小时前
Redis 从入门到精通(十四):Redis 7.x 新特性全解 —— 系列收官之作
数据库·redis·缓存
小小工匠15 小时前
Redis - 缓冲区管理:避免溢出引发的“惨案“
redis·性能优化·集群·内存管理·持久化