对黑马点评中Redis缓存穿透与击穿解决方案的小理解
2026/6/26 3:15:58 网站建设 项目流程

一.首先是封装后的代码

@Component @Slf4j public class CacheClient { private final StringRedisTemplate stringRedisTemplate; public CacheClient(RedisTemplate redisTemplate, StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } public void set(String key, Object value, long time, TimeUnit timeUnit) { stringRedisTemplate.opsForValue().set(key, value.toString(), time, timeUnit); } public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback,Long time, TimeUnit timeUnit) { //先从reids看看有没有 String key = keyPrefix + id; String json = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { //有,直接返回 return BeanUtil.toBean(json, type); } //判断是不是空值 //要知道知道空值与空字符串不是一个,这里说的如果是后面再reddis中存的空字符串就直接报错,不可数据库上压力的可能 if (json != null) { return null; } R r = dbFallback.apply(id); if (r == null) { //还要将空值写入redis,为了防止缓存穿透 stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES); //正常返回 return null; } this.set(key, r, time, timeUnit); return r; } private static final ExecutorService CACHE_THREAD_POOL = Executors.newFixedThreadPool(10); public void setWithLogicalExpire(String key, Object value, long time, TimeUnit unit) { RedisData redisData = new RedisData(); redisData.setData(value); redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time))); stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); } public <R,ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type,Function<ID,R> dbFallback,Long time, TimeUnit timeUnit) { //先从reids看看有没有 String key = keyPrefix + id; String shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isBlank(shopJson)) { //有,直接返回 return null; } //由于下面设置的是属性的过期时间,所以要麻烦的取一下 //在Java中不方便操作JSON格式语句,先转换为通过toBean方法转换为RedisData对象,后面的字节码文件就是告诉toBean方法,按照RedisData类的属性模板来转换 RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class); //随后又通过get方法得到一个object的对象,但是object没有get,set方法,所以还要继续转成JSONObject类型的 JSONObject data = (JSONObject) redisData.getData(); //随后又将JSONObject类型的对象,转换为Shop类型的对象,方便传回 R r = JSONUtil.toBean(data, type); //时间的类型是LocalDateTime,直接与当前时间比较,判断是否过期即可 LocalDateTime expirTime = redisData.getExpireTime(); //如果过期时间在当前时间之后,说明没有过期 if (expirTime.isAfter(LocalDateTime.now())) { //没有过期,直接返回 return r; } //只有过期了,才需要重建缓存 String lockKey = LOCK_SHOP_KEY + id; boolean islock = tryLock(lockKey); if (islock) { CACHE_THREAD_POOL.submit(() -> { //saveShop2Redis是当前类的方法,所以要使用this调用 try { R r1 = dbFallback.apply(id); this.set(key, r1, time, timeUnit); } catch (Exception e) { throw new RuntimeException(e); } finally { unLock(lockKey); } }); } return r; } private boolean tryLock(String key) { Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); //防止空指针,如果是空指针的话会直接返回 false而不是抛异常 return BooleanUtil.isTrue(flag); } private void unLock(String key) { stringRedisTemplate.delete(key); } }
1.关于开头的构造注入
  • 首先要明确StringRedisTemplate stringRedisTemplate是我通过Java程序与redis交流的工具类,但他光导包是没有用的,他的创建需要大量的配置,例如redis的密码等等,这些由spring boot来自动完成后创建完整可用的bean存在容器里,而我们要拿到spring管理的bean对象用import是没有用的,他只可以找到类而非最后的对象
  • 那我们要如何拿到这个bean对象呢?首先类上面的@Component注解会让spring启动时吧当前类实例化成对象,存入容器中,在创建bean的时候spring也读构造函数,随后识别到StringRedisTemplate后将它对应的bean对象传入,为什么非要这个对象?一是需要其中已经配置好的连接设置,二是RedisTemplate的方法都是实例方法,只可以通过对象调用
2.关于set方法的设置
  • 就是为了省事
原来要写 stringRedisTemplate.opsForValue().set("user:1", userInfo.toString(), 30, TimeUnit.MINUTES);
但是现在只要写cacheClient.set("user:1", userInfo, 30, TimeUnit.MINUTES);
3.关于setWithLogicalExpire方法
  • 这个其实就是为了后面解做准备,缓存击穿就是同一个热点 key 过期的瞬间大量请求直接打到了数据库
public void setWithLogicalExpire(String key, Object value, long time, TimeUnit unit) { //创建RedisData,这个类只有两个属性:Object data真正要缓存的业务对象,LocalDateTime expireTime:自定义的逻辑过期时间 RedisData redisData = new RedisData(); //将你的数据放进包装对象 redisData.setData(value); //不直接设置TTL过期时间,而是直接设置一个逻辑过期时间点的属性 redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time))); //随后将整个RedisData转成JSON,存入redis stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData)); }
4.与3相连的queryWithLogicalExpire方法(解决热点key缓存击穿)
  • 首先为了提高复用性,需要对返回值做一些变动
//<R,ID>这个是声明了一个自定义的类型,R就是后面R的类型 public <R,ID> R queryWithLogicalExpire( String keyPrefix, ID id, // ID用作入参类型 Class<R> type, // R代表要转换的实体类 Function<ID,R> dbFallback, // 函数入参ID,返回R Long time, TimeUnit timeUnit )
  • 解析双层 JSON 结构
//第一层 JSON 转RedisData对象,分出两块内容:业务数据、逻辑过期时间 RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class); //`redisData.getData()`返回 Object,但是object无法解析内部字段,所以接着转JSONObject JSONObject data = (JSONObject) redisData.getData(); //随后就是用方法toBean就是反序列化成需要的对象 R r = JSONUtil.toBean(data, type); //同时取出预先存入的逻辑过期时间`expirTime`,用于判断缓存是否失效 LocalDateTime expirTime = redisData.getExpireTime();
  • 在判断缓存已经过期后就要进入异步更新的逻辑,首先是获取锁
String lockKey = LOCK_SHOP_KEY + id; boolean islock = tryLock(lockKey);
  • 随后开始异步更新
//只有当前线程拿到锁才有下一步 if (islock) { //submit把括号里的任务交给新的子线程去跑,主线程不会等待这段代码执行完毕,会直接跳出if,执行最后的return,返回旧数据,下面的子线程只进行大括号里面的 CACHE_THREAD_POOL.submit(() -> { try{ //apply就是传入参数t后执行传入的逻辑 R r1 = dbFallback.apply(id); //this就是当前对象实例,将最新的数据存进去 this.set(key, r1, time, timeUnit); } catch (Exception e) { throw new RuntimeException(e); //不论try里面的代码是不是对的,一定会执行内部代码 } finally { unLock(lockKey); } }); } //最后return r,但检测到过期的这一次还是旧数据
5.关于queryWithMutex(同步互斥锁解决穿透)
public Shop queryWithMutex(Long id) { //1. 查询缓存 String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); if (StrUtil.isNotBlank(shopJson)) { //缓存命中,直接返回 return BeanUtil.toBean(shopJson, Shop.class); } //2. 缓存是空字符串(之前存的无效id标记,防穿透) if (shopJson != null) { return null; } //走到这里:shopJson == null,缓存彻底过期/无缓存,需要重建 String lockKey = LOCK_SHOP_KEY + id; Shop shop = null; try { //3. 尝试获取分布式锁 boolean flag = tryLock(lockKey); if (!flag) { //没抢到锁:休眠50ms,递归重新执行本方法,再次查缓存 Thread.sleep(50); return queryWithMutex(id); } //4. 抢到锁,查询数据库 shop = getById(id); if (shop == null) { //数据库无数据,存入空字符串防穿透 stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } //5. 数据库有数据,同步写入Redis缓存 stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); } catch (InterruptedException e) { throw new RuntimeException(e); } finally { //6. 无论正常/异常,强制释放锁,防止死锁 unLock(lockKey); } return shop; }
  • 与queryWithLogicalExpire的区别
互斥锁 缓存没了才上锁,抢到锁才查库写缓存;没抢到就等着重试;能拦截不存在 id(防穿透),用户会卡顿 逻辑过期 缓存一直都在,过期直接返回旧数据,后台悄悄更新;不用等用户无延迟,没法拦截无效 id
6.关于queryWithPassThrough(解决缓存穿透)
  • 还是用自定义泛型,方便复用
  • 主要注意空字符串与null

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询