缓存
缓存(Cache),就是数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,
缓存的优点是速度快,好用,「把热点数据提前放到内存里」,让系统访问数据更快、更省资源。
为什么要用缓存呢?
当系统中有一些数据:
访问频率很高(比如热门商品、用户信息)
但数据变化频率低(不需要实时刷新)
那每次都从数据库读取就会浪费资源、慢、甚至压垮数据库。
这时,我们就可以把这类数据提前“缓存”起来,放到更快的存储介质中(比如内存),让后续访问直接从缓存读取,效率提升 几十甚至上千倍。
缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力
实际开发过程中,企业的数据量,少则几十万,多则几千万,这么大数据量,如果没有缓存来作为"避震器",系统是几乎撑不住的,所以企业会大量运用到缓存技术;
但是缓存也会增加代码复杂度和运营的成本:

缓存的基本思路
查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis。

实际应用
在黑马点评项目中,查询主页面商户信息中,根据id查询商户信息:

可以避免频繁查询数据库,增大数据库的压力。
思路
1.选择合适的key,用来存储商户信息例如 :key = “cache:shop:” + id。
2. 根据传入的id,判断redis是否包含该商户信息。
3. 存在则直接返回商户信息,商户信息可以使用json字符串存入。
4. 不存在,根据id查询数据库,并把查询的商户信息存入redis,并返回该信息。
5. 数据库查询不存在,则可返回错误信息。
代码实现
public Shop queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
Shop bean = JSONUtil.toBean(shopJson, Shop.class);
return bean;
}
//4.不存在,根据id查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if (shop == null) {
return Result.fail("店铺不存在!");
}
//6.存在,写入redis并返回
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
//7.返回
return Result.ok(shop);
}
缓存的更新策略
redis内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。
更新的常用方法
**内存淘汰:**redis自动进行,当redis内存达到咱们设定的max-memery的时候,会自动触发淘汰机制,淘汰掉一些不重要的数据(可以自己设置策略方式)
**超时剔除:**当我们给redis设置了过期时间ttl之后,redis会将超时的数据进行删除,方便咱们继续使用缓存
**主动更新:**我们可以手动调用方法把缓存删掉,通常用于解决缓存和数据库不一致问题
方法对比

数据库和缓存数据不一致
由于我们的缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库中数据发生变化,而缓存却没有同步,此时就会有一致性问题存在,处理方案如下:

此处采用方案一解决。
此时要考虑几个问题:
- 删除缓存还是更新缓存?
这主要取决于应用场景。通常有两种做法:直接更新缓存或是在数据发生变化时删除缓存。直接更新缓存适用于读操作远多于写操作的场景,可以减少数据库的压力并提高响应速度。然而,实现起来较为复杂,因为需要保证缓存和数据库的一致性。
删除缓存则相对简单,当数据发生变更时,只需将相关缓存删除,后续读取请求会重新从数据库加载最新数据到缓存中。这种方式简化了设计,但可能会导致短期内缓存命中率下降。(延时加载,只有当有人来请求时,才更新缓存,效率更高,避免无效更新)
- 如何保证数据库和缓存操作同时成功或失败?
实际上,很难保证数据库和缓存操作的原子性(即要么全部成功,要么全部失败)。一个常见的做法是使用分布式事务或者补偿机制来尽量达到一致性效果,但这通常会带来额外的复杂性和性能开销。
一种简化的方法是采用“最终一致性”策略,允许短暂的不一致窗口存在,通过异步的方式(例如消息队列)来同步数据库和缓存的数据,从而逐步消除差异。
在单体系统中,把数据库和缓存操作放在一个事务中
在分布式系统中,利用TCC等分布式方案
- 先操作缓存还是数据库?
在进行数据更新时,通常建议先更新数据库,后删除(或更新)缓存。这是因为如果先操作缓存,在更新数据库之前如果系统出现故障,可能会导致缓存中的数据与数据库不一致。而先更新数据库的话,即使后续缓存操作失败,也只是暂时丢失缓存加速的效果,并不会导致数据的永久不一致。当然,这也依赖于应用的具体需求和容忍度。
先操作删除缓存再写入数据库
正常情况:

异常情况:在写入数据库之前,有其他线程混入,导致数据库和缓存不一致
因为写入数据库速度远慢与缓存,所以异常情况还是比较高的。

先更新数据库再删除缓存
正常情况

异常情况
假设缓存失效,查询缓存未命中会查询数据库,然后再把数据写入缓存,这段时间是很短的,在这段时间完成数据库写入或更新操作是很低的,所以异常情况远低于第一种方案。

商铺缓存和数据库一致业务实现
核心思路如下:
修改ShopController中的业务逻辑,满足下面的需求:
根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间。
代码实现
public Shop queryById(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
Shop bean = JSONUtil.toBean(shopJson, Shop.class);
return bean;
}
//4.不存在,根据id查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if (shop == null) {
return Result.fail("店铺不存在!");
}
//6.存在,写入redis并返回
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//7.返回
return Result.ok(shop);
}
根据id修改店铺时,先修改数据库,再删除缓存。
在更新数据库时添加一段删除缓存的代码即可
//2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
注意添加@Transactional事务注解,保证数据一致性。
缓存穿透及其解决思路
缓存穿透是指查询一个根本不存在的数据,由于缓存中没有该数据,导致每次请求都会直接访问数据库,从而可能造成数据库的压力增大。这种情况通常发生在恶意攻击或者数据确实不存在但频繁被请求时。
解决思路
缓存空对象:当业务系统发现某条数据在数据库中不存在时,可以将这个“空结果”也放入缓存,并设置一个较短的过期时间。这样下次再有相同的请求时,就可以直接从缓存中获取到“空结果”,而不需要再次访问数据库。这种方法简单有效,但是会占用一定的缓存空间。

布隆过滤器(Bloom Filter):这是一种高效的数据结构,用于判断一个元素是否在一个集合中。使用布隆过滤器可以在缓存之前进行一次快速检查,如果布隆过滤器表明某个key不在数据库中,那么可以直接返回,无需查询缓存或数据库。这种方式能有效地减少对数据库的无谓访问,但布隆过滤器存在一定的误判率(即可能会错误地认为某个key存在于集合中),不过这种误判不会导致数据不一致的问题。

实际场景演示
核心思路如下:
在原来的逻辑中,我们如果发现这个数据在mysql中不存在,直接就返回404了,这样是会存在缓存穿透问题的
现在的逻辑中:如果这个数据不存在,我们不会返回404 ,还是会把这个数据写入到Redis中,并且将value设置为空,欧当再次发起查询时,我们如果发现命中之后,判断这个value是否是null,如果是null,则是之前写入的数据,证明是缓存穿透数据,如果不是,则直接返回数据。
流程图演示

代码实现
//解决缓存穿透问题
public Shop queryWithPassThrough(Long id) {
String key = CACHE_SHOP_KEY + id;
//1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在,直接返回
Shop bean = JSONUtil.toBean(shopJson, Shop.class);
return bean;
}
//判断命中的是否是空值(不为null但是为空值)
if (shopJson != null) {
//返回错误信息
return null;
}
//4.不存在,根据id查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if (shop == null) {
//空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
//6.存在,写入redis并返回
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
//7.返回
return shop;
}
布隆过滤器就不再演示了。
小总结:
缓存穿透产生的原因是什么?
- 用户请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力
缓存穿透的解决方案有哪些?
- 缓存null值
- 布隆过滤
- 增强id的复杂度,避免被猜测id规律
- 做好数据的基础格式校验
- 加强用户权限校验
- 做好热点参数的限流
缓存雪崩
缓存雪崩是指在某一时间段内,大量的缓存数据同时失效或者Redis服务宕机,导致大量请求直接打到数据库上,造成数据库压力骤增,严重时可能导致数据库崩溃。这种情况通常发生在设置了相同的过期时间给一组缓存项,或者整个缓存系统出现故障的情况下。
解决思路
设置不同的过期时间:避免为一组缓存项设置相同的过期时间,可以通过在设定过期时间时添加一个随机值(例如,在原有过期时间基础上增加或减少几分钟),使得不同缓存项的过期时间错开,从而防止缓存同时失效。
使用互斥锁(Mutex Lock):当某个缓存数据失效后,第一个请求获取到锁并负责从数据库加载数据更新缓存,其他相同请求则等待锁释放,直接从缓存中读取数据。这种方法可以有效减轻数据库的压力,但需要考虑锁机制带来的性能损耗和复杂度。
多级缓存策略(后期讲解):实现多层次的缓存结构,如本地缓存加分布式缓存的方式。即使分布式缓存出现雪崩,本地缓存仍然能够处理部分请求,降低对数据库的直接影响。
热点数据永不过期:对于一些访问频率极高的关键数据,可以考虑设置为永不过期,并通过后台异步线程定期更新这些数据,确保它们始终是最新的状态。
降级机制(后续课程有讲解):设计合理的服务降级方案,在极端情况下,比如缓存完全不可用时,可以返回默认值、旧值或者其他不影响业务流程的数据,以保护数据库免受过大压力。

缓存击穿
缓存击穿是指针对某些非常热点的数据(即访问频率极高的数据),在缓存中由于过期时间的到来而被删除或失效,导致大量并发请求直接穿透缓存到达数据库查询同一数据的情况。这种情况特别容易发生在高并发系统中,可能会导致数据库瞬间承受巨大的压力。
图演示

解决方案
解决方案一、使用锁来解决:
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。
由于只有一条线程获取了锁,其他线程只能等待锁释放,因此效率较低。

解决方案二、逻辑过期方案
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。

**互斥锁方案:**由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响
逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦。

利用互斥锁解决缓存击穿问题
核心思路:相较于原来从缓存中查询不到数据后直接查询数据库而言,现在的方案是 进行查询之后,如果从缓存没有查询到数据,则进行互斥锁的获取,获取互斥锁后,判断是否获得到了锁,如果没有获得到,则休眠,过一会再进行尝试,直到获取到锁为止,才能进行查询
如果获取到了锁的线程,再去进行查询,查询后将数据写入redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行操作数据库的逻辑,防止缓存击穿。
流程图演示

锁创建示例
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
修改原来的业务代码,添加互斥锁业务。
public Shop queryWithMutex(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1、从redis中查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get("key");
// 2、判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 存在,直接返回
return JSONUtil.toBean(shopJson, Shop.class);
}
//判断命中的值是否是空值
if (shopJson != null) {
//返回一个错误信息
return null;
}
// 4.实现缓存重构
//4.1 获取互斥锁
String lockKey = "lock:shop:" + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2 判断否获取成功
if(!isLock){
//4.3 失败,则休眠重试
Thread.sleep(50);
return queryWithMutex(id);
}
//4.4 成功,根据id查询数据库
shop = getById(id);
// 5.不存在,返回错误
if(shop == null){
//将空值写入redis
stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
//返回错误信息
return null;
}
//6.写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_NULL_TTL,TimeUnit.MINUTES);
}catch (Exception e){
throw new RuntimeException(e);
}
finally {
//7.释放互斥锁
unlock(lockKey);
}
return shop;
}
利用逻辑过期解决缓存击穿问题
思路分析:
当用户开始查询redis时,判断是否命中,如果没有命中则直接返回空数据,不查询数据库,而一旦命中后,将value取出,判断value中的过期时间是否满足,如果没有过期,则直接返回redis中的数据,如果过期,则在开启独立线程后直接返回之前的数据,独立线程去重构数据,重构完成后释放互斥锁。

1.需要对数据添加一个过期时间,选择新建一个实体类,避免对原来代码有侵入性。
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
2.缓存预热
目的是在系统启动或某些特定时机,将热点数据提前加载到 Redis 缓存中,以提升后续访问性能,避免缓存击穿或缓存穿透等问题。
//缓存预热
public void saveShop2Redis(Long id,Long expireSeconds) throws InterruptedException {
//1.查询店铺数据
Shop shop = getById(id);
Thread.sleep(200);
//2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
//3.写入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
3.业务代码
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire( Long id ) {
String key = CACHE_SHOP_KEY + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return shop;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
CACHE_REBUILD_EXECUTOR.submit( ()->{
try{
//重建缓存
this.saveShop2Redis(id,20L);
}catch (Exception e){
throw new RuntimeException(e);
}finally {
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return shop;
}
封装redis工具类
基于StringRedisTemplate封装一个缓存工具类,满足下列需求:
-
方法1:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间
-
方法2:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题
-
方法3:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
-
方法4:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
将逻辑进行封装,基于上方的方法抽取一般方法。
@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
//方法1
public void set(String key, Object value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
//方法2
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)));
// 写入Redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
//方法2
public <R,ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.存在,直接返回
return JSONUtil.toBean(json, type);
}
// 判断命中的是否是空值
if (json != null) {
// 返回一个错误信息
return null;
}
// 4.不存在,根据id查询数据库
R r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
return r;
}
//方法3
public <R, ID> R queryWithLogicalExpire(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.存在,直接返回
return null;
}
// 4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if(expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return r;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock){
// 6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R newR = dbFallback.apply(id);
// 重建缓存
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return r;
}
//方法4
public <R, ID> R queryWithMutex(
String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
String key = keyPrefix + id;
// 1.从redis查询商铺缓存
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
// 3.存在,直接返回
return JSONUtil.toBean(shopJson, type);
}
// 判断命中的是否是空值
if (shopJson != null) {
// 返回一个错误信息
return null;
}
// 4.实现缓存重建
// 4.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
R r = null;
try {
boolean isLock = tryLock(lockKey);
// 4.2.判断是否获取成功
if (!isLock) {
// 4.3.获取锁失败,休眠并重试
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
}
// 4.4.获取锁成功,根据id查询数据库
r = dbFallback.apply(id);
// 5.不存在,返回错误
if (r == null) {
// 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 返回错误信息
return null;
}
// 6.存在,写入redis
this.set(key, r, time, unit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
// 7.释放锁
unlock(lockKey);
}
// 8.返回
return r;
}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}
1272

被折叠的 条评论
为什么被折叠?



