当前位置: 首页 > news >正文

项目中缓存雪崩,击穿,穿透的应对方法

1.三个问题的本质

问题类型通俗解释(以一个外卖项目为例)后果
缓存穿透用户查询一个不存在的菜品(如 ID=-1 的菜品),缓存和数据库都没有,导致请求每次都打到数据库数据库被大量无效请求拖垮(比如恶意攻击)
缓存雪崩大量缓存 key(如所有菜品缓存)在同一时间过期,导致所有请求瞬间落到数据库数据库瞬间压力暴增,可能直接宕机
缓存击穿某个热点 key(如爆款菜品)过期瞬间,刚好有大量请求访问这个 key数据库被这波集中请求打崩

我们来一一解决。

2.缓存穿透

核心思路:让不存在的数据也在缓存中留个 “标记”,避免重复查询数据库。

2.1 方案一:缓存空值

当查询数据库发现数据不存在时,往 Redis 缓存一个 “空值”(如 null),并设置较短的过期时间(比如 5 分钟)。

2.1.1 注意

如果项目中使用了springCache,Spring Cache 中使用 @Cacheable 注解时,默认不会缓存 null 值

disableCachingNullValues()方法,这个方法的作用是主动禁用缓存 null 值—— 相当于 “明确告诉 Spring Cache:即使返回 null,也不能存到 Redis”。这会直接导致所有返回 null 的请求都无法被缓存,彻底失去拦截穿透的能力,是 “缓存穿透” 的 “帮凶”,必须禁止使用此方法

以一个菜品查询方法为例:

@Cacheable(value = "dish", key = "#id") // 缓存key:dish::1
public Dish getById(Long id) {return dishMapper.selectById(id); // 如果数据库查不到,返回null
}
  • 默认情况:当 dishMapper.selectById(id) 返回 null 时,Spring Cache 不会往 Redis 中存任何数据。
  • 问题:这会导致缓存穿透 —— 每次查这个不存在的 ID,都会直接打数据库(因为缓存里没有)。

解决方法:只需在 Spring Cache 的配置类中,设置 “允许缓存 null 值”,并指定空值的过期时间(避免长期占用缓存)。

@Configuration
@EnableCaching
public class CacheConfiguration {@Beanpublic CacheManager cacheManager(RedisConnectionFactory factory) {// 1. 基础配置(适用于大多数缓存)RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(1))           // 正常数据默认过期时间1小时.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())).serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))//.disableCachingNullValues() 不启用表示允许缓存null值.prefixCacheNameWith("sky:");             // 缓存前缀// 2. 为特定缓存设置不同的过期时间Map<String, RedisCacheConfiguration> configMap = new HashMap<>();configMap.put("dish", defaultConfig.entryTtl(Duration.ofMinutes(5)));   // 菜品空值5分钟过期configMap.put("order", defaultConfig.entryTtl(Duration.ofMinutes(30))); // 订单空值30分钟过期// 3. 创建缓存管理器return RedisCacheManager.builder(factory).cacheDefaults(defaultConfig).withInitialCacheConfigurations(configMap).transactionAware().build();}
}

关键配置说明:

配置项作用为什么重要?
不调用 .disableCachingNullValues ()允许缓存 null 值让 “查不到的菜品 ID” 也能在 Redis 中创建键(如 sky:dish::999),值为 null
configMap 单独设置过期时间控制 null 值的缓存时长避免 null 值永久占用 Redis(如菜品 null 缓存 5 分钟后自动删除),平衡 “防穿透” 和 “缓存空间”

2.1.2 具体实现

这里以一个查询菜品为例:

@Service
public class DishService {@Autowiredprivate DishMapper dishMapper;@Autowiredprivate RedisTemplate<String, Object> redisTemplate;// 查询菜品详情(带防穿透处理)public Dish getById(Long id) {//如果在controller层使用了@Cacheable注解,则不需要在service层进行缓存判断//反之则需要在service层进行缓存判断
//        // 1. 定义缓存 key(格式:业务名:id,如 dish:1)
//        String key = "dish:" + id;
//
//        // 2. 先查缓存
//        Dish dish = (Dish) redisTemplate.opsForValue().get(key);
//        if (dish != null) {
//            // 2.1 缓存有数据,直接返回(避免查数据库)
//            return dish;
//        }// 3. 缓存没数据,查数据库dish = dishMapper.selectById(id);// 4. 如果数据库也没有(防止穿透)if (dish == null) {// 4.1 往缓存放一个空值,过期时间设短一点(如5分钟)redisTemplate.opsForValue().set(key, null, 5, TimeUnit.MINUTES);return null; // 返回空给前端}// 5. 数据库有数据,写入缓存(正常过期时间,如1小时)redisTemplate.opsForValue().set(key, dish, 1, TimeUnit.HOURS);return dish;}
}

作用

  • 第一次查不存在的菜品(如 ID=-1),数据库返回空后,缓存会存一个 null
  • 后续再查这个 ID,直接从缓存拿 null,不会再访问数据库,保护数据库。

2.2 方案二: 布隆过滤器

如果恶意请求太多(比如大量随机 ID 攻击),缓存空值会占用 Redis 空间,这时可以用布隆过滤器提前拦截。布隆过滤器能提前拦截 99% 以上的无效请求(如不存在的 ID),比缓存空值更省 Redis 空间,适合高并发场景。

操作

  1. 项目启动时,把所有有效菜品 ID 提前存入布隆过滤器(一个专门判断 “数据是否可能存在” 的工具);
  2. 收到查询请求时,先通过布隆过滤器判断 ID 是否 “可能存在”:
    • 若 “不可能存在”(如 ID=-1),直接返回空,不查缓存和数据库;
    • 若 “可能存在”,再走正常的 “缓存→数据库” 流程。
// 1. 初始化布隆过滤器(项目启动时执行)
@Component
public class BloomFilterInit implements CommandLineRunner {@Autowiredprivate DishMapper dishMapper;// 布隆过滤器(预计存100万条数据,误差率0.01)private BloomFilter<Long> dishIdBloomFilter = BloomFilter.create(Funnels.longFunnel(), 1000000, 0.01);@Overridepublic void run(String... args) throws Exception {// 加载所有有效菜品ID到布隆过滤器List<Long> allDishIds = dishMapper.selectAllIds();for (Long id : allDishIds) {dishIdBloomFilter.put(id);}}// 提供判断方法public boolean mightContain(Long id) {return dishIdBloomFilter.mightContain(id);}
}// 2. 在Service中使用
@Service
public class DishService {@Autowiredprivate BloomFilterInit bloomFilter;public Dish getById(Long id) {// 先通过布隆过滤器判断ID是否可能存在if (!bloomFilter.mightContain(id)) {return null; // 直接返回空,不查缓存和数据库}// 后面流程(查缓存→查数据库→写缓存)// ...}
}
  • 布隆过滤器能提前拦截 99% 以上的无效请求(如不存在的 ID),比缓存空值更省 Redis 空间,适合高并发场景。

3.缓存雪崩

核心思路:让缓存的过期时间 “错开”,避免同一时间大量失效;同时给数据库加 “保险”。

3.1 设置均匀的过期时间

给每个缓存 key 设置 “基础过期时间 + 随机偏移量”,让过期时间分散在一个时间窗口内(比如 1 小时 ± 10 分钟),避免 “同一时间大量失效”。

这里使用Spring Cache注解实现,比较简洁。

3.1.1 缓存配置类修改:

@Configuration
@EnableCaching
public class CacheConfiguration {// 全局默认配置(适用于大多数缓存:套餐、用户等)private static final Duration DEFAULT_TTL = Duration.ofHours(1); // 默认过期1小时private static final int DEFAULT_RANDOM_TTL = 600; // 默认随机偏移0-10分钟// 特殊缓存的配置(仅需要差异化的缓存才定义)private static final Duration DISH_TTL = Duration.ofHours(1); // 菜品基础过期1小时private static final int DISH_RANDOM_TTL = 600; // 菜品随机偏移0-10分钟private static final Duration ORDER_TTL = Duration.ofHours(2); // 订单基础过期2小时private static final int ORDER_RANDOM_TTL = 1200; // 订单随机偏移0-20分钟@Beanpublic CacheManager cacheManager(RedisConnectionFactory factory) {// 1. 基础配置(适用于大多数缓存)RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(1))           // 正常数据默认过期时间1小时.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())).serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))//.disableCachingNullValues() 不启用表示允许缓存null值(解决穿透).prefixCacheNameWith("sky:");             // 缓存前缀 sky:value::key// 2. 特殊缓存配置(仅覆盖需要差异化的部分)Map<String, RedisCacheConfiguration> configMap = new HashMap<>();// 菜品缓存:基础1小时 + 0-10分钟随机过期时间(覆盖默认)configMap.put("dish", defaultConfig.entryTtl(DISH_TTL));// 订单缓存:基础2小时 + 0-20分钟随机过期时间(覆盖默认)configMap.put("order", defaultConfig.entryTtl(ORDER_TTL));// 其他特殊缓存(如有)在这里添加,例如:// configMap.put("vipUser", defaultConfig.entryTtl(Duration.ofDays(1)));// 3. 构建缓存管理器:默认配置兜底,特殊配置覆盖return RedisCacheManager.builder(factory).cacheDefaults(defaultConfig) // 全局默认规则(大多数缓存用).withInitialCacheConfigurations(configMap) // 特殊缓存规则(覆盖默认).transactionAware() // 支持事务:缓存操作与数据库事务同步(避免脏数据).build();}// 统一的随机过期时间工具方法(所有缓存通用)public static int getRandomTtl(String cacheName) {// 特殊缓存用自己的随机范围,其他用默认switch (cacheName) {case "dish": return new Random().nextInt(DISH_RANDOM_TTL);case "order": return new Random().nextInt(ORDER_RANDOM_TTL);// 新增特殊缓存时,在这里添加分支即可default: return new Random().nextInt(DEFAULT_RANDOM_TTL); // 默认随机范围}}
}

配置中:

场景操作方式优势说明
大多数通用缓存(套餐、用户等)直接在方法上使用 @Cacheable(value = "缓存名称")(如 @Cacheable(value = "setmeal")自动继承全局默认配置(1 小时基础过期 + 0-10 分钟随机偏移),无需修改配置类,新增缓存时仅需添加注解,零配置成本
特殊缓存(菜品、订单等)在 configMap 中单独配置(如 configMap.put("dish", defaultConfig.entryTtl(DISH_TTL))可自定义基础过期时间(如订单 2 小时),覆盖全局默认规则,满足不同业务的差异化缓存需求
随机过期时间管理调用统一工具方法 CacheConfiguration.getRandomTtl("缓存名称") 生成随机偏移量集中管理各缓存的随机范围(如菜品 0-10 分钟、订单 0-20 分钟),修改时仅需调整配置类常量,全项目生效,避免分散维护

3.1.2 手动添加随机过期时间

配置类修改好了以后,我们要在控制类中手动添加随机过期时间,其中分为两类:

非特殊缓存:使用默认设置的过期时间和随机偏移

@Service
public class SetmealService {@Autowiredprivate SetmealMapper setmealMapper;@Autowiredprivate RedisTemplate<String, Object> redisTemplate;// 非特殊缓存:使用全局默认配置(无需在configMap中定义)@Cacheable(value = "setmeal", key = "#id")public Setmeal getById(Long id) {Setmeal setmeal = setmealMapper.selectById(id);// 必须手动添加随机过期时间(否则只有固定的1小时过期)if (setmeal != null) {// 生成与@Cacheable一致的key(前缀+缓存名+::+id)String cacheKey = "sky:setmeal::" + id; //基础时间:全局默认1小时(3600秒,无需手动定义)//这里硬编码了,其实不太合适,可以在配置类中添加get方法long baseTtl = 3600; // 随机偏移:调用全局默认随机方法(0-10分钟)int randomTtl = CacheConfiguration.getRandomTtl("setmeal"); // 最终过期时间 = 基础时间 + 随机偏移(关键:避免固定时间过期)redisTemplate.expire(cacheKey, baseTtl + randomTtl, TimeUnit.SECONDS);}return setmeal;}
}

特殊缓存:根据业务需要特别设置过期时间和随机偏移

@Service
public class OrderService {@Autowiredprivate OrderMapper orderMapper;@Autowiredprivate RedisTemplate<String, Object> redisTemplate;// 使用订单特殊缓存:基础时间2小时(覆盖默认1小时)@Cacheable(value = "order", key = "#orderId")public Order getById(Long orderId) {// 查询数据库获取订单信息Order order = orderMapper.selectById(orderId);// 设置最终过期时间:2小时基础 + 0-20分钟随机(与默认配置区分)if (order != null) {// 生成与@Cacheable一致的key(前缀+缓存名+::+订单ID)String cacheKey = "sky:order::" + orderId; // 基础时间:2小时(7200秒,来自订单特殊配置)long baseTtl = CacheConfiguration.ORDER_TTL.getSeconds(); // 随机偏移:调用订单专属随机方法(0-1200秒)int randomTtl = CacheConfiguration.getRandomTtl("order"); // 最终过期时间 = 7200 + 随机值(如300秒)= 7500秒(2小时5分钟)redisTemplate.expire(cacheKey, baseTtl + randomTtl, TimeUnit.SECONDS);}return order;}
}

redisTemplate.expire(cacheKey, baseTtl + randomTtl, TimeUnit.SECONDS):设置 Redis 中某个键的过期时间,采用的是基础过期时间加上随机过期时间的策略。

参数说明:
1. cacheKey: 要设置过期时间的 Redis 键名
2. baseTtl + randomTtl: 过期时间总秒数,由两部分组成:
3. baseTtl: 基础过期时间(固定值)
4. randomTtl: 随机过期时间偏移量(通过 getRandomTtl() 方法获取)
5. TimeUnit.SECONDS: 时间单位为秒

3.1.3 核心要点

  1. 过期时间分散逻辑:最终过期时间 = 基础时间 + 随机偏移,确保同一类缓存的过期时间分布在 “基础时间~基础时间 + 随机范围” 之间(如订单分散在 2h~2h20m);
  2. key 的一致性:手动生成的 key 必须与@Cacheable自动生成的 key 完全一致(依赖配置类的prefixCacheNameWith前缀);
  3. 避免硬编码:基础时间从配置类动态获取(如getOrderBaseTtlSeconds()),修改时只需调整配置类,无需改动业务代码;
  4. 特殊与默认缓存区分:通用缓存用全局默认规则,特殊缓存在configMap中单独配置,兼顾简洁性和灵活性。

3.2 互斥锁兜底

当某些热点 key 因为 “随机过期时间没完全错开” 而同时失效时,通过加锁确保 “同一时间只有一个请求去数据库更新缓存”,其他请求等待后从缓存获取数据,避免大量请求瞬间压垮数据库(缓存雪崩的兜底防护)。

3.2.1 互斥锁的核心实现原理

基于 Redis 的setIfAbsent方法(原子操作)实现分布式互斥锁,确保同一时间只有一个线程能获取锁:

  • setIfAbsent(key, value, timeout):当 key 不存在时,设置值并返回true(获取锁成功);若 key 已存在,直接返回false(获取锁失败);
  • 锁必须设置过期时间(如 5 秒),防止持有锁的线程意外崩溃导致死锁;
  • 配合重试机制:未获取到锁的线程等待一段时间后重试,直到获取锁或超时。

3.2.2 具体实现

(1). 封装 Redis 锁工具类(通用组件)
@Component
public class RedisLockUtil {@Autowiredprivate StringRedisTemplate stringRedisTemplate;/*** 获取锁* @param lockKey 锁的唯一标识(如"lock:order:1001")* @param timeout 锁的自动过期时间* @param unit 时间单位* @return true=获取锁成功,false=获取锁失败*/public boolean tryLock(String lockKey, long timeout, TimeUnit unit) {// 用UUID作为锁的value,用于释放锁时验证身份(避免误删其他线程的锁)String lockValue = UUID.randomUUID().toString();// setIfAbsent:原子操作,key不存在则设置值并返回true,否则返回falseBoolean success = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, timeout, unit);// 注意:Redis返回的Boolean可能为null,需转成boolean(避免NPE)return Boolean.TRUE.equals(success);}/*** 释放锁* @param lockKey 锁的唯一标识*/public void unlock(String lockKey) {// 先查询锁的value(自己设置的UUID)String lockValue = stringRedisTemplate.opsForValue().get(lockKey);if (lockValue != null) {// 只有当锁的value与自己设置的一致时才删除(避免删除其他线程的锁)stringRedisTemplate.delete(lockKey);}}
}

(2) Controller 层方法集成互斥锁(核心调整)

因为我的controller层方法上加了注解@Cacheable,所以这里以在controller层实现互斥锁举一个例子。

@RestController
@RequestMapping("/order")
public class OrderController {@Autowiredprivate OrderService orderService;@Autowiredprivate RedisTemplate<String, Object> redisTemplate;@Autowiredprivate RedisLockUtil redisLockUtil;// 最大重试次数private static final int MAX_RETRY_COUNT = 3;// 重试间隔(毫秒)private static final long RETRY_INTERVAL = 50;// Controller层加@Cacheable:缓存名称"order",key为订单ID@Cacheable(value = "order", key = "#orderId")@GetMapping("/{orderId}")public Result<Order> getById(@PathVariable Long orderId) {// 1. 定义缓存key和锁key(与@Cacheable生成的key保持一致)String cacheKey = "sky:order::" + orderId; // 前缀+缓存名+::+key(与配置类prefix一致)String lockKey = "lock:order:" + orderId;  // 锁key:与缓存key一一对应// 二次查缓存:防止“前一个线程已更新缓存,当前线程已进入方法体”的并发场景Order order = (Order) redisTemplate.opsForValue().get(cacheKey);if (order != null) {return Result.success(order);}// 3. 尝试获取互斥锁(最多重试3次)int retryCount = 0;while (retryCount < MAX_RETRY_COUNT) {// 获取锁(5秒过期,确保大于查库+更新缓存的耗时)boolean locked = redisLockUtil.tryLock(lockKey, 5, TimeUnit.SECONDS);if (locked) { // 4. 获取锁成功try {// 三次查缓存:防止“持有锁期间,其他线程已释放锁并更新缓存”order = (Order) redisTemplate.opsForValue().get(cacheKey);if (order != null) {return Result.success(order);}// 4.2 调用Service查数据库(此时只有当前线程能执行)order = orderService.getById(orderId); // Service层仅负责查库,无缓存逻辑if (order == null) {// 空值缓存(防穿透,5分钟过期)redisTemplate.opsForValue().set(cacheKey, null, 300, TimeUnit.SECONDS);return Result.success(null);}// 4.3 手动设置缓存(带随机过期时间,覆盖@Cacheable的默认固定时间)long baseTtl = CacheConfiguration.getOrderBaseTtlSeconds(); // 2小时int randomTtl = CacheConfiguration.getRandomTtl("order"); // 0-20分钟redisTemplate.opsForValue().set(cacheKey, order, baseTtl + randomTtl, TimeUnit.SECONDS);return Result.success(order);} finally {// 4.4 释放锁(必须在finally中执行)redisLockUtil.unlock(lockKey);// 等待50ms,给持有锁的线程时间处理}} else { // 5. 获取锁失败,等待后重试retryCount++;try {Thread.sleep(RETRY_INTERVAL);//} catch (InterruptedException e) {Thread.currentThread().interrupt();//恢复中断状态return Result.error("查询失败,请重试");}}}// 6. 重试耗尽,返回错误return Result.error("系统繁忙,请稍后再试");}
}

原理解析:

1. 锁的粒度设计(lockKey = "lock:order:" + orderId

  • 原理:为每个缓存 key(如订单 1001)配专属锁,而非全局锁。
  • 必要性:避免查一个订单锁住所有订单查询,减少锁竞争。

2. 二次查缓存(进入方法体后)

  • 原理@Cacheable初始检查后,可能有其他线程已更新缓存,需再次验证。
  • 必要性:避免缓存已存在仍查库,减少数据库无效压力。

3. 原子抢锁(tryLock方法)

  • 原理setIfAbsent确保 “检查锁是否存在” 和 “设置锁” 是原子操作,避免多个线程同时认为 “锁不存在” 而同时抢锁成功。
  • 必要性:限制缓存失效时只有一个请求查库,防数据库雪崩。

4. 未抢锁重试(while 循环 + sleep)

  • 原理:未抢锁线程等短时间(如 50ms)后重试,不直接失败。
  • 必要性:重试后可从缓存取数据,提升用户体验;限制重试次数防无限等待。

5. 三次查缓存(抢锁成功后)

  • 原理:抢锁过程中可能有其他线程已释放锁并更新缓存,需最终确认。
  • 必要性:避免持有锁却重复查库,是防冗余查库的最后防护。

6. 锁的过期时间(如 5 秒)

  • 原理:过期时间大于 “查库 + 更缓存” 最大耗时,超时自动释放。
  • 必要性:防止线程异常(如 OOM)导致锁无法释放,避免死锁。

7. finally 块释放锁

  • 原理:无论业务成功 / 失败,finally 块都主动释放锁。
  • 必要性:缩短锁占用时间,提升并发效率,补充过期时间的防护。

8. 手动更新缓存(带随机过期时间)

  • 原理:手动设置 “基础时间 + 随机偏移”,覆盖@Cacheable固定过期。
  • 必要性:配合互斥锁双重防雪崩,解决缓存集中过期的源头。

3.3 基于SpringCache实现

如果我们在controller的每个需要设置缓存空值和互斥锁的方法中一一实现,这样会造成大量重复代码(冗余、难维护),所以我们想到用AOP+自定义注解封装通用逻辑。但如果项目中使用了Spring Cache(如@Cacheable),也可直接扩展 Spring Cache 的默认逻辑,无需重复造 AOP 轮子。

接下来是详细实现方案,使用本地缓存Caffeine,适合单体项目。

(1)引入依赖

需引入 Spring Cache 核心包 + Caffeine 包:

<!-- Spring Cache 核心(依赖 spring-context) -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-cache</artifactId>
</dependency><!-- Caffeine 缓存实现(本地缓存首选) -->
<dependency><groupId>com.github.ben-manes.caffeine</groupId><artifactId>caffeine</artifactId><version>3.1.8</version> <!-- 选择稳定版本 -->
</dependency>

作用:提供 Spring Cache 注解支持和高性能本地缓存实现,避免手写缓存逻辑。

(2) 开启Spring Cache支持

在 Spring Boot 启动类或配置类上添加 @EnableCaching 注解,开启 AOP 缓存切面:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;@SpringBootApplication
@EnableCaching // 关键:开启 Spring Cache 自动代理(AOP 切入缓存注解)
public class OrderApplication {public static void main(String[] args) {SpringApplication.run(OrderApplication.class, args);}
}

作用:激活 Spring 对 @Cacheable/@CachePut/@CacheEvict 等注解的解析,通过 AOP 自动拦截标注方法,实现 “查询前查缓存、查询后写缓存”。

(3) 配置 Caffeine 缓存管理器(核心:支持空值 + 过期时间)

创建缓存配置类,自定义 Caffeine 缓存管理器,重点配置:

  • 缓存过期时间(避免缓存雪崩);
  • 允许缓存 null 值(防缓存穿透);
  • 初始容量和最大容量(控制内存占用)。
@Configuration
public class CacheConfig {/*** 配置 Caffeine 缓存管理器*/@Beanpublic CacheManager caffeineCacheManager() {// 1. 配置 Caffeine 缓存规则(过期时间、容量)Caffeine<Object, Object> caffeine = Caffeine.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES) // 缓存写入后5分钟过期(防缓存雪崩).initialCapacity(100) // 初始缓存容量.maximumSize(1000); // 最大缓存容量(超过后按 LRU 淘汰)// 2. 创建 Caffeine 缓存管理器CaffeineCacheManager cacheManager = new CaffeineCacheManager();cacheManager.setCaffeine(caffeine);cacheManager.setAllowNullValues(true); // 关键:允许缓存 null 值(防缓存穿透)cacheManager.setCacheNames(java.util.Collections.singletonList("orderCache")); // 指定缓存名称(可多个)return cacheManager;}
}

核心作用

  • expireAfterWrite:设置缓存过期时间,避免缓存长期有效导致数据不一致,同时防止 “缓存雪崩”(若所有缓存同时过期,大量请求会穿透到数据库);
  • setAllowNullValues(true):默认 Spring Cache 不缓存 null,开启后可将 “数据库查不到的空结果” 存入缓存,后续请求直接从缓存拿 null,避免反复查库(防缓存穿透);
  • CacheManager:统一管理缓存实例(如 orderCache),后续注解可指定该缓存。

(4) 用 Caffeine LoadingCache 实现互斥锁(防缓存击穿)

Spring Cache 原生注解不直接支持互斥锁,但 Caffeine 的 LoadingCache 提供 get(K key) 方法:当缓存未命中时,自动调用 load(K key) 加载数据,且同一 key 只会被一个线程加载(天然互斥)

需封装 LoadingCache 实例,结合 Spring 注入使用:

import com.github.benmanes.caffeine.cache.LoadingCache;
import org.springframework.stereotype.Component;import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;// 1. 先定义订单 DAO(模拟数据库查询)
@Component
class OrderDao {// 模拟数据库查询:若订单ID不存在,返回 nullpublic Order getOrderById(Long orderId) {System.out.println("【数据库查询】orderId=" + orderId); // 用于验证是否防击穿if (orderId == 1001L) {return new Order(1001L, "手机订单");}return null; // 模拟空结果}
}// 2. 封装 Caffeine LoadingCache(实现互斥锁)
@Component
public class OrderCacheService {// 注入 DAO(实际项目用 @Autowired)@Resourceprivate OrderDao orderDao;// 初始化 LoadingCache:key=orderId,value=Order(支持互斥加载)private final LoadingCache<Long, Order> orderLoadingCache = Caffeine.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES) // 同缓存管理器过期时间.maximumSize(1000).build(this::loadOrderFromDb); // 缓存未命中时,调用 loadOrderFromDb 加载数据/*** 缓存未命中时,从数据库加载数据(互斥逻辑:同一 key 仅一个线程执行此方法)*/private Order loadOrderFromDb(Long orderId) {// 此处仅一个线程会执行数据库查询,其他线程等待结果return orderDao.getOrderById(orderId);}/*** 对外提供查询方法:优先查缓存,未命中则互斥查库*/public Order getOrderFromCache(Long orderId) {try {// 关键:get() 方法自带互斥锁,同一 orderId 只会有一个线程查库return orderLoadingCache.get(orderId);} catch (Exception e) {// 异常时返回 null(避免缓存异常导致服务不可用)return null;}}
}

互斥锁核心作用

  • LoadingCache.build(this::loadOrderFromDb):指定缓存未命中时的加载逻辑(loadOrderFromDb);
  • orderLoadingCache.get(orderId):当多个线程同时查询同一 orderId 且缓存失效时,Caffeine 会确保只有一个线程执行 loadOrderFromDb(查库),其他线程阻塞等待缓存更新,彻底避免 “缓存击穿”。

(5) 业务层结合 Spring Cache 注解(简化调用)

若想进一步简化代码,可在业务方法上用 @Cacheable 注解,直接关联 Caffeine 缓存管理器,无需手动调用 LoadingCache。此时需注意:@Cacheable 本身无互斥锁,需通过 自定义缓存解析器 或 切面 结合锁,或直接依赖 LoadingCache 的互斥能力。

简化示例(业务层方法):

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;@Service
public class OrderService {@Resourceprivate OrderCacheService orderCacheService;/*** @Cacheable 注解:自动查缓存,未命中则调用方法体(方法体内部用 LoadingCache 实现互斥)* cacheNames:指定用步骤3配置的 "orderCache" 缓存* key:缓存key,用 SpEL 表达式取 orderId(确保key唯一,粒度到订单ID)*/@Cacheable(cacheNames = "orderCache", key = "#orderId")public Order getOrder(Long orderId) {// 方法体:调用带互斥锁的缓存查询(若缓存未命中,内部会互斥查库)return orderCacheService.getOrderFromCache(orderId);}
}

注解作用

  • cacheNames = "orderCache":指定使用步骤 3 配置的缓存管理器(关联 Caffeine 缓存);
  • key = "#orderId":以订单 ID 为缓存 key(粒度到单个订单,避免全局锁,提高并发);
  • 自动逻辑:调用 getOrder(1001) 时,先查 orderCache 中 key=1001 的值,命中则直接返回;未命中则执行方法体(调用 orderCacheService 带互斥锁的查询),并将结果存入缓存。

只有当需要极致自定义逻辑(比如缓存更新的特殊触发条件、复杂的锁策略)时,才需要考虑手动 AOP;绝大多数业务场景下,Spring Cache 是更优解。

4. 缓存击穿

4.1 介绍

缓存击穿是 高并发场景下的缓存异常问题,核心定义和特点如下:

  • 核心场景:某一个 热点 Key(如电商的热门商品 ID、秒杀活动的商品 ID)在缓存中突然过期(或被主动删除),此时大量并发请求同时查询该 Key,发现缓存未命中后,会 同时穿透到数据库,导致数据库瞬间压力骤增(甚至宕机)。
  • 与其他缓存问题的区别
    • 缓存穿透:请求查询的是 不存在的 Key(如恶意查询 ID=-1),缓存和数据库都无数据,请求直接打穿到 DB。
    • 缓存雪崩:大量 Key 同时过期(如缓存服务重启、批量 Key 设置相同过期时间),导致大量请求同时穿透到 DB。
    • 缓存击穿:仅针对 单个热点 Key 过期,是 “单点突破” 型问题。

可以发现缓存击穿和缓存雪崩是同一个类型,缓存击穿是一个key失效,缓存雪崩是许多个key同时失效,所以他们的解决方法差不多。

4.2 解决方案

解决缓存击穿的核心思路是:避免大量请求在热点 Key 过期时同时查询数据库。以下两种方案是工业界最常用的实现,结合 Spring Boot 生态(如 Redis、Redisson)展开,步骤可直接落地。

我以在单体项目中实现为例。

单体项目中,所有用户请求最终都会进入「同一个服务进程」(比如 1 个 Tomcat 实例),因此:

  • 本地锁(如synchronized)能覆盖所有请求,不会出现 “跨实例锁失效” 的问题;
  • 实现成本低,无需额外引入 Redis 分布式锁、ZooKeeper 锁等组件。

4.3 实现步骤(以「Java+Redis+MySQL」为例)

我们以 “查询商品详情”(热点 key:product:10086)为业务场景,拆解每一步的逻辑和代码思路,关键步骤会标注 “防坑点”。

(1)优先查询缓存

所有请求先访问 Redis 缓存,尝试获取热点 key 的数据:

  1. 用 Redis 客户端(如 Jedis、Redisson)查询 key:String cacheData = redisTemplate.opsForValue().get("product:10086");
  2. 判断缓存是否命中:
    • 缓存命中cacheData != null且不是 “空值缓存”):直接将缓存数据反序列化为对象(如Product),返回给前端,流程结束;
    • 缓存未命中cacheData == null):进入下一步 “加本地锁控制查 DB”。

(2) 加本地锁,控制仅 1 个请求查 DB(核心步骤)

这一步是 “防击穿” 的关键:通过本地锁让所有 “缓存未命中” 的请求排队,只允许 1 个请求进入锁内查 DB,其他请求等待锁释放后复用缓存。

关键细节:锁内必须 “二次查缓存”

很多人会漏这一步 —— 比如多个请求同时到 “缓存未命中”,第一个请求加锁进入查 DB,其他请求在锁外等待;当第一个请求建完缓存释放锁后,第二个请求拿到锁如果直接查 DB,就白等了(此时缓存已存在)。因此锁内必须二次查缓存,确保缓存存在就直接复用。

具体操作如下(伪代码):

// 1. 定义本地锁(锁对象建议是“静态常量”,确保全进程唯一;锁的粒度要细,最好绑定热点key,避免锁整个方法)
private static final Object CACHE_LOCK = new Object(); // 2. 缓存未命中时,加本地锁
synchronized (CACHE_LOCK) { // 或用 ReentrantLock 实现更灵活的锁控制// 【防坑点】锁内二次查询缓存:避免等待期间已有请求建好了缓存String cacheDataAgain = redisTemplate.opsForValue().get("product:10086");if (cacheDataAgain != null) {// 二次查缓存命中:直接返回,无需查DBreturn JSON.parseObject(cacheDataAgain, Product.class);}// 3. 锁内二次查缓存仍未命中:只有这1个请求去查DBProduct product = mysqlMapper.selectProductById(10086); // 查数据库// 4. 处理DB查询结果:建缓存(关键:包括“空值缓存”)if (product == null) {// 情况1:DB也没数据(如商品已下架)→ 设“空值缓存”(过期时间短,比如5分钟)// 目的:避免后续请求反复查DB(防止衍生“缓存穿透”)redisTemplate.opsForValue().set("product:10086", "", 300, TimeUnit.SECONDS);return null; // 返回“商品不存在”} else {// 情况2:DB有数据→ 正常建缓存(过期时间合理,比如1小时)String productJson = JSON.toJSONString(product);redisTemplate.opsForValue().set("product:10086", productJson, 3600, TimeUnit.SECONDS);return product; // 返回商品详情}
}
// 锁会在代码块结束后自动释放(synchronized是自动释放,Lock需手动调用unlock())

(3):返回结果(流程收尾)

  • 若 DB 查询到数据:返回 DB 数据(同时已将数据写入缓存,后续请求直接查缓存);
  • 若 DB 查询不到数据:返回 “商品不存在”(同时已写入空值缓存,避免后续请求反复查 DB)。

文章转载自:

http://Bozgc7mk.LwtLd.cn
http://Z56cMoNY.LwtLd.cn
http://ZhOLe71k.LwtLd.cn
http://yVCNmOXW.LwtLd.cn
http://5JwHOyab.LwtLd.cn
http://89SYZpcK.LwtLd.cn
http://3lwnJ7p9.LwtLd.cn
http://zqaEMdpP.LwtLd.cn
http://ymiWt8xB.LwtLd.cn
http://c0NnBc8a.LwtLd.cn
http://djczXDK1.LwtLd.cn
http://2t3NJdkV.LwtLd.cn
http://X5U6t40Z.LwtLd.cn
http://wbpPYtY9.LwtLd.cn
http://XsfDgxw7.LwtLd.cn
http://rVakjwmo.LwtLd.cn
http://fHOAjXh8.LwtLd.cn
http://dliRhQDk.LwtLd.cn
http://rySltl8T.LwtLd.cn
http://F6IHBfLP.LwtLd.cn
http://dBNXSoR2.LwtLd.cn
http://ZiF4BGSL.LwtLd.cn
http://TZYZeFpa.LwtLd.cn
http://fiTTQNdW.LwtLd.cn
http://wCw5xt2U.LwtLd.cn
http://JqiH7JKG.LwtLd.cn
http://EIA9T8Dw.LwtLd.cn
http://2LFaoEYn.LwtLd.cn
http://V0qdeKOK.LwtLd.cn
http://QbjKU3v9.LwtLd.cn
http://www.dtcms.com/a/372492.html

相关文章:

  • AI推介-多模态视觉语言模型VLMs论文速览(arXiv方向):2025.06.10-2025.06.15
  • struct结构体内存对齐详解
  • 使用QLoRA 量化低秩适配微调大模型介绍篇
  • 变量与常量
  • 第7.10节:awk语言 exit 语句
  • 心路历程-权限的了解
  • 从0开始制做一个Agent
  • AIGC(AI生成内容)
  • CameraService笔记
  • JDK21对虚拟线程的实践
  • 054章:使用Scrapy框架构建分布式爬虫
  • 计算机视觉(十一):边缘检测Canny
  • 如何解决pip安装报错ModuleNotFoundError: No module named ‘wheel’问题
  • 监控系统 | 脚本案例
  • TI-92 Plus计算器:高等数学之函数特性判断
  • IDEA 配置tomcat服务器
  • HTTP中Payload的含义解析
  • docker-compose build命令及参数
  • 接入第三方升级协议OTA教程
  • IO模型多路转接
  • Python-基础语法
  • FastApi框架
  • 单片机的bin、exe、elf、hex文件差异
  • 基于ResNet50的智能垃圾分类系统
  • 大模型推理参数讲解
  • Linux 性能调优之 OOM Killer 的认知与观测
  • Linux->日志的实现
  • 西门子 S7-200 SMART PLC :3 台电机顺启逆停控制(上篇)
  • SAP系统两种部署方式:公有云VS私有云 企业如何选择?
  • 用博图FB类比c#中sdk的api