秒杀业务学习笔记

在一些场景中经常会有限时限量抢购需求,例如优惠券秒杀,用户在限定时间和限定库存范围之内进行用户抢购并且要求每个用户只能下单一次,下面简单设计以下优惠券下单流程:

全集唯一ID

在设计优惠券下单业务逻辑之前,首先解决全局唯一ID问题,当用户抢购成功时,会生成订单并保存到数据库订单表中,而订单表中如果使用数据库自增ID就会存在以下问题:

1. ID规律性明显:如果订单ID逐渐递增,那么订餐id很可能被猜出

2. 收单表数据量的限制:如果拥有很多用户,这个递增的ID可能超出表限制

因此需要设计全局唯一ID,使这个ID具有:唯一性、递增性和安全性。同时要求在分布式高并发场景下,生成全局唯一ID具有高可用高性能,而我们直到Redis能够满足以上特性,因此基于Redis设置一种全局ID生成器

为了增加ID的安全性,并不直接使用Redis自增的数据,而是拼接一些其他的信息,如启用一个64位数值保存ID,其中首位表示符号位永远位0,而其后跟随一个31位的时间戳,最后再拼接一个32位的序列号。

时间戳生成方式:固定一个BEGIN_TIME,每次以下单时间减去固定时间

序列号生成方式:1. UUID 2. Redis自增 3. 雪花算法 4. 数据库自增

在此我们采用Redis自增即可,而Redis自增策略:每天一个key,方便统计订单量;ID构造是时间戳+计时器。

实现代码如下:

 public long nextId(String keyPrefix){
        // 生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;
        // 生成序列号
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
        // 拼接并返回
        return timestamp << COUNT_BITS | count;
    }

优惠券秒杀下单

实现下单业务之前,首先需要在数据库中新建表来存储优惠券信息。一般优惠券分为两种:平价券特价券,平价券的购买没有限制,而特价券需要秒杀购买。因此新建一个tb_voucher表,其中存储优惠券的基本信息,优惠金额,使用规则,优惠券类型等;同时,由于秒杀券中需要存储秒杀券库存和使用时间,因此新建一个tb_seckill_voucher表存储秒杀券的库存、开始抢购时间、结束抢购时间等。

秒杀下单-Controller层

秒杀下单需要提供优惠券信息,并返回优惠券ID

秒杀下单-Service层接口及实现逻辑

在进行秒杀券下单时,需要判断两点:

1. 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单

2. 秒杀券库存是否充足,不足则无法下单

实现代码:

public Result seckillVoucher(Long voucherId) {

        // 查询优惠券信息
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        // 判断时间时候开始
        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("秒杀券已抢完!");
        }
        
        // 下单,保存订单信息到订单表,其中有ID、订单ID、用户ID等信息
        createVoucherOrder(voucherId)
    }

超卖问题

在高并发多线程场景下,上述逻辑可能存在线程安全问题,比如正常下单逻辑为(查询库存-判断库存是否大于0(是:报错、否:下单且库存-1)),但如果此时线程2在线程1的查询库存以及判断库存之间到来且查询库存,如果此时库存为1,那么线程1和线程2都能够进行下单,最后库存为-1,优惠券售出数量超过优惠券数量,这就叫做超卖问题。

因此超卖问题就是典型的多线程安全问题,针对这一问题的常见解决办法就是加锁:

悲观锁是最为容易的实现方式,我们只需要在用户下单之前对其进行加锁,下单成功之后释放锁,而另一个线程也下单是,首先也对其进行加锁,如果此时其他线程正在下单过程中,那么这个线程获取锁失败就会等待并重新获取锁直到获取锁成功·。但这个方法存在问题: 等待时间过长,如果一直没有获取锁,就会一直等待。

因此一般不会采用悲观锁的方法,而是采用乐观锁。乐观锁不会加锁,只有在更新数据时去判断有没有其他线程对数据做了修改,如果没有则认为是安全的,自己再更新数据,如果已经被其他线程修改说明发生安全问题,此时需要重试或异常。即在对数据库更新的语句中加入where条件语句。

乐观锁的关键是判断之前查询的数据是否有被修改,常见的方式有:版本号法CAS法

版本号法就是在秒杀券信息中新增一个字段version。查询时查询库存和版本号,如果判断库存大于0后,还需要判断此时版本号是否与之前查询的版本号相同,如果相同说明没有其他线程修改,如果不同说明被其他线程修改,需要重试或异常。

CAS法不会新增字段,而是直接通过原数据进行判断。查询时记录当前库存数目,如果判断库存大于0之后,还需检查当前库存是否与之前记录库存相同,如果相同说明没有其他线程修改,如果不同说明被其他线程修改,需要重试或异常。

乐观锁虽然性能好,但成功率较低,比如当前库存为100,200个用户同时下单,此时都会查询到库存为100,而当有一个用户下达能成功后,库存实际变为99,此时其余用于会判断发现有线程对库存就进行修改从而认为发生安全问题并报异常,但此时库存显然充足。

因此可对判断条件进行修改,直接将判断当前库存与之前查询库存的sql语句换位判断当前库存是否大于0即where stock > 0。

如果必须通过是否修改来判断,可以通过分段锁的方式,将资源分为多分轮流分配。

一人一单

对于秒杀优惠券,通常一个用户只能下单一次,因此需要在下单之前判断当前用户是否已经过买过,只有没有购买过的用户可以下单。

此时业务流程图:

可以看到单从业务逻辑上看不难实现,仅需根据优惠券id和用户id在订单表中查询,如果查询不为空,说明当前用户购买过这个优惠券,此时无法下单,如果查询不存在,说明可以下单。但是实际上并没有这么简单。

假如当前用户在两台设备上进行下单,设为线程1和线程2,我们认知中的下单逻辑如下:

但是两个线程并发时,业务执行顺序可能为:线程1查询订单-线程2查询订单-线程1判读按是否存在-线程2判断是否存在,此时由于并没有下单,线程1和线程2都会查询到未下单并进下单,从而导致一个用户购买到两张优惠券,这种就是一人一单中存在的并发安全问题

我们可以尝试使用乐观锁进行判断,此时由于下单是向数据库中插入信息,并没有where语句进行判断即没有办法判断数据是否修改过,因此不可以使用乐观锁进行判断,因此我们通过悲观锁实现一人一单,将查询-判断-下单加锁。将其封装为一个方法createVoucherOrder,并通过synchronized进行修饰,保证这个方法在运行时,同一时刻只能有一个方法进入临界区,这样如果有一个线程正在访问该方法时,如果其他线程也要访问这个方法就会阻塞。

但是注意我们是要求每个用户只能下一单,如果synchronized直接修饰方法后,其他用户下单时,如果此时有其余用户正在下单,当前下单用户会阻塞,这显然不是正确的处理方法,因此synchronized修饰方法中的代码块,并以用户id为参数synchronized(userId.toString().intern())修饰查询订单、判断订单是否存在以及扣减库存。

由于createVoucherOrder是一个被Transactional修饰的事务方法,如果synchronized修饰方法案中的代码块,在事务提交之前锁释放,此时其他线程可以获取锁仍然能够重复下单,因此使用synchronized修饰调用的createVoucherOrder方法。同时事务如果想生效需要拿到代理对象,因此我们需要通过AopContext.currentProxy()获取代理对象。

集群模式下一人一单安全问题

在集群模式下,通过synchronized加锁方法仍会出现并发安全问题。因为此时存在多个JVM,每个锁可以监视JVM内部的锁监视器,但是无法监视其他JVM,因此需要实现一种跨JVM(跨进程)的JVM锁。

分布式锁

既然synchronized只能在JVM内部进行加锁并监视,因此我们需要实现一种锁,这种锁能够跨JVM进行加锁监视,这种锁我们称为分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

分布式锁需要具有以下特性:多进程可见、互斥、高可用、高性能、安全性

分布式锁实现方案

分布式锁的核心是实现多进程之间互斥,而满足这一点方式有很多,常见的方式有三种:

基于Redis实现分布式锁

在实现分布式锁时,需要实现两个基本的方法:获取锁释放锁

获取锁:要求方法互斥,去报只有一个进程获取锁,因此可以利用redis的setnx的互斥特性,并添加过期时间,从而防止因服务器宕机引起的死锁问题。

释放锁:1. 手动通过命令释放 del key 2. 超时自动释放。

基于Redis实现分布锁初级版本

我们定义一个类,并实现两个接口(tryLock、unlock),从而利用Redis实现分布式锁。

之后在用户下单之前调用tryLock,如果获取锁成功再进行下单,如果获取锁失败,即Redis中已存在当前用户执行当前业务锁,则无法下单。

但是在一些极端情况下,这种方式仍然会存在问题。如果当前有三个线程,线程1首先获取锁成功,但若之后线程1发生阻塞并超出锁自动释放时间,此时锁会自动释放,之后线程2尝试获取锁成功并执行业务,如果此时线程1恢复正常执行并在业务完成后执行释放锁命令,此时会将线程2的锁释放,如果此时线程3尝试获取锁会成功加锁,此时发生线程安全问题。

因此我们在释放锁之前要判断当前锁是否为自己的锁,由于锁中存储的是加锁线程ID,因此需判断锁标识与当前线程ID是否相同。

改进Redis分布式锁(通过锁标识判断能否释放锁)

想要通过锁标识判断能否释放锁,就需要将当前线程ID存入到Redeis的锁中,由于不同JVM的线程可能冲突,因此我们通过UUID+线程ID拼接的方式作为锁标识,其中UUID用于跨进程,线程ID用于区分同一个JVM中的线程。

改进Redis分布式锁(判断锁成功但在释放锁之前阻塞)

还存在一种极端情况,线程1获取锁成功并执行业务,业务执行完成后判断锁标识,此时会判断通过并释放锁,如果在判断通过之后,释放锁之前恰好发生阻塞,此时线程1不会继续执行,而若在线程1恢复正常之前,Redis中的锁超时自动释放,此时其他线程可以正常获取锁,若线程2此时获取锁成功并执行业务,此时线程1苏醒并释放锁,会将Redis中属于线程2的锁释放掉,此时若有其他线程来获取锁,会发生并发安全问题。

修改:将判断锁和释放锁做个一种原子操作

Redis中虽然提供了事务操作,但其会将所有操作放在一起处理,不会获取中间结果,因此不适用于当前业务。我们可以通过Lua脚本,将多个Redis命令写入Lua脚本,然后Redis调用这个Lua脚本从而确保了原子性

我们在Lua脚本中执行:获取锁标识、判断锁标识、如果一致释放锁并返回、返回0

其中KEYS和ARGV是传递的参数组,分别存储的锁key和当前线程标识。

-- 获取锁的线程标识
local id = redis.call('get',KEYS[1])
-- 标胶线程标识和锁中标识是否一样
if(id == ARGV[1]) then
    -- 释放锁
    return redis.call('del',KEYS[1])
end
return 0
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static{
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
}

public void unlock() {
        // 调用lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(key_prefix + name),
                ID_PREFIX + Thread.currentThread().getId());
}

基于Redis的分布式锁实现思路:

优化Redis分布式锁

上述实现的分布式锁存在问题:不可重入、不可重试、超市释放、主从一致性问题

利用Redisson实现上述功能。

Redisson基本用法

1. 引入Redisson依赖并配置Redisson客户端

2. 使用Redisson锁

Redisson可重入锁原理:在保存锁时为一个map结构,其中以thread作为field,计数器作为value标识相同线程的重入次数。

Redisson的可重试机制的原理的精妙之处在于利用了消息订阅和信号量机制,只有等有释放锁发生时才重试,这样防止cpu浪费。

Redis分布式锁的主从不一致问题,可以通过将所有节点设置为主节点解决,每次向所有节点发送,只有全部通过才可能执行。

秒杀流程优化(阻塞队列登场)

至此已经实现较为完善的秒杀下单业务,但是整个业务逻辑还有可以进行优化的空间。当前业务逻辑为用户下单后,查询优惠券、判断时间、判断库存、查询订单、校验一人一单、减库存和创建订单,多个操作串行执行,用户多时响应时间长,用户体验不好,因此可以将判断秒杀库存和校验一人一单操作(耗时短且足够足够判断用户能否下单)放入redis中由一个线程执行,执行完后保存优惠券id、用户id和订单id到阻塞队列,返回给用户订单id,其余操作由另一个线程异步读取队列中的信息执行,两个线程并行执行。

既然基于Redis进行商品下达,就要把库存信息和订单信息放入Redis中,库存信息设置库存量即可,而订单信息中以商品ID为key,value记录为用户ID,由于value不可以i重复,因此可以采用set数据结构。

注意:Lua脚本的扣减库存是从Redis中预减库存,不会修改数据库中的库存信息,数据库库存信息修改由另一个线程异步执行。同时要求我们在新增秒杀券的同时,将优惠券信息放入Redis中。

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

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

-- 3.业务脚本
-- 3.1 判断库存是否充足
if(tonumber(redis.call('get',stockKey)) <= 0) then
    -- 库存不足,返回1
    return 1
end
-- 3.2 判断当前用户是否下单
if(redis.call('sismember',orderKey,userId) == 1) then
    -- 用户重复下单
    return 2
end

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

-- 3.5 发送消息到队列中 XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)

return 0

基于阻塞队列优化秒杀下单时,由于受到JVM内存限制,当队列存满时将不能继续存储,并且如果当前服务宕机后,队列中的订单信息全部丢失,因此存在数据安全问题

基于Redis消息队列实习异步秒杀

乍一看消息队列似乎和阻塞队列功能相同,但是消息队列并不基于JVM内存,因此存储空间远大于阻塞队列,而且Redis中会对数据进行持久化,数据安全性远大于阻塞队列,并且消息队列具有消息确认机制,只有当消息被消费一次之后才可以出队,因此确保每个消息至少执行一次。

Redis中提供了三种不同的方式来实现消息队列:List、PubSub、Stream

基于List的消息队列存在消息丢失和只支持单消费者的缺点,因为消息一旦出队,消息将直接消息。

因为PubSub在Redis中并不用于存储数据,仅仅作为一个消息队列,因此不具有数据持久化功能,数据不会保存在内存中。由于消息都保存在了用户客户端,而且用户客户端存储消息有上限,一旦超出则消息丢失。而且如果向消费者发送消息时,如果消费者关闭,此时发送消息丢失

漏读解决方案:消费者组-将多个消费者划分到一个组中,监听同一个队列,具有下列特点:

因为消费者组读消息时如果消费者不存在会自动创建消费者,因此一般无需手动添加消费者。

即正常情况下设置ID为>读取下一个未消费的消息,如果发生异常,则设置ID为其他即可读取pending-list中未确认的消息。

案例

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

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

-- 3.业务脚本
-- 3.1 判断库存是否充足
if(tonumber(redis.call('get',stockKey)) <= 0) then
    -- 库存不足,返回1
    return 1
end
-- 3.2 判断当前用户是否下单
if(redis.call('sismember',orderKey,userId) == 1) then
    -- 用户重复下单
    return 2
end

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

-- 3.5 发送消息到队列中 XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)

return 0
// 创建线程池和线程任务(内部类实现)
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    @PostConstruct // 项目初始化后线程下单马上执行
    private void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    // 代理对象
    private IVoucherOrderService proxy;

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

        private void handlePendingList() {
            while(true){
                try {
                    // 1. 获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 STREAMS stream.orders 0
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1),
                            StreamOffset.create(queueName, ReadOffset.from("0"))
                    );
                    // 2. 判断消息是否获取成果
                    if(list == null || list.isEmpty()){
                        // 2.1 获取失败,说明pending-list没有消息,跳出循环
                        break;
                    }
                    // 3. 获取成功,解析消息中的订单信息
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> values = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                    // 4. 进行下单
                    handlerVoucherOrder(voucherOrder);
                    // 4. ACK确认 SACK stream.orders g1 id
                    stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
                } catch (Exception e) {
                    log.error("处理pending-list异常",e);
                    try {
                        Thread.sleep(20);
                    } catch (InterruptedException ex) {
                        ex.printStackTrace();
                    }
//                    handlePendingList();
                }
            }
        }
    }

private void handlerVoucherOrder(VoucherOrder voucherOrder) {
        // 获取用户,应该开的新线程,不可以从ThreadLocal中获取
        Long userId = voucherOrder.getUserId();
        // 创建锁对象
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        // 获取锁
        boolean isLock = lock.tryLock();
        if(!isLock){
            // 获取锁失败
            log.error("不允许重复下单");
            return;
        }
        try {
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            lock.unlock();
        }
    }

@Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder) {
        // 一人一单
        Long userId = voucherOrder.getUserId();
        // 1. 查询订单
        int count = query().eq("user_id",userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
        // 判断是否存在
        if(count > 0){
            // 已经购买过
            log.error("仅可购买一次");
            return;
//            return Result.fail("仅可购买一次!");
        }

        // 充足 扣减库存
        boolean success = seckillVoucherService.update().setSql("stock = stock - 1")  // set
                .eq("voucher_id", voucherOrder.getVoucherId()).gt("stock",0) // where
                .update();

        if(!success){
            log.error("库存不足");
            return;
//            return Result.fail("库存不足!");
        }

        // 创建订单
        save(voucherOrder);
    }

  • 13
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值