黑马点评项目全面业务总结

1 黑马点评项目

1.1 短信登陆

1.1.1 短信登陆简介

session共享问题:多台服务器并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题。

  • 在进行短信登录时,运用redis的String数据结构把手机号作为key,验证码作为value进行存储。
  • 查询用户获得用户信息后,运用redis的hash结构,用token当做key存储(token的意思是“令牌”,是服务器生成的一段加密字符串),用户信息作为一个一个hash存储。
    为什么不用String数据结构?
    Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD,并且内存占用更少。String是以JSON字符串来保存的,虽然比较直观,但是不能进行CRUD。

1.1.2 校验登录状态

主要逻辑:验证手机号格式 如果不符合,返回错误信息,如果符合生成验证码,就运用redis的String数据结构对手机号和验证码进行存储 redis存储一般有个公共前缀并且设置有效时间 一般两分钟 最后返回ok;

//Slfg4    日志注解
    public Result sendCode(String phone, HttpSession session) {
        //1:先验证手机号格式  不符合就返回错误信息
        if(RegexUtils.isPhoneInvalid(phone)){
            //2:如果不符合,返回错误信息
            return Result.fail("手机号格式错误");
        }
            //4:生成验证码
            String code = RandomUtil.randomNumbers(6);
           //保存验证码到redis phone作为key 并且有一个公共前缀

            stringRedisTemplate.opsForValue()
            .set(RedisConstants.LOGIN_CODE_KEY +phone,code
                    ,RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
            //6:发送验证码
            log.debug("验证码是{}",code);
            return Result.ok();

    }

1.1.3短信验证码登录和注册

主要逻辑:前端给手机号,以手机号从redis里面拿出验证码,比较验证码,如果不等则返回错误"验证码不正确",如果相等看看是否被注册过,如果没有被注册过,需要插入数据,没注册过的话直接返回UserDto对象(注:UserDto对象相比User对象少了一些敏感信息,例如:密码),然后将对象存储到redis的hash结构里面,并设置有效期 一般为30分钟

  @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        //获取手机号
        String phone = loginForm.getPhone();
        if(RegexUtils.isPhoneInvalid(phone)){
            //2:如果不符合,返回错误信息
            return Result.fail("手机号格式错误");
        }
        //从reids中拿出验证码
        String code = stringRedisTemplate.opsForValue()
        .get(RedisConstants.LOGIN_CODE_KEY + phone);
        if(loginForm.getPhone().equals(code)){
            //如果验证码错误直接返回false
            return Result.fail("验证码不正确");
        }
        //如果正确 在确定手机号是否已经被注册过
        User user = query().eq("phone", phone).one();
        //生成token  用hutool工具类生成的uuid toString(true)可以把uuid中的-去掉
        String token = UUID.randomUUID().toString(true);
        if(user==null){
            //没有注册过新建并插入新数据
            user=CreateNewUser(phone);
        }
        //hutool工具类 Beanutil
        UserDTO userDTO= BeanUtil.copyProperties(user, UserDTO.class);
        //运用redis中的map数据结构存储userDto对象
        Map<String,String> map=new  HashMap<>();
        map.put("id",userDTO.getId().toString());
        map.put("nickName",userDTO.getNickName());
        map.put("icon",userDTO.getIcon());
        stringRedisTemplate.opsForHash()
        .putAll(RedisConstants.LOGIN_USER_KEY+token,map);
        //设置时间一般是30分钟不进行操作,就会失效
        stringRedisTemplate
        .expire(RedisConstants.LOGIN_USER_KEY+token, 
        RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
      return Result.ok(token);
    }

    private User CreateNewUser(String phone) {
     User user=new User();
     user.setPhone(phone);
     user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(4));
     save(user);
     return  user;
    }

1.1.4 拦截器的实现

黑马点评项目全面业务总结_第1张图片

主要逻辑:一个拦截器是更新拦截器,主要是更新token的有效期,一个拦截不合法的路径,更新拦截器首先获得token对象 如果token为空直接放行。若不为空的话token刷新token的有效期,然后用token从redis里面拿出UserDTO的map对象,然后把map对象转换为UserDTO对象,存入ThreadLocal域中。在拦截器执行之后将TheadLocal域中的对象释放掉,避免发生内存泄漏.一个拦截器只用判断ThreadLocal域中有没有UserDTO对象,如果有则放行,如果没有就拦截.

//更新拦截器 主要是更新token有效期 另外拦截器不是spring管理的bean 
//里面不能用自动注入注解 需要用构造方法
public class RefreshInterceptor implements HandlerInterceptor {
    private StringRedisTemplate stringRedisTemplate;

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

    @Override
    public boolean preHandle(HttpServletRequest request,
     HttpServletResponse response, Object handler) throws Exception {
        //从对象头获得token
        String token = request.getHeader("authorization");
        if(StrUtil.isBlank(token)){
            return true;
        }
        //若不为空放行,并且把用户放进TheadLocal并且把时间重置为30分钟
        Map<Object, Object> map = stringRedisTemplate.opsForHash()
                .entries(RedisConstants.LOGIN_USER_KEY+token);
        if(map.isEmpty()){
            return true;
        }
        //hutool工具类 将map转换为实体类对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
        UserHolder.saveUser(userDTO);
        stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY+token,
                RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);

        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
     //释放Thread中的user类 避免内存泄露
        UserHolder.removeUser();
    }
}
//目的是拦截不合法的路径
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request
    , HttpServletResponse response, Object handler) throws Exception {
        System.out.println("执行拦截器");
        System.out.println(UserHolder.getUser());
        if(UserHolder.getUser()==null){
           //状态码401 表示没授权
           response.setStatus(401);
           return false;
       }
       return true;
    }
}

拦截器的配置

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    //登录拦截器
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
             "/shop/**",
             "/voucher/**",
             "/shop-type/**",
             "upload/**",
             "/blog/hot",
             "/user/code",
             "/user/login"
        ).order(1);
        //刷新放行拦截器
        registry.addInterceptor(new RefreshInterceptor(stringRedisTemplate))
        .addPathPatterns("/**").order(0);
    }
}

1.2 商户查询缓存

1.2.1 redis缓存简介

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

缓存的作用?

  • 降低后端负载
  • 提高读写效率,降低相应时间

缓存的成本?

  • 数据一致性成本
  • 代码维护成本
  • 运维成本

1.2.2添加商户缓存

具体流程:根据前端返回的id的数据,查商品信息,接收到id后,先从redis里面拿数据,如果有直接返回,如果没有在从数据库里面拿商品信息,如果没有报错商品不存在,如果有先往redis缓存里存入数据并且设置有效期(避免缓存数据与数据库的数据长期不一致)。

    @Override
    public Result queryShopById(Long id) {
        //根据id看看redis有没有缓存
        String shopString = stringRedisTemplate.opsForValue()
        .get(RedisConstants.CACHE_SHOP_KEY + id);
        if(StrUtil.isNotBlank(shopString)){
            //如果有 直接返回
            //hutool工具类
            Shop shop = JSONUtil.toBean(shopString, Shop.class);
            return Result.ok(shop);
        }
        //如果没有从数据库查
        Shop shop = getById(id);
        if(shop==null){
            //数据库没有 返回404 没有该商品
            return Result.fail("该店铺不存在");
        }
        //数据库有 往redis插入数据 并且返回数据
        stringRedisTemplate.opsForValue()
        .set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil
        .toJsonStr(shop),RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(shop);
    }

1.2.3 缓存更新策略

操作缓存和数据库有三个问题?

  1. 删除缓存还是更新缓存

    • 更新缓存:每次更新数据库都会更新缓存,无效写操作比较多(比如:如果一直改而没有查 缓存也会一直改,效率低)
    • 删除缓存:更新数据库时让缓存失效,查询时在更新缓存( 采用)
  2. 如何保证缓存与数据库的操作同时成功或失效

    • 单体系统,将缓存与数据库操作放在一个事务
    • 分布式系统,利用TCC等分布式事务方案
  3. 先操作缓存还是先操作数据库?

    • 先删除缓存,在操作数据库:如果线程一删除了缓存,线程二查询缓存没查到,然后写入了缓存,这时候线程一才更新完数据库 这时候会造成数据不一致
    • 先操作数据库,在删除缓存:如果线程一查询缓存没命中,查询数据库,线程二更新数据库 并且删除缓存,这时候线程一才写入缓存 会造成数据不一致,但是一般缓存的读写比数据库快 所以这种方式几率很小

黑马点评项目全面业务总结_第2张图片

具体流程:首先获得前端给的修改的数据 ,判断id是否为空,为空直接返回错误,不为空就先更新数据库,在删除redis的缓存

  @Override
    @Transactional//事务注解
    public Result updateShop(Shop shop) {
        Long id = shop.getId();
        if(id==null){
            //先检查id是否为空
            return Result.fail("商品id不能为空");
        }
        //先更新数据库
        updateById(shop);
        //在删除缓存
        stringRedisTemplate.delete(RedisConstants.CACHE_SHOP_KEY+id);
         return Result.ok();  
    }

1.2.4解决缓存穿透

黑马点评项目全面业务总结_第3张图片

具体流程 如果没有该商品 则往redis插入一个空字符串,设置短的有效期,下一次如果在redis里面查的是空字符串的话,则直接返回商品不存在

  //缓存穿透解决方案
if(StrUtil.isNotBlank(shopString)){
            //如果有 直接返回
     Shop shop = JSONUtil.toBean(shopString, Shop.class);
     return Result.ok(shop);
   }
//判断字符串不为null 则为一个空字符串 直接返回404 不用经过数据库查询
if("".equals(shopString)){
            return Result.fail("该商铺不存在");
  }

if(shop==null){
       //数据库没有 则往redis插入一个空字符串,并且设置一个短的有效期
    stringRedisTemplate.opsForValue()
    .set(RedisConstants.CACHE_SHOP_KEY+id,""
    ,RedisConstants.CACHE_NULL_TTL,TimeUnit.MINUTES);
            return Result.fail("该店铺不存在");
   }

黑马点评项目全面业务总结_第4张图片

1.2.5解决缓存击穿

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

常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期

互斥锁解决
具体流程:当redis里面查不到之后,先上锁,锁用的是redis的String数据结构,如果上锁失败,先睡眠,在重新去获得数据.如果上锁成功,进行第二次从redis里面查,如果还查不到,从数据库查,并且插入reids数据,释放锁,返回数据.

    private Shop queryWithMutex(Long id){
        //获得key
        String key=RedisConstants.CACHE_SHOP_KEY + id;
        //从redis里面获得数据
        String shopString = stringRedisTemplate.opsForValue().get(key);
        if(StrUtil.isNotBlank(shopString)){
            Shop shop = JSONUtil.toBean(shopString, Shop.class);
            return shop;
        }
        if("".equals(shopString)){
            return null;
        }
        //获得锁的key
        String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
        Shop shop=null;
        try {
            //尝试获得锁
            if (!tryLock(lockKey)) {
                //如果没有获的 睡眠 50ms 重新获取值
                Thread.sleep(RedisConstants.LOCK_SLEEP_TTL);
                return queryWithMutex(id);
            }
            //如果获得锁 进行第二次 从redis里面获得数据
            String string = stringRedisTemplate.opsForValue().get(key);
            if (StrUtil.isNotBlank(string)) {
                shop = JSONUtil.toBean(string, Shop.class);
                return shop;
            }
            if ("".equals(string)) {
                return null;
            }
            //第二次没获得 从数据库查
            shop = getById(id);
            //没查到赋空字符串
            if (shop == null) {
                stringRedisTemplate.opsForValue()
                .set(RedisConstants.CACHE_SHOP_KEY + id, "",
                 RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            //查到则直接往redis里面插入值
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop),
                    RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
        }catch (InterruptedException e){
            throw new RuntimeException(e);
        }finally {
            //释放锁
            unLock(lockKey);
        }
        return shop;
    }

逻辑过期(感觉挺重要)

具体流程:一般这种热点key在使用之前需要进行预热,也就是把数据先提前送到缓存中,并设置一个逻辑时间,然后拿到id查数据,如何缓存里面没有则直接返回null,如果有则查看它的逻辑过期时间是否已经过期,如果过期则拿锁,若拿不到直接返回旧值,如果拿到了再从redis拿出来看看是否过期,如果这次没过期,则直接返回,如果依然过期,则开启一个新的线程将商铺数据重新写入redis,最后释放互斥锁

//首先设置一个Shop和过期时间的Bean
@Data
//@Data相当于@Getter @Setter @RequiredArgsConstructor @ToString @EqualsAndHashCode这5个注解的合集
public class RedisData {
    private LocalDateTime expireTime;
    private Object data;
}

//自定义一个线程用于开启线程
private static final ExecutorService CACHE_REBUILD_EXECUTOR=
         new ThreadPoolExecutor(10,10,0,
         TimeUnit.MINUTES,new LinkedBlockingDeque<>());
    private Shop queryWithLogicalExpire(Long id){
        //从redis拿数据
        String shopString = stringRedisTemplate.opsForValue()
        .get(RedisConstants.CACHE_SHOP_KEY + id);    //如果为空 直接返回空
        if(StrUtil.isBlank(shopString)){
          return null;
        }
        //不为空拿出对象和过期时间
        RedisData redisData = JSONUtil.toBean(shopString, RedisData.class);
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        //如果过期时间没到期 直接返回对象
        if(redisData.getExpireTime().isAfter(LocalDateTime.now())){
            return shop;
        }
        String lockKey=RedisConstants.LOCK_SHOP_KEY+id;
        //如果过期时间到了 尝试获取锁
        Boolean lock = tryLock(lockKey);
        if(!lock){
            //如果没拿到锁直接返回旧数据
            return shop;
        }
        //拿到锁后在进行一次验证过期时间如果这一次已经被修改了 则直接返回
        shopString = stringRedisTemplate.opsForValue()
        .get(RedisConstants.CACHE_SHOP_KEY + id);
        if(StrUtil.isNotBlank(shopString)){
            redisData = JSONUtil.toBean(shopString, RedisData.class);
            shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
            if(redisData.getExpireTime().isAfter(LocalDateTime.now())){
                return shop;
            }
        }
        //如果没被修改 运用自定义线程池 开启一个新的线程 进行更新缓存操作
        CACHE_REBUILD_EXECUTOR.submit(()->{
            try {
                this.saveRedis(id,20L);
            }catch (Exception e){
                throw new RuntimeException(e);
            }
            finally {
                this.unLock(lockKey);
            }
        });
        return shop;
    }
    //逻辑过期 缓存预热
    public void saveRedis(Long id,Long outTime){
        Shop shop = getById(id);
        RedisData rs=new RedisData();
        rs.setData(shop);
        rs.setExpireTime(LocalDateTime.now().plusSeconds(o3utTime));
           stringRedisTemplate.opsForValue()
           .set(RedisConstants.CACHE_SHOP_KEY+id,JSONUtil.toJsonStr(rs));
    }

1.3 优惠卷秒杀

1.3.1 Redis实现全局唯一id

全局id生成器,一般需要满足一下几个特征:

  • 唯一性
  • 高可用
  • 高性能
  • 递增性
  • 安全性

黑马点评项目全面业务总结_第5张图片

id的组成部分:

  • 符号位:1bit,永远为0
  • 时间戳:31bit,以秒为单位,可使用69年
  • 序列号:32bit,秒内的计数器,支持每秒产生2^32割不同的id
@Component
public class RedisIdWorker {
    //开始的时间戳 用的是2022/10/19 的时间戳
    private static final long BEGIN_TIMESTAMP=1666137600L;
    //序列号长度
    private static final int COUNT_BITS=32;
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    public long nextId(String keyPrefix){
        //生成时间戳
        LocalDateTime now = LocalDateTime.now  //2022-10-19T16:07:19.784197600
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        Long timestamp=nowSecond-BEGIN_TIMESTAMP;
        //生成序列号
        //获得当前日期,精确到天
        //key中有年月日,方便计算一年一月一天的销售总量
        String date=now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
        //自增长
        Long count=stringRedisTemplate.opsForValue()
        .increment("inc"+keyPrefix+":"+date);
        //将生成的时间戳向右移动32位 然后将序列号或到后32位
        return timestamp<<COUNT_BITS|count;
    }
}
    @Resource
    private RedisIdWorker redisIdWorker;
    //线程池 注意线程池里面的线程必须比CountDownLatch中定义的值多
    private ExecutorService ex=new ThreadPoolExecutor(500,500,0,
     TimeUnit.SECONDS,new LinkedBlockingDeque<>());
    @Test
    void m1() throws InterruptedException {
        //CountDownLatch允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕
     CountDownLatch latch=new CountDownLatch(300);
     Runnable task=()-> {
         for (int i = 0; i < 100; i++) {
             long id = redisIdWorker.nextId("order");
             System.out.println("id=" + id);
         }
         //执行一次用countDown减一
         latch.countDown();
     };
     long begin=System.currentTimeMillis();
         for(int i=0;i<300;i++){
             ex.submit(task);
         }
        //全部执行完 才能往下执行
         latch.await();
         Long end=System.currentTimeMillis();
        System.out.println("time="+(end-begin));
     }

1.3.2 实现优惠卷秒杀一人一单功能

    public Result seckillVoucher(Long voucherId) {
        //获得秒杀优惠卷的id
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        
        if(voucher==null){
            return Result.fail("优惠卷不存在");
        }
        if(voucher.getBeginTime().isAfter(LocalDateTime.now())){
            return Result.fail("秒杀已经开始");
        }
        if(voucher.getEndTime().isBefore(LocalDateTime.now())){
            return Result.fail("秒杀已经结束");
        }
        if(voucher.getStock()<1){
            return Result.fail("优惠卷已经被发放完");
        }
        //加锁
        Long userId= UserHolder.getUser().getId();
        //把userId当成锁 toString() 会将id变成字符串
        // 但是toString的源码只是new 了一个新字符串
        //同个id toString()还是不同对象 所以用intern()方法 将字符串放入字符串常量池 并返回
        synchronized (userId.toString().intern()) {
            //获取代理对象(事务)  处理事务失效问题 还没学到 以后解决
            IVoucherOrderService proxy=
            (IVoucherOrderService)AopContext.currentProxy();
            /*这个对象需要在接口中声明 
            并且在启动类加入@EnableAspectJAutoProxy(exposeProxy = true)注解
            暴露代理对象  还需要加入aspectjweaver 依赖
            */
            return proxy.createVoucherOrder(voucherId);
        }
    }
    @Transactional
    public Result createVoucherOrder(Long voucherId) {
        //为什么不在方法里面加锁?
        //因为 在释放锁之后事务spring才提交事务 释放锁 
        //还没提交的时候 可能另一个线程可能拿到锁
        //线程不安全
        Long userId= UserHolder.getUser().getId();
        //实现一人一单
        int count = query().eq("user_id", userId)
        .eq("voucher_id", voucherId).count();
        if(count>0){
            return Result.fail("您已经购买到了");
        }
        //前面已经判断库存是否充足
        //这次在判断 是一种乐观锁的机制 
        //运用cas机制 在进行库存加减的时候 需要再次进行判断 库存是否有
        boolean success = seckillVoucherService.update()
                .setSql("stock=stock-1")
                .eq("voucher_id", voucherId)
                //
                .gt("stock",0)
                .update();
        if(!success){
            return Result.fail("优惠卷已经被发放完");
        }

        VoucherOrder voucherOrder=new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(UserHolder.getUser().getId());
        voucherOrder.setVoucherId(voucherId);
        this.save(voucherOrder);
        return Result.ok(orderId);
    }

然而上述锁只能运用在单体项目中,如果在分布式项目上并不能起到一人一单功能,所以需要分布式锁
分布式锁:满足分布式系统或集群模式下多线程可见并且互斥的锁

  • 多进程可见
  • 高可用
  • 安全性
  • 互斥
  • 高性能 等等
private StringRedisTemplate stringRedisTemplate;
    private String name;
    public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.name = name;
    }
    private static final String KEY_PREFIX="lock:";
    private static final String ID_PREFIX= UUID.randomUUID().toString(true)+"-";
  @Override
     // 上锁
    public boolean tryLock(long timeoutSec) {
        //运用uuid和线程id生成 value
        String threadId = ID_PREFIX+Thread.currentThread().getId();
        //前缀和name组成 key
        //设置过期时间 如果redis宕机后 锁还能等时间结束后释放  避免造成死锁
        Boolean success = stringRedisTemplate
        .opsForValue().setIfAbsent(KEY_PREFIX + name, threadId
        ,timeoutSec, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(success);
    }
    @Override
    public void unLock() {
        //在释放锁的时候 如果线程一 拿到锁 但是进行阻塞 然后锁失效了 
        //线程二拿到锁 线程一在阻塞消失后 直接删除了线程二的锁 
        //解决方案 lua脚本
        String threadID = ID_PREFIX+Thread.currentThread().getId();
        String id1=stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        if(threadID.equals(id1)){
            stringRedisTemplate.delete(KEY_PREFIX+name);
        }
    }
--- lua脚本能保证代码执行的原子性
if(redis.call('get',KEYS[1])==ARGV[1]) then
    return redis.call('del',KEYS[1])
end
return 0
 private  static  final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        //初始化
        UNLOCK_SCRIPT=new DefaultRedisScript<>();
        //设置脚本位置   classPath下的资源
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        //设置返回值
        UNLOCK_SCRIPT.setResultType(Long.class);
    }
   public void unLock() {
         stringRedisTemplate.execute(UNLOCK_SCRIPT,
                 Collections.singletonList(KEY_PREFIX+name),
                 ID_PREFIX+Thread.currentThread().getId()
              );
    }

使用Redisson分布式锁

黑马点评项目全面业务总结_第6张图片

Redisson可重入锁的原理

Redisson的锁重试,和看门狗机制

异步秒杀思路

黑马点评项目全面业务总结_第7张图片

异步秒杀的主要流程:在秒杀的时候 判断库存是否充足 如果不充足 直接返回错误,如果是充足的话,将优惠卷id,用户id和订单id存入阻塞队列,另开线程进行数据库交互

-- lua脚本在判断库存是否充足时 是原子性的 避免产生线程问题
-- 优惠卷id
local voucherId=ARGV[1]
-- 1.2 用户id
local userId=ARGV[2]
-- 2.数据key
-- 2.1 库存key
-- .. 字符串连接符
local stockKey='seckill:stock:' .. voucherId
local orderKey='seckill:order:' .. voucherId
--3脚本业务
--3.1判断库存是否充足 get stockKey
if(tonumber(redis.call('get',stockKey))<=0) then
    return 1
end
if(redis.call('sismember',orderKey,userId)==1) then
    return 2
end
--3.4 扣库存
redis.call('incrby',stockKey,-1)
redis.call('sadd',orderKey,userId)
return 0
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder>
 implements IVoucherOrderService {
     @Resource
     private SeckillVoucherServiceImpl seckillVoucherService;
     @Resource
     private RedisIdWorker redisIdWorker;
     @Resource
     private StringRedisTemplate stringRedisTemplate;
     @Resource
     private RedissonClient redissonClient;
     private  IVoucherOrderService proxy;
    /*阻塞队列特点:当一个线程尝试从队列中获取元素,
    没有元素,线程就会被阻塞,直到队列中有元素,线程才会被唤醒,并去获取元素
    */
     private BlockingQueue<VoucherOrder> orderTasks
     =new ArrayBlockingQueue<>(1024*1024);
    //线程池的创建 
     private static  final ExecutorService SECKILL_ORDER_EXECUTOR= 
     Executors.newSingleThreadExecutor();
    private  static  final DefaultRedisScript<Long> SECKILL_SCRIPT;
    static {
        //初始化
        SECKILL_SCRIPT=new DefaultRedisScript<>();
        //设置脚本位置   classPath下的资源
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        //设置返回值
        SECKILL_SCRIPT.setResultType(Long.class);
    }
    //spring的知识 目的是为了让 在类创建时 对这个方法进行初始话
    @PostConstruct//spring注解
    private  void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }
 @Override
    public Result seckillVoucher(Long voucherId){
        //执行lua脚本
        Long userId = UserHolder.getUser().getId();
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString()
        );
        int r=result.intValue();
        //判断结果为0
        
        if(r!=0){
            return Result.fail(r==1?"库存不足":"不能重复下单");
        }
        // 保存阻塞队列 将新建的对象存入阻塞队列
        VoucherOrder voucherOrder=new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        //不为0 代表没有购买职责
        //处理spring事务失效问题
        proxy=(IVoucherOrderService) AopContext.currentProxy();
        //加入队列 开辟线程 
        orderTasks.add(voucherOrder);
        //直接返回
        return Result.ok(orderId);
    }
    private   class VoucherOrderHandler implements Runnable{
        @Override
        public void run() {
            while(true){
                try{//这个线程主要从阻塞队列拿出voucherOrder对象 一直循环
                    VoucherOrder voucherOrder=orderTasks.take();
                    handleVoucherOrder(voucherOrder);
                }catch (Exception e){
                    log.error("处理订单异常",e);
                }
            }
        }
    }
    private  void handleVoucherOrder(VoucherOrder voucherOrder){
        //不能在线程里面拿对象了 因为线程变了
        Long userId=voucherOrder.getUserId();
        //分布式锁 获取锁 双重保障
        RLock lock = redissonClient.getLock("lock:order"+userId);
        boolean b = lock.tryLock();
        if(!b){
            log.error("不允许重复下单");
            return;
        }
        try{
            //调用方法
            proxy.createVoucherOrder(voucherOrder);
        }finally {
            lock.unlock();
        }

    }
   
    @Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder) {
        Long userId= voucherOrder.getUserId();
        //实现一人一单
        int count = query().eq("user_id", userId)
        .eq("voucher_id",voucherOrder.getVoucherId()).count();
        if(count>0){
            log.error("用户已经购买过一次!");
            return;
        }
        boolean success = seckillVoucherService.update()
                .setSql("stock=stock-1")
                .eq("voucher_id", voucherOrder.getVoucherId())
                .gt("stock",0)
                .update();
        if(!success){
            log.error("库存不足");
        }
        this.save(voucherOrder);
    }

}

1.4点赞功能实现

需求:

  • 同一个用户只能点赞一次,再次点击则取消点赞
  • 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)

实现步骤:

  • 给Blog类中添加一个isLike字段,标示是否被当前用户点赞
  • 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,一点赞过则点赞-1
  @Override
    public Result likeBlog(Long id) {
        //获得登录信息
        Long userId = UserHolder.getUser().getId();
        //判断当前登录用户是否已经点赞
        //往redis里面存入key是前缀加上博客id,value是用户id
        Boolean member = stringRedisTemplate.opsForSet()
        .isMember(RedisConstants.BLOG_LIKED_KEY + id, userId.toString());
        if(BooleanUtil.isFalse(member)){
            //如果未点赞,可以点赞
            //数据库点赞+1
            boolean success = update().setSql("liked=liked+1")
            .eq("id", id).update();
            if(success) {
      //保存用户到redis的set集合
     //stringRedisTemplate.opsForZSet().add(RedisConstants.BLOG_LIKED_KEY + id,
     //    userId.toString(),System.currentTimeMillis());存放在zet中
                stringRedisTemplate.opsForSet()
                .add(RedisConstants.BLOG_LIKED_KEY + id, userId.toString());
            }
        }else{
            //如果已点赞,取消点赞
            //数据库点赞数-1
            boolean success = update().setSql("liked=liked-1").eq("id", id).update();
            if(success) {
           //把用户从redis的set集合移除
                stringRedisTemplate.opsForSet().remove(RedisConstants.BLOG_LIKED_KEY + id, userId.toString());
            }
        }
        return Result.ok();
    }
//在每次查询的时候需要判断该用户是否已经点赞了这个博客
    private void isBlogLiked(Blog blog){
        // 获得这个用户
        Long userId = UserHolder.getUser().getId();
        //key前缀加博客id  
        String key=RedisConstants.BLOG_LIKED_KEY+blog.getId();
        //查redis里面有没有数据 如果有 isLike返回假 如果没有 则返回真
        Boolean isMember = stringRedisTemplate.opsForSet()
        .isMember(key, userId.toString());
        blog.setIsLike(BooleanUtil.isTrue(isMember));
    }

1.4.1 点赞排行榜

在实现点赞排行榜时 不能用set集合做判断了 因为set是无序的 因此将set要改成zset 将时间戳存放到score中 实现排行

    @Override
    public Result queryBlogLikes(Long id) {
        //1 查询top5的点赞用户
        String key=RedisConstants.BLOG_LIKED_KEY+id;
        Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
        //解析出其中的用户id
        if(top5==null||top5.isEmpty()){
            return  Result.ok(Collections.emptyList());
        }
        //运用了jdk8中的新特性 有时间学学 将set集合中的String统统改为Long
        List<Long> ids = top5.stream().map(Long::valueOf)
        .collect(Collectors.toList());
        //hutool下的string工具类
        String idsStr = StrUtil.join(",", ids);
        //用in不会根据根据自己的顺序进行排序
        //需要用 select * from tb_user where in(5,1) order by field(id,5,1)
        List<UserDTO> userDTOS = userService.query()
                .in("id",ids)
                .last("ORDER BY FIELD(id,"+idsStr+")").list()
                .stream()
                .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
                .collect(Collectors.toList());
        return Result.ok(userDTOS);
    }

1.4.2 关注和取关功能实现

public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {
    @Override
    public Result follow(Long followUserId, Boolean isFollow) {
        //获得登录用户
        Long userId = UserHolder.getUser().getId();
        //判断到底是关注还是取关
        if(isFollow){
            //直接往表里插入数据
         Follow follow=new Follow();
         follow.setUserId(userId);
         follow.setFollowUserId(followUserId);
         save(follow);
        }else {
            //删除数据
            remove(new QueryWrapper<Follow>()
            .eq("user_id", userId).eq("follow_user_id", followUserId));
        }
        //关注,新增取关
        //取关,删除
        return Result.ok();
    }

    @Override
    public Result isFollow(Long followUserId) {
        //查看有没有关注
        Long userId = UserHolder.getUser().getId();
        Integer count = query()
        .eq("user_id", userId).eq("follow_user_id", followUserId).count();
        return Result.ok(count>0);
    }

1.4.2 共同关注

在开发共同关注时,需要将关注和取关功能的功能改善一下 需要将用户id的key和关注的用户的id作为value 放到set 因为set可以进行查找供同拥有的value

   @Override
    public Result followCommons(Long id) {
        //用户id
        Long userId=UserHolder.getUser().getId();
        //用户id key
        String key=RedisConstants.FOLLOW+userId;
        //查找另一个用户的id
        String key1=RedisConstants.FOLLOW+id;
        //进行value查重
        Set<String> intersect = stringRedisTemplate
        .opsForSet().intersect(key, key1);
        if(intersect==null||intersect.isEmpty()){
            return Result.ok(Collections.emptyList());
        }
        List<Long> ids= intersect.stream().map(Long::valueOf)
        .collect(Collectors.toList());
        List<UserDTO> collect = userService.listByIds(ids)
                .stream()
                .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
                .collect(Collectors.toList());
        return Result.ok(collect);
    }

1.4.3 订阅好友关注

feed流

拉模式:大v都有自己的发件箱,当发消息的时候会发到自己的发件箱里面,等用户上线查看收件箱,会将用户关注的所有大v的发件箱复制一份放到用户的收件箱,重新按时间戳进行排序,供用户读取!弊端:如果这个人是个变态,关注着几千多个人,成千上万个数据会复制到收件箱,耗费内存

推模式:大v在写消息时,会将关注自己的所有人的收件箱里面写一份,供粉丝阅读。缺点:如果大v有太多粉丝,也会造成太耗费内存

拉推结合模式:对待僵尸粉采用拉模式,对待活跃粉丝采用推模式

黑马点评项目全面业务总结_第8张图片

本次实现使用的是推模式

//在发布笔记时,向各个用户的收件箱发送   运用的时有序zset集合
@Override
    public Result saveBlog(Blog blog) {
        //获得登录用户/ 
        UserDTO user = UserHolder.getUser();
        blog.setUserId(user.getId());
        boolean success = save(blog);
        if(!success){
            return Result.fail("新增笔记失败");
        }
        //查询该用户的所有粉丝
         select * from tb_follow where follow_id ='user.getId()'
        List<Follow> follows = followService.query()
        .eq("follow_user_id", user.getId()).list();
        //
        long l = System.currentTimeMillis();
        for(Follow follow:follows){
            //对每个粉丝的收件箱进行推送
            String key=RedisConstants.FEED_KEY+follow.getUserId();
            stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),l);
        }
        return Result.ok(blog.getId());
    }
//滚动查询的意思是 当查询的时候   本来是10条数据 分页 一页两条数据 如果这时候插入一条新数据 索引变化 分页会重复查询 数据 这时候需要滚动查询 在查询第二页数据时 记录最后一个数据的score数据 并记录 这一页score 有几个 然后根据这个score数据查第三页 
@Override
    public Result queryBlogOfFollow(Long max, Integer offset) {
        //获得当前用户id
        Long userId = UserHolder.getUser().getId();
        //查询收件箱 key max最大值 min最小值 limit offset偏移量 count 分页数量
        String key=RedisConstants.FEED_KEY+userId;
         //value 是blogId score 就是分数
        Set<ZSetOperations.TypedTuple<String>> typedTuples = 
            stringRedisTemplate.opsForZSet().
            reverseRangeByScoreWithScores(key, 0, max, offset, 2);
        //非空判断
        if(typedTuples==null||typedTuples.isEmpty()){
            return Result.ok();
        }
        //解析数据:blogId,minTime,offset
        ArrayList<Long> ids = new ArrayList<>(typedTuples.size());
        long minTime=0;
        int os=1;
        for(ZSetOperations.TypedTuple<String> tuple:typedTuples){
            ids.add(Long.valueOf(tuple.getValue()));
            long time =tuple.getScore().longValue();
            if(time==minTime){
                os++;
            }else{
                minTime=time;
                os=1;
            }
        }
        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);
        }
        ScrollResult r=new ScrollResult();
        r.setList(blogs);
        r.setOffset(os);
        r.setMinTime(minTime);
        return Result.ok(r);
    }

1.5用户签到

  @Override
    public Result sign() {
        //获取当前用户
        Long userId = UserHolder.getUser().getId();
        //获取日期
        LocalDateTime now=LocalDateTime.now();
        String keySuffix=now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
        //拼接key
        String key=RedisConstants.USER_SIGN_KEY+userId+keySuffix;
        //获得今天是本月第几天
        int dayOfMonth = now.getDayOfMonth();
        //写入redis
        stringRedisTemplate.opsForValue().setBit(key,dayOfMonth-1,true);
        return Result.ok();
    }

1.5.1 查询连续签到了几天

 @Override
    public Result signCount() {
        //获取当前用户
        Long userId = UserHolder.getUser().getId();
        //获取日期
        LocalDateTime now=LocalDateTime.now();
        String keySuffix=now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
        //拼接key
        String key=RedisConstants.USER_SIGN_KEY+userId+keySuffix;
        //获得今天是本月第几天
        int dayOfMonth = now.getDayOfMonth();
        List<Long> result=stringRedisTemplate.opsForValue().bitField(key,
                BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType
                .unsigned(dayOfMonth)).valueAt(0));
        if(result==null||result.isEmpty()){
            //没有任何签到结果
            return Result.ok(0);
        }
        Long num=result.get(0);
        if(num==null || num==0){
            return Result.ok(0);
        }
        //循环遍历
        int count=0;
        while(true){
            //让这个数字与1做与运算,得到数字的最后的一个bit
            if((num&1)==0){
                //如果为0,说明未签到 ,结束
                break;
            }else{
                //如果不为0,说明已签到,计数器+1
                count++;
            }
            //把数字右移一位,抛给最后的比特位,继续下一个bit位
            num>>>=1;
        }
        return Result.ok(count);
    }

总结

jdk8的新特性学一下 spring再重新学一下

你可能感兴趣的:(java,redis,开发语言)