day33 缓存击穿方案与缓存工具类封装

1. 今天做了什么

  • 学习缓存击穿问题的两种解决方案:互斥锁和逻辑过期
  • 实现了互斥锁解决缓存击穿(queryWithMutex)
  • 实现了逻辑过期解决缓存击穿(queryWithLogicalExpire)
  • 对逻辑过期方案的适用场景产生了疑问
  • 学习缓存工具类封装 CacheClint(讲义中为 CacheClient),将缓存穿透、缓存击穿的处理逻辑从 Service 中抽成通用工具方法,ShopServiceImpl 改为调用工具类
  • 首次接触”把函数当作参数传进方法”的写法:泛型方法 public <R,ID> R queryWithPassThrough(...) 中的 Function<ID,R> dbFallback 参数,调用时传入方法引用(如 this::getById)

2. 核心知识点

缓存击穿

一些高并发访问、且缓存重建时间较长的数据发生过期时,在缓存尚未建立之前,所有请求都无法命中,直接查询数据库重建缓存,给数据库带来巨大压力。也叫热点 Key 问题。

解决方案一:互斥锁

  • 为查询数据库、建立缓存的过程添加互斥锁
  • 当一个线程缓存未命中,拿到锁开始查数据重建缓存
  • 在此期间其他所有请求无法拿到锁,进入等待(休眠后重试)
  • 直到缓存重建完成,其他请求从缓存中查到数据
  • 特点:实现简单,无额外内存占用,保证数据一致性,但其他线程需要等待

互斥锁实现思路

  1. 使用 Redis 的 setnx(setIfAbsent),只有 key 不存在时才设置成功
  2. 尝试获取锁就是尝试用 setnx 设置 key,成功返回 true,其他线程无法获取锁
  3. 释放锁就是对设置的 key 进行删除
  4. 为防止锁无法释放,设置 key 时加失效时间

解决方案二:逻辑过期

  • 不设置物理过期时间,添加一个字段作为逻辑过期时间
  • 缓存始终能命中(key 不会物理失效)
  • 当一次请求查到缓存,发现缓存逻辑过期,获取互斥锁,开启新线程执行缓存重建,并返回旧数据
  • 重建期间,其他请求无法获取锁,直接返回查到的旧数据
  • 特点:无需等待,性能好,但实现稍复杂,额外占用内存存储过期时间,不保证数据强一致性

逻辑过期实现要点

  1. 使用 RedisData 类封装数据和逻辑过期时间,不侵入实体类
  2. 缓存 key 不设置物理 TTL,逻辑过期时间存在 value 中
  3. 缓存未命中直接返回 null,不再去查数据库
  4. 获取到锁则开启新线程建立缓存并释放锁
  5. 无论是否获取到锁,最后都返回旧数据

缓存工具类封装(CacheClint)

  • 把缓存穿透(缓存空值)、缓存击穿(互斥锁、逻辑过期)的完整处理逻辑从 ShopServiceImpl 抽到独立的工具类 CacheClint 中,Service 只负责调用
  • 工具类方法使用泛型方法签名,可适配任意实体类型,不再写死 Shop
  • 提供的方法:set(带 TTL 写入)、setWithLogicalExpire(带逻辑过期写入)、queryWithPassThrough(解决穿透)、queryWithMutex(互斥锁解决击穿)、queryWithLogicalExpire(逻辑过期解决击穿)

泛型方法与函数式参数(重点,第一次见)

  • 方法签名:public <R,ID> R queryWithPassThrough(String prefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit)
  • <R,ID> 是方法级别的泛型声明,R 是返回类型,ID 是主键类型,都在调用时才确定
  • Function<ID,R> dbFallback 是一个函数式接口参数,含义是”接收一个 ID,返回一个 R“,代表”根据 id 从数据库查询数据”这个行为
  • 函数也能当参数:调用时传方法引用 this::getById(或 Lambda),工具类内部通过 dbFallback.apply(id) 触发查询
  • 好处:缓存逻辑只写一次,具体查库动作由调用方注入,任何实体都能复用这套缓存方案

3. 深度思考

疑问:逻辑过期方案中,缓存未命中直接返回 null,未建立缓存的资源无法访问,这样的设计合理吗?

解答:

逻辑过期方案的设计前提就是热点数据提前预热,它不是一个通用的缓存查询方案。详细说明如下:

1. 为什么缓存未命中直接返回 null?

逻辑过期方案的核心假设是”缓存始终存在”——因为 key 不设物理过期时间,只要预热过就一直在 Redis 中。

如果缓存未命中时去查数据库,会产生两个问题:

  • 高并发场景下,第一次访问某个 key 时,大量请求同时打到数据库,本质上就是缓存击穿问题本身
  • 逻辑过期方案的设计目标是”请求不等待、快速返回”,如果要查数据库就违背了这个目标

所以逻辑过期方案故意在缓存未命中时返回 null,就是为了强制要求热点数据必须提前预热。

2. 实际项目中怎么用?

实际项目中不会只用逻辑过期一种方案,而是多种方案并存:

数据类型 适用方案 说明
普通数据 互斥锁 + 缓存穿透(queryWithMutex) 正常缓存查询,缓存未命中查数据库,用互斥锁防止击穿
热点数据 逻辑过期(queryWithLogicalExpire) 提前预热到 Redis,过期后异步重建,始终返回旧数据不等待

3. 为什么函数也能当参数传入?

  • Java 中函数式接口(如 Function<ID,R>)只有一个抽象方法,因此可以用方法引用或 Lambda 表达式的形式”打包一段行为”作为值传递
  • this::getById 就是”方法引用”,它把 getById(ID) 这个方法本身变成了一个 Function<ID,R> 对象传入工具类
  • 工具类不关心”怎么查数据库”,只负责”缓存逻辑 + 在需要时调用 dbFallback.apply(id)“,实现行为参数化,这也是工具类能被任意实体复用的原因
  • 这是之前没有见过的写法:以前方法参数都是数据,这次参数本身是一段可执行逻辑

4. 踩过的坑

5. 遗留问题 / 想不通