优惠券秒杀之超卖问题、一人一单问题(乐观锁、悲观锁synchronized、@Transaction、AOP、分布式锁、Lua、Redisson、异步+redis优化秒杀速度、Stream消息队列)

目录

一、优惠券购买:

在这里插入图片描述

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private SeckillVoucherServiceImpl seckillVoucherService;
    @Autowired
    private RedisIdWorker idWorker;

    @Transactional //业务逻辑层事务
    @Override
    public Result seckillVoucher(Long voucherId) {
        // 查询优惠券
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        // 判断秒杀是否开始
        LocalDateTime beginTime = seckillVoucher.getBeginTime();
        LocalDateTime endTime = seckillVoucher.getEndTime();
        if(LocalDateTime.now().isAfter(beginTime) & endTime.isAfter(LocalDateTime.now())){
            if (seckillVoucher.getStock() > 0){
                // 库存-1
                seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();// where voucherId=#{voucherId} and stock=#{seckillVoucher.getStock()}
                // 创建订单
                VoucherOrder voucherOrder = new VoucherOrder();
                long orderId = idWorker.nextId("order");// 自己编写的唯一ID生成器
                voucherOrder.setId(orderId);
                Long userId = UserHolder.getUser().getId();//Thread Local
                voucherOrder.setUserId(userId);
                voucherOrder.setVoucherId(voucherId);
                save(voucherOrder);
                return Result.ok(voucherOrder);
            }
            return Result.fail("无库存");
        }
        return Result.fail("不在秒杀时间内");
    }
}

二、超卖问题—更新操作的线程安全问题:

优惠券购买逻辑需要两次访问数据库,第一次首先执行查询操作获取优惠券库存,第二次才会执行更新操作扣减库存。但由于这两步操作并不是原子性的,所以多线程并发问题容易导致更新操作过程中多个线程查询库存,导致库存为负。
在这里插入图片描述

  • 悲观锁:认为线程安全问题一定会发生,所以在操作数据之前先获取锁,确保线程串行执行。
  • 乐观锁:认为线程安全问题不一定会发生,所以查询操作不加锁,仅在更新数据时去判断刚才查询到的数据是否被修改:
    • 如果没有修改则认为是安全的,自己才更新数据
    • 如果已经被其他线程修改则重新执行业务逻辑

1.乐观锁-版本号法:

在数据库表中加一个version字段,当执行update操作时,需要用where语句来确保刚才查到的version和当前的version值相等才允许更新,并且同时更新version=version+1。若where version = #{version}不成立则数据库不会更新。本质就是利用了数据库语句ACID的隔离性
在这里插入图片描述

2.乐观锁-CAS法:

在版本号法的基础上,将version直接用stock替代。
在这里插入图片描述
注意加失败重试的逻辑,不然实际库存大于0但是由于多线程会导致stock=#{stock}判断失败,直接return fail了,会出现大于库存的线程数无法将库存减为0。

其实简单的where stock > 0就能完美解决问题,不知道老师为什么要讲这个乐观锁…

不过这里还是思考一下版本号法什么时候会优于CAS法吧:当stock既会增加优惠减少的情况下部分业务感觉会更倾向版本号法。

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private SeckillVoucherServiceImpl seckillVoucherService;
    @Autowired
    private RedisIdWorker idWorker;

    @Transactional //业务逻辑层事务
    @Override
    public Result seckillVoucher(Long voucherId) {
        // 查询优惠券
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        // 判断秒杀是否开始
        LocalDateTime beginTime = seckillVoucher.getBeginTime();
        LocalDateTime endTime = seckillVoucher.getEndTime();
        if(LocalDateTime.now().isAfter(beginTime) & endTime.isAfter(LocalDateTime.now())){
            if (seckillVoucher.getStock() > 0){
                // 库存-1,解决超卖问题
                boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).
                        eq("stock", seckillVoucher.getStock()).update();// where voucherId=#{voucherId} and stock=#{seckillVoucher.getStock()}
                if (success==false){
            		IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();// 获取代理对象
            		// 失败重试
            		return proxy.seckillVoucher(voucherId);
                }
                // 创建订单
                VoucherOrder voucherOrder = new VoucherOrder();
                long orderId = idWorker.nextId("order");// 自己编写的唯一ID生成器
                voucherOrder.setId(orderId);
                Long userId = UserHolder.getUser().getId();//Thread Local
                voucherOrder.setUserId(userId);
                voucherOrder.setVoucherId(voucherId);
                save(voucherOrder);
                return Result.ok(voucherOrder);
            }
            return Result.fail("无库存");
        }
        return Result.fail("不在秒杀时间内");
    }
}

三、一人一单问题—插入操作的线程安全问题:

一人一单问题类似于超卖问题,超卖问题是先查后更新,会有线程安全问题,而一人一单问题是先查后插入,也会有线程安全问题。

在这里插入图片描述

1.悲观锁synchronized:

插入操作引发的线程安全问题无法使用乐观锁解决,所以只能使用悲观锁。

synchronized的底层原理:

  • 每个 Java 对象JVM 中都隐式关联一个 Monitor 对象 (天生属性,一对一关系)
  • Monitor 内部有三个核心结构:
    • Owner:记录当前持有锁的线程(同一时间只能有一个线程持有);
    • Entry Set(阻塞队列):记录所有尝试获取锁但失败的线程(处于 BLOCKED 状态);
    • Wait Set(等待队列):记录调用wait()后释放锁的线程(处于 WAITING 状态)。
  • 线程竞争synchronized锁时,synchronized 借用 每个Java对象的 Monitor 属性实现悲观锁,本质是竞争目标对象关联的 Monitor 的 Owner 身份
    • 若 Monitor 的 Owner 为空,当前线程直接成为 Owner,上锁成功
    • 若 Owner 已被其他线程占用,当前线程进入 Entry Set 阻塞,直到 Owner 释放锁后被唤醒竞争

在这里插入图片描述

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private SeckillVoucherServiceImpl seckillVoucherService;
    @Autowired
    private RedisIdWorker idWorker;

    @Override
    public Result seckillVoucher(Long voucherId) {
        // 查询优惠券
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        // 判断秒杀是否开始
        LocalDateTime beginTime = seckillVoucher.getBeginTime();
        LocalDateTime endTime = seckillVoucher.getEndTime();
        if(LocalDateTime.now().isAfter(beginTime) & endTime.isAfter(LocalDateTime.now())){
            if (seckillVoucher.getStock() > 0){
                return createVoucherOrder(voucherId, seckillVoucher.getStock());
            }
            return Result.fail("无库存");
        }
        return Result.fail("不在秒杀时间内");
    }

    @Transactional //业务逻辑层事务
    public Result createVoucherOrder(Long voucherId, int stock){
        // 一人一单
        Long userId = UserHolder.getUser().getId();//Thread Local
        synchronized (userId.toString().intern()){// 互斥锁解决一人一单问题
            int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
            if (count > 0){
                return Result.fail("无法重复下单");
            }
            // 库存-1,解决超卖问题
            boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).
                    gt("stock", 0).update();// where voucherId=#{voucherId} and stock>0}
            if (success==false){
            	return Result.fail("库存不足");
            }
            // 创建订单
            VoucherOrder voucherOrder = new VoucherOrder();
            long orderId = idWorker.nextId("order");// 自己编写的唯一ID生成器
            voucherOrder.setId(orderId);
            voucherOrder.setUserId(userId);
            voucherOrder.setVoucherId(voucherId);
            save(voucherOrder);
            return Result.ok(voucherOrder);
        }
    }
}

可不可以加在方法上? 为什么是对同一用户上锁而不是对所有用户上同一把锁? 可以,加在方法上锁针对的就是当前对象,整个方法在多线程下串行执行,但是不同用户执行该方法也需要串行,一人一单只需要确保同一用户的多个线程串行执行即可。如果对所有用户上同一把锁那么效率会降低很多,但是确实也能解决线程安全问题,甚至超卖问题都不用加锁了

因此,如果希望对所有用户的线程都要满足串行执行,那么在方法上加锁,如果希望对同一用户的多个线程满足串行执行,那么可以在用户id上(session,token)加锁。

为什么要用toString()?因为userId是从ThreadLocal中获取的,同一用户的多个线程获得的userId虽然值是一样的,但是对象却指向不同的存储空间,所以需要用toString()来转成字符串,确保锁的是userId值而不是某个userId对象

能不能直接用toString()?不行,userId.toString()底层默认new String创建对象,这样的话即使同一用户id但是使用userId.toString()后返回值也是不同的对象,互斥锁还是锁的对象。而userId.toString().intern()方法会去字符串常量池中找到与userId.toString()对象的 值(而非内存地址) 一样的地址(引用)并返回。

2.@Transactional和synchronized先后关系:

事务针对的是单个线程内的操作要么全部执行那么全不执行,锁针对的是多个线程之间执行顺序为串行。

如果@Transactional在外那么先释放锁后提交事务,如果synchronized在外那么先提交事务后释放锁。

上面的代码是先释放锁后提交事务,这时候还是会有线程安全问题,因为当前线程的事务还没有提交,数据库还没有更新,锁就释放了,当前用户的另一个线程就拿到锁开始查询了,所以查询订单结果依然是0就会出现二次下单。

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private SeckillVoucherServiceImpl seckillVoucherService;
    @Autowired
    private RedisIdWorker idWorker;

    @Override
    public Result seckillVoucher(Long voucherId) {
        // 查询优惠券
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        // 判断秒杀是否开始
        LocalDateTime beginTime = seckillVoucher.getBeginTime();
        LocalDateTime endTime = seckillVoucher.getEndTime();
        if(LocalDateTime.now().isAfter(beginTime) & endTime.isAfter(LocalDateTime.now())){
            if (seckillVoucher.getStock() > 0){
                Long userId = UserHolder.getUser().getId();//Thread Local
                // 先提交事务后释放锁,解决一人一单问题
                synchronized (userId.toString().intern()) {
                    return createVoucherOrder(voucherId, seckillVoucher.getStock());
                }
            }
            return Result.fail("无库存");
        }
        return Result.fail("不在秒杀时间内");
    }

    @Transactional //业务逻辑层事务
    public Result createVoucherOrder(Long voucherId, int stock){
        // 一人一单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0){
            return Result.fail("无法重复下单");
        }
        // 库存-1,解决超卖问题
        boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).
                gt("stock", 0).update();// where voucherId=#{voucherId} and stock>0}
        if (success==false){
        	return Result.fail("库存不足");
        }
        // 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = idWorker.nextId("order");// 自己编写的唯一ID生成器
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        return Result.ok(voucherOrder);
    }
}

3.Spring动态代理导致@Transactional失效:

上述代码在createVoucherOrder()方法上使用了@Transactional注解,但是在调用时却使用this.createVoucherOrder()的方式进行调用,this指代的是当前的目标对象VoucherOrderServiceImpl,而非代理对象IVoucherOrderService。

事务注解的生效依赖 Spring 动态代理,而 this.createVoucherOrder() 是直接调用目标对象的方法,绕过了 Spring 生成的代理对象IVoucherOrderService,导致事务拦截器无法介入,注解失效。

@Transactional 注解本身是标记在目标对象(VoucherOrderServiceImpl)的方法上,但事务增强逻辑(如开启 / 提交 / 回滚事务)是织入在代理对象中的。

Spring事务管理:@Transactional失效的原因
Spring动态代理:AOP

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private SeckillVoucherServiceImpl seckillVoucherService;
    @Autowired
    private RedisIdWorker idWorker;

    @Override
    public Result seckillVoucher(Long voucherId) {
        // 查询优惠券
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        // 判断秒杀是否开始
        LocalDateTime beginTime = seckillVoucher.getBeginTime();
        LocalDateTime endTime = seckillVoucher.getEndTime();
        if(LocalDateTime.now().isAfter(beginTime) & endTime.isAfter(LocalDateTime.now())){
            if (seckillVoucher.getStock() > 0){
                Long userId = UserHolder.getUser().getId();//Thread Local
                IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();// 获取代理对象
                // 先提交事务后释放锁,解决一人一单问题
                synchronized (userId.toString().intern()) {
                    return proxy.createVoucherOrder(voucherId, seckillVoucher.getStock(), userId);// 代理对象调用方法才能进行AOP
                }
            }
            return Result.fail("无库存");
        }
        return Result.fail("不在秒杀时间内");
    }

    @Transactional //业务逻辑层事务
    @Override
    public Result createVoucherOrder(Long voucherId, Integer stock, Long userId){
        // 一人一单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0){
            return Result.fail("无法重复下单");
        }
        // 库存-1,解决超卖问题
        boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).
                gt("stock", 0).update();// where voucherId=#{voucherId} and stock>0}
        if (success==false){
        	return Result.fail("库存不足");
        }
        // 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = idWorker.nextId("order");// 自己编写的唯一ID生成器
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        return Result.ok(voucherOrder);
    }
}

4.集群下synchronized的缺陷:

每个Java对象在JVM中都有一个Monitor对象,而synchronized依赖于这个Monitor对象实现悲观锁,这就有一个问题,在集群模式下会部署多个tomcat服务器,每个服务器都有各自的JVM,这就使得每个Java对象在不同的服务器中有不同的Monitor对象,导致不同服务器之间相同Java对象(例如用户id)的线程不会串行执行,同一服务器内的线程才会串行执行,这样的话有多少个tomcat服务器用户就可以下多少单,不满足一人一单的要求。

总结:synchronized只能保证单个JVM内部的多个线程之间的互斥,无法保证集群下多个JVM之间的进程互斥。
在这里插入图片描述

四、分布式锁:

synchronized悲观互斥锁只能保证单个JVM内部的多个线程之间的互斥,无法保证集群下多个JVM之间的进程互斥。

而分布式锁就解决了这个问题,既能保证单个JVM内部的多个线程之间的互斥,也能保证集群下多个JVM之间的进程互斥

也就是集群模式下所有线程都可见的锁。

分布式锁类型:

  • MySQL:mysql的事务机制具有隔离性(@Transactional只有原子性),写操作执行时mysql会自动分配事务,保证互斥性。
  • Redis:setnx命令(只有数据不存在时才能写成功,否则失败)能保证只有一个进程setnx成功(获取锁),当该进程完成操作后删除setnx的内容或设置过期时间就是释放锁
  • Zookeeper:可以创建数据节点,节点具备唯一性和有序性,有序性就能实现队列从而实现串行执行,就是互斥。

在这里插入图片描述

1.Redis实现分布式锁:

  • 获取锁:SET userlock thread1 NX EX 10,NX是互斥,EX是设置超时时间
    • 设置超时时间是为了防止服务器宕机释放锁命令无法执行。获取锁和设置ttl设置为原子操作是为了防止设置超时时间时服务器就宕机无法释放锁。
    • 确保只有一个线程获取锁
    • 获取锁失败则返回false
  • 释放锁:DEL userlock

在这里插入图片描述
注意,这里还是对同一用户的不同线程加锁而不是对所有用户加同一把锁,每个用户应该分别维护一把锁。

@Component// 这里用接口的方式应该是为了交给Spring代理,方便AOP使用增强逻辑
public class SimpleRedisLock implements Ilock{

    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    // name是用户id,表示对同一用户的不同线程加锁
    @Override
    public boolean tryLock(String name, long timeoutSec) {
        long current_id = Thread.currentThread().getId();// 获取当前线程标识,作为value
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent("lock:" + name, String.valueOf(current_id),
                timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);//防止直接返回success自动拆箱导致空指针
    }

    @Override
    public void unlock(String name) {
        // 获取分布式锁的标识
        String id = stringRedisTemplate.opsForValue().get(name);
        String current_id = String.valueOf(Thread.currentThread().getId());
        if(current_id.equals(id)){// 这里多一步判断是为了防止锁提前过期释放导致删除的锁不是当前线程的锁,感觉超时时间设置长一点就行了
            stringRedisTemplate.delete("lock:" + name);
        }
    }
}

2.分布式锁解决集群下的一人一单问题:

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private SeckillVoucherServiceImpl seckillVoucherService;
    @Autowired
    private RedisIdWorker idWorker;
    @Autowired
    private Ilock ilock;

    @Override
    public Result seckillVoucher(Long voucherId) {
        // 查询优惠券
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        // 判断秒杀是否开始
        LocalDateTime beginTime = seckillVoucher.getBeginTime();
        LocalDateTime endTime = seckillVoucher.getEndTime();
        if(LocalDateTime.now().isAfter(beginTime) & endTime.isAfter(LocalDateTime.now())){
            if (seckillVoucher.getStock() > 0){
                Long userId = UserHolder.getUser().getId();//Thread Local
                IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();// 获取代理对象
                // 传入setnx的key和ttl
                boolean islock = ilock.tryLock("order:" + userId, 5);
                if (islock){ // 互斥锁解决一人一单问题,且先提交事务后释放锁
                    try {//这里try不用catch throw 异常,因为是在事务外部,不抛出异常不会导致事务执行错误
                        return proxy.createVoucherOrder(voucherId, seckillVoucher.getStock(), userId);// 代理对象调用方法才能进行AOP
                    }finally {
                        ilock.unlock("order:" + userId);//释放锁
                    }
                } else{
                  Result.fail("一人只能下一单");
                }

            }
            return Result.fail("无库存");
        }
        return Result.fail("不在秒杀时间内");
    }

    @Transactional //业务逻辑层事务
    @Override
    public Result createVoucherOrder(Long voucherId, Integer stock, Long userId){
        // 一人一单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0){
            return Result.fail("无法重复下单");
        }
        // 库存-1,解决超卖问题
        boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).
                gt("stock", 0).update();// where voucherId=#{voucherId} and stock>0}
        if (success==false){
        	return Result.fail("库存不足");
        }
        // 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = idWorker.nextId("order");// 自己编写的唯一ID生成器
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        return Result.ok(voucherOrder);

3.可重入分布式锁:

不可重入的问题,就是说如果当前线程执行seckillVoucher()方法获取了针对当前用户的锁后准备执行createVoucherOrder()方法来扣减库存,但是由于其他用户的线程此时抢占了CPU提前完成了扣减库存,当前线程由于eq(“stock”, stock)失败就会递归重试,递归后由于当前线程持有锁,此时就会出现setIfAbsent()无法再次获取锁的问题,导致死锁

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    public Result seckillVoucher(Long voucherId) {
        // 查询优惠券
        // 判断秒杀是否开始
        if(){
            if (){
                // 传入setnx的key和ttl
                boolean islock = ilock.tryLock("order:" + userId, 5);
                if (islock){ 
                  	return proxy.createVoucherOrder(voucherId, seckillVoucher.getStock(), userId);// 代理对象调用方法才能进行AOP
                }
            }
            return Result.fail("无库存");
        }
        return Result.fail("不在秒杀时间内");
    }
    @Transactional //业务逻辑层事务
    public Result createVoucherOrder(Long voucherId, Integer stock, Long userId){
        // 一人一单
        // 库存-1,解决超卖问题
        boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).
                eq("stock", stock).update();// where voucherId=#{voucherId} and stock>0}
        if (success==false){
        	// 失败重试,这里就会有可重入问题
            return proxy.seckillVoucher(voucherId);
        }
        // 创建订单
    }

可重入锁就是在我们自己编写的分布式锁的基础上,增加state字段,当前线程如果已经持有锁,那么就可以多次获取该锁(用当前线程id比较和redis中锁的value是否一致),每次获取state++,每次释放state- -,直到state变为0时释放锁会执行del删除锁,该线程完全释放锁。

在这里插入图片描述

@Component// 这里用接口的方式应该是为了交给Spring代理,方便AOP使用增强逻辑
public class SimpleRedisLock implements Ilock{

    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    // name是用户id,表示对同一用户的不同线程加锁
    @Override
    public boolean tryLock(String name, long deadline, long timeoutSec) {
        long current_id = Thread.currentThread().getId();// 获取当前线程标识,作为value
        String current_sid = String.valueOf(current_id);
        long time = System.currentTimeMillis() + (deadline * 1000);// 计算超时截止时间
        // 循环申请锁
        while (System.currentTimeMillis()<time){
            // 判断是否存在锁
            Boolean success = stringRedisTemplate.opsForHash().putIfAbsent("lock:" + name, "current_sid", current_sid);
            stringRedisTemplate.opsForHash().putIfAbsent("lock:" + name, "count", "1");
            if (Boolean.FALSE.equals(success)){
                stringRedisTemplate.expire("lock:" + name, timeoutSec, TimeUnit.SECONDS);
                return true;// 首次获取锁成功
            }else{
                // 判断锁的持有者是否是当前线程
                if (stringRedisTemplate.opsForHash().get("lock:" + name, "current_sid") != null){
                    // 重置过期时间
                    stringRedisTemplate.expire("lock:" + name, timeoutSec, TimeUnit.SECONDS);
                    // 计数+1
                    stringRedisTemplate.opsForHash().increment("lock:" + name, "count", 1);
                    return true;
                }
            }
        }
        // 存在锁且持有者非本线程
        return false;
    }

    @Override
    public void unlock(String name) {
        String current_sid = String.valueOf(Thread.currentThread().getId());
        Object reentrantCountObj = stringRedisTemplate.opsForHash().get("lock:" + name, "count");
        // 当前线程持有锁
        if (reentrantCountObj!= null){
            // 计数-1
            Long newCount = stringRedisTemplate.opsForHash().increment("lock:" + name, "count", -1);
            // 计数减为0
            if (newCount.equals(0)){
                stringRedisTemplate.delete("lock:" + name);
            }
        }
    }
}

五、Lua脚本

Lua脚本中可以编写多条Redis命令,确保多条命令共同执行时是一个原子操作 (原子性+隔离性)。隔离性是因为Redis是单线程执行,原子性是因为lua脚本作为 “单命令” 独占 Redis,期间阻塞其他请求。

1.编写脚本:

Lua执行redis命令的语法:redis.call('命令名称', 'key', '其它参数',...)

-- 释放锁的lua脚本
-- 锁的key
local key = KEYS[1]
-- 当前线程标识
local threadId = ARGV[1]

-- 获取锁中的线程标识
local id = redis.call('get',key)
-- 比较线程标识与锁中的标识是否一致
if(id == threadId) then
    -- 释放锁
    return redis.call('del', key)
end

return 0

2.调用脚本:

语法:EVAL 脚本内容字符串 参数数量 key [key...] arg [arg...]
脚本中的key、value可以作为参数传递,key类型参数会放入KEYS数组,其它参数会放入ARGV数组,使用数组下标取值,默认下标从1开始

// 不加参数(无形参)
EVAL "return redis.call('set', 'name', 'jack')" 0
// 加参数
EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name jack

StringRedisTemplate调用Lua脚本的API如下:
在这里插入图片描述

	// 调用lua脚本释放锁
    public void unlock1(String name) {
        // RedisScript是一个脚本类,用来接收lua脚本文件
        DefaultRedisScript<Long> unlock_script = new DefaultRedisScript<>();
        // ClassPath默认是resources文件夹,设置lua脚本文件位置
        unlock_script.setLocation(new ClassPathResource("unlock.lua"));
        // 设置lua脚本的返回值
        unlock_script.setResultType(Long.class);

        // 调用lua脚本,注意KEYS传的是List集合
        stringRedisTemplate.execute(unlock_script, Collections.singletonList("lock:" + name), String.valueOf(Thread.currentThread().getId()));
    }

3.Lua实现可重入锁:

3.1 获取锁:

local key = KEYS[1]; --锁的key,一人一单问题就是用户id,同一用户的多个线程竞争同一把锁
local threadId = ARGV[1]; --持有锁的线程标识
local releaseTime = ARGV[2]; --锁的有效期

--锁是未被占有
if(redis.call('exists', key)==0) then
    redis.call('hset', key, theadId, '1'); --获取锁
    redis.call('expire', key, releaseTime); --设置有效期
    return 1; --返回表示获取成功
end;

--锁已被占有,且锁是本线程占有的
if(redis.call('hexists', key, threadId)==1) then
    redis.call('hincrby', key, threadId, '1'); --重入计数自增1
    redis.call('expire', key, releaseTime); --重置有效期
    return 1;
end;

--锁已被占有,且不是本线程占有的
return 0;

3.2 释放锁:

local key = KEYS[1]; --锁的key,一人一单问题就是用户id,同一用户的多个线程竞争同一把锁
local threadId = ARGV[1]; --持有锁的线程标识
local releaseTime = ARGV[2]; --锁的有效期

--当前锁不是被本线程持有
if(redis.call('hexists', key, threadId)==0) then
    return nil;
end;

--当前所被本线程持有
local count = redis.call('hincrby', key, threadId, -1); --可重入次数-1
--可重入次数是否减为0
if(count>0) then
    redis.call(‘expire’, key, releaseTime) --重置有效期
    return nil;
else
    redis.call('del', key)
end;

return nil;    

六、Redisson:

Redison提供了用redis实现的分布式Java对象,有一系列的分布式服务,包括分布式锁。实际开发中其实不用手写分布式锁,直接调用框架就行。
在这里插入图片描述

1.基础配置:

1.引入依赖:

        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.13.6</version>
        </dependency>

2.编写配置类配置redisson:

package com.hmdp.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedisConfig {
    @Bean //将方法的返回值放到IOC容器中交给Spring管理
    public RedissonClient redissonClient(){
        // 对redis的配置
        Config config = new Config();
        // useSingleServer单结点,config.useClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://192.168.50.128:6379");
        return Redisson.create(config);
    }
}

2.Redisson解决一人一单问题:

原理和我们自己写的可重入锁其实差不多,就是封装了一下,底层用的是lua保证原子操作。

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private SeckillVoucherServiceImpl seckillVoucherService;
    @Autowired
    private RedisIdWorker idWorker;
    @Autowired
    private RedissonClient redissonClient;

    @Override
    public Result seckillVoucher(Long voucherId) {
        // 查询优惠券
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        // 判断秒杀是否开始
        LocalDateTime beginTime = seckillVoucher.getBeginTime();
        LocalDateTime endTime = seckillVoucher.getEndTime();
        if(LocalDateTime.now().isAfter(beginTime) & endTime.isAfter(LocalDateTime.now())){
            if (seckillVoucher.getStock() > 0){
                Long userId = UserHolder.getUser().getId();//Thread Local
                IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();// 获取代理对象
                // 创建锁(可重入锁),指定锁的名称
                RLock lock = redissonClient.getLock("order:" + userId);
                // 获取锁,参数:最大等待时间(期间会重试),锁的自动释放时间,时间单位
                boolean isLock = lock.tryLock();
                if (isLock){ // 分布式锁解决一人一单问题,且先提交事务后释放锁
                    try {//这里try不用catch throw 异常,因为是在事务外部,不抛出异常不会导致事务执行错误
                        return proxy.createVoucherOrder(voucherId, seckillVoucher.getStock(), userId);// 代理对象调用方法才能进行AOP
                    }finally {
                        // 释放锁
                        lock.unlock();
                    }
                } else{
                  Result.fail("一人只能下一单");
                }

            }
            return Result.fail("无库存");
        }
        return Result.fail("不在秒杀时间内");
    }

    @Transactional //业务逻辑层事务
    @Override
    public Result createVoucherOrder(Long voucherId, Integer stock, Long userId){
        // 一人一单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0){
            return Result.fail("无法重复下单");
        }
        // 库存-1,解决超卖问题
        boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).
                eq("stock", stock).update();// where voucherId=#{voucherId} and stock=#{seckillVoucher.getStock()}
        if (success==false){
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();// 获取代理对象
            // 失败重试,这里就会有可重入问题
            return proxy.seckillVoucher(voucherId);
        }
        // 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = idWorker.nextId("order");// 自己编写的唯一ID生成器
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        return Result.ok(voucherOrder);
    }
}

3.Redisson可重入锁源码:

redisson的可重入锁中,可重入机制和我们在第四、五章中写的差不多,通过计数的方式实现可重入,但是Redisson更高级的地方在于它的不可重试和超时释放的解决思路。

  • 不可重入:同一个线程无法多次获取同一把锁(失败时递归重试)
  • 不可重试:获取锁只尝试一次,失败就返回false,没有重试机制
  • 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,锁提前释放会有安全隐患

Redisson获取锁和释放锁的流程图如下,下面来看Redisson是如何解决不可重试和超时释放的:
在这里插入图片描述

1)获取锁

3.1 获取锁 tryLockInnerAsync()

该方法用于执行lua脚本获取锁:

  • 若锁不存在,新建锁(获取锁),key=getName()用户id,field=threadId线程id,value=1,并设置超时释放时间leaseTime,反馈空nil
  • 若锁存在,如果锁持有者是当前线程,那么重入次数value++,重置超时释放时间,返回空nil
  • 若锁持有者不是当前线程,那么会返回锁的剩余有效期
// waitTime表示当前线程可以等待的最长时间,leaseTime表示锁的超时释放时间
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
	// 记录锁释放时间
    internalLockLeaseTime = unit.toMillis(leaseTime);
	// 执行lua脚本
    return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
            "if (redis.call('exists', KEYS[1]) == 0) then " +
                    "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return nil; " +
                    "end; " +
                    "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return nil; " +
                    "end; " +
                    "return redis.call('pttl', KEYS[1]);",
            Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

3.2 定时刷新锁的过期时间逻辑 scheduleExpirationRenewal()

该方法用于刷新锁的剩余有效期,防止拿到锁的线程在业务执行完释放锁前,锁的有效期到期而提前释放锁,引起线程安全问题。

调用关系:scheduleExpirationRenewal()->renewExpiration()->renewExpirationAsync()

// 该方法用于判断当前锁是否是新的,调用renewExpiration()方法为新的锁设置定时任务
private void scheduleExpirationRenewal(long threadId) {
    ExpirationEntry entry = new ExpirationEntry();
    /** 根据锁的key从EXPIRATION_RENEWAL_MAP中获取对应的ExpirationEntry对象
    *getEntryName()当前锁的key
    *EXPIRATION_RENEWAL_MAP的作用:在MAP中每个锁会有唯一的ExpirationEntry对象,从而保证锁和ExpirationEntry对象是一对一关系
    **/
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
    
    if (oldEntry != null) {// 当前锁不是第一次创建
        oldEntry.addThreadId(threadId);
    } else {// 当前锁是第一次创建
        entry.addThreadId(threadId);
        // 为新锁设置定时任务
        renewExpiration();
    }
}
// 该方法用于设置定时任务,定时任务调用renewExpirationAsync()方法,每过leaseTime/3秒执行一次,并将定时任务存储到ExpirationEntry对象中
private void renewExpiration() {
	// 获取当前锁对应的ExpirationEntry对象
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) {
        return;
    }
    // 定时任务,设锁的超时释放时间初始传入为leaseTime,则该任务在leaseTime/3后执行
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
        	// 获取当前锁对应的ExpirationEntry对象
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
                return;
            }
            // 获取当前线程的id
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
                return;
            }
            // 刷新锁的超时释放时间,也就是说如果锁没有释放,那么每过leaseTime/3秒重置锁的超时释放时间为leaseTime
            RFuture<Boolean> future = renewExpirationAsync(threadId);
            future.onComplete((res, e) -> {
                if (e != null) {
                    log.error("Can't update lock " + getName() + " expiration", e);
                    return;
                }                
                if (res) {
                    // 递归当前方法,每过leaseTime/3秒执行定时任务,重置锁的超时释放时间为leaseTime
                    renewExpiration();
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    // 将当前的定时任务存储到当前锁对应的ExpirationEntry对象中
    ee.setTimeout(task);
}
// 该方法使用lua脚本来重置锁的超时释放时间
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
	// 这里if判断多余了,因为只有当前线程拿到锁后才能执行该方法
	// 使用pexpire重置锁的超时释放时间
    return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return 1; " +
                    "end; " +
                    "return 0;",
            Collections.singletonList(getName()),
            internalLockLeaseTime, getLockName(threadId));
}

总结一下关键点感觉就是:

// 从MAP中根据锁的key获取对应的ExpirationEntry对象(一对一关系,ExpirationEntry对象中保存了2个内容,一个是占用锁的线程id,一个是定时任务)
ExpirationEntry oldentry = EXPIRATION_RENEWAL_MAP.putIfAbsent(key, entry);
// 当前锁是第一次在redis中创建,还没有ExpirationEntry对象
if(EXPIRATION_RENEWAL_MAP.putIfAbsent(key, entry) == null){
	ExpirationEntry entry = new ExpirationEntry(); // 为该锁实例化一个ExpirationEntry对象
	// 创建一个定时任务
	task = renewExpiration();
	entry.setTimeout(task); //将定时任务添加到entry对象中,定时任务每过一段时间自动执行,直到锁被删除定时任务停止执行	
}	
entry.addThreadId(threadId);// 修改或写入线程id(确保一致性,因为可能上一个线程已经释放锁了,现在是另一个线程持有锁)	

// 定时任务。leaseTime是初始给定超时释放时间
renewExpiration(){
	wait(leaseTime/3); //等待leaseTime/3秒
	redis.call('pexpire', key, leaseTime);// 重置锁key的超时释放时间为初始值:leaseTime*2/3->leaseTime
	// 递归调用自己
	renewExpiration()
}

3.3 获取锁的剩余有效期(调用3.1和3.2,被3.4调用) tryAcquireAsync()

  • 该方法调用tryLockInnerAsync()尝试获取锁,如果获取锁成功那么返回null,如果获取锁失败那么返回锁的剩余有效期。

  • 该方法调用scheduleExpirationRenewal()刷新锁的剩余有效期,防止拿到锁的线程在业务执行完释放锁前,锁的有效期到期而提前释放锁,引起线程安全问题。

// waitTime表示当前线程可以等待的最长时间,leaseTime表示锁的超时释放时间
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
	// 判断是否传入锁超时释放时间(我们没有传)
    if (leaseTime != -1L) {
    	// 两个方法是一样的,区别在于是否指定锁的超时释放时间
        return this.tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    } else {
    	// 给定默认锁超时释放时间为this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout()=30s
        RFuture<Long> ttlRemainingFuture = this.tryLockInnerAsync(waitTime, this.commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
        // 回调函数,ttlRemaining就是tryLockInnerAsync()的返回值
        ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
            if (e == null) {
            	// tryLockInnerAsync()返回null表示获取锁成功
                if (ttlRemaining == null) {
                	//renew更新Expiration超时释放时间
                    this.scheduleExpirationRenewal(threadId);
                }
            }
        });
        return ttlRemainingFuture;
    }
}

3.4 获取锁失败时尝试获取锁的逻辑tryLock()

Redisson获取锁失败时不会一直尝试获取锁,这样会浪费计算资源,且频繁的尝试没有意义,所以Redisson采用了一种发布订阅模式来判断何时重新尝试获取锁

1.首先执行获取锁的代码
// 给定当前线程可以等待的最长时间,等待时间内可以多次尝试尝试获取锁
isLock = lock.tryLock(1L, TimeUnit.SECONDS);// waitTime表示当前线程可以等待的最长时间,leaseTime表示锁的超时释放时间
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
	// 将等待时间换转成毫秒
    long time = unit.toMillis(waitTime);
    // 获取当前时间
    long current = System.currentTimeMillis();
    // 获取线程ID
    long threadId = Thread.currentThread().getId();
    // 调用tryAcquireAsync尝试获取锁,返回剩余锁的有效期或null(tryAcquire就是简单的return了tryAcquireAsync)
    Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
    if (ttl == null) {// 表示获取锁成功
        return true;
    } else {// 表示获取锁失败
        time -= System.currentTimeMillis() - current;// 计算当前线程剩余可以等待的时间(waitTime-获取锁消耗的时间)
        if (time <= 0L) {// 若获取锁消耗的时间大于最长等待时间
            this.acquireFailed(waitTime, unit, threadId);
            return false;// 获取锁失败
        } else {// 若等待时间还有剩余
            current = System.currentTimeMillis();// 更新当前时间
            RFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);// 重点:订阅其他线程释放锁的信号,等待获得释放锁的信号,而不是盲目的重试占用CPU资源(这里如果有多个线程等待的话感觉可以设置一个队列,先到先得)
            if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {// 若等待信号的时间超过剩余时间
                if (!subscribeFuture.cancel(false)) {
                    subscribeFuture.onComplete((res, e) -> {
                        if (e == null) {
                            this.unsubscribe(subscribeFuture, threadId);// 取消订阅
                        }
                    });
                }

                this.acquireFailed(waitTime, unit, threadId);
                return false;// 获取锁失败
            } else {// 若剩余等待时间内接收到其他线程释放锁的信号
                boolean var14;
                try {
                    time -= System.currentTimeMillis() - current;// 计算当前线程剩余可以等待的时间(waitTime-获取锁消耗的时间-等待接收信号的时间)
                    if (time > 0L) {// 若等待时间还有剩余
                        boolean var16;
                        do {// 循环开始
                            long currentTime = System.currentTimeMillis();//更新当前时间
                            ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);//同该方法的第四条语句,调用tryAcquireAsync重新尝试获取锁,返回剩余锁的有效期或null(因为此时接收到其他线程释放锁的信号了)
                            if (ttl == null) {// 获取锁成功
                                var16 = true;
                                return var16;
                            }
							// 获取锁又失败了(啰嗦了,用个队列就好很多)
                            time -= System.currentTimeMillis() - currentTime;
                            if (time <= 0L) {// 该线程的剩余等待时间不够了
                                this.acquireFailed(waitTime, unit, threadId);
                                var16 = false;
                                return var16;// 获取锁失败
                            }
							// 若该线程还有等待时间,即剩余等待时间大于0
                            currentTime = System.currentTimeMillis();// 更新当前时间
                            // 再次订阅其他线程释放锁的信号并等待,直到接收到信号
                            if (ttl >= 0L && ttl < time) {
                                ((RedissonLockEntry)subscribeFuture.getNow()).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
                            } else {
                                ((RedissonLockEntry)subscribeFuture.getNow()).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
                            }
							// 此时接收到其他线程释放锁的信号了
                            time -= System.currentTimeMillis() - currentTime;// 计算当前线程剩余可以等待的时间
                        } while(time > 0L);// 循环这个过程,直到当前线程的剩余等待时间小于0

                        this.acquireFailed(waitTime, unit, threadId);
                        var16 = false;
                        return var16;// 当前线程剩余等待时间不足(<0),获取锁失败
                    }

                    this.acquireFailed(waitTime, unit, threadId);
                    var14 = false;
                } finally {
                    this.unsubscribe(subscribeFuture, threadId);// 获取锁失败都要取消订阅
                }

                return var14;// 当前线程剩余等待时间不足(<0),获取锁失败
            }
        }
    }
}

感觉这里的while又一次判断获取锁成功和失败有点麻烦,直接设置一个队列,每次只有队首的线程能获得订阅的信号从而获取锁,非队首的一直等待到达队首才能等待获取订阅的信号不就行了。

总结一下关键点感觉就是:

waitTime // 当前线程可以等待的最长时间,等待时间内可以多次尝试尝试获取锁,假设可以实时更新
do{
ttl = tryAcquireAsync() // 获取锁
// 获取锁成功
if(ttl == null)
	return true;
// 获取锁失败
RFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);// 订阅其他线程释放锁的信号,等待获得释放锁的信号,等到信号才会执行下一步操作
}while(waitTime>0);

2)释放锁

3.5 释放锁 unlockInnerAsync()

释放锁的源码没什么重要的地方就不介绍了,这里只注意一点:发布释放锁信号的代码为redis.call('publish', KEYS[2], ARGV[1]);其他获取锁失败的线程在3.4节通过订subscribe阅来监听这个信号。

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                    "return nil;" +
                    "end; " +
                    "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                    "if (counter > 0) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                    "return 0; " +
                    "else " +
                    "redis.call('del', KEYS[1]); " +
                    "redis.call('publish', KEYS[2], ARGV[1]); " +
                    "return 1; " +
                    "end; " +
                    "return nil;",
            Arrays.asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}

七、异步秒杀思路(提高业务执行速度的方法)

1.提高业务执行速度的方法:

之前秒杀的业务流程为:查询优惠券->判断库存->查询订单->校验一人一单->扣减库存->创建订单。业务串行执行,业务的耗时为6步耗时之和,其中查询优惠券、查询订单、扣减库存、创建订单的操作需要访问数据库,耗时较长

异步秒杀的思想就是针对上述单线程执行的业务流程,将具有独立性的操作从串行的业务流程中拆分出来,使用额外的线程执行这部分操作,提高响应速度,尤其是访问数据库的操作

除此之外,将访问数据库的操作优化为访问redis同样可以提高业务的执行速度。

2.秒杀优化思路分析:

  • 查询优惠券 ->判断库存 ->查询订单->校验一人一单 ->扣减库存 ->创建订单
  • 因此,在redis上,我们的思路是:
    • 将stock存到redis,每次判断库存 都对redis进行操作,减少了数据库的操作,提高速度。注:判断库存实际上就包含了查询优惠券和扣减库存的操作,相当于这两步操作都优化为对redis操作了
    • 将order存到redis,每次校验一人一单 都对redis进行操作,减少了数据库的操作,提高速度。注:校验一人一单实际上就包含了查询订单的操作,相当于查询订单优化为对redis操作了。
  • 而在异步线程上,我们的思路是:
    • 创建订单 的操作使用子线程访问数据库执行,独立于主线程,子线程可以随时写数据库,且不影响主线程,从而减少主线程的执行速度。(这就是为什么不把创建订单操作也改为对redis操作的原因,相比于改为对redis操作减少业务时间,直接分配其他线程则是完全去掉了这部分的业务时间)

修改后的业务流程如下,其中“查询优惠券->判断库存->查询订单->校验一人一单->扣减库存”都改为对redis操作,“创建订单”则分配额外的子线程来访问数据库。

由于“查询优惠券->判断库存->查询订单->校验一人一单->扣减库存”涉及多步操作,和之前一样具有线程安全问题,所以应该使用Lua脚本来保证操作的原子性和隔离性(相当于使用Lua脚本代替了@Transactional和synchronized)

在这里插入图片描述

2.1 redis优化秒杀资格判断:

经过上面的分析,这部分需要完成“查询优惠券->判断库存->查询订单->校验一人一单->扣减库存”的任务,由主线程执行。

则需求如下:
1.新增秒杀券写入数据库时,要将优惠券信息保存到redis,方便查询优惠券、判断库存、扣减库存任务执行。
2.基于lua脚本访问redis,执行“查询优惠券->判断库存->查询订单->校验一人一单->扣减库存”判断用户是否抢购成功。
2.1.若用户抢购成功,需要将用户id存入redis中优惠券的set集合,方便查询订单、校验一人一单任务执行。
2.2.若用户抢购成功,需要扣减库存。
3.若用户抢购成功,需要将用户id、优惠券id、订单id封装后存入阻塞队列,由异步线程写数据库。

2.1.1 将秒杀券信息写入redis:
// 新增优惠券
@Transactional
public void addSeckillVoucher(Voucher voucher) {
    // 保存优惠券
    save(voucher);
    // 保存秒杀券信息到数据库
    SeckillVoucher seckillVoucher = new SeckillVoucher();
    seckillVoucher.setVoucherId(voucher.getId());
    seckillVoucher.setStock(voucher.getStock());
    seckillVoucher.setBeginTime(voucher.getBeginTime());
    seckillVoucher.setEndTime(voucher.getEndTime());
    seckillVoucherService.save(seckillVoucher);
    // 保存秒杀券信息到redis
    stringRedisTemplate.opsForValue().set("seckill:stock:" + voucher.getId(), voucher.getStock().toString());
}
2.1.2 基于lua脚本判断用户是否抢购成功:
-- 优惠券id
local voucherId = ARGV[1]
-- 用户id
local userId = ARGV[2]
-- 库存key
local stockkey = 'seckill:stock' .. voucherId
-- 订单key
local orderkey = 'seckill:stock' .. voucherId

-- 判断库存是否充足
if(tonumber(redis.call('get', stockkey))<=0) then
    return 1
end
-- 判断用户是否下单
if(redis.call('sismember', orderkey, userId) == 1) then
    -- 重复下单
    return 2
end

-- 第一次下单
-- 扣库存
redis.call('incrby', stockkey, -1)
-- 用户id存入优惠券set集合
redis.call('sadd', orderkey, userId)

return 0
2.1.3 将抢购成功的信息存入阻塞队列:
@Autowired
private RedisIdWorker idWorker;
@Autowired
private StringRedisTemplate stringRedisTemplate;

private static final DefaultRedisScript<Long> SECKILL_SCRIPT; // 执行lua文件的类,并使用静态代码块实例化
static {
    SECKILL_SCRIPT = new DefaultRedisScript<>();
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
    SECKILL_SCRIPT.setResultType(Long.class);
}
// 阻塞队列,当线程从队列中获取元素时,若没有元素那么该线程会被阻塞,直到获取元素
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);

@Override
public Result seckillVoucher(Long voucherId) {
    // 执行lua脚本,传入参数KEYS ARGV
    Long result = stringRedisTemplate.execute(SECKILL_SCRIPT, Collections.emptyList(), voucherId, UserHolder.getUser().getId().toString());

    // 返回值不为0,无购买资格
    if (result.intValue() == 1){
        return Result.fail("库存不足");
    }else if (result.intValue() == 2){
        return Result.fail("不能重复下单");
    }

    // 返回值为0,有购买资格,将下单信息保存到阻塞队列
    long orderId = idWorker.nextId("order");
    long userId = UserHolder.getUser().getId();
    // 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setId(orderId);
    voucherOrder.setUserId(userId);
    voucherOrder.setVoucherId(voucherId);
    // 将订单存入阻塞队列
    orderTasks.add(voucherOrder);
    // 返回订单id
    return Result.ok(orderId);
}

2.2 异步线程优化更新数据库操作:

经过上面的分析,这部分需要完成“创建订单”的任务,由异步子线程执行。

需求如下:
1.开启额外的线程任务,独立于主线程,不断从阻塞队列中获取订单信息,实现异步下单功能。
2.开启额外的线程任务,独立于主线程,解决缓存更新问题,不断将redis中的库存写入数据库。(缓存更新问题:商户查询、缓存更新笔记

这里任务1老师讲的很乱,代码很多冗余,因为一人一单和超卖问题在lua中都判断了,并且lua满足原子性和隔离性,没必要用事务和分布式锁了,所以简化一下代码应该是这样:

这里的任务2老师忘了吧,压根都没有提,是我自己想到的,所以我直接和任务1合并了。

2.2.1 异步保存订单、异步更新库存到数据库:
//订单业务类
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Autowired 
    private SeckillVoucherServiceImpl seckillVoucherService;// 秒杀券业务类
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    // 1.创建阻塞队列,当线程从队列中获取元素时,若没有元素那么该线程会被阻塞,直到获取元素
    private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);
    
    // 2.创建一个线程
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
    
    // 3.内部类,定义线程写数据库的执行逻辑
    private class VoucherOrderHandler implements Runnable{
        @Override
        public void run() {
            // 不断从队列中取
            while (true){
                try {
                    // 获取队列中队首的订单信息,若没有订单则阻塞直到有订单可用
                    VoucherOrder order = orderTasks.take();
                    // 写订单到数据库
                    save(order);
                    // 更新订单库存stock到数据库,注意使用的是seckillVoucherService的mybatisplus,而不是当前订单类
                    String stock = stringRedisTemplate.opsForValue().get("seckill:stock:" + order.getVoucherId());
                    seckillVoucherService.update().set("stock",stock).eq("voucher_id", order.getVoucherId()).update();
                }catch (Exception e){
                    e.printStackTrace();
                }
            }
        }
    }

    @PostConstruct // 该注解的作用是将当前方法在当前类初始化完毕后开始执行
    private void init(){
    	// 4.让线程SECKILL_ORDER_EXECUTOR在当前类初始化后就开始执行VoucherOrderHandler中定义的方法
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }
}

八、消息队列

阻塞队列位于JVM,受到内存上限的限制,且没有持久化机制,队列中的信息有丢失的风险。

消息队列(MQ)位于JVM之外,存入的信息具有持久化机制。 包含3个角色:
1.消息队列:存储和管理消息
2.生产者:发送消息到消息队列
3.消费者:从消息队列中获取消息并处理消息

在这里插入图片描述

1.redis实现消息队列的方式:

  • List结构:双向链表 结构模拟阻塞队列,从而实现消息队列的效果
  • PubSub:基本的点对点消息队列
  • Stream:比较完善的消息队列模型

1.1 List实现消息队列:

List优点是利用redis存储,不受限于JVM内存上限,且可以利用redis的持久化机制将信息持久化,但还是会有 信息丢失风险(出队后写数据库之前服务器宕机)

原理是使用lpush结合brpop来将双向链表模拟成队列,其中brpop在链表为空时会阻塞,直到有元素。

1.2 PubSub实现消息队列:

PubSub基于发布订阅模式,消费者可以订阅一个或多个channel,生产者向channel存入信息后,订阅该channel的消费者都能收到相关信息 。因此支持多生产者多消费者,消息可以指定发送给1个消费者也可以同时发送给多个消费者。缺点是redis不支持对该模型的数据持久化

  • 订阅一个或多个频道:subscribe channel [channel]
  • 向一个频道发送信息:publish channel msg
  • 订阅与pattern格式匹配的所有频道:psubscribe pattern [pattern],其中通配符有? * []

1.3 Stream实现消息队列:

Stream是redis 数据类型,同string、hash、list,具有持久化机制。

  • 发送消息的命令xadd key [nomkstream] [maxlen/minid =/~ threshold limit count] */id field value field value.....
    • key:队列id,向哪个队列发送信息
    • nomkstream:如果队列不存在是否自动创建,默认自动创建
    • maxlen:设置消息队列的最大消息数量
    • */id:指定每条信息的唯一id,*代表由redis自动生成,格式是“ms时间戳-递增数字”
    • field value:消息具体内容,格式是多个key-value键值对

Stream是一个数据类型,因此读消息后消息并不会出队,而是依然保存在Stream中,不仅支持持久化,也没有信息丢失风险

  • 读取消息的命令:xread [COUNT count] [BLOCK millseconds] STREAMS key id
    • count:每次读取消息的最大数量
    • block:没有消息时阻塞时长,赋值0表示永久阻塞,默认不阻塞返回null
    • STREAMS key:队列id,从哪个队列读取信息
    • id:从队列中第几个信息开始读,0表示从队首开始,$代表从最新的消息开始,所以$会出现漏读情况

1.4 Stream消费者组实现消息队列:

消费者组提供了三种机制来解决漏读和信息丢失风险:

  1. 消息分流:将多个消费者划分到同一组中,监听同一个队列,队列中的信息由多个消费者共同处理。
  2. 消息标识:消费者组维护一个id指针,每次读取信息后指针后移确保每一个信息都会被读取
  3. 消息确认:消费者组维护一个pending-list,记录每个已消费但未处理的信息(读信息后写数据库之前服务器宕机),当信息被处理后通过XACK标记信息为已处理,并从pending-list中移除避免信息丢失风险(读信息后写数据库之前服务器宕机)

  • 创建消费者组的命令xgroup create key groupName id [mkstream]
    • key: 队列id,指定该消费者组用来处理哪个消息队列
    • groupName:消费者组名称
    • id:从队列中第几个信息开始读,0表示从队首开始,$代表从最新的消息开始
    • mkstream:如果消息队列不存在是否自动创建,默认自动创建
  • 删除指定的消费者组:xgroup destory key groupName
  • 给指定的消费者组添加消费者:xgroup createconsumer key groupName consumerName
  • 删除消费者组中的指定消费者:xgroup delconsumer key groupName consumerName
  • 读取消息的命令xreadgroup Group group consumer [COUNT count] [BLOCK millseconds] [noack] STREAMS key id
    • group:指定读取消息的消费者组
    • consumer:指定消费者组中的消费者,不存在则自动创建
    • count:每次读取消息的最大数量
    • block:没有消息时阻塞时长,赋值0表示永久阻塞,默认不阻塞返回null
    • noack:是否需要手动消息确认,默认需要
    • STREAMS key:队列id,从哪个队列读取信息
    • id:从队列中第几个信息开始读,>表示读最早入队的哪个未消费的消息(消息标识),用于正常消费;0代表从pending-list中读已消费但未ACK的消息,用于出现异常时异常处理

2. Stream消费者组消息队列实现异步更新库存到数据库

2.1 修改Lua脚本,判定有抢购资格后,向消息队列中添加订单信息

-- 优惠券id
local voucherId = ARGV[1]
-- 用户id
local userId = ARGV[2]
-- 订单id
local orderId = ARGV[3]
-- 库存key
local stockkey = 'seckill:stock' .. voucherId
-- 订单key
local orderkey = 'seckill:stock' .. voucherId

-- 判断库存是否充足
if(tonumber(redis.call('get', stockkey))<=0) then
    return 1
end
-- 判断用户是否下单
if(redis.call('sismember', orderkey, userId) == 1) then
    -- 重复下单
    return 2
end

-- 第一次下单
-- 扣库存
redis.call('incrby', stockkey, -1)
-- 用户id存入优惠券set集合
redis.call('sadd', orderkey, userId)
-- 将订单信息发送到消息队列中
redis.call('xadd', 'stream.order', '*', "voucherId", voucherId, "userId", userId, "id", orderId)

return 0

2.2 创建Stream类型的消息队列,在子线程中获取消息队列中的信息保存到数据库

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private SeckillVoucherServiceImpl seckillVoucherService;
    @Autowired
    private RedisIdWorker idWorker;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    private static final DefaultRedisScript<Long> SECKILL_SCRIPT; // 执行lua文件的类,并使用静态代码块实例化
    static {
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }
    // 创建一个线程
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
    private class VoucherOrderHandler implements Runnable{
        @Override
        public void run() {
            // 不断从队列中取
            while (true){
                try {
                    /** 获取redis Stream消息队列中最早未被处理的订单信息,若没有订单则阻塞直到有订单可用
                     * g1:消费者组key, c1:消费者key,没有会自动创建, count(1):取1个订单, block(Duration.ofSeconds(2):阻塞2s
                     * stream.order:消息队列key, ReadOffset.lastConsumed():取消息队列中最早未被处理的订单信息,底层就是">"
                     * 返回是List因为有时候会设置count取多条订单信息
                     */
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                            StreamOffset.create("stream.order", ReadOffset.lastConsumed()));
                    if (list!=null && !list.isEmpty()){//这里判断有必要,因为阻塞2s后还没数据就会返回null
                        //<String, Object, Object>表示<消息id, field, value>
                        MapRecord<String, Object, Object> record = list.get(0);
                        Map<Object, Object> field_value = record.getValue();
                        VoucherOrder order = BeanUtil.fillBeanWithMap(field_value, new VoucherOrder(), true);
                        // 写订单到数据库
                        save(order);
                        // ACK确认,给定消息队列key,消费者组key,订单消息key
                        stringRedisTemplate.opsForStream().acknowledge("stream.order", "g1", record.getId());


                        // 更新订单库存stock到数据库
                        String stock = stringRedisTemplate.opsForValue().get("seckill:stock:" + order.getVoucherId());
                        seckillVoucherService.update().set("stock",stock).eq("voucher_id", order.getVoucherId()).update();
                    }
                }catch (Exception e){
                    // 处理已消费但未确认的订单信息(读取后但因为宕机未写入数据库)
                    // 这里要从pending-list中读,所以用ReadOffset.from("0")
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1),
                            StreamOffset.create("stream.order", ReadOffset.from("0")));
                    if (list!=null && !list.isEmpty()) {//这里判断有必要,因为阻塞2s后还没数据就会返回null
                        //<String, Object, Object>表示<消息id, field, value>
                        MapRecord<String, Object, Object> record = list.get(0);
                        Map<Object, Object> field_value = record.getValue();
                        VoucherOrder order = BeanUtil.fillBeanWithMap(field_value, new VoucherOrder(), true);
                        // 写订单到数据库
                        save(order);    
                        // ACK确认,给定消息队列key,消费者组key,订单消息key
                        stringRedisTemplate.opsForStream().acknowledge("stream.order", "g1", record.getId());
                    }
                }
            }
        }
    }

    @PostConstruct // 该注解的作用是将当前方法在当前类初始化完毕后开始执行
    private void init(){
        // 创建消费者组,给定消息队列key和组key
        stringRedisTemplate.opsForStream().createGroup("stream.order","g1");
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    @Override
    public Result seckillVoucher(Long voucherId) {
        long orderId = idWorker.nextId("order");
        Long userId = UserHolder.getUser().getId();

        // 执行lua脚本,传入参数KEYS ARGV
        Long result = stringRedisTemplate.execute(SECKILL_SCRIPT, Collections.emptyList(), voucherId, userId.toString(), String.valueOf(orderId));

        // 返回值不为0,无购买资格
        if (result.intValue() == 1){
            return Result.fail("库存不足");
        }else if (result.intValue() == 2){
            return Result.fail("不能重复下单");
        }

        // 返回值为0,有购买资格,返回订单id,写数据库操作由子线程完成,订单信息已经在lua脚本中保存到redis消息队列中了
        return Result.ok(orderId);
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

姓蔡小朋友

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值