黑马点评-day02-缓存笔记

认识缓存

什么是缓存?

缓存是一种具备高效读写能力的数据暂存区域,用于临时存储高频访问的数据,以提升访问效率。


缓存的作用

  • 降低后端负载:减少对数据库等底层存储的直接访问压力,避免高并发场景下后端服务被打垮。
  • 提高服务读写响应速度:将数据从低速存储(如数据库)迁移到高速缓存(如Redis),显著缩短接口响应时间,提升用户体验。

缓存的成本

  • 开发成本:需要额外编写缓存读写、更新、失效等逻辑,增加代码复杂度。
  • 运维成本:需要部署、监控缓存服务(如Redis集群),保障其高可用与稳定性。
  • 一致性问题:缓存与数据库数据可能存在短暂不一致,需要设计合理的更新策略(如缓存更新模式、过期时间)来保证数据最终一致。

一、缓存穿透代码解析与疑问解答

思路

解决方案(被动防御)

  1. 缓存null:
    这样查到缓存,就不会打到数据库了
  2. 布隆过滤器:
    通过二进制存储数据库中某些信息的hash值判断是否存在, 但这种准确度不确定

原始代码

java 复制代码
//缓存穿透
private Shop queryWithPassThrough(Long id) {
    //从Redis查询店铺缓存
    String key  = CACHE_SHOP_KEY + id;
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //1.缓存命中
    if(StrUtil.isNotBlank(shopJson)) {
       //1.1 缓存中的是店铺信息
        return JSONUtil.toBean(shopJson, Shop.class);
    }
       //1.2命中为空
    if(shopJson != null) {
        return null;
    }
    
    //2.未命中
    //2.1查询数据库
    Shop shop = getById(id);
    //2.2结果不存在
    if (shop == null) {
        //2.2.1返回空值到redis,避免穿透
        stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
    }
    //2.3能查到,写入缓存
    stringRedisTemplate.opsForValue(0.set(key, JOSNUtil.toJsonStr(shop), CACHE_SHOP_TTL,TimeUnit.MINUTES);
    //3.返回店铺信息
    return shop;
}

核心疑问解答

疑问1:1.2 命中为空 if(shopJson != null) 能否改成 == ""

前置说明

Hutool 工具类 StrUtil.isBlank() 判定规则:

包含空字符""空白字符串(length > 0)" "null 三种情况均返回 true。所以只要判断了 !=null, 那就只能等于空字符串了。

直接写 == "" 的问题

Java 中字符串相等判断不能用 ==

  • == 比较的是对象内存地址,而非字符串内容;
  • equals() 方法才是用于比较字符串内容是否相等的正确方式。
推荐写法
java 复制代码
if ("".equals(shopJson)) { 
    // 用""调用equals,避免shopJson为null时触发空指针异常
    return null;
}

疑问2:2.3 写入缓存时为什么要设置过期时间

给缓存设置过期时间(TTL)是缓存设计的核心原则,主要解决以下问题:

(1)防止缓存与数据库数据不一致(缓存脏数据)

店铺信息(如价格、营业状态)可能在数据库中更新,若缓存永久有效,更新后缓存中的旧数据会持续返回,导致用户看到错误信息。设置过期时间后,缓存到期自动失效,下次请求会从数据库加载最新数据并重新缓存。

(2)防止 Redis 内存溢出

若所有缓存永久保存,Redis 内存会持续增长,最终触发内存淘汰策略(可能删除重要缓存)或直接内存溢出。设置过期时间可自动清理不再使用的缓存,控制 Redis 内存占用。

(3)兼容缓存击穿的后续防护(可选)

过期时间是实现"缓存击穿"防护(如互斥锁、逻辑过期)的基础,即使当前代码仅处理缓存穿透,设置过期时间也为后续扩展防护策略预留空间。

总结

  1. if(shopJson != null) 不建议直接改 == "",推荐使用 "" .equals(shopJson) 避免空指针且精准匹配空字符串;
  2. 缓存设置过期时间核心目的是防止数据不一致、控制 Redis 内存占用,同时兼容后续缓存击穿防护扩展。

二、缓存雪崩解析

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:

  • 给不同的Key的TTL添加随机值
  • 利用Redis集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

三、缓存击穿代码解析与疑问解答


思路

互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响

逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦

解决方案 优点 缺点
互斥锁 - 没有额外的内存消耗 - 保证一致性 - 实现简单 - 线程需要等待,性能受影响 - 可能有死锁风险
逻辑过期 - 线程无需等待,性能较好 - 不保证一致性 - 有额外内存消耗 - 实现复杂

1.互斥锁解决

原始代码

java 复制代码
private boolean tryGetLock(String key) {
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
}

private void unlock(String key) {
    stringRedisTemplate.delete(key);
}
java 复制代码
//互斥锁解决击穿
private Shop queryWithMutex(Long id) {
	//1.从redis中获取店铺缓存
    String key = CHCAHE_SHOP_KEY + id;
    String shopJson =  stringRedisTemplate.opsForValue().get(key);
    
    //2.判断缓存是否命中
    //2.1缓存命中
    if (StrUtil.isNotBlank(shopJson)) [
        return JSONUtil.toBean(shopJson, Shop.class);
    ]
    //2.2命中的为空值(防穿透)
    if (shopJson != null) {
        reutrn null;
    }
    //3.缓存未命中,尝试获取互斥锁
	String lockKey = LOCK_SHOP_KEY + id;
    boolean lock = false;
    Shop shop = null;
    try {
        lock = tryGetLock(lockKey);
        //3.1获取失败, 休眠一会重试
        if (!lock) {
            Thread.sleep(200);
             // 苏醒后先查缓存,这时可能其他线程做完了,避免重复查库
   		    String cacheShop = stringRedisTemplate.opsForValue().get(key);
		    if (StrUtil.isNotBlank(cacheShop)) {
		        return JSONUtil.toBean(cacheShop, Shop.class);
		    }
            return querWithMutex(id); //重试
        }
        //3.2获取成功,查询数据库
        shop = getById(id);
        //3.3.1数据库没查到
        if (shop == null) {
            //防穿透,null写入redis
            stringRedisTemplate.opForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        //3.3.2查到了,写入缓存
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
	}catch (InterruptedException e) {
            throw new RuntimeException(e);
    } finally {
        //释放锁
        unlock(lockKey);
        //4.返回店铺信息
        return shop;
    }
}

核心问题解答

1. 获取不到锁休眠苏醒后,获取到锁需注意的事项

休眠苏醒后重新竞争锁并成功,需重点关注以下3点:

1.1 再次检查缓存(核心)

休眠期间可能已有其他线程完成"查库+写缓存"操作,此时无需重复查询数据库,直接返回缓存数据即可:

java 复制代码
if (!lock) {
    Thread.sleep(200);
    // 苏醒后优先查缓存,避免重复查库
    String cacheShop = stringRedisTemplate.opsForValue().get(key);
    if (StrUtil.isNotBlank(cacheShop)) {
        return JSONUtil.toBean(cacheShop, Shop.class);
    }
    return queryWithMutex(id); // 未命中缓存再重试
}

1.2 控制重试次数/超时时间

避免无限递归重试导致栈溢出,建议增加重试次数限制:

java 复制代码
// 重载方法,增加重试次数参数
private Shop queryWithMutex(Long id, int retryCount) {
    if (retryCount >= 3) { // 最多重试3次
        throw new RuntimeException("获取锁超时,请稍后重试");
    }
    // ... 核心逻辑
    if (!lock) {
        Thread.sleep(200);
        return queryWithMutex(id, retryCount + 1); // 重试次数+1
    }
}

1.3 锁的过期时间兜底

锁的10秒过期时间是死锁的兜底方案,但需保证业务执行时间 < 锁过期时间(否则可能释放其他线程的锁):

  • 若业务逻辑执行超过10秒,当前线程的锁已自动过期,此时释放锁可能误删其他线程持有的锁;
  • 建议根据实际业务耗时调整锁的过期时间(如调整为30秒)。

2. stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS) 详解

2.1 函数作用

该方法等价于Redis原生命令 SET NX + EX,是实现分布式锁的核心原子操作:

  • 仅当 key 不存在 时,才会设置 key 的值(保证锁的唯一性);
  • 同时为 key 设置过期时间(10秒),避免业务异常导致死锁;
  • 整个操作是原子性的(判断key是否存在 + 设置值 + 设置过期时间一步完成),无并发安全问题。

2.2 参数说明

参数 含义
key 分布式锁的唯一标识(如 lock:shop:1),区分不同资源的锁
"1" 锁的value值,仅作为占位符(无实际业务意义),可替换为任意字符串
10 过期时间数值,此处为10
TimeUnit.SECONDS 过期时间单位,此处为秒,即锁的有效期为10秒

2.3 返回值

  • truekey 不存在,设置成功(获取锁成功);
  • falsekey 已存在,设置失败(获取锁失败)。

2.4 扩展:value值优化(避免误释放锁)

"1" 是无意义占位符,可替换为UUID(保证每个线程的value唯一),释放锁时校验value,避免误删其他线程的锁:

java 复制代码
// 获取锁时存储唯一UUID
String uuid = UUID.randomUUID().toString();
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, uuid, 10, TimeUnit.SECONDS);

// 释放锁时校验value是否匹配
private void unlock(String key, String uuid) {
    String value = stringRedisTemplate.opsForValue().get(key);
    if (uuid.equals(value)) { // 仅释放当前线程持有的锁
        stringRedisTemplate.delete(key);
    }
}

1.逻辑过期解决

java 复制代码
private boolean tryGetLock(String lockKey) {
    Boolean flag = stringRedisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); // 锁超时10秒,防止死锁
    return Boolean.TRUE.equals(flag);
}

//释放互斥锁
private void unlock(String lockKey) {
    stringRedisTemplate.delete(lockKey);
}
java 复制代码
@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}
java 复制代码
//逻辑过期解决击穿
private static final ExecutorService CACHE_REBUILD_EXECTOR = Executors.newFixedThreadPool(10);

public Shop queryWithLogicalExpire(Long id) {
	String key = CACHE_SHOP_KEY + id;
    //1.从redis缓存
    String json = stringRedisTemplate.opsForValue().get(key);
    //2.判断是否存在
    //2.1未命中,直接返回
    if (StrUtil.isBlank(json)) {
        return null;
    }
    //3.命中,判断缓存是否过期
    //需要把json反序列化成对象
    RedisData redisData = JSONUtil.toBean(json, RedisData.class);
    JSONObject data = (JSONObject)redisData.getData();  //店铺信息
    Shop shop = JSONUtil.toBean(data, Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    
    //4.判断是否过期
    //4.1没过期
    if (expireTime.isAfter(LocalDateTime.now())) {
        return shop;  
    }
    //4.2过期, 重建缓存
    //5.获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
   	boolean isLock = false;
    isLock = tryGetLock(lockKey);
    //6.判断是否获取锁成功
    //6.1成功
    if (isLock) {
        //开启独立线程, 实现缓存重建
        //注意获取锁成功后,应该再次检查缓存是否过期, 存在则无序重建
        String newJson = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(newjson)) {
            return null;
        }
        RedisData newRedisData = JSONUtil.toBean(newJson, RedisData.class);
        if (expireTime.isAfter(LocalDateTime.now()) {
            // 如果新缓存未过期,说明已被其他线程重建,直接返回即可
            JSONObject newData = (JSONObject) newRedisData.getData(); //店铺信息
            Shop newShop = JSONUtil.toBean(newData, Shop.class);
			return newShop;
    	}
        //确认缓存过期,异步重建缓存
        CACHE_REBUILD_EXECTOR.submit(() ->{
                try {
                    //重建缓存
                	this.saveShop2Redis(id,20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //释放锁
                    unlock(lockKey);
                }
            });    
    }
   //6.3失败,返回过期信息
   return shop;
}

核心问题解答

  1. JSONObject data = (JSONObject)redisData.getData(); 为什么用JSONObject做强转?redisData.getData()获取的是什么类型?
    1.1 强转JSONObject的原因
    RedisData类中data字段定义为Object类型,而Redis中存储的是JSON格式的字符串,反序列化后默认会被JSONUtil.toBean()解析为JSONObject对象(而非直接的Shop对象),因此需要强转后再二次解析为Shop对象:
  • 第一步:把Redis中整个JSON字符串反序列化为RedisData对象(包含expireTimedata);
  • 第二步:redisData.getData()拿到的是RedisDatadata字段对应的JSON子串,类型为JSONObject
  • 第三步:通过JSONUtil.toBean(data, Shop.class)JSONObject转为具体的Shop实体类。

1.2 redisData.getData()的实际类型
redisData.getData()声明类型Object(因为RedisData类中data字段定义为Object),但实际运行类型JSONObject(由JSONUtil.toBean()的反序列化规则决定)。

若直接用Shop shop = (Shop)redisData.getData()强转会报类型转换异常,必须先转JSONObject再解析。


  1. boolean isLock = false; isLock = tryGetLock(lockKey); 为何不能直接isLock = tryGetLock(lockKey);
    2.1 语法层面:可以直接赋值
    从语法上,boolean isLock = tryGetLock(lockKey); 完全可行,原代码先定义isLock = false再赋值属于冗余写法 ,并非必须。
    2.2 代码设计层面的潜在考量
    原代码可能是为了:
  • 可读性:显式初始化变量,让新手更容易理解变量的初始状态;
  • 容错性:若tryGetLock()返回null(理论上不会,因为方法返回boolean),避免NullPointerException
    但实际开发中,更简洁的写法是直接赋值:boolean isLock = tryGetLock(lockKey);,无需额外初始化。

  1. 为何获取锁后要再次检查缓存是否过期?不是已经检查过了?
    3.1 核心原因:"检查过期"到"获取锁"存在时间窗口
    第一次检查缓存过期(expireTime.isAfter(LocalDateTime.now()))和获取锁(tryGetLock(lockKey))之间,可能发生以下情况:
  • 线程A:检查到缓存过期 → 准备获取锁(耗时10ms);
  • 线程B:同时检查到缓存过期 → 先于线程A获取锁 → 完成缓存重建(更新了Redis中缓存的过期时间);
  • 线程A:10ms后获取到锁,若不再次检查,会重复执行"查库+写缓存",造成资源浪费。

3.2 举例说明

第一次检查:线程A判断缓存过期(时间T1);

获取锁耗时:线程A竞争锁耗时50ms(时间T1→T2);

线程B:在T1-T2期间已完成缓存重建,Redis中缓存的过期时间已更新为未过期;

线程A:获取锁后若不再次检查,会重复重建缓存,违背"缓存重建只执行一次"的设计目标。

  1. if(isLock)中直接复用json = stringRedisTemplate.opsForValue().get(key)(不定义新变量newJson)有何问题?
    4.1 核心差异:变量的语义性数据时效性
  • 不定义新变量(直接复用json):

    java 复制代码
    // 原写法:复用已有变量
    json = stringRedisTemplate.opsForValue().get(key); 
  • 定义新变量newJson

    java 复制代码
    // 推荐写法:定义新变量
    String newJson = stringRedisTemplate.opsForValue().get(key);

4.2 直接复用json的核心问题

(1)语义混淆,可读性差
  • 方法开头的json变量:代表第一次查询缓存的旧数据(时间点T1),语义是"初始缓存数据";
  • 获取锁后重新赋值的json:代表最新查询缓存的新数据 (时间点T2),语义是"校验用缓存数据";
    两次赋值的语义完全不同,复用同一个变量会导致代码阅读者无法区分"初始数据"和"最新数据",增加维护成本。
(2)丢失初始数据,存在逻辑风险

若后续代码需要用到"第一次查询的缓存数据"(比如返回过期缓存),复用json会覆盖原有值,导致初始数据丢失:

java 复制代码
// 错误示例:复用json导致初始数据丢失
String json = stringRedisTemplate.opsForValue().get(key); // T1:初始数据
if (expireTime.isBefore(LocalDateTime.now())) { // 缓存过期
    if (isLock) {
        json = stringRedisTemplate.opsForValue().get(key); // T2:覆盖为最新数据
        // 后续若需要返回过期缓存(原逻辑return shop),但shop是基于初始json解析的,若误用到新json会出问题
    }
}
return shop; // shop基于初始json解析,但若代码误写为基于新json,会返回错误数据
(3)无任何收益,违背编码规范

复用变量仅"节省一个变量定义",但牺牲了代码的可读性和可维护性,不符合"一个变量对应一个语义"的编码规范。

4.3 结论
不建议直接复用json变量 ,必须定义新变量(如newJson):

  • 明确区分"初始缓存数据"和"获取锁后最新缓存数据"的语义;
  • 保留初始数据,避免后续逻辑误用;
  • 降低代码理解成本,符合"见名知意"的编码原则。

总结

  1. 获取锁后重新查询缓存不能复用原有json变量,核心是保证语义清晰、避免数据覆盖;
  2. 定义新变量(如newJson)是遵循"一个变量对应一个语义"的编码规范,无额外性能损耗但大幅提升可读性;
  3. 复用变量会丢失初始缓存数据,存在后续逻辑误用的风险。
相关推荐
小陈phd3 小时前
多模态大模型学习笔记(二十三)——一文搞懂数虚拟人:从定义、分类到核心技术全景
笔记·学习
清风徐来QCQ3 小时前
redis 面试可能会问的问题
数据库·redis·面试
小江的记录本3 小时前
【Spring Boot】Spring Boot 全体系知识结构化拆解(附 Spring Boot 高频面试八股文精简版)
java·spring boot·后端·spring·面试·tomcat·mybatis
Thomas.Sir3 小时前
从底层源码深入剖析 MyBatis 工作原理
java·架构·mybatis
eggwyw3 小时前
Spring 中使用Mybatis,超详细
spring·tomcat·mybatis
油丶酸萝卜别吃3 小时前
springboot项目中redis常见的增删改查操作是哪些?
spring boot·redis·bootstrap
码农4274 小时前
点评项目深入改造-------日常学习笔记
java·笔记·学习·搜索引擎·全文检索
清风徐来QCQ4 小时前
Redis以及如何在springboot中使用
数据库·redis·缓存
测试_AI_一辰4 小时前
Agent & RAG 测试工程笔记 13:RAG检索层原理拆解:从“看不懂”到手算召回过程
人工智能·笔记·功能测试·算法·ai·ai编程