黑马点评【Redis】

🙈网上都说黑马的redis的课好,我也来,简单记录一下重要的知识点和解决问题的思路,因为之前了解过一些redis的基本用法,这里就直接进入实战了

源码:https://gitee.com/lzy612/hmdp

在这里插入图片描述

一、短信登录功能

1、Session实现

首先搞一个lower版本的,后面再用redis进行优化

在这里插入图片描述

这个逻辑我也见到很多次了,希望下次可以不假思索,直接说出来,
这里的发送验证码,和登录就不记录了,具体写一写校验登录的方法

拦截器代码

public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1、获取session
        HttpSession session = request.getSession();
        // 2、获取session中的用户
        UserDTO userDto = (UserDTO)session.getAttribute("user");
        // 3、判断用户是否存在
        if (userDto == null) {
            // 4、不存在就拦截
            response.setStatus(401);
            return false;
        }
        // 5、存在就将信息放到线程中去,并放行
        UserHolder.saveUser(userDto);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户,防止内存泄露
        UserHolder.removeUser();
    }
}

注册代码

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
                // 放行
                "/user/code", // 发送验证码
                "/user/login", // 登录
                "/shop/**",   // 商店所有的
                "blog/hot", // 热点评论的
                "/shop-type/**", // 商店类型的
                "/voucher/**"    // 优惠卷的
        );
    }
}

💥知识点

  1. 用户的请求会带着cookie,登录的凭证sessionId就在cookie中
  2. 使用springboot的拦截器来实现对登录校验的拦截,就不用多次在controller中写这个逻辑了
  3. 这里考虑到了,我们在后面的请求中可能要用到session中存的一些数据,但是如果要是每一个请求都要获取的session的,就要在方法的参数上多加一个,这样就有点造轮子的嫌疑了,所以这里使用了将信息放到线程中去,有人要用就从这里面取出来,没人用就算了
  4. 还有一个问题在使用完这个线程中的数据的时候,可以用拦截器中的after那个方法将线程中的数据删除掉,虽然我现在可能写不写这个东西无所谓,好习惯有头有尾

2、集群的session共享问题

在这里插入图片描述

多台Tomcat并不共享Session,如果一通过nginx访问一个tomcat的集群,你刚在tomcat1机器里面输入了密码登录信息,然后当你下一个请求进入的tomcat2中,但是这个tomcat2并没有session,你还得重新登录,非常的不合理

这里使用的解决方案就是使用redis来取代session

满足的特点:

  • 数据共享
  • 内存数据
  • key,value结构

在这里插入图片描述

🍿修改发送验证码

将code存储到session中变成存储到redis中去,将手机号作为唯一值作为key,验证码code作为redis,并且同时设置好过期时间

🍿修改登录部分

这里有点东西

登录过程验证完校验码后,要将得到的用户信息存储到redis中,这里用什么格式去存储,又是一个讲究,

  1. 首先是key,这里的话还是可以用手机号的,用手机号提取出用户的信息,看似没有什么问题,但是要用户信息做什么呢 ,用于后面的登录校验判断,还有一些要请求其他页面的时候需要使用该用户的信息,这就好像是一个人在大街上拿了一块金条去买东西,你说能不被人惦记吗,所以这里要使用一个随机的token来作为key,当这里业务处理完的时候,将这个token返回给前端,请求的时候,就携带上这个token,相当于一个人拿了张银行卡去买东西,虽然功能是相同的,但是更加的安全
  2. 然后是value,redis有5大数据结构,肯定不是瞎设计的,所以我们存储value的时候也要研究一下,如果是用string类型的话,我们就需要将用户的信息变成一个json格式的,json格式有什么,中间有{}、:等的符号,一个两个不说,但是如果好多的数据,那岂不是要浪费大量的存储空间,并且存储成json格式的话,你取只能整个取出来,不能要哪个取哪个,费劲。所以我们这里需要采用一个更加的方式,视频中给出的是使用Hash来存储,这就很好的解决了string的问题,但是这里我们怎么讲一个实体对象,变成一个key-value对呢,这里可以采用BeanUtil的beanToMap这个API,直接将实体封装成一个key-value结构的数据,这时千万不要忘记加过期时间,同时也要考虑到整个的逻辑,如果我们登录了以后这个过期时间就开始倒计时了,我们如果正在访问的页面好好的,突然过期时间到了,点击下一个要看的页面,直接就弹出去了,用户体验及其不好,所以过期时间应该在访问一个页面的时候进行更新,也就是登录验证的地方,也就是拦截器的位置
  3. 这个应该不算是一个逻辑问题,算是一个用法方面的问题
 Map<String, Object> map = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));

自定义key-value的类型,能设置好多的规则

🍿修改登录拦截器部分

  1. 这部分涉及到了前端拿着token来获取用户信息,有了这个信息,就可以畅通无阻的访问页面了,通过访问头获取token

String token = request.getHeader(“authorization”);

  1. 然后这里应该也算是技巧,将key-value又转换成实体类,果然解铃还须系铃人

这里又会发现一个问题,说实话我以为写到这个程度就算是很完美了,想不到啊,太细了!!!!之前不是说逻辑方面,要进入到一个页面就要刷新token的有效期,但是有一些页面不会被拦截,也就是不会刷新token有效期,这里采用了一个拦截器链来解决这个问题

在这里插入图片描述

  1. 相当于就是将之前那个拦截器拆开了
  2. 第一个拦截器的主要功能就是拦截所有的请求,同时将用户信息放到线程中,如果放到了线程中之后的话,就刷新token的有效期,反正无论发生怎么样的情况,这个拦截的请求都会放心
  3. 第二个拦截器就是通过判断线程中有没有东西,来判断是否通过登录验证,通过了就放行

二、商户查询缓存

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

之前不知道这个步骤就叫做缓存命中了缓存命中就是请求到redis中的数据成功返回就是缓存命中

1、根据id查询商品缓存的流程

在这里插入图片描述

这里感觉还可以没啥说的

2、缓存更新策略

在这里插入图片描述
在这里插入图片描述

这里提到删除缓存和操作数据库谁前谁后的线程安全问题,记录一下

第一组删除缓存,再操作数据库

理想情况:
在这里插入图片描述

异常情况

在这里插入图片描述

也就是在你删除缓存和更新数据库之间,被趁虚而入了,感觉就是这两个操作不是原子操作

第二组操作数据库,再删除缓存

正常情况

在这里插入图片描述

异常情况
在这里插入图片描述

比较两种异常情况的发生概率

这里目的就是查询到正确的数据

  1. 第一种当删除缓存完之后,要更新数据库,更新数据库需要一段时间来准备,所以说这里有一段时间差,这时候可能就会被趁虚而入,一个迅速的查询操作就会侵入,导致更新数据的这个命令没有执行完,那边就把你旧的数据弄走了,概率来说很大
  2. 第二种当如果一个缓存恰好过时了,然后你去查询没有命中,去查询数据库了,然后其他线程更新了你要查询的数据,然后你写入缓存,就是旧的数据,后者明显比前者概率更小
  3. 其实我觉得这件可以这样理解,我们把查询缓存未命中,查询数据库可以看做是一体的,中间被钻空的几率是很小的,然后删除缓存和更新数据库的执行顺序可以这样看,如果先执行删除缓存的话,再执行更新数据库中间的时间较长,也就是被侵入的几率打;如果先执行更新数据库的话,然后紧接着删除缓存,中间的时间较短,被侵入的几率有,但是很小。也就是从这样来看的话,先操作数据,再删除缓存较为优

在这里插入图片描述

我这里的理解可能还不到位,慢慢来吧

3、缓存穿透

在这里插入图片描述

新知识点,缓存穿透和布隆过滤

解决缓存穿透
在这里插入图片描述

之前是这样的,从开始走到结束的过程中,看起来都挺好的,但是如果有人恶意多次查询redis中没有的数据和数据库中没有的数据,这样就会导致数据库的压力增大,甚至崩坏掉,所以要解决这个问题

在这里插入图片描述

视频中是使用缓存空对象来解决

在这里插入图片描述

4、缓存雪崩

在这里插入图片描述

5、缓存击穿

在这里插入图片描述

在这里插入图片描述

这个逻辑过期就和逻辑删除感觉一样,通过在value中设置一个逻辑的ttl过期时间来代替这个数据的ttl,然后你还可以获取到这个数据但是还是有点小问题,就是数据可能不一致,这里通过新开一个线程去处理新的重建任务,此时其他的线程就会访问一个旧的数据

在这里插入图片描述

  1. 基于互斥锁方式解决缓存击穿问题

在这里插入图片描述

// 利用互斥锁解决缓存击穿
public Shop queryWithMutex(Long id) {
    String key = CACHE_SHOP_KEY + id;
    // 1、从redis中查缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2、如果命中直接返回即可
    if(StrUtil.isNotBlank(shopJson)){
        // 存在直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        // 如果判断命中的是空值的话就直接结束
        return shop;
    }
    // 未命中
    if(shopJson != null){
        return null;
    }
    // *实现缓存重建*
    // 1.获取互斥锁
    String lockKey = LOCK_SHOP_KEY + id;
    Shop shopInfo = null;
    try {
        boolean isLock = tryLock(lockKey);
        // 2.判断是否获取成功
        if(!isLock){
            // 3.失败,则休眠并重试
            Thread.sleep(50);
            return queryWithMutex(id);
        }
        // 如果获取成功的话就再检查一下缓存是否存在,存在就可以直接返回了,不用再重建缓存了
        if(StrUtil.isNotBlank(shopJson)){
            // 存在直接返回
            Shop shopAgain = JSONUtil.toBean(shopJson, Shop.class);
            // 如果判断命中的是空值的话就直接结束
            return shopAgain;
        }

        // 如果我们在等待了许久之后,redis还是空的话,我们就根据id查数据库,去重建redis缓存
        shopInfo = this.getById(id);
        // 模拟网络延迟
        Thread.sleep(200);
        // 4、判断商铺是否存在
        if(shopInfo == null){
            // 如果不存在的话会将空值写入reids
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        // 5、如果存在就将数据写到redis中返回即可
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shopInfo), CACHE_SHOP_TTL, TimeUnit.MINUTES );
    } catch (Exception e) {
        throw new RuntimeException(e);
    }finally {
        // 释放锁
        unlock(id.toString());
    }
    return shopInfo;
}

// 尝试获取锁
private boolean tryLock(String key){
    Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.MINUTES);
    return BooleanUtil.isTrue(aBoolean);
}

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

通过jmeter测试

在这里插入图片描述

在这里插入图片描述

就查询一次数据库,太美了

  1. 基于逻辑过期方式解决缓存击穿问题
    注意:这里的数据不会过期,所以不用考虑缓存穿透的问题

在这里插入图片描述

// 存储热点数据
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));
}
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
    // 1、从redis中查缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    // 2、未命中后直接返回
    if(StrUtil.isBlank(shopJson)){
        return null;
    }

    // 命中后,先把json反序列出来
    RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
    JSONObject data = (JSONObject)redisData.getData();
    Shop shop = JSONUtil.toBean(data, Shop.class);
    LocalDateTime expireTime = redisData.getExpireTime();
    // 判断是否过期
    if(expireTime.isAfter(LocalDateTime.now())){
        // 未过期就返回客户信息
        return shop;
    }
    String lockKey = LOCK_SHOP_KEY + id;
    // 已经过期,需要缓存重建
    // 获取互斥锁
    boolean isLock = tryLock(lockKey);
    // 判断时候获取锁成功
    if(isLock){
        // 成功,开启独立线程,去完成缓存重建
        CACHE_REBUILD_EXECUTOR.submit(() -> {
            // 重建缓存
            try {
                this.saveShop2Redis(id, 20L);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }finally {
                // 释放锁
                unlock(lockKey);
            }
        });
    }
    return shop;
}

6、缓存工具封装

在这里插入图片描述

三、优惠券秒杀

1、全局唯一id生成策略

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

@Component
public class RedisIdWorker {
    // 开始的时间戳
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    // 序列号的位数
    private static final long COUNT_BITS = 32;

    private StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate){
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public long nextId(String keyPrefix){
        // 1、生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;

        // 2、生成序列号
        // 2.1、获取当前日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
        // 2.2、自增长
        Long count = stringRedisTemplate.opsForValue().increment("icr" + keyPrefix + ":" + date);

        // 3、拼接并返回
        return timestamp << COUNT_BITS | count;
    }
}

视频中展示的是通过redis自增,说实话不太懂,目前就知道能搞一堆不重复的id,就像那个加密策略一样

在这里插入图片描述

2、下单功能

在这里插入图片描述

下单功能实现

  1. 查询优惠券的信息
  2. 判断秒杀是否开始
    1. 判断秒杀是否开始
    2. 判断秒杀是否结束
  3. 如果开始了
    1. 判断库存是否充足
      1. 扣减库存
      2. 生成订单
      3. 返回订单id
    2. 不充足就返回异常
  4. 没开始也返回异常

这里一看就是存在一个并发问题,也就是下面的超卖问题

3、超卖问题

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

乐观锁版本号法实现:通过多添加一个version,在进行库存减一的时候where中的version就是就是查询的version,如果和数据库中的version一样的就正常,如果不一样的话就不会发生数据库的库存减一,也就是相当于是阻止了非法操作,但是我再考虑这个是不是要在数据库中直接新加一个字段,并且如果没有扣减成功是不是该线程就会直接废了

在这里插入图片描述

使用了这个CAS,测试的时候,成功率太低了,就是我上面我猜的,如果库存扣减发生异常的时候,就直接就失败了,都没有机会,直接就消失了

然后这里又修改了一下,把判断库存的数量弄成了大于0,之前那样是太小心了,只要是不按套路来就直接失败了,现在这样就会提高成功率,当即将发生超卖的时候就给拦截住,让他失败
在这里插入图片描述

在这里插入图片描述

4、一人一单

遏制黄牛买卖
在这里插入图片描述

这里通过实现这个逻辑,还是会出现并发的问题,在查询订单的时候涌入一大堆的线程,还是会发生一人多单的情况,这里应该使用悲观锁去解决这个问题,之前那个是更新优惠卷的操作,所以适合用乐观锁,这里这个是要查询一个数据,不太适合,所以使用悲观锁去解决

@Transactional
public Result createVoucherorder(Long voucherId){

    Long userId = UserHolder.getUser().getId();

    // 根据优惠券id和用户id查询订单
    Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();

    // 判断订单是否存在
    if (count > 0) {
        // 用户已经购买了
        return Result.fail("用户已经购买了");
    }

    // 4、充足就可以扣减库存
    boolean success = seckillVoucherService.update().setSql("stock = stock - 1")
            .eq("voucher_id", voucherId)
            // 多设施一个条件
            .gt("stock", 0)
            .update();
    if (!success) {
        // 扣减失败
        return Result.fail("库存不足!");
    }

    // 5、然后就创建订单并返回订单id
    VoucherOrder voucherOrder = new VoucherOrder();
    // 订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 用户id

    voucherOrder.setUserId(userId);
    // 代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);

    return Result.ok(voucherId);
}

在这里插入图片描述
两个知识点

  1. 上锁的方式,这里使用用户的id进行上锁,但是单纯的将userId用toString底层还是新建了一个stirng的字符串,事实上还是不一样的值,所以要用那个intern来解决这个,确保userId始终是一个值
  2. 第二个方式就是关于事务失效的,事务啥的都是由spring去统一进行管理的,你这个要加事务的方法,没有在spring的管理之下,所以会造成事务失效,这里给出的解决办法就是通过AOP获取到它的代理对象,把这个方法注入到spring中,这样才会生效

这里我有个小疑问,就是虽然是一人一单的实现了,但是我看还是查询了数据库100次,大量的访问数据库的话,会不会数据库会直接崩掉,正常来说不会有一个人闲着没事干,成百上千的访问,就怕那些恶意的,所以我觉得将这种优惠卷的东西,还是通过redis来做一下缓冲,要不就直接把这东西放到redis中去

5、集群下的线程并发安全问题

在这里插入图片描述
在这里插入图片描述

都要改,要不然的,nginx就不会负载均衡

然后通过debug后就可以复现,一人两单的问题

在这里插入图片描述

在这里插入图片描述

又发现自己的知识欠缺的地方了,jvm虚拟机要提上日程了,在集群和分布式的情况下,之前解决一人一单的解决方案就不太行了

6、分布式锁实现版本1

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

这样的话,死锁的风险会很高,因为这三个操作不具有原子性

在这里插入图片描述
在这里插入图片描述

这个把上述的问题基本解决了一下,就算是获取锁和释放锁直接出了差错,但是还是有过期时间,以免死锁,但是又引入了新的问题,你怎么知道一个合适的过期时间呢,太短了可能没执行完就没了,太长了又影响性能,这就很烦人了

在这里插入图片描述

在这里插入图片描述

public class SimpleRedisLock implements ILock{

    private String name;

    private StringRedisTemplate stringRedisTemplate;

    private static final String KEY_PREFIX = "lock:";

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
        this.name = name;
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        // 1、获取线程标识
        long threadId = Thread.currentThread().getId();
        // 2、获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        // 做拆箱的时候注意空指针的问题
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unlock() {
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

7、分布式锁误删问题

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

这里的锁的误删是因为业务阻塞问题,通过在释放锁之前加一个判断锁是否是自己的来解决误删锁的问题

在这里插入图片描述

然后下面这种情况是,当你判断完锁是否是自己的,然后在判断锁和释放锁之间发生了阻塞的话,就又回到了之前那种情况,又会去删除别人的锁,也就是目前的判断锁和释放锁的操作不是一个原子操作,很容易被趁虚而入

8、Lua脚本解决多条命令原子性问题

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

调用Lua脚本改造分布式锁
在这里插入图片描述

在这里插入图片描述

这个已经是比较成熟了,但是还有有进步的空间,我靠!太麻烦了,太细致了!

9、Redission

9.1、基本介绍

在这里插入图片描述

在这里插入图片描述

9.2、Redisson入门

在这里插入图片描述

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient(){
        // 配置
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.84.132:6379");
        // 创建ReissonClient对象
        return Redisson.create(config);
    }
}

在这里插入图片描述

在这里插入图片描述

这里直接换成Redisson的锁,直接就能用,好像是之前都是在造轮子,但是一步一步过来的,原理也清楚了些

9.3、Redisson可重入锁原理

在这里插入图片描述

通过使用Hash来实现可重入锁

在这里插入图片描述

在这里插入图片描述

9.4、Redisson的锁重试和WatchDog机制

在这里插入图片描述

这里好难啊,,,,先放一放

在这里插入图片描述

9.5、Redisson的multiLock原理

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

10、Redis优化秒杀

在这里插入图片描述

-- 1、参数列表

-- 1.1、优惠卷id
local voucherId = ARGV[1]

-- 1.2、用户id
local userId = ARGV[2]

-- 2、数据key
-- 2.1、库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2、订单key
local orderKey = 'seckill:order:' .. voucherId


-- 3、脚本业务
-- 3.1、判断库存是否充足get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2、库存不足,返回1
    return 1
end

-- 3.2、判断用户是否下单
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 3.3、存在,说明是重复下单
    return 2
end

-- 3.4、扣库存
redis.call('incrby', stockKey, -1)
-- 3.5、下单(保存用户)
redis.call('sadd', orderKey, userId)
return 0
// 秒杀部分优化
@Override
public Result seckillVoucher(Long voucherId) {
    // 获取用户
    Long userId = UserHolder.getUser().getId();
    // 1、执行lua脚本
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.emptyList(),
            voucherId.toString(),
            userId.toString()
    );
    // 2、判断结果是否是0
    int r = result.intValue();
    if(r != 0){
        // 2.1、不为0,代表没有购买资格
        return Result.fail(r == 1? "库存不足" : "不能重复下单");
    }
    // 2.2、为0,有购买资格,把下单信息保存到阻塞队列中
    long order = redisIdWorker.nextId("order");

    return Result.ok(order);
}

这里用到了阻塞队列还有线程来进行扣库存

// 队列
    private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
    // 线程池
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    @PostConstruct
    private void init(){
        // 初始化完毕就运行这个
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    // 使用线程来结合阻塞队列实现扣库存
    private class VoucherOrderHandler implements Runnable{
        @Override
        public void run() {
        	while(true){
	            try {
	                // 1、获取队列中的订单信息
	                VoucherOrder take = orderTasks.take();
	                // 2、创建订单
	                handleVoucherOrder(take);
	            } catch (InterruptedException e) {
	                log.error("处理订单异常", e);
	            }
            }
        }
    }

    private void handleVoucherOrder(VoucherOrder take) {
        // 获取用户
        Long userId = take.getUserId();
        // 创建锁对象(这个是自己定义的锁)
        RLock lock = redissonClient.getLock("lock:order:" + userId);

        boolean isLock  = lock.tryLock();

        // 判断是否获取锁成功
        if(!isLock){
            // 获取锁失败,返回错误或重试
            log.error("不允许重复下单");
            return ;
        }
        try {
            proxy.createVoucherorder(take);
        } finally {
            lock.unlock();
        }
    }
@Transactional
    public void createVoucherorder(VoucherOrder voucherOrder){

        Long userId = voucherOrder.getUserId();

        // 根据优惠券id和用户id查询订单
        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();

        // 判断订单是否存在
        if (count > 0) {
            // 用户已经购买了
            log.error("用户已经购买过一次");
            return;
        }

        // 4、充足就可以扣减库存
        boolean success = seckillVoucherService.update().setSql("stock = stock - 1")
                .eq("voucher_id", voucherOrder.getVoucherId())
                // 多设施一个条件
                .gt("stock", 0)
                .update();
        if (!success) {
            // 扣减失败
            log.error("库存不足!");
            return;
        }
        save(voucherOrder);
    }

在这里插入图片描述

跟着视频做到这里,还有有些小疑问,在此之前我们使用的是一个判断库存的乐观锁,一个是判断一人一单的Redisson的非重入锁,那时候已经基本完成了我们所需要的任务,但是为了提高性能,我们将在redis优化中,将判断库存和一人一单提取到了redis中去做判断,然后将合法的订单生成出来放入阻塞队列中,然后另开一个线程来异步的进行数据库的操作,这时候,我发现我们一共运用了4个锁了,除了之前那两个锁以外,我们又在redis中加了一个库存的锁,一个判断一人一单的锁,功能重复实现了,我觉得把后面的那两个删除了也可以,视频中最后给那些判断的返回值都弄成了return;应该就是这个原因了吧

11、Redis消息队列实现异步秒杀

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

最后呢,使用了redis中的stream来进行对项目进行了优化,其实这里提供了三种方式,最优的就是Stream了,所以我觉得我其他两种就当是了解了,学一下stream,速度快,安全

应该熟悉的命令

  1. 创建一个消费者组:XGROUP CREATE key的名称 消费者组的名称 起始id标识 MKSTREAM

示例:XGROUP CREATE stream.orders g1 0 MKSTREAM

  1. 向消费者组里面添加消息:XADD key的名称 * k1 v1 k2 v2…

示例(lua脚本):redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)

  1. 从消费者组里面获取消息:XREADGROUP GROUP 消费者组的名称 消费者名称 COUNT 读取几个 BLOCK 等待时间 STREAMS key的名称 从未消费的开始(>)/从pending-list中读取第一个(0)

示例(java的api):stringRedisTemplate.opsForStream().read( Consumer.from("g1", "c1"), StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)), StreamOffset.create("stream.orders", ReadOffset.lastConsumed())

感觉还有一个重要的点,就是读取消息队列中的消息,正常的读取后就要进行ACK确认,出异常的话就要再去读取pending-list中的消息,进行处理读取pending-list的命令也有些不同,下面就贴点代码

lua代码

-- 1、参数列表

-- 1.1、优惠卷id
local voucherId = ARGV[1]

-- 1.2、用户id
local userId = ARGV[2]

-- 1.3、订单id
local orderId = ARGV[3]

-- 2、数据key
-- 2.1、库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2、订单key
local orderKey = 'seckill:order:' .. voucherId


-- 3、脚本业务
-- 3.1、判断库存是否充足get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2、库存不足,返回1
    return 1
end

-- 3.2、判断用户是否下单
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 3.3、存在,说明是重复下单
    return 2
end

-- 3.4、扣库存
redis.call('incrby', stockKey, -1)
-- 3.5、下单(保存用户)
redis.call('sadd', orderKey, userId)

-- 这个地方就是将我们需要的消息放到消费者组里面
-- 3.6、 发送消息到队列中, XADD stream.orders * k1 v1 k2 v2
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0

java代码

private class VoucherOrderHandler implements Runnable{
  String queueName = "stream.orders";
    @Override
    public void run() {
        while(true) {
            try {
                // 1、获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS streams.order >
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                        Consumer.from("g1", "c1"),
                        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                        StreamOffset.create(queueName, ReadOffset.lastConsumed())
                );
                // 2、判断消息获取是否成功
                // 2.1、如果获取失败,说明没有消息,继续下一次循环
                if (list == null || list.isEmpty()){
                    // 如果获取失败,说明没有消息,继续下一次循环
                    continue;
                }
                // 解析消息中的订单信息
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> value = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                // 3、如果获取成功就可以下单
                handleVoucherOrder(voucherOrder);
                // 4、ACK确认
                stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());

            } catch (Exception e) {
                log.error("处理订单异常", e);
                handlePendingList();
            }
        }
    }

    private void handlePendingList(){
        while(true) {
            try {
                // 1、获取pending-list中的订单信息 XREADGROUP GROUP g1 c1  STREAMS streams.order 0
                List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                        Consumer.from("g1", "c1"),
                        StreamReadOptions.empty().count(1),
                        StreamOffset.create(queueName, ReadOffset.from("0"))
                );

                // 2、判断消息获取是否成功
                // 2.1、如果获取失败,说明没有消息,继续下一次循环
                if (list == null || list.isEmpty()){
                    // 如果获取失败,说明pending-list中没有消息,结束循环
                    break;
                }
                // 解析消息中的订单信息
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> value = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                // 3、如果获取成功就可以下单
                handleVoucherOrder(voucherOrder);
                // 4、ACK确认
                stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());

            } catch (Exception e) {
                log.error("处理pengding-list订单异常", e);
                try {
                    Thread.sleep(20);
                } catch (InterruptedException ex) {
                    throw new RuntimeException(ex);
                }
            }
        }
    }
}

四、达人探店

1、发布探店笔记

他给我实现了

2、实现查看发布探店笔记的接口

在这里插入图片描述

@Override
public Result queryBlogById(Long id) {
    // 1、查询blog
    Blog blog = getById(id);
    if(blog == null){
        return Result.fail("笔记不存在");
    }
    // 2、查询blog有关的用户
    queryBlogUser(blog);
    return Result.ok(blog);
}

private void queryBlogUser(Blog blog){
    Long userId = blog.getUserId();
    User user = userService.getById(userId);
    blog.setName(user.getNickName());
    blog.setIcon(user.getIcon());
}

3、完善点赞功能

在这里插入图片描述

根据应用场景去选择合适的数据类型去实现,这里就使用set集合,这里面本来就不允许重复,判断集合中是否点赞过,没点赞就+1,点过赞就-1

@Override
public Result likeBlog(Long id) {
    // 1、获取登录用户
    Long userId = UserHolder.getUser().getId();
    // 2、判断当前登录用户是否已经点赞
    String key = "blog:liked:" + id;
    Boolean member = stringRedisTemplate.opsForSet().isMember(key, userId.toString());
    if(BooleanUtil.isFalse(member)){
        // 3、如果未点赞。可以点赞
        // 3.1、数据库点赞数 + 1
        boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
        // 3.2、保存用户到Redis的set集合
        if(isSuccess){
            stringRedisTemplate.opsForSet().add(key, userId.toString());
        }
    }else{
        // 4、如果已点赞,取消点赞
        // 4.1、数据库点赞数 -1
        boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
        // 4.2、把用户从Redis的set集合中移除
        if(isSuccess){
            stringRedisTemplate.opsForSet().remove(key, userId.toString());
        }
    }
    return Result.ok();
}

4、点赞排行榜

在这里插入图片描述

这里要把刚才点赞功能的那个给修改一下,换成sortedSet

在这里插入图片描述

小tips:这里发现点赞排行榜的点赞顺序有问题,视频中解释的数据库的问题,也就是使用in的那个地方的问题,使用了in和要查询的顺序就反过来了,然后使用了order by field来解决这个问题

 // 2、解析出其中的用户id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
// 3、解析出用户id的用户
String idStr = StrUtil.join(",", ids);
List<UserDTO> collect = userService.query().
        in("id", ids).last("order by field (id," + idStr + ")").list()
        .stream()
        .map(user -> BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());

这里在查询id的用户的时候,就使用in和last配置实现了在数据库中那个正确查询顺序的操作,很神奇,又学到了一点东西

在这里插入图片描述

5、好友关注

首先这里的按钮只有关注和取消关系,当点击关注的时候,会往后台发送一条请求,将关注的用户id和是否关注发到后台,通过判断是取关还是关注,来进行对数据库中关注表的增删,然后返回,并且还有一个请求在判断页面上是否是关注还是取消关注

实现思路

  1. 获取登录用户的id,和关注的id
  2. 判断关注还是取关
  3. 不同的情况进行对数据库的增删

另一个请求

  1. 获取登录用户的id和关注的id
  2. 在数据库中是否能查询出来,然后将count是否大于0的布尔值返回即可

6、好友共同关注

实现思路

  1. 通过redis中的set类型,在进行好友关注和取关的同时,将关注信息放到redis中
  2. 通过redis中的一个方法,来求指定key的交集,也就睡好友共同关注,然后转换为UserDto进行返回(这里多次使用到了stream,map映射什么的东西,不太懂,得学

7、关注推送

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

需要去临时拉取信息,延迟高

在这里插入图片描述

已经拉好了,直接读取就可以了

在这里插入图片描述

通过对大V和粉丝的区分,进行活跃用户推消息,普通用户拉消息,这样就稍微均衡一些

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

实现思路

  1. 修改保存博文业务,保存博文成功即查询follow表中关注的该用户的粉丝id
  2. 推送笔记的id给粉丝,也就是将博客的id推送到粉丝的收件箱里面,这里是使用了zsort的数据结构来存储信息的,为什么要用这个东西,是因为传统的那个分页会出现重复读的现象,所以要使用滚动分页,那个lastId就取时间戳即可

8、滚动分页查询收件箱

在这里插入图片描述

这个实现思路,是通过来倒序根据分数来查询数据,并进行分页展示的,其中不仅要记录上一次查询的最小时间戳,也要查询上一次结果中,与最小值一样的元素的个数作为偏移量进行查询

在这里插入图片描述

@Override
    public Result queryBlogOfFollow(Long max, Integer offset) {
        // 1、获取当前用户
        Long userId = UserHolder.getUser().getId();
        // 2、查询收件箱
        String key = FEED_KEY + userId;
        Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
                .reverseRangeByScoreWithScores(key, 0, max, offset, 2);
        // 3、解析数据:blogId, 时间戳, offset
        if(typedTuples == null || typedTuples.isEmpty()){
            return Result.ok(Collections.emptyList());
        }
        ArrayList<Long> ids = new ArrayList<>(typedTuples.size());
        long minTime = 0;
        int os = 1;
        for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
            // 4.1、获取blogId
            ids.add(Long.valueOf(typedTuple.getValue()));
            // 4.2、获取分数(时间戳)
            // 4.3、获取offset
            long time = typedTuple.getScore().longValue();
            // 相当于是当前的和上一次的做对比
            if(time == minTime){
                os++;
            }else{
                minTime = time;
                // 恢复现场
                os = 1;
            }
        }
        // 4、查询blog
        String idStr = StrUtil.join(",", ids);
        List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
        for (Blog blog : blogs) {
            // 查询blog有关的用户
            queryBlogUser(blog);
            // 查询blog是否被点赞了
            isBlogLiked(blog);
        }
        // 5、封装并返回
        ScrollResult r = new ScrollResult();
        r.setList(blogs);
        r.setOffset(os);
        r.setMinTime(minTime);
        return Result.ok(r);
    }

实现步骤

  1. 获取到用户的id
  2. 查询收件箱
  3. 通过对收件箱信息的解析,获取blogId,时间戳,offset,这里就跟复杂,又要获取到最小的时间戳,而且还有获取到最小时间戳的个数
  4. 又是之前排行榜遇到的那个问题,查询的和我要的是反的,通过以下方式解决

List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();

  1. 最后是对一些数据的补充和封装

9、附近商户

9.1、GEO数据结构和用法

在这里插入图片描述

9.2、导入店铺数据到GEO中

在这里插入图片描述

@Test
public void loadShopData(){
    // 1、查询店铺信息
    List<Shop> list = shopService.list();
    // 2、把店铺分组,把typeId分组,id一致的放到一个集合
    // stream流好牛啊
    Map<Long, List<Shop>> map = list
            .stream()
            .collect(Collectors.groupingBy(Shop::getTypeId));
    // 3、分批写入redis
    for (Map.Entry<Long, List<Shop>> longListEntry : map.entrySet()) {
        // 1、获取类型id
        Long typeId = longListEntry.getKey();
        String key = "shop:geo:" + typeId;
        // 2、获取同类型的店铺
        List<Shop> value = longListEntry.getValue();
        List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>();
        // 3、写入redis
        for (Shop shop : value) {
            // stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
            locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(), new Point(shop.getX(), shop.getY())));
        }
        stringRedisTemplate.opsForGeo().add(key, locations);
    }
}

9.3、实现附近商户功能

@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
    // 1、判断是否需要根据坐标查询
    if(x == null || y == null){
        Page<Shop> page = query()
                .eq("type_id", typeId)
                .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
        return Result.ok(page.getRecords());
    }

    // 2、计算分页参数
    int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
    int end = current * SystemConstants.DEFAULT_PAGE_SIZE;

    // 3、查询redis,按照距离排序,分页,结果:shopId,distance
    String key = SHOP_GEO_KEY + typeId;

    GeoResults<RedisGeoCommands.GeoLocation<String>> radius = stringRedisTemplate.opsForGeo()
            .radius(key, new Circle(new Point(x, y), new Distance(5000, Metrics.MILES)), RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().
                    //包含距离,包含经纬度,升序前五个
                            includeDistance().includeCoordinates().sortAscending().limit(end));

    // 4、解析出id
    if(radius == null){
        return Result.ok(Collections.emptyList());
    }

    List<GeoResult<RedisGeoCommands.GeoLocation<String>>> content = radius.getContent();
    if(content.size() <= from){
        // 没有下一页了,结束
        return Result.ok(Collections.emptyList());
    }
    // 截取from-end的部分
    List<Long> ids = new ArrayList<>(content.size());
    Map<String, Distance> distanceMap = new HashMap<>(content.size());

    content.stream().skip(from).forEach(res -> {
        // 店铺id
        String shopIdStr = res.getContent().getName();
        ids.add(Long.valueOf(shopIdStr));
        // 获取距离
        Distance distance = res.getDistance();
        distanceMap.put(shopIdStr, distance);
    });

    // 5、根据id查询Shop
    String idStr = StrUtil.join(",", ids);
    List<Shop> list = query().in("id", ids).last("ORDER BY FIELD (id," + idStr + ")").list();
    for (Shop shop : list) {
        shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
    }

    // 6、返回
    return Result.ok(list);
}

逻辑有点复杂,一个是使用了滚动分页,说实话我看这后端不知道是怎么实现了,应该是前端设计成这样子的,而且这个计算分页参数也是一些小小的经验,还有那个redis的附近商店查询,我的redis有点旧,我就使用了那个低版本的去实现了,还有那个用stream的跳过啥的然后在forEach中去收集一些数据,之前还有在stream中去构造,修改一些数据,这东西真方便,这个redis实战看完就学那个,最后还是in ("id", ids).last("order by field(id, + idsStr + ")",已经见过好多次了

10、用户签到

在这里插入图片描述

在这里插入图片描述

@Override
public Result sign() {
    // 1、获取当前登录用户
    Long userId = UserHolder.getUser().getId();
    // 2、获取日期
    LocalDateTime now = LocalDateTime.now();
    // 3、拼接key
    // 获取年月的时间
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    // 拼接好了  前缀+用户id+年月
    String key = USER_SIGN_KEY + userId + keySuffix;
    // 4、获取今天是本月的第几天
    int dayOfMonth = now.getDayOfMonth();
    // 5、写入redis
    stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
    return Result.ok();
}

在这里插入图片描述

@Override
public Result signCount() {
    // 1、获取当前登录用户
    Long userId = UserHolder.getUser().getId();
    // 2、获取日期
    LocalDateTime now = LocalDateTime.now();
    // 3、拼接key
    // 获取年月的时间
    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
    // 拼接好了  前缀+用户id+年月
    String key = USER_SIGN_KEY + userId + keySuffix;
    // 4、获取今天是本月的第几天
    int dayOfMonth = now.getDayOfMonth();
    // 5、获取本月截止今天为止的所有签到记录,返回的是一个十进制的数字
    List<Long> result = stringRedisTemplate.opsForValue().bitField(
            key,
            BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
    );
    if(result == null || result.isEmpty()){
        // 没有任何签到结果
        return Result.ok();
    }
    Long aLong = result.get(0);
    if(aLong == null || aLong == 0){
        return Result.ok(0);
    }
    // 6、循环遍历
    int count = 0;
    while(true){
        // 7、让这个数字与1做与运算,得到数字的最后一个bit位  8、判断这个bit位是否为0
        if((aLong & 1) == 0){
            // 9、如果是0,说明未签到,结束
            break;
        }else{
            // 10、如果不为0.说明已签到,计数器加1
            count++;
        }
        // 11、把数字右移一位,抛弃最后一个bit位,继续判断下一个bit位
        // 右移一位
        aLong >>>= 1;
    }

    return Result.ok(count);
}

这里用了一个双重判断, 还用了进制和位运算,这些都不太熟悉,很难受

11、HyperLogLog

在这里插入图片描述

在这里插入图片描述

@Test
void testHyperLogLog(){
    String[] values = new String[1000];
    int j = 0;
    for(int i = 0; i < 1000000 ; i++){
        j = i % 1000;
        values[j] = "user_" + i;
        if(j == 999){
            stringRedisTemplate.opsForHyperLogLog().add("hl2", values);
        }
    }
    // 统计数量
    Long count = stringRedisTemplate.opsForHyperLogLog().size("hl2");
    System.out.println("count = " + count);
}
  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值