【Redis】Redis实战:黑马点评之秒杀优化

Redis实战:黑马点评之秒杀优化

1 分布式锁优化秒杀

1.1 问题引入

通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。

1.我们将服务启动两份,端口分别为8081和8082:

【Redis】Redis实战:黑马点评之秒杀优化_第1张图片

2.然后修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡:

【Redis】Redis实战:黑马点评之秒杀优化_第2张图片

3.重新加载nginx,命令为nginx.exe -s reload

4.经过测试,最终发现在集群模式下,有多少个服务,用户最多就能下多少单,也就是说在集群模式下,我们之前使用的悲观锁失效了

为什么会出现上述现象呢?原因是由于现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,而每个jvm内部有一个锁监视器,用来记录当前获取锁的线程id,假设在服务器A的tomcat内部,有两个线程分别为线程1、线程2,这两个线程由于在同一个jvm上运行,且锁对象是同一个,当线程1获取锁对象时就会被锁监视器记录下来,此时线程2就无法获取锁对象了,这样也就实现了互斥锁。

但是如果现在是服务器B的tomcat内部,又有两个线程分别为线程3、线程4,这两个线程虽然和线程1、线程2运行着同样的代码,但是却是在不同的jvm上运行的,这也就意味着它们拥有不同的锁监视器,即便在第一个jvm中,锁监视器已经获取到了线程1的id,但是在第二个jvm中,锁监视器仍然是空的,这也就意味着线程3和线程4都能去获得锁,这样的话,同一部分代码,同一个锁对象,在不同的服务器上却是不同的锁,线程3与线程4能实现互斥,但是却无法和线程1与线程2实现互斥,这就是集群环境下,syn锁失效的原因。

在这种情况下,我们就需要使用分布式锁来解决这个问题。

【Redis】Redis实战:黑马点评之秒杀优化_第3张图片

1.2 SetNX优化

注:关于分布式锁的相关知识笔者系统地整理在了另一篇文章中:【Redis】Redis高级:分布式锁,这里只讲解代码的编写

第一步:在util包下编写ILock接口

/**
 * 分布式锁父接口
 */
public interface ILock {

    /**
     * 尝试获取锁
     * @param timeoutSec 锁持有的超时时间,过期后锁自动释放
     * @return 返回true表示锁获取成功,返回false表示锁获取失败
     */
    boolean tryLock(long timeoutSec);

    /**
     * 释放锁
     */
    void unlock();

}

第二步:编写SimpleRedisLock实现ILock(这里已解决锁误删问题和原子性问题)

public class SimpleRedisLock implements ILock {

    /**
     * 业务名称,由调用者创建对象时传入,目的是保证在不同的业务中都有不同的key作为锁
     */
    private String name;

    private StringRedisTemplate stringRedisTemplate;

    /**
     * 锁前缀
     */
    private static final String KEY_PREFIX = "lock:";

    /**
     * 随机生成的线程ID前缀,这里用final保证在同一个jvm上面前缀是一致的
     */
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

    /**
     * 脚本对象
     */
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

    /**
     * 使用静态代码块加载lua脚本,在类启动的时候就将脚本加载进内存
     */
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        //设置脚本路径
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        //设置脚本返回值类型。这里随意即可
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

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

    /**
     * 获取锁
     * @param timeoutSec 锁持有的超时时间,过期后锁自动释放
     * @return
     */
    @Override
    public boolean tryLock(long timeoutSec) {
        //获取当前线程标识
        String id = ID_PREFIX+Thread.currentThread().getId();
        //获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(
            KEY_PREFIX + name, 
            id, 
            timeoutSec, 
            TimeUnit.MINUTES
        );
        return Boolean.TRUE.equals(success);
    }

    /**
     * 释放锁
     */
    @Override
    public void unlock() {
        //获取当前线程表示
        String id = ID_PREFIX+Thread.currentThread().getId();

        //调用lua脚本执行锁释放操作
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name), //需要释放的锁的id
                ID_PREFIX + Thread.currentThread().getId() //需要进行判断的线程标识
        );
    }
}

第三步:在resources目录中新建一个名为unlock.lua的脚本,内容如下:

---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by 左右盲.
--- DateTime: 2022/9/19 21:21

-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
    -- 一致,则删除锁
    return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0

第四步:改造秒杀代码,将syn锁替换成我们自定义的分布式锁

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

    @Autowired
    private ISeckillVoucherService seckillVoucherService;

    @Autowired
    private RedisIdWorker redisIdWorker;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 实现秒杀下单
     * @param voucherId
     * @return
     */
    @Override
    public Result seckillVoucher(Long voucherId) {
        //获取秒杀券信息
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);

        //判断秒杀券是否存在
        if(seckillVoucher == null){
            return Result.fail("秒杀券不存在");
        }

        //判断秒杀是否开始或是否结束
        LocalDateTime now = LocalDateTime.now();
        //如果现在时间在开始时间之前或者在结束时间之后说明秒杀活动未开始或者已结束
        if(now.isBefore(seckillVoucher.getBeginTime())||now.isAfter(seckillVoucher.getEndTime())){
            return Result.fail("不在活动时间内");
        }

        //判断库存是否足够
        if(seckillVoucher.getStock() < 1){
            return Result.fail("库存不足");
        }

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

        //创建锁对象,由于这里我们希望保证一人一单,因此针对userId来创建锁
        SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);

        boolean tryLock = redisLock.tryLock(1000l);
        
        //获取锁对象,如果失败则表示此时该用户已经在下单了
        if(!tryLock){
            return Result.fail("不允许重复下单");
        }

        try {
            //由spring帮我们创建当前类的代理对象,由代理对象来调用方法
            //由于代理对象是spring创建的,自然就能进行事务的管理了
            IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } catch(Exception e){
            throw new RuntimeException(e.getMessage());
        }finally {
            //释放锁对象
            redisLock.unlock();
        }
        
    }

    @Transactional
    public  Result createVoucherOrder(Long voucherId) {
        //判断该用户有没有下单过
        Integer count = query()
                .eq("user_id", UserHolder.getUser().getId())
                .eq("voucher_id", voucherId).count();

        if(count>0){
            return Result.fail("已经购买过了!");
        }

        //扣减库存
        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();
        //使用自定义的全局id生成器生成订单id
        Long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //保存用户id
        voucherOrder.setUserId(UserHolder.getUser().getId());
        //保存优惠券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        //返回订单id
        return Result.ok(orderId);
    }
}

1.3 Redisson优化

第一步:引入依赖

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

第二步:配置Redisson客户端:

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){
        // 配置
        Config config = new Config();
        // 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://192.168.211.100:6379")
                .setPassword("123456");
        // 创建RedissonClient对象
        return Redisson.create(config);
    }
    
}

第三步:改造秒杀代码,将syn锁替换成我们自定义的分布式锁

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

    @Autowired
    private ISeckillVoucherService seckillVoucherService;

    @Autowired
    private RedisIdWorker redisIdWorker;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private RedissonClient redissonClient;

    /**
     * 实现秒杀下单
     * @param voucherId
     * @return
     */
    @Override
    public Result seckillVoucher(Long voucherId) {
        //获取秒杀券信息
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);

        //判断秒杀券是否存在
        if(seckillVoucher == null){
            return Result.fail("秒杀券不存在");
        }

        //判断秒杀是否开始或是否结束
        LocalDateTime now = LocalDateTime.now();
        //如果现在时间在开始时间之前或者在结束时间之后说明秒杀活动未开始或者已结束
        if(now.isBefore(seckillVoucher.getBeginTime())||now.isAfter(seckillVoucher.getEndTime())){
            return Result.fail("不在活动时间内");
        }

        //判断库存是否足够
        if(seckillVoucher.getStock() < 1){
            return Result.fail("库存不足");
        }

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

        //创造锁对象
        RLock lock = redissonClient.getLock("order:" + userId);
        
        //获取锁对象
        boolean tryLock = lock.tryLock();

        //如果获取锁对象失败则表示此时该用户已经在下单了
        if(!tryLock){
            return Result.fail("不允许重复下单");
        }

        try {
            //由spring帮我们创建当前类的代理对象,由代理对象来调用方法
            //由于代理对象是spring创建的,自然就能进行事务的管理了
            IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } catch(Exception e){
            throw new RuntimeException(e.getMessage());
        }finally {
            //释放锁对象
            lock.unlock();
        }

    }

    @Transactional
    public  Result createVoucherOrder(Long voucherId) {
        //判断该用户有没有下单过
        Integer count = query()
                .eq("user_id", UserHolder.getUser().getId())
                .eq("voucher_id", voucherId).count();

        if(count>0){
            return Result.fail("已经购买过了!");
        }

        //扣减库存
        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();
        //使用自定义的全局id生成器生成订单id
        Long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //保存用户id
        voucherOrder.setUserId(UserHolder.getUser().getId());
        //保存优惠券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        //返回订单id
        return Result.ok(orderId);
    }
}

2 异步优化秒杀

2.1 问题引入

让我们来看看之前实现业务逻辑的整体流程:

【Redis】Redis实战:黑马点评之秒杀优化_第4张图片

在我们的业务流程中,有很多操作都是要去查询数据库的,而且这些操作都是由一个线程串行执行完成的,再加上程序中又使用了分布式锁,因此程序的执行效率会变得很慢,并发能力很弱,那么应该如何优化呢?

我们发现,上述业务逻辑实际上可以分成两种操作:

  • 一种是判断当前用户是否有秒杀资格,比如判断库存,校验一人一单,这种操作都是逻辑执行时间较短的读操作
  • 一种是下单操作,包括减库存和创建订单,这种操作都是对数据库的写操作,耗时相对较久

我们可以将上述两种操作拆分开来,由不同的线程来执行,初步猜想是主线程来执行耗时较短的读操作,当判断用户具有秒杀资格后,就异步调用其他线程去完成下单操作,并直接将订单id返回给用户,这样也就大大提高了效率

我们进一步思考一下上述两种操作还有没有优化的空间

首先是判断用户是否具有秒杀资格的读操作,这种操作还是会直接访问数据库,效率太低了,我们可以考虑将秒杀需要读取的相关数据保存在缓存之中,例如优惠券的数量、已下单的用户id等,当我们需要判断优惠券数量时或者查看当前用户是否已经下单时,直接基于缓存来判断,这样就能大大提高效率。

然后是下单操作,如果我们每次判断完用户秒杀资格后都开启一个线程去异步完成数据库的修改,这样虽然可以,但是这是基于时效性考虑的,同时开启多个线程必然会加大服务器的压力。但是当我们判断完用户具有秒杀资格后,我们就可以将订单id返回给用户了,下单操作是基于数据库的更改,我们只要慢慢完成就可以了,这种情况下,我们就可以使用阻塞队列,当判断用户有资格下单后,就可以将优惠券 id、用户 id以及订单 id 等信息存储到阻塞队列中,然后由独立线程异步读取阻塞队列中的信息,完成操作。

【Redis】Redis实战:黑马点评之秒杀优化_第5张图片

2.2 基于Redis优化秒杀资格判断

首先我们尝试通过redis完成秒杀资格判断,这里面有一个问题,那就是我们如何在redis中去快速校验一人一单和有库存判断?

要想在 Redis 中判断库存是否充足以及一人一单,我们实际上需要去缓存两条信息,第一条是剩余库存数量,第二条信息是当前已下单的用户id。

我们可以在秒杀券活动开始之前提前将秒杀券库存缓存在redis中,通过判断当前缓存中的库存数量是否大于0来判断库存是否充足,当有用户具备秒杀资格时,缓存中的库存数量就减一,这里是基于缓存的修改,最终会同步到数据库,由于最开始缓存中的库存数量与数据库中的库存数量就是一致的,也不用担心出现一致性问题。剩余库存数量我们可以直接用Redis中的String类型来保存,key为优惠券的id,value为库存数量

当前已下单用户我们可以基于Redis中的set类型来存储。Redis中一条set类型的数据是可以存放多条元素的,而且每个元素都是不可重复的,我们可以使用优惠券id作为key(这里会带上不同的前缀与库存数量的key做区分),已下单用户的id作为value,每有一个用户下单,我们就将该用户id记录在set中,当判断当前用户是否已经下单时,我们就可以通过set的SISMEMBER命令判断该set中是否存在该元素的id,这样也就完成了一人一单的校验

为了保证上述过程的原子性,我们可以将上述redis操作编写成lua脚本

【Redis】Redis实战:黑马点评之秒杀优化_第6张图片

接下来我们开始编写代码

第一步:修改VoucherServiceImpl中addSeckillVoucher的方法,在保存秒杀优惠券信息的同时,将其库存数量加载到缓存中

    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    @Override
    @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);
        
        //将秒杀券数量加载到缓存中
        stringRedisTemplate.opsForValue().set(
                RedisConstants.SECKILL_STOCK_KEY+voucher.getId(),
                voucher.getStock().toString()
        );
    }

第二步:在resources目录中新建一个名为seckill.lua的脚本,内容如下:

-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]

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

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

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

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

-- 3.6.秒杀成功,返回0
return 0

第三步:修改VoucherOrderServiceImpl中的秒杀代码

@Autowired
private ISeckillVoucherService seckillVoucherService;

@Autowired
private RedisIdWorker redisIdWorker;

@Autowired
private StringRedisTemplate stringRedisTemplate;

/**
  * 脚本对象
  */
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

/**
  * 使用静态代码块加载lua脚本,在类启动的时候就将脚本加载进内存
  */
static {
    UNLOCK_SCRIPT = new DefaultRedisScript<>();
    //设置脚本路径
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
    //设置脚本返回值类型
    UNLOCK_SCRIPT.setResultType(Long.class);
}

/**
  * 实现秒杀下单
  * @param voucherId
  * @return
  */
public Result seckillVoucher(Long voucherId){
    //获取当前用户id
    Long userId = UserHolder.getUser().getId();
    //随机生成订单id
    long orderId = redisIdWorker.nextId("order");
    //执行lua脚本
    Long result = stringRedisTemplate.execute(
        UNLOCK_SCRIPT,//参数1,需要执行的脚本对象
        Collections.emptyList(),//参数2,脚本中为key的参数,这里我们脚本中没有为key的参数,直接给一个空集合
        voucherId.toString(), userId.toString()//非key的其他参数
    );
    //判断返回结果
    if(result!=0){
        //不等于零表示没有秒杀资格
        return Result.fail(result!=1?"重复下单":"库存不足");
    }

    //TODO 秒杀成功 将信息保存到阻塞队列中 这里尚未完成

    //返回已生成的订单id
    return Result.ok(orderId);
}

2.3 基于阻塞队列优化秒杀下单

先修改IVoucherOrderService接口,在接口中添加一个void handlerVoucherOrder(VoucherOrder voucherOrder);

public interface IVoucherOrderService extends IService<VoucherOrder> {

    Result seckillVoucher(Long voucherId);

    Result createVoucherOrder(Long voucherId);

    void handlerVoucherOrder(VoucherOrder voucherOrder);

}

修改VoucherOrderServiceImpl代码

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

    @Autowired
    private ISeckillVoucherService seckillVoucherService;

    @Autowired
    private RedisIdWorker redisIdWorker;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private RedissonClient redissonClient;

    /**
     * 脚本对象
     */
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

    /**
     * 使用静态代码块加载lua脚本,在类启动的时候就将脚本加载进内存
     */
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        //设置脚本路径
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        //设置脚本返回值类型
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

    /**
     * 阻塞队列,当有线程试图从队列中获取元素时,如果队列中没有元素,当前线程就会被阻塞
     * 队列中元素的类型为VoucherOrder
     * 队列的长度为1024*1024
     */
    private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);

    /**
     * 用于处理下单业务的线程池,这里的线程池是单线程的
     */
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    /**
     * 这里为了让独立对象使用到代理对象,需要将代理对象提到成员变量的位置
     */
    private IVoucherOrderService proxy;

    /**
     * 线程任务类,里面存放下单的业务逻辑
     */
    private class VoucherOrderHandler implements Runnable {
        @Override
        public void run() {
            //不断从队列中获取订单信息
            while (true){
                try {
                    //获取队列中的订单信息,如果没有订单信息则阻塞
                    VoucherOrder voucherOrder = orderTasks.take();
                    //创建订单,单独写在另一个方法中
                    proxy.handlerVoucherOrder(voucherOrder);
                } catch (Exception e) {
                    log.error("处理订单异常:{}",e);
                }
            }
        }
    }

    /**
     * @PostConstruct是在Bean在加载到容器之后就会执行的方法
     * 在项目启动时就提交任务
     */
    @PostConstruct
    private void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    /**
     * 创建订单方法
     * @param voucherOrder
     */
    @Transactional
    public void handlerVoucherOrder(VoucherOrder voucherOrder) {
        //这边由于是单线程,而且redis中已经做了秒杀资格的判断,这里直接下单即可
        //扣减库存
        seckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherOrder.getVoucherId()).gt("stock",0)
                .update();
        //保存订单信息
        save(voucherOrder);
    }


    /**
     * 判断秒杀资格
     * @param voucherId
     * @return
     */
    public Result seckillVoucher(Long voucherId){
        //获取当前用户id
        Long userId = UserHolder.getUser().getId();
        //执行lua脚本
        Long result = stringRedisTemplate.execute(
                UNLOCK_SCRIPT,//参数1,需要执行的脚本对象
                Collections.emptyList(),//参数2,脚本中为key的参数,这里我们脚本中没有为key的参数,直接给一个空集合
                voucherId.toString(), userId.toString()//非key的其他参数
        );
        System.err.println(result);

        //判断返回结果
        if(result!=0){
            //不等于零表示没有秒杀资格
            return Result.fail(result!=1?"重复下单":"库存不足");
        }

        //随机生成订单id
        long orderId = redisIdWorker.nextId("order");

        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);

        /*
         * 获取当前类的代理对象并赋给成员变量,因为我们需要在独立线程中去使用,而代理对象又是基于ThreadLocal的
         * 因此在独立线程中是没办法获取代理对象的,为了能让独立线程也使用到代理对象,我们需要将代理对象提到成员变量的位置
         */
        proxy = (IVoucherOrderService) AopContext.currentProxy();

        // 秒杀成功 将信息保存到阻塞队列中
        orderTasks.add(voucherOrder);

        //返回已生成的订单id
        return Result.ok(orderId);
    }
}

这种方式实现的阻塞队列是基于jvm的内存,如果在高并发的情况下,无数的订单涌入队列,就容易出现jvm内存溢出的问题,所以我们为队列设置了长度上限。但是如果消息进入队列的速度快于独立线程的处理速度,那么队列就很可能达到长度上限,无法进入队列的消息可能就会丢失。而且我们是基于内存存储消息的,如果服务宕机了或者已经取出订单了正准备去处理时出现异常了,那么已下单的订单信息就会丢失。因此上述实现方案在长度限制和数据安全两方面存在问题,我们需要提出别的解决方案。

2.4 基于stream消息队列优化秒杀下单

注:关于redis消息队列的相关知识笔者系统的整理在了另一篇文章中:【Redis】Redis高级:消息队列,这里只讲解代码的编写:

第一步:提前在redis中创建消费者组:

# 创建消费者组g1,设置mkstream(队列不存在则创建),从队列中第一个元素开始读取
xgroup create stream.orders g1 0 mkstream

第二步:改造lua脚本

-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local voucherId = ARGV[3]

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

-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2.库存不足,返回1
    return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 3.3.存在,说明是重复下单,返回2
    return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.将订单信息存入到消息队列中 xadd stream.orders * k1 v1 k2 v2
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0

第三步:修改VoucherOrderServiceImpl中的代码

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

    @Autowired
    private ISeckillVoucherService seckillVoucherService;

    @Autowired
    private RedisIdWorker redisIdWorker;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private RedissonClient redissonClient;

    /**
     * 脚本对象
     */
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

    /**
     * 使用静态代码块加载lua脚本,在类启动的时候就将脚本加载进内存
     */
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        //设置脚本路径
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        //设置脚本返回值类型
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

    /**
     * 用于处理下单业务的线程池,这里的线程池是单线程的
     */
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    /**
     * 这里为了让独立对象使用到代理对象,需要将代理对象提到成员变量的位置
     */
    private IVoucherOrderService proxy;

    /**
     * 线程任务类,里面存放下单的业务逻辑
     */
    private class VoucherOrderHandler implements Runnable {
        @Override
        public void run() {
            String streamName = "stream.orders";
            //不断从队列中获取订单信息
            while (true){
                try {
                    // 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
                    List<MapRecord<String, Object, Object>> mapRecords = stringRedisTemplate.opsForStream().read(
                            //给定消费者组名和消费者名
                            Consumer.from("g1", "c1"),
                            //给定需要获取的消息数量和没有消息时的阻塞时长
                            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                            //指定队列名和获取消息的起始ID,ReadOffset.lastConsumed()是一个枚举,实际上就是">"
                            StreamOffset.create(streamName, ReadOffset.lastConsumed())
                    );

                    // 2.判断订单信息是否为空
                    if(mapRecords==null||mapRecords.isEmpty()){
                        //说明当前没有需要处理的订单,进行下一次循环
                        continue;
                    }

                    // 3.解析数据
                    // record由三个部分组成,第一个部分是消息id,第二部分是消息的key,第三部分是消息的value
                    MapRecord<String, Object, Object> record = mapRecords.get(0);
                    // 这里得到的就是我们存放进去的消息,我们需要将其转为java对象
                    Map<Object, Object> recordValue = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(recordValue, new VoucherOrder(), true);

                    // 4.创建订单
                    proxy.handlerVoucherOrder(voucherOrder);

                    // 5.确认消息 XACK
                    stringRedisTemplate.opsForStream().acknowledge(streamName,"g1",record.getId());

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

                    //当订单处理过程中出现异常时,需要从pendingList中去获取订单并处理
                    handlePendingList();
                }
            }
        }
    }

    /**
     * 订单处理过程中出现异常的处理方案
     */
    private void handlePendingList() {
        String streamName = "stream.orders";
        while (true) {
            try {
                // 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0
                List<MapRecord<String, Object, Object>> mapRecords = stringRedisTemplate.opsForStream().read(
                        //给定消费者组名和消费者名
                        Consumer.from("g1", "c1"),
                        //给定需要获取的消息数量
                        StreamReadOptions.empty().count(1),
                        //指定队列名和获取消息的起始ID,这里从pendingList中的第一条数据开始获取
                        StreamOffset.create(streamName, ReadOffset.from("0"))
                );

                // 2.判断pendingList是否为空,为空说明所有消息都已经得到确认
                if(mapRecords==null||mapRecords.isEmpty()){
                    break;
                }

                // 3.解析数据
                // record由三个部分组成,第一个部分是消息id,第二部分是消息的key,第三部分是消息的value
                MapRecord<String, Object, Object> record = mapRecords.get(0);
                // 这里得到的就是我们存放进去的消息,我们需要将其转为java对象
                Map<Object, Object> recordValue = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(recordValue, new VoucherOrder(), true);

                // 4.创建订单
                proxy.handlerVoucherOrder(voucherOrder);

                // 5.确认消息 XACK
                stringRedisTemplate.opsForStream().acknowledge(streamName,"g1",record.getId());

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

                //上述处理过程中仍然出现异常,让线程休眠一会之后再继续下一次循环
                try {
                    Thread.sleep(20);
                } catch (InterruptedException ex) {
                    throw new RuntimeException(ex);
                }
            }
        }
    }

    /**
     * @PostConstruct是在Bean在加载到容器之后就会执行的方法
     * 在项目启动时就提交任务
     */
    @PostConstruct
    private void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    /**
     * 创建订单方法
     * @param voucherOrder
     */
    @Transactional
    public void handlerVoucherOrder(VoucherOrder voucherOrder) {
        //这边由于是单线程,而且redis中已经做了秒杀资格的判断,这里直接下单即可
        //扣减库存
        seckillVoucherService.update()
                .setSql("stock = stock -1")
                .eq("voucher_id", voucherOrder.getVoucherId()).gt("stock",0)
                .update();
        //保存订单信息
        save(voucherOrder);
    }


    /**
     * 判断秒杀资格
     * @param voucherId
     * @return
     */
    public Result seckillVoucher(Long voucherId){
        //获取当前用户id
        Long userId = UserHolder.getUser().getId();
        //随机生成订单id
        long orderId = redisIdWorker.nextId("order");
        //执行lua脚本
        Long result = stringRedisTemplate.execute(
                UNLOCK_SCRIPT,//参数1,需要执行的脚本对象
                Collections.emptyList(),//参数2,脚本中为key的参数,这里我们脚本中没有为key的参数,直接给一个空集合
                voucherId.toString(), userId.toString(),String.valueOf(orderId)//非key的其他参数
        );
        System.err.println(result);

        //判断返回结果
        if(result!=0){
            //不等于零表示没有秒杀资格
            return Result.fail(result!=1?"重复下单":"库存不足");
        }
        /*
         * 获取当前类的代理对象并赋给成员变量,因为我们需要在独立线程中去使用,而代理对象又是基于ThreadLocal的
         * 因此在独立线程中是没办法获取代理对象的,为了能让独立线程也使用到代理对象,我们需要将代理对象提到成员变量的位置
         */
        proxy = (IVoucherOrderService) AopContext.currentProxy();

        //返回已生成的订单id
        return Result.ok(orderId);
    }
}

你可能感兴趣的:(#,Redis,redis,java,jvm)