学习日报 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 效果好 用户体验可能受影响(返回降级结果) 非核心流程、允许临时降级

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

相关推荐
hong_zc2 小时前
redis事务
redis
麦兜*2 小时前
Redis高可用架构设计:主从复制、哨兵、Cluster集群模式深度对比
java·数据库·spring boot·redis·spring·spring cloud·缓存
王嘉俊9252 小时前
Redis 入门:高效缓存与数据存储的利器
java·数据库·redis·后端·spring·缓存·springboot
Vahala0623-孔勇3 小时前
MyBatis缓存架构深度拆解:从PerpetualCache的LRU陷阱到Redis分布式二级缓存防穿透实战
缓存·架构·mybatis
kimi7045 小时前
HTTP(web缓存与历史迭代)
缓存
June`5 小时前
Redis核心应用:从单机到分布式架构解析
数据库·redis·缓存
会挠头但不秃6 小时前
Redis数据结构和常用命令
数据库·redis·缓存
庸人自扰618 小时前
Redis从零讲解
数据库·redis·缓存
扫地的小何尚9 小时前
NVIDIA Dynamo深度解析:如何优雅地解决LLM推理中的KV缓存瓶颈
开发语言·人工智能·深度学习·机器学习·缓存·llm·nvidia