黑马点评面试知识总结2-商户查询缓存

缓存的牛逼之处:

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

使用缓存会带来设什么弊端:

1.数据一致性成本。2.代码维护成本。3.运维成本

简单来说就是:把经常用的数据放在速度快的内存里,不要总去访问磁盘,人多了扛不住

本节主要功能如下:

1.添加商户缓存

首先这个功能是叫查询商户缓存,不是商品缓存

思路非常简单,就是在查询数据库之前添加一层redis,先查redis,若查空了再查数据库,然后存到redis,但随之也会引发很多问题。

这部分有一点点代码,后面会改,但是还是放出来:

String key="cache:shop"+id;//商户id
String ShopJson=StringRedisTemplate.opsForValue.get(key);
//redis查询商户信息
若存在,直接返回
不存在查询数据库
查到了就写入redis
StringRedisTemplate.opsForValue.set(key,JSONUtil.toJsonStr(shop));
查不到就返回错误

2.缓存更新

缓存总有满的时候,需要想办法更新

在本项目中存在的问题是,数据库中数据更新的同时要对缓存进行更新,本项目采用如下方案来实现:

Cache Aside Pattern 人工编码方式:缓存调用者在更新完数据库后再去更新缓存,也称之为双写方案

当然还有其他的两种方法:

Read/Write Through Pattern : 由系统本身完成,数据库与缓存的问题交由系统本身去处理

Write Behind Caching Pattern :调用者只操作缓存,其他线程去异步处理数据库,实现最终一致

但是本节不做讨论

具体实现:

更新数据库后到底是更新缓存还是删除缓存?

    答案是删除缓存,

  • 更新缓存:每次更新数据库都更新缓存,无效写操作较多

  • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存

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

  • 单体系统,将缓存与数据库操作放在一个事务

  • 分布式系统,利用TCC等分布式事务方案

什么是分布式事务?

TCC(Try-Confirm-Cancel)分布式事务

TCC(Try-Confirm-Cancel)是一种 两阶段提交(2PC)的改进版,用于解决 分布式事务一致性 问题,特别适用于 强一致性业务场景(如支付、订单处理等)。

TCC 通过 三种操作 保障事务一致性:

  1. Try(尝试阶段): 预留资源,不提交事务。
  2. Confirm(确认阶段): 真正提交事务,执行业务逻辑。
  3. Cancel(取消阶段): 释放资源,回滚事务。

核心思想:先尝试锁定资源(Try),如果所有操作都成功,则提交(Confirm);如果有失败,则回滚(Cancel)。


TCC 事务执行流程

假设有一个 转账系统,Alice 向 Bob 转账 100 元,涉及 两个服务

  • 账户服务(Account Service):冻结 Alice 账户中的 100 元。
  • 银行服务(Bank Service):确认 Bob 账户收到 100 元。

🚀 1. Try(预留资源)

  • 账户服务:冻结 Alice 的 100 元,不真正扣款。
  • 银行服务:确保 Bob 账户可以接收 100 元。

✅ 2. Confirm(确认提交)

  • 账户服务:真正扣除 Alice 的 100 元。
  • 银行服务:Bob 账户正式增加 100 元。

❌ 3. Cancel(回滚释放)

  • 账户服务:解冻 Alice 账户的 100 元。
  • 银行服务:取消 Bob 账户的资金变更。

Confirm 和 Cancel 只能执行一次,避免数据不一致

应当是先操作数据库,再删除缓存,原因在于两个线程并发来访问时,假设线程1先来,他先把缓存删了,此时线程2过来,他查询缓存数据并不存在,此时他写入缓存,当他写入缓存后,线程1再执行更新动作时,实际上写入的就是旧的数据,新的数据被旧数据覆盖了。

这里要注意是先删除数据库,不然可能写入旧数据。

代码更改:

即删除数据库后删除缓存

3.解决缓存穿透问题

这么一个小小的查询商户的功能可能存在很多问题,第一个问题就是缓存穿透

缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

常见的解决方案有两种:

布隆过滤器能保证数据库中一定存在数据,所以没有就是没有,这种方式优点在于节约内存空间,但是存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突

编码(采用空值的方法):

    解决缓存穿透的查询店铺
    public Shop queryWithPassThrough(Long id) {
        String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        //存在?
        if (StrUtil.isNotBlank(shopJson)) {
            //存在,直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
        }
        //不存在的情况下如果为“”,可能是缓存穿透,所以判断是否为空值
        if (shopJson != null) {
            return null;
        }
        //为真正的空null,查询数据库
        Shop shop = getById(id);
        //不存在,将空值写入redis,防止缓存穿透
        if (shop == null) {
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);

            return null;
        }
        //存在,数据写入redis
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        //返回数据
        return shop;

    }

4.解决缓存雪崩问题

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

解决方案:

  • 给不同的Key的TTL添加随机值

  • 利用Redis集群提高服务的可用性

  • 给缓存业务添加降级限流策略

  • 给业务添加多级缓存

无编码,了解就行了

5.缓存击穿问题

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

缓存击穿就是一个特别重要的key挂了,而缓存穿透是很多普通的key有问题,就是皇帝和平民的区别

首先要清楚缓存击穿在本项目中发生的情况是什么:

就是类似于os中的中断,第一个问题还没解决,第二个就来了,归根到底就是因为这是一个热点key。

解决方案

1. 互斥锁

互斥锁的方案很好理解,首先我们来看思路,代码会放在后面

简单来说就是第一个没查到redis的人获得锁,只要这个人能够去访问数据库,其他的人再来查redis的话就睡眠,等一会再重试

这种方案显而易见,有锁就会有冲突,可能会有死锁,而且串行的性能不好。

2. 逻辑过期

第二种方案名字叫做逻辑过期,但是我更愿意叫他弱的互斥锁。实现原理是这样的,逻辑过期的意思是指把过期时间写在value里面,而不是真的设置一个过期时间,这样这个数据就会永远存在于redis中,只不过变成了我们自己写代码来判断是否过期。

具体实现逻辑是这样的,仍然是第一个查询redis失败的线程获得互斥锁,但是获得锁之后并不是直接操作数据库,而是开启一个线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁。那这个主线程要干什么,等着吗?不是的,这个线程会这样做,即使这个数据过期了,我还是要返回这个过期的数据。而且不只是它,在新线程更新数据的这个时间段内,其他到来的查询redis的线程也会查到过期,但是无法获得互斥锁,所以也返回这个过期的数据。

这就是这个方式的缺点:构建完缓存之前,返回的都是脏数据。

两种方案的对比:

 代码实现

1.互斥锁:

首先是获取互斥锁以及删除锁的代码,这部分的实现逻辑是用redis的setnx实现的,setnx的特性就是如果redis中存在key就什么也不会做。那这里有人会问了,你这个setnx什么也不做就意味着不会阻塞,那是怎么实现刚才说的,第二个没有获取锁的线程循环重试的呢?那就是下一段代码中的try里面的内容,有一个递归调用,当获取锁的失败的时候就递归调用重新查询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);
                tip:这里使用递归调用的意思就是说重新查询redis
                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;
    }
2.逻辑过期:

接下来是逻辑过期的代码实现,首先补充一下逻辑上的缺失,我们默认这种方案查询商户的时候redis中是存在数据的,但是如果redis中没命中怎么办,很好解决,直接返回,因为我们之前说热点key是逻辑过期,换句话说就是永远存在,只是逻辑上不存在了,所以如果redis中没有,那数据库肯定也没有呀,这热点key都是我们提前设置好的,所以不用查数据库,数据库根本没有。

代码实现:

事前准备:如何实现在redis的value中添加一个过期时间?

@Data
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

采用这个方案,新建一个实体类,并且用到了Object类,任何类都可以是他的子类,这样就可以存储任何类型的数据了,类似于泛型的思想。而且这种方式对原来的代码没有侵入性,不用修改原来的代码,十分合理。

业务逻辑:(请注意一下,这个代码并不是基于缓存穿透的代码写的,并没有缓存空值的一说,基于逻辑过期的热点key一定存在于redis中,不存在的话一定是出问题了,返回空就行,和之前的逻辑反过来了)

//这里是线程池的使用,我也不太会,但是能看懂
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.不存在,直接返回,热点key不在数据库里,肯定有问题
        return null;
    }
    // 4.命中,需要先把json反序列化为对象
    // 这部分代码就是把过期时间从value中解析出来
    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;
}

更新缓存数据的代码:

    public  void saveShop2Redis(Long id,Long expireSeconds) throws InterruptedException {
        //查询店铺信息
        Shop shop = getById(id);
        //j模拟
        Thread.sleep(200);
        //新建redisData,过期时间
        RedisData redisData = new RedisData();
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        redisData.setData(shop);
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(redisData));
    }

这部分就是业务逻辑中调用的函数

6.工具类的封装

这一大节到现在其实业务逻辑的代码并没有多少,就是基于一个功能函数不断的增加各种应对措施,这一章节是工具类的封装,就是对之前的做一个总结并封装工具类,但是用到了很多的泛型。

类和常量的定义:

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

这里定义了stringredistemplate和线程池

工具类的第一个功能:

最普通的,存储key和value并且带有过期时间,这就可以用于最原始的代码,就是既没解决缓存击穿也没解决缓存穿透的,纯纯是添加一层redis,ttl是用来兜底的,解决缓存更新问题。

 public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }
工具类的第二个功能:

稍微复杂一点,设置的是逻辑过期时间,这样的话就永远不会从redis删除,只会逻辑上存在过期


    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));
    }
工具类的第三个功能(设置空值防止缓存穿透):

这就是通过设置空值的方法来解决缓存穿透问题,就是之前讲的那个函数改了改,使用了泛型


    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;
    }
工具类的功能四(逻辑过期防止缓存击穿):

 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;
    }
工具类的功能5(互斥锁防止缓存击穿):
 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);
    }

封装完工具类之后,业务逻辑就十分的简洁明了:

@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.MINUTES);

        // 互斥锁解决缓存击穿
        // Shop shop = cacheClient
        //         .queryWithMutex(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

        // 逻辑过期解决缓存击穿
        // Shop shop = cacheClient
        //         .queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, 20L, TimeUnit.SECONDS);

        if (shop == null) {
            return Result.fail("店铺不存在!");
        }
        // 7.返回
        return Result.ok(shop);
    }

十分清楚!

这节课的代码量就不到100行,但是讲了很多知识。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值