Redis进阶学习03---Redis完成秒杀和Redis分布式锁的应用

Posted 大忽悠爱忽悠

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Redis进阶学习03---Redis完成秒杀和Redis分布式锁的应用相关的知识,希望对你有一定的参考价值。

Redis进阶学习03---Redis完成秒杀和Redis分布式锁的应用


全局唯一ID




注意事项

  • 用户传入一个key,我们基于这个key在redis中创建一个自增长key,这样可以确保,根据该key,redis创建出来的全局唯一id是不会重复的,但是有一个问题,就是redis单个key的自增长有最大值的限制,因此如果这个需要为这个传入的key自增长超过2的64次方后,便会出现异常,因此,我们可以给这个自增长key再拼接一个时间戳,该时间戳可以精确到天
  • 我们还需要通过位运算符向高位移动32位,为全局唯一ID腾出32位的位数进行记录

完整代码

```java
@Component
public class RedisWorker 
  private static final long BEGIN_TIMESTAMP;
  private final StringRedisTemplate stringRedisTemplate;
  private final String INCR_PREFIX="incr:";
  private final String INCR_DELIMITER=":";
    /**
     * 位运算向高位移动的位数,为了给redis自增长key腾出32位的空间
     */
  private final int COUNT_BITS=32;

    public RedisWorker(StringRedisTemplate stringRedisTemplate) 
        this.stringRedisTemplate = stringRedisTemplate;
    

    static 
      //生产时间戳
      LocalDateTime begin_time = LocalDateTime.of(2002, 1, 2, 0, 0, 0);
     //计算开始时间戳
     BEGIN_TIMESTAMP=begin_time.toEpochSecond(ZoneOffset.UTC);
  

    /**
     * <P>
     *     基于传入key生成一个全局唯一ID
     * </P>
     * @param keyPrefix 需要为某个传入的key生成一个全局唯一ID
     * @return
     */
  public long nextId(String keyPrefix)
     //1.生成时间戳
      LocalDateTime now = LocalDateTime.now();
      long nowTimeStamp = now.toEpochSecond(ZoneOffset.UTC);
      long timeStampGap=nowTimeStamp-BEGIN_TIMESTAMP;

      //2.生成序列号
      //2.1 获取当前日期,精确到天
      String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
      //redis单个key的自增长有上限,最大为2的64次方
      //如果自增长key不存在,redis会自动创建一个
      Long increment = stringRedisTemplate.opsForValue().increment(INCR_PREFIX + keyPrefix + INCR_DELIMITER + date);
      return timeStampGap << COUNT_BITS | increment;
  



测试

@SpringBootTest
class HmDianPingApplicationTests 
    @Autowired
    private IShopService iShopService;
    @Autowired
    private RedisWorker redisWorker;
    
    private static final ExecutorService es= Executors.newFixedThreadPool(500);
    /**
     * 测试生成全局唯一ID
     */
    @Test
    public void testGloballyUniqueID() throws InterruptedException 
        CountDownLatch countDownLatch=new CountDownLatch(300);

        Runnable task=()->
            for (int i = 0; i < 100 ; i++) 
                long nextId = redisWorker.nextId("order");
                System.out.println(nextId);
            
            countDownLatch.countDown();
        ;

        long start = System.currentTimeMillis();
        for (int i = 0; i < 300; i++) 
            es.submit(task);
        
        countDownLatch.await();
        long end = System.currentTimeMillis();
        System.out.println("cost time "+(end-start)+" ms");
    

大家可以自己测试一下


全局唯一ID生成策略总结

数据库自增指的是单独使用数据库中某一张表来专门存放主键,当我们需要的时候,只需要提前从该表中读取出一批主键集合,缓存在内存中即可,但是该方法显然太慢了,因此不推荐使用


全局优惠卷秒杀下单

    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) 
        //1.查询优惠卷
        SeckillVoucher seckillVoucher = iSeckillVoucherService.getById(voucherId);
        //2.判断秒杀是否开始
        if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.now()))
            //尚未开始
            return Result.fail("秒杀尚未开始!");
        
        //3.判断秒杀书否已经结束
        if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now()))
            //已经结束
            return Result.fail("秒杀已经结束");
        
        //4.判断库存是否充足
        if(seckillVoucher.getStock()<1)
            return Result.fail("库存不足!");
        
        //5.扣减库存
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId)
                .update();
        if(!success)
            return Result.fail("扣减失败");
        
        //6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1 订单id
        long orderId = redisWorker.nextId("order");
        voucherOrder.setId(orderId);
        //6.2用户id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        //6.3 代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //7。 返回订单id
        return Result.ok(orderId);
    


解决超卖问题

为什么会产生超卖问题:


当库存只剩一件的时候,此时三个线程打进入,同时查询,发现只剩一件库存,然后会挨个执行扣减库存的逻辑,此时就会导致超卖问题的发生。


解决超卖问题的方法


乐观锁解决超卖问题

版本号法


比较版本号是否变化,每次操作完版本号加一


CAS法


比较数据本身是否发生变化


cas法具体代码实现

就拿上面例子中出现的超卖问题为例,通过cas法进行解决,其实很简单,只需要改一行代码即可:

        //5.扣减库存
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId)
                //cas比较stock数据是否变化,如果发生了变化,不进行处理
                .eq("stock",seckillVoucher.getStock())
                .update();


发现超卖问题没有了,但是却只卖出去了23件,只是为什么?

这是因为当一堆线程尝试去并发修改数据时,最先修改得手的线程,改变了stock的值后,后面其他的线程,都会因为stock值与旧值不符,而更新失败。


这里可以简单优化一下,让stock大于0即可

        //5.扣减库存
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)
                .update();


小结


实现一人一单


其实我们只需要再扣减库存前判断一下当前用户是否已经抢购过票否,即可:


基于悲观锁实现一人一单的方案

    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) 
        //1.查询优惠卷
        SeckillVoucher seckillVoucher = iSeckillVoucherService.getById(voucherId);
        //2.判断秒杀是否开始
        if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.now()))
            //尚未开始
            return Result.fail("秒杀尚未开始!");
        
        //3.判断秒杀书否已经结束
        if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now()))
            //已经结束
            return Result.fail("秒杀已经结束");
        
        //4.判断库存是否充足
        if(seckillVoucher.getStock()<1)
            return Result.fail("库存不足!");
        
        //5.一人一单
        Long userId = UserHolder.getUser().getId();
        //加上悲观锁--我们这里要确保每一个用户id一把锁,toString底层是创建一个新的String对象,
        // 我们这里把每次得到的用户id放入字符串常量池中,确保其唯一性
        synchronized (userId.toString().intern())
            Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
            if(count>0)
                return Result.fail("用户已经购买过一次了");
            
        
        //6.扣减库存
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)
                .update();
        if(!success)
            return Result.fail("扣减失败");
        
        //7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //7.1 订单id
        long orderId = redisWorker.nextId("order");
        voucherOrder.setId(orderId);
        //7.2用户id
        voucherOrder.setUserId(userId);
        //7.3 代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //8。 返回订单id
        return Result.ok(orderId);
    




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



在单机模式下,我们可以通过加互斥锁来保证线程安全性,原理是利用jvm的锁监视器来完成的

但是在集群模式下,我们会部署多台tomcat,每一台tomcat对应一台全新的JVM,那么每台jvm都有自己的锁监视器,这样就导致每台jvm内部能够保证线程安全性,但是多台jvm之间无法保证线程安全性,从而导致集群模式下的并发安全问题


分布式锁




基于Redis的分布式锁


上面获取锁的过程还是存在一些问题,如果添加锁和设置过期时间两条命令之间,发生故障,也会导致锁无法释放,因此我们必须确保添加锁和设置过期时间两者执行的原子性


set命令可以同时设置过期时候,和添加互斥性,实现获取锁和设置过期时间的原子性。

如果获取锁失败,我们之间快速返回失败信息,不会阻塞去尝试获取锁。


实现分布式锁的版本一

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 timeSec) 
        //获取线程编号
        long threadId = Thread.currentThread().getId();
       //获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeSec, TimeUnit.SECONDS);
        //success可能为null,这样拆箱过程会报错
        return Boolean.TRUE.equals(success);
    

    @Override
    public void unLock() 
      stringRedisTemplate.delete(KEY_PREFIX+name);
    

应用到上面悲观锁解决一人一单的代码中去:

    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) 
        //1.查询优惠卷
        SeckillVoucher seckillVoucher = iSeckillVoucherService.getById(voucherId);
        //2.判断秒杀是否开始
        if(seckillVoucher.getBeginTime().isAfter(LocalDateTime.now()))
            //尚未开始
            return Result.fail("秒杀尚未开始!");
        
        //3.判断秒杀书否已经结束
        if(seckillVoucher.getEndTime().isBefore(LocalDateTime.now()))
            //已经结束
            return Result.fail("秒杀已经结束");
        
        //4.判断库存是否充足
        if(seckillVoucher.getStock()<1)
            return Result.fail("库存不足!");
        
        //5.一人一单
        Long userId = UserHolder.getUser().getId();
        //创建锁对象
        SimpleRedisLock simpleRedisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
        //尝试获取分布式锁
        boolean isLock = simpleRedisLock.tryLock(1200L);
        if(!isLock)
            return Result.fail("重复下单!!!");
        
        //我们只需要确保下面这两行代码的集群并发问题被解决
        try
            Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();

            if(count>0)
                return Result.fail("用户已经购买过一次了");
            
        finally 
            simpleRedisLock.unLock();
        

        //6.扣减库存
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherId)
                .gt("stock",0)
                .update();
        if(!success)
            return Result.fail("扣减失败");
        
        //7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //7.1 订单id
        long orderId = redisWorker.nextId("order");
        voucherOrder.setId(orderId);
        //7.2用户id
        voucherOrder.setUserId(userId);
        //7.3 代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //8。 返回订单id
        return Result.ok(orderId);
    

大家自习用jemeter去进行并发测试即可


借助Redis做秒杀和限流的思考

Redis进阶学习04---秒杀优化和消息队列

电商的秒杀和抢购

一文带你了解Redis优化高并发下的秒杀性能

电商网站秒杀和抢购的高并发技术实现和优化

视频 |电商平台Redis高并发秒杀超卖实战