Redis实战篇(二:商户查询缓存)

news2025/1/11 7:10:15

目录

 

三、商户查询缓存

1.缓存介绍

2.添加商户缓存

(1)缓存模型和思路

(2)代码实现

3.店铺类型缓存 

4.缓存更新策略

5.实现商铺缓存与数据库双写一致 

6.缓存穿透

(1)介绍

(2)编码解决商品查询的缓存穿透问题

7.缓存雪崩 

 8.缓存击穿

(1)介绍

(2)利用互斥锁解决缓存击穿问题 

(3)利用逻辑过期解决缓存击穿问题

9.缓存工具封装


 

三、商户查询缓存

1.缓存介绍

缓存(Cache)就是数据交换的缓冲区,是存储数据的临时地方,一般读写性能较高。

缓存有很多种形式,如:

硬件缓存: 一般指的是机器上的 CPU、硬盘等等组件的缓存区间,一般是利用的内存作为一块中转区域,都通过内存交互信息,减少系统负载,提供传输效率。

客户端缓存: 一般指的是某些应用,例如浏览器、手机 App、视频缓冲等等,都是在加载一次数据后将数据临时存储到本地,当再次访问时候先检查本地缓存中是否存在,存在就不必去远程重新拉取,而是直接读取缓存数据,这样来减少远端服务器压力和加快载入速度。

服务端缓存: 一般指远端服务器上,考虑到客户端请求量多,某些数据请求量大,这些热点数据经常要到数据库中读取数据,给数据库造成压力,还有就是 IO、网络等原因有一定延迟,响应客户端较慢。所以,在一些不考虑实时性的数据中,经常将这些数据存在内存中(内存速度非常快),当请求时候,能够直接读取内存中的数据及时响应。

为什么要使用缓存?

① 缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,缓存可以大大降低用户访问并发量带来的服务器读写压力。

② 实际开发中,企业的数据量,少则几十万,多则几千万,这么大的数据量。如果没有缓存,系统是几乎撑不住的,所以企业会大量运用缓存技术。

③ 但是缓存也会增加代码复杂度和运营成本,也有可能引发数据不一致的现象(缓存中的数据没有及时更新客户端中的新数据)。

2.添加商户缓存

(1)缓存模型和思路

我们先启动前端和后端的项目,登陆之后随便访问一个商户,查看浏览器发送的请求。

 不出意外是 ShopController 里的业务逻辑:

可以发现:在我们查询商户信息时,我们是直接操作从数据库中去进行查询的,这样效果肯定慢

 所以我们可以在客户端与数据库之间加上一个 Redis 缓存

先从 Redis 中查询,如果没有查到,再去 MySQL 中查询,同时查询完毕之后,将查询到的数据也存入 Redis,这样当下一个用户来进行查询的时候,就可以直接从 Redis 中获取到数据。以此往复,Redis 中的数据越来越多,命中率也越来越大,大大加快了服务端的响应速度。

 因此,我们根据 id 查询商户的流程就可以修改为:

(2)代码实现

ShopController:

    @GetMapping("/{id}")
    public Result queryShopById(@PathVariable("id") Long id) {
        return shopService.queryById(id);
    }

 IShopService:

public interface IShopService extends IService<Shop> {

    Result queryById(Long id);
}

 ShopServiceImpl:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        // 1.从redis查询商铺缓存
        String shopKey = CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        // 4.不存在,根据id查询数据库
        Shop shop = getById(id);
        // 5.判断数据库中是否存在
        if(shop == null){
            // 6.不存在,返回错误
            return Result.fail("店铺不存在");
        }
        // 7.存在,写入redis
        stringRedisTemplate.opsForValue().set(shopKey,JSONUtil.toJsonStr(shop));
        // 8.返回
        return Result.ok(shop);
    }
}

运行结果:

打开 Redis,也能够看到我们缓存的数据:

3.店铺类型缓存 

完成了商户数据缓存之后,我们尝试做一下商户类型数据缓存。

在很多地方,我们都会用到商户类型的数据,而且这个数据还不会变,所以很合适加入缓存中。

 在 ShopTypeController 中,这个操作也只是简单的一个查询数据库的操作。

修改后,代码如下:

ShopTypeController:

@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {
    @Resource
    private IShopTypeService typeService;

    @GetMapping("list")
    public Result queryTypeList() {
        return typeService.queryList();
    }
}

 IShopTypeService:

public interface IShopTypeService extends IService<ShopType> {

    Result queryList();
}

 ShopTypeServiceImpl:

@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryList() {
        // 1.在redis中查询店铺类型缓存
        String key = CACHE_SHOP_TYPE_KEY;//"cache:shop:list"
        List<String> stringTypeList = stringRedisTemplate.opsForList().range(key, 0, -1);//0是开始索引,-1是结束索引(表示最后一个元素)
        // 2.判断是否命中
        if (!stringTypeList.isEmpty()) {
            // 3.命中,直接返回
            // List<String>  ===> List<ShopType>
            List<ShopType> shopTypeList = stringTypeList.stream()
                    .map(jsonStr -> JSONUtil.toBean(jsonStr, ShopType.class))
                    .collect(Collectors.toList());
            return Result.ok(shopTypeList);
        }
        // 4.没有命中,查询数据库
        List<ShopType> shopTypeList = query().orderByAsc("sort").list();
        // 5.判断数据库中是否存在
        if (shopTypeList == null) {
            // 6.不存在,返回错误信息
            return Result.fail("店铺类型不存在!");
        }
        // 7.存在,存入redis
        // List<ShopType>  ===> List<String>
        stringTypeList = shopTypeList.stream()
                .map(shopType -> JSONUtil.toJsonStr(shopType))
                .collect(Collectors.toList());
        stringRedisTemplate.opsForList().rightPushAll(key, stringTypeList);
        // 8.返回
        return Result.ok(shopTypeList);
    }
}

 运行结果:

可以看到,第二次加载时速度明显快速很多很多。

打开 Redis,也能够看到我们缓存的数据:

4.缓存更新策略

数据同时保存在缓存和数据中,涉及数据一致性问题,如果对数据库数据做了一些修改,缓存是不知道的,这种场景下会造成业务数据错误。

所以,这时就涉及到缓存更新策略。

内存淘汰超时剔除主动更新
说明不用自己维护,
利用Redis的内存淘汰机制,
当内存不足时自动淘汰部分数据。
下次查询时更新缓存。
给缓存数据添加TTL时间,
到期后自动删除缓存。
下次查询时更新缓存。
编写业务逻辑,
在修改数据库的同时,
更新缓存。
一致性一般
维护成本

业务场景:

  • 低一致性需求:使用内存淘汰机制,例如店铺类型的查询缓存(因为这个很长一段时间都不需要更新)
  • 高一致性需求:主动更新,并以超时剔除作为兜底方案,例如店铺详情查询的缓存

主动更新策略有如下三种方式:

  1. Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库之后再去更新缓存,也称之为双写方案
  2. Read/Write Through Pattern:缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。但是维护这样一个服务很复杂,市面上也不容易找到这样的一个现成的服务,开发成本高
  3. Write Behind Caching Pattern:调用者只操作缓存,其他线程去异步处理数据库,最终实现一致性。但是维护这样的一个异步的任务很复杂,需要实时监控缓存中的数据更新,其他线程去异步更新数据库也可能不太及时,而且缓存服务器如果宕机,那么缓存的数据也就丢失了

综上,我们还是选择方案一,因为它最为可控。

但是,人工编码方式会同时操作缓存和数据库,这时就会有三个问题需要考虑:

① 删除缓存还是更新缓存?

  • 更新缓存:每次更新数据库都需要更新缓存,无效写操作较多
  • 删除缓存:更新数据库时让缓存失效,再次查询时更新缓存

假设我们每次操作完数据库之后,都去更新一下缓存,但是如果中间并没有人查询数据,那么这个更新动作只有最后一次是有效的,中间的更新动作意义不大。

所以我们可以把缓存直接删除,等到有人再次查询时,再将缓存中的数据加载出来 

② 如何保证缓存与数据库的操作同时成功 / 同时失败?

  • 单体系统:将缓存与数据库操作放在同一个事务
  • 分布式系统:利用TCC等分布式事务方案

 ③ 先操作缓存还是先操作数据库?

我们来仔细分析一下这两种方式的线程安全问题:

假设我们有两个线程:一个更新操作,一个查询操作(初始缓存和数据库中的值都为 10)

i.先删除缓存,再操作数据库:

正常情况:

异常情况:

删除缓存的操作很快,但是更新数据库的操作相对较慢。

如果此时有一个线程 2 刚好进来查询缓存,由于我们刚刚才删除缓存,所以线程 2 需要查询数据库,并写入缓存(旧数据:10)。

但是我们更新数据库的操作还未完成,所以线程 2 查询到的数据是脏数据,出现线程安全问题。

发生这种情况的概率还是比较大的,因为更新数据库(写)操作很慢,查询数据库(读)和写入缓存的操作又很快,所以完全有可能在这之间发生异常。

而且,后续继续查询操作,缓存中数据还是旧数据,就会引发长时间的数据不一致问题。 

ii.先操作数据库,再删除缓存:

正常情况: 

异常情况:

线程 1 在查询缓存的时候,缓存 TTL 刚好失效,需要查询数据库并写入缓存,这个操作耗时相对较短(相比较于上图来说),但是就在这么短的时间内,线程2进来了,更新数据库,删除缓存。

但是线程 1 虽然查询完了数据(更新前的旧数据:10),但是还没来得及写入缓存,所以线程 2 的更新数据库与删除缓存,并没有影响到线程 1 的查询旧数据。

之后线程 1 继续写入缓存,又会造成数据不一致,引发线程安全问题。

发生这种情况的概率并不高,因为前提是查询前缓存 TTL 刚好失效,这个本身就具有偶然性。再者,更新数据库操作非常慢,而写入缓存的这个操作很快(微秒级别),所以很难在这么短的时间内,线程 2 能一直抢占到 CPU 把数据库进行更新。

所以大概率写入缓存操作是先执行的,后面更新数据库,再删除缓存。下次继续查询的话,缓存中没有,还是会去数据库中查,就不会持续引发数据不一致问题了。

综上所述,虽然这二者都存在线程安全问题,但是相对来说,方案二出现线程安全问题的概率相对较低,所以我们最终采用方案二:先操作数据库,再删除缓存。

5.实现商铺缓存与数据库双写一致 

修改 ShopController 中的业务逻辑,满足以下要求:

  1. 根据 id 查询店铺时,如果缓存未命中,则查询数据库,并将数据库结果写入缓存,并设置TTL
  2. 根据 id 修改店铺时,先修改数据库,再删除缓存

查询操作: 

修改 ShopService的queryById 方法,写入缓存时设置一下 TTL

stringRedisTemplate.opsForValue().set(shopKey,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);

 更新操作:

 之前的 update 方法:

修改后的 update 方法:

    @PutMapping
    public Result updateShop(@RequestBody Shop shop) {
        // 写入数据库
        return shopService.update(shop);
    }

 IShopService:

public interface IShopService extends IService<Shop> {

    Result queryById(Long id);

    Result update(Shop shop);
}

 ShopServiceImpl:

    @Override
    @Transactional
    public Result update(Shop shop) {
        Long id = shop.getId();
        if(id == null){
            return Result.fail("店铺id不能为空");
        }
        // 1.更新数据库
        updateById(shop);
        // 2.删除缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
        return Result.ok();
    }

重启服务器,删除之前 Redis 中残留的商铺信息,因为之前没有设置 TTL,默认永不删除。

所以后续查询操作都会直接从缓存中找,不会从数据库中查,从而也就不会覆盖原来的商品信息,给它设置TTL 了。

此时,我们点击任意一个餐厅,该餐厅的数据就会缓存到 Redis 中,并设置 TTL 了。

 对于更新的操作一般是由管理端去做,我们这里是用户端,所以我们得借助 postman 工具。

修改前:

我们打开 postman,发送一个 PUT 请求,请求路径为:http://localhost:8080/api/shop/

json 数据如下:

{
  "area": "大关",
  "openHours": "10:00-22:00",
  "sold": 4215,
  "images": "https://qcloud.dpfile.com/pc/jiclIsCKmOI2arxKN1Uf0Hx3PucIJH8q0QSz-Z8llzcN56-_QiKuOvyio1OOxsRtFoXqu0G3iT2T27qat3WhLVEuLYk00OmSS1IdNpm8K8sG4JN9RIm2mTKcbLtc2o2vfCF2ubeXzk49OsGrXt_KYDCngOyCwZK-s3fqawWswzk.jpg,https://qcloud.dpfile.com/pc/IOf6VX3qaBgFXFVgp75w-KKJmWZjFc8GXDU8g9bQC6YGCpAmG00QbfT4vCCBj7njuzFvxlbkWx5uwqY2qcjixFEuLYk00OmSS1IdNpm8K8sG4JN9RIm2mTKcbLtc2o2vmIU_8ZGOT1OjpJmLxG6urQ.jpg",
  "address": "金华路锦昌文华苑29号",
  "comments": 3035,
  "avgPrice": 80,
  "updateTime": 1642066339000,
  "score": 37,
  "createTime": 1640167839000,
  "name": "101茶餐厅",
  "x": 120.149192,
  "y": 30.316078,
  "typeId": 1,
  "id": 1
}

运行结果:

打开 redis,发现我们商铺的缓存已经被删除了。

如果我们重新查看商铺:

打开 redis,发现商铺信息同时发生了修改。 

6.缓存穿透

(1)介绍

缓存穿透:是指客户端查询一个一定不存在的数据,由于缓存是不命中时需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,那么这个数据就穿透了缓存,直击数据库,进而给数据库带来压力。

但是数据库能承载的并发不如 redis 这么高,如果此时有恶意攻击创建很多线程,并发访问不存在的资源,那一直会反复的查询数据库,数据库就有可能崩溃。

常见的解决方案有两种:

① 缓存空对象:

哪怕这个数据在数据库里不存在,我们也把这个这个数据存在 redis 中去(也就会产生额外的内存消耗),这样下次用户过来访问这个不存在的数据时,redis 缓存中也能找到这个数据,不用去查数据库。

可能造成的短期不一致是指:在缓存中空对象的存活期间,我们更新了数据库,把这个空对象变成了正常的可以访问的数据,但由于空对象的 TTL 还没过,所以当用户来查询的时候,查询到的还是空对象,等 TTL 过了之后,才能访问到正确的数据,不过这种情况可以通过缩短 TTL 或者新增时主动加入缓存进行解决。

  • 优点:实现简单,维护方便
  • 缺点:额外的内存消耗,可能造成短期的不一致(可以通过新增的时候主动把数据放入redis中解决)

② 布隆过滤:

布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,根据哈希思想去计算这个数据的哈希值,在二进制数组对应位置填 0 或 1,表示是否存在。

每次查询数据,先经过判断布隆过滤器当前这个要查询的数据是否存在。如果布隆过滤器判断存在,则放行,这个请求会去访问 redis,如果此时 redis 中的数据过期了,就去数据库中查询该数据,再将其放到 redis 中。如果布隆过滤器判断这个数据不存在,则直接返回。

这种思想的优点在于节约内存空间,但存在误判,原因在于:布隆过滤器使用的是哈希思想,只要是哈希思想,都可能存在哈希碰撞现象。

  • 优点:内存占用较少,没有多余的 key
  • 缺点:实现复杂,可能存在误判

(2)编码解决商品查询的缓存穿透问题

在原来的逻辑中,我们如果发现这个数据在 MySQL 中不存在,就直接返回一个错误信息了,但是这样存在缓存穿透问题。

所以我们选用方案一:缓存空对象的方式解决该问题。

现在的逻辑是:

如果这个数据不存在,将这个数据写入到 Redis 中,并且将 value 设置为空字符串,然后设置一个较短的 TTL,返回错误信息。

当再次发起查询时,先去Redis 中判断 value 是否为空字符串,如果是空字符串,则说明是刚刚我们存的不存在的数据,直接返回错误信息。

@Override
    public Result queryById(Long id) {
        // 1.从redis查询商铺缓存
        String shopKey = CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        // 判断redis中是否为空值
        if(shopJson != null){
            // 是我们设置的空字符串,返回错误信息
            return Result.fail("店铺不存在");
        }
        // 4.不存在,根据id查询数据库
        Shop shop = getById(id);
        // 5.判断数据库中是否存在
        if(shop == null){
            // 6.不存在,返回错误
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(shopKey,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
            return Result.fail("店铺不存在");
        }
        // 7.存在,写入redis
        stringRedisTemplate.opsForValue().set(shopKey,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
        // 8.返回
        return Result.ok(shop);
    }

重启项目,访问地址:http://localhost:8080/api/shop/0

第一次访问,由于缓存中没有,我们会直接查询数据库,控制台就会打印出 SQL 语句:

第二次访问,此时 redis 中已经缓存了空值,将不再经过数据库,控制台也就不会打印 SQL 语句了。

 总结:

  • 缓存穿透产生的原因是什么?
    • 用户请求的数据在缓存中和在数据库中都不存在,不断发起这样的请求,会给数据库带来巨大压力
  • 缓存产投的解决方案有哪些?
    • 缓存 null 值
    • 布隆过滤
    • 增强id复杂度,避免被猜测 id 规律
    • 做好数据的基础格式校验
    • 加强用户权限校验
    • 做好热点参数的限流(sentinel)

7.缓存雪崩 

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

解决方案:

  • 给不同的 Key 的 TTL 添加随机值,让其在不同时间段分批失效
  • 利用 Redis 集群提高服务的可用性(使用一个或者多个哨兵实例组成的系统,对 redis 节点进行监控,在主节点出现故障的情况下,能将从节点中的一个升级为主节点,进行故障转义,保证系统的可用性。 )
  • 给缓存业务添加降级限流策略(sentinel、resilience4j)
  • 给业务添加多级缓存(浏览器本地缓存、 Nginx 本地缓存、JVM进程缓存、数据库缓存)

 8.缓存击穿

(1)介绍

缓存击穿:也叫热点 Key 问题,就是一个被高并发访问并且缓存重建业务比较复杂的 key 突然失效了,那么无数请求访问就会在瞬间给数据库带来巨大的冲击。

比如:一件秒杀中的商品的 key 突然失效了,大家都在疯狂抢购。但这个商品信息写入缓存前需要从多个数据库的表中查找数据,甚至要做各种表关联的运算,最终才得到一个结果写入 redis 缓存。

那么这个写入缓存的耗时可能就会比较长(几十甚至上百毫秒),在这个时间段内 redis 中一直没有缓存,无数的请求访问都会直接抵达数据库,从而造成数据库崩溃。

解决方案:

① 互斥锁

利用锁的互斥性,所有线程在操作数据库之前,都必须获得一个互斥锁。

假设第一个线程过来,查询缓存未命中后,先获取互斥锁,再进行查询数据库重建缓存数据操作。这其中有其他线程线程过来,发现缓存中没有,想要查询数据库的,都得先获取互斥锁。

由于互斥锁已经被第一个线程拿到了,所以它们都只能等待一段时间继续重试。直到第一个线程写入缓存后,把锁释放。其他线程才能从缓存中拿到数据。

② 逻辑过期

我们之所以会出现缓存击穿问题,主要原因是在于我们对 key 设置了TTL,如果我们不设置 TTL,那么就不会有缓存击穿问题,但是不设置 TTL,数据又会一直占用我们的内存,所以我们可以采用逻辑过期方案。

我们设置一个过期时间在 redis 的 value 中,注意:这个过期时间并不会直接作用于 Redis,而是我们后续通过逻辑去处理。

假设线程 1 去查询缓存,然后从 value 中判断当前数据已经过期了,此时线程 1 去获得互斥锁。获得了锁后,它会开启一个新线程去进行之前的重建缓存数据的逻辑,直到新开的线程完成者逻辑之后,才会释放锁。而线程 1 直接进行返回旧数据。

这个释放锁的过程中,只要有其他线程来访问数据,同样会获取互斥锁。但是它们如果获取不到,不会一直等待,而是直接返回旧数据。

直到锁释放之后,其他线程才能从缓存中拿到新数据。

 对比互斥锁与逻辑删除:

解决方案优点缺点
互斥锁没有额外的内存消耗
保证一致性
实现简单(只要加个锁就行)
线程需要等待,性能受影响
可能有死锁风险(互相等待)
逻辑过期线程无需等待,性能较好不保证一致性(一段时间内获取的都是旧数据)
有额外内存消耗(多了一个expire 字段)
实现复杂

(2)利用互斥锁解决缓存击穿问题 

需求:修改根据 id 查询商铺的业务,基于互斥锁方式来解决缓存击穿问题。

注意:

① 这里的锁不能用 Lock 或者 synchronized 实现,因为基于两者实现的话,线程拿不到锁就只能一直等待。而我们这里,线程拿到锁和拿不到锁,它的执行逻辑是需要我们自定义的。

核心思路就是利用 redis 的 setnx 方法来表示获取锁,如果 redis 没有这个key,则插入成功,返回1,如果已经存在这个 key,则插入失败,返回 0。

② 为避免发生异常情况,线程 1 在拿到锁之后由于某种原因发生了异常,导致将来这个锁一直没有释放,其他线程不仅获取不到数据,还拿不到锁。

所以我们在利用 setnx 获取锁的时候,往往会设置一个 TTL 有效期,一般是程序正常执行的十倍。如果程序出现异常没有释放锁,将来过一段时间之后还会自动释放。

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        //互斥锁解决缓存击穿
        Shop shop = querywithMuyex(id);
        if (shop == null) {
            return Result.fail("店铺不存在"):
        }
        return Result.ok(shop);
    }

    public Shop querywithMuyex(Long id) {
        // 1.从redis查询商铺缓存
        String shopKey = CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(shopJson, Shop.class);
        }
        // 判断redis中是否为空值
        if (shopJson != null) {
            // 是我们设置的空字符串,返回错误信息
            return null;
        }
        // 4.实现缓存重建
        // 4.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        Shop shop = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2.判断是否获取成功
            if (!isLock) {
                // 4.3.失败,则休眠并重试
                Thread.sleep(50);
                return querywithMuyex(id);
            }
            // 4.4.不存在,根据id查询数据库
            shop = getById(id);
            // 模拟热点key重建的延时
            Thread.sleep(200);
            // 5.判断数据库中是否存在
            if (shop == null) {
                // 6.不存在,返回错误
                // 将空值写入redis
                stringRedisTemplate.opsForValue().set(shopKey, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            // 7.存在,写入redis
            stringRedisTemplate.opsForValue().set(shopKey, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 8.释放互斥锁
            unlock(lockKey);
        }
        // 9.返回
        return shop;
    }

    // 获取锁
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
        System.out.println(flag);
        // 需要转换为基本数据类型,自动拆箱可能会有空指针异常,所以使用hutool的工具类拆箱
        return BooleanUtil.isTrue(flag);
    }

    // 释放锁
    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }
}

细节:

热点 key 问题需要满足两个条件:高并发和缓存重建较为复杂。我们这里由于查询数据库是在本地,所以缓存重建一瞬间就结束了。为了模拟缓存重建的延迟,我们在查询数据库后休眠 200 ms。

我们要测试高并发,所以要借助 JMeter 工具:

 执行,查看结果:

可以看到,执行结果都是成功的。

再打开控制台,我们发现 SQL 语句只执行了一次, 证明在如此高并发的场景下,没有出现所有的请求都全打到数据库的情况,说明我们互斥锁的解决方案成功了。

(3)利用逻辑过期解决缓存击穿问题

需求:修改根据 id 查询商铺的业务,基于逻辑过期方式来解决缓存击穿问题。

注意: 

① 理论上来说,缓存是不会出现未命中的情况,因为 key 是不会过期的。一旦这个 key 添加到缓存中,它是可以永久存在的。像这种热点 key,一般在活动开始前,人工提前加入缓存设置逻辑过期时间,直到活动结束,再人工删除。

因此,在查询时,其实不用判断缓存是否命中,如果真的查到缓存不存在,这只能说明这个商品的 key 它不在活动当中,它不属于一个热点 key。在这里,为了健壮性考虑,我们还是判断一下它有没有命中,但不考虑缓存穿透的问题,直接返回空就行。

② 我们想要给商铺添加一个过期时间,可以在 Shop 类中加入一个新的字段,但这样会修改原有的代码。为了对原有的代码没有侵入性,我们可以新建一个实体类(包含原有的数据和过期时间)。

热点 key 加入缓存步骤:

① 新增实体类

@Data
public class RedisData<T> {
    private LocalDateTime expireTime;
    private T data;
}

② 在 ShopServiceImpl 中新增方法,模拟人工将热点 key 加入缓存,并设置过期时间

    public void saveShop2Redis(Long id, Long expireSeconds) {
        // 1.查询店铺数据
        Shop shop = getById(id);
        // 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));
    }

③ 编写测试方法,进行单元测试

@SpringBootTest
class HmDianPingApplicationTests {
    @Resource
    private ShopServiceImpl shopService;

    @Test
    void testSave() {
        shopService.saveShop2Redis(1L, 10L);
    }
}

运行,打开 redis:

 热点 key 提前加入缓存成功了,接下来我们就来解决缓存击穿的问题。

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryById(Long id) {
        // 逻辑过期解决缓存击穿
        Shop shop = queryWithLogicalExpire(id);
        if (shop == null) {
            return Result.fail("店铺不存在");
        }
        return Result.ok(shop);
    }

    //自定义线程池(上限为10)
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    public Shop queryWithLogicalExpire(Long id) {
        // 1.从redis查询商铺缓存
        String shopKey = CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(shopKey);
        // 2.判断是否存在
        if (StrUtil.isBlank(shopJson)) {
            // 3.不存在,直接返回
            return null;
        }
        // 4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(shopJson, 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) {
            // 6.3.成功,再次检查缓存是否过期
            // doubleCheck:重新检查缓存,可能在获取锁之前其他线程刚好重建缓存完毕
            RedisData redisDataDoubleCheck = JSONUtil.toBean(stringRedisTemplate.opsForValue().get(shopKey), RedisData.class);
            LocalDateTime expireTimeDoubleCheck = redisDataDoubleCheck.getExpireTime();
            if (LocalDateTime.now().isBefore(expireTimeDoubleCheck)) {
                // 6.3.1.未过期,直接返回
                shop = JSONUtil.toBean((JSONObject) redisDataDoubleCheck.getData(), Shop.class);
                // 返回前记得释放锁
                unlock(lockKey);
                return shop;
            }
            // 6.3.2.过期,继续重建缓存
            // 6.4.开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 重建缓存
                    this.saveShop2Redis(id, 20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 释放锁
                    unlock(lockKey);
                }
            });
        }
        // 6.5.失败,返回过期的商铺信息
        return shop;
    }

    // 获取锁
    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        // 需要转换为基本数据类型,自动拆箱可能会有空指针异常,所以使用hutool的工具类拆箱
        return BooleanUtil.isTrue(flag);
    }

    // 释放锁
    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }

    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));
    }
}

细节:

① 同样,这里为了模拟缓存重建的延迟,我们在查询数据库后休眠 200 ms。

② 在拿到锁,准备重建缓存前,我们需要二次检查(DoubleCheck)缓存中的数据是否过期。因为,可能恰好在拿到锁之前,另一个线程完成了缓存重建,把锁还了回来,而当前线程刚好拿到。


在使用 JMeter 进行测试之前,我们可以先把数据库中的数据修改一下,造成缓存和数据库出现数据不一致的现象。这样就会有一部分线程使用之前的旧数据,一部分线程在缓存重建完使用新数据。

redis 中的数据还是之前手动加入的旧数据:

 打开 JMeter,设置 1s 内 100 个 QPS,不要设置太多,以免后面的将前面的覆盖了,只能看到新数据的请求:

测试,结果可以看到:

前一部分请求取出的还是缓存中的旧数据, 后一部分请求取出的就是缓存重建后的新数据:

打开控制台,发现查询数据库的 SQL 也只是执行了一次:

注意:如果这里全是旧数据,可以把缓存重建时,查询数据库后的休眠时间缩小一点。 

9.缓存工具封装

基于 StringRedisTemplate 封装一个缓存工具类,需满足下列要求:

  • 方法1:将任意 Java 对象序列化为 JSON,并存储到 String 类型的 Key 中,并可以设置 TTL 过期时间。
    //方法1
    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }
  • 方法2:将任意 Java 对象序列化为 JSON,并存储在 String 类型的 Key 中,并可以设置逻辑过期时间,用于处理缓存击穿问题
    //方法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));
    }
  • 方法3:根据指定的 Key 查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题
    // 方法3
    public <R,ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback,Long time, TimeUnit unit) {
        // 1.从redis查询缓存
        String key = keyPrefix + id;
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(json)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(json, type);
        }
        // 判断redis中是否为空值
        if (json != null) {
            // 是我们设置的空字符串,返回错误信息
            return null;
        }
        // 4.不存在,根据id查询数据库
        R r = dbFallback.apply(id);
        // 5.判断数据库中是否存在
        if (r == null) {
            // 6.不存在,返回错误
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", time, unit);
            return null;
        }
        // 7.存在,写入redis
        // 调用刚刚写好的方法1
        this.set(key,r,time,unit);
        // 8.返回
        return r;
    }

此时,ShopServiceImpl 使用缓存穿透,就可以直接调用该方法:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private CacheClient cacheClient;

    @Override
    public Result queryById(Long id) {
        // 缓存穿透
        Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.SECONDS);
        if (shop == null) {
            return Result.fail("店铺不存在");
        }
        return Result.ok(shop);
    }
}
  • 方法4:根据指定的 Key 查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题
    //自定义线程池(上限为10)
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        // 1.从redis查询缓存
        String key = keyPrefix + id;
        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.成功,再次检查缓存是否过期
            // doubleCheck:重新检查缓存,可能在获取锁之前其他线程刚好重建缓存完毕
            RedisData redisDataDoubleCheck = JSONUtil.toBean(stringRedisTemplate.opsForValue().get(key), RedisData.class);
            LocalDateTime expireTimeDoubleCheck = redisDataDoubleCheck.getExpireTime();
            if (LocalDateTime.now().isBefore(expireTimeDoubleCheck)) {
                // 6.3.1.未过期,直接返回
                r = JSONUtil.toBean((JSONObject) redisDataDoubleCheck.getData(), type);
                // 返回前记得释放锁
                unlock(lockKey);
                return r;
            }
            // 6.3.2.过期,继续重建缓存
            // 6.4.开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    R r1 = dbFallback.apply(id);
                    // 写入redis
                    this.setWithLogicalExpire(key, r1, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    // 释放锁
                    unlock(lockKey);
                }
            });
        }
        // 6.5.失败,返回过期的信息
        return r;
    }

此时,ShopServiceImpl 使用缓存击穿,就可以直接调用该方法:

@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Resource
    private CacheClient cacheClient;

    @Override
    public Result queryById(Long id) {
        // 逻辑过期解决缓存击穿
        Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.SECONDS);
        if (shop == null) {
            return Result.fail("店铺不存在");
        }
        return Result.ok(shop);
    }
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2256992.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

etcd分布式存储系统快速入门指南

在分布式系统的复杂世界中&#xff0c;确保有效的数据管理至关重要。分布式可靠的键值存储在维护跨分布式环境的数据一致性和可伸缩性方面起着关键作用。 在这个全面的教程中&#xff0c;我们将深入研究etcd&#xff0c;这是一个开源的分布式键值存储。我们将探索其基本概念、特…

Spring Boot + Spring AI快速体验

Spring AI快速体验 1 什么是Spring AI主要功能 2 快速开始2.1 版本说明2.2 配置文件2.3 pom依赖2.3.1 spring maven仓库2.3.2 核心依赖 2.4 定义ChatClient2.5 启动类2.6 测试 3 参考链接 1 什么是Spring AI Spring AI是Spring的一个子项目&#xff0c;是Spring专门面向于AI的…

【网络篇】TCP知识

TCP首部格式&#xff1f; 为什么需要 TCP 协议&#xff1f; TCP 工作在哪一层&#xff1f; IP 层是不可靠的&#xff0c;它不保证网络包的交付、不保证网络包的按序交付也不保证网络包中的数据的完整性。如果需要保障网络数据包的可靠性&#xff0c;那么就需要由上层&#xff0…

【Liunx篇】基础开发工具 - yum

文章目录 &#x1f335;一.Liunx下安装软件的方案&#x1f43e;1.源代码安装&#x1f43e;2.rpm包安装&#x1f43e;3.包管理器进行安装 &#x1f335;二.软件包管理器-yum&#x1f335;三.yum的具体操作&#x1f43e;1.查看软件包&#x1f43e;2.安装软件包&#x1f43e;3.卸载…

Co-Slam论文及复现记录

Overview 输入RGB-D流&#xff1a; { I t } t 1 N { D t } t 1 N \{I_t\}^{N}_{t1}\{D_t\}^{N}_{t1} {It​}t1N​{Dt​}t1N​&#xff0c;它们带有已知相机内参 K ∈ R 3 3 K\in \mathbb{R}^{3\times 3} K∈R33。通过联合优化相机姿态 { ξ t } t 1 N \{\xi_t\}^{N}_{t1} {…

《探索形象克隆:科技与未来的奇妙融合》

目录 一、什么是形象克隆 二、形象克隆的技术原理 三、形象克隆的发展现状 四、形象克隆的未来趋势 五、形象克隆的应用场景 六、形象克隆简单代码案例 Python 实现数字人形象克隆 Scratch 实现角色克隆效果&#xff08;以猫为例&#xff09; JavaScript 实现 Scratc…

解决view-ui-plus 中表单验证不通过问题,select 组件开启multiple模式 总是提示错误,即使不验证也提示,有值也验证失败

&#x1f609; 你好呀&#xff0c;我是爱编程的Sherry&#xff0c;很高兴在这里遇见你&#xff01;我是一名拥有十多年开发经验的前端工程师。这一路走来&#xff0c;面对困难时也曾感到迷茫&#xff0c;凭借不懈的努力和坚持&#xff0c;重新找到了前进的方向。我的人生格言是…

ARM V8 GIC中断模块

文章目录 1. 缩略语2. 简介2.1. 中断类型2.1.1 SGI软件中断2.1.2 PPI私有外设中断2.1.3 SPI 共享外设中断2.1.4 LPI(locality-specific peripheral interrupts) 2.2. GIC 架构2.2.1 Distributor2.2.2 Redistributor2.2.3 CPU Interfaces 2.3. 属性层次&#xff08;affinity&…

perl Window安装教程

perl Window安装教程 下载地址 https://platform.activestate.com/tangxing806/ActivePerl-5.28/distributions 运行state-remote-installer.exe 按下图截图步骤 检查perl版本 参考文献&#xff1a; perl安装教程

算法日记(2024.12.09)

1.二叉树的最小深度 给定一个二叉树&#xff0c;找出其最小深度。 最小深度是从根节点到最近叶子节点的最短路径上的节点数量。 说明&#xff1a;叶子节点是指没有子节点的节点。 示例 1&#xff1a; 输入&#xff1a;root [3,9,20,null,null,15,7] 输出&#xff1a;2 …

《操作系统 - 清华大学》6 -5:局部页面置换算法:最不常用置换算法 (LFU, Least Frequently Used)

文章目录 1. 最不常用算法的工作原理2.最不常用算法特征3. 示例 1. 最不常用算法的工作原理 最不常用算法&#xff1a;注意并不是表示算法本身不常用&#xff0c;而是采取最不常使用页面的策略&#xff0c;Least Frequently Used&#xff0c; LFU。LRU 是最久未被访问的页&…

Hive分区值的插入

对于Hive分区表&#xff0c;在我们插入数据的时候需要指定对应的分区值&#xff0c;而这里就会涉及很多种情况。比如静态分区插入、动态分区插入、提供的分区值和分区字段类型不一致&#xff0c;或者提供的分区值是NULL的情况&#xff0c;下面我们依次来展现下不同情况下的表现…

OpenAI12天 –第3天的实时更新,包括 ChatGPT、Sora、o1 等

OpenAI提前开启了假期&#xff0c;推出了为期 12 天的活动&#xff0c;名为“OpenAI 12 天”。在接下来的一周左右的每一天&#xff0c;OpenAI 都将发布现有产品的新更新以及新软件&#xff0c;包括备受期待的 Sora AI 视频生成器。 OpenAI 首席执行官 Sam Altman 表示&#x…

C#导出数据库到Excel文件(.NET)

随着企业业务的增长和复杂性的增加&#xff0c;对数据进行有效的分析、共享和报告变得至关重要&#xff1b;而Excel&#xff0c;作为一款广泛接受的数据处理工具&#xff0c;提供了强大的计算能力、可视化选项以及与多种数据分析工具的兼容性&#xff0c;使得它成为从数据库导出…

基于Java后台实现百度米制坐标转WGS84地理坐标实战

目录 前言 一、需求简介 1、信息查询 二、Java后台转换 1、相关属性 2、相关转换方法 3、实例转换 三、Leaflet可视化 1、准备展示数据 2、Marker标记 3、可视化效果 四、总结 前言 在现代信息技术高速发展的今天&#xff0c;地理信息系统&#xff08;GIS&#xff0…

声音克隆GPT-SoVITS

作者&#xff1a;吴业亮 博客&#xff1a;wuyeliang.blog.csdn.net 一、原理介绍 GPT-SoVITS&#xff0c;作为一款结合了GPT&#xff08;生成预训练模型&#xff09;和SoVITS&#xff08;基于变分信息瓶颈技术的歌声转换&#xff09;的创新工具&#xff0c;正在声音克隆领域掀…

自动驾驶数据集的应用与思考

数据作为新型生产要素&#xff0c;是数字化、网络化、智能化的基础&#xff0c;是互联网时代的“石油”“煤炭”&#xff0c;掌握数据对于企业而言是能够持续生存和发展的不竭动力&#xff0c;对于需要大量数据训练自动驾驶系统的企业而言更是如此。 而随着激光雷达、毫米波雷…

开源项目:轻型图像分割 unet_lite

DataBall 助力快速掌握数据集的信息和使用方式&#xff0c;会员享有 百种数据集&#xff0c;持续增加中。 需要更多数据资源和技术解决方案&#xff0c;知识星球&#xff1a; “DataBall - X 数据球(free)” -------------------------------------------------------------…

贪心算法专题(四)

目录 1. 单调递增的数字 1.1 算法原理 1.2 算法代码 2. 坏了的计算器 2.1 算法原理 2.2 算法代码 3. 合并区间 3.1 算法原理 3.2 算法代码 4. 无重叠区间 4.1 算法原理 4.2 算法代码 5. 用最少数量的箭引爆气球 5.1 算法原理 ​5.2 算法代码 1. 单调递增的数字…

【大模型系列篇】GPU资源容器化访问使用指南

在当今的高性能计算和机器学习领域&#xff0c;GPU&#xff08;图形处理单元&#xff09;因其卓越的并行计算能力而扮演着至关重要的角色。随着容器化技术如 Docker 的普及&#xff0c;越来越多的数据科学家和开发者选择将他们的应用和工作负载封装到 Docker 容器中&#xff0c…