秒杀业务分析--redis锁实现

目录

1. 全局唯一ID

1.1 特性和结构

 1.2 redis实现全局唯一id

2. 秒杀业务

2.1 核心业务分析

 2.2 代码实现

2.3 超卖问题

2.4 乐观锁解决超卖问题

2.5  实现一人一单

2.5.1 逻辑分析

2.5.2 判断订单是否存在代码实现

2.5.3 问题分析

2.5.4 最终代码


1. 全局唯一ID

1.1 特性和结构

        秒杀业务往往会带来大量的数据,而mysql单表的容量不宜超过500w,数据量过大就要进行拆分表,但逻辑上他们是属于统一张表,因此id不能有冲突,需要保证id唯一性,引入全局唯一id。

全局id特性:

  1. 唯一性
  2. 高性能
  3. 安全性
  4. 递增性
  5. 高可用性

结构包括:1位符号位 永远为0,31为时间戳,32位序列号(秒内计数器,每秒可以产生2^32个不同的id)。

 1.2 redis实现全局唯一id

public class RedisIdWorker {
    //开始时间戳
    private static final Long BEGIN_TIMESTAMP = 1704067200L;
    //序列号位数
    private static final int COUNT_BITS = 32;
    private final StringRedisTemplate stringRedisTemplate;
    public RedisIdWorker (StringRedisTemplate stringRedisTemplate){
        this.stringRedisTemplate = stringRedisTemplate;
    }
    public Long getRedisId(String keyPrefix){
        //获取当前时间戳
        LocalDateTime now = LocalDateTime.now();
        long second = now.toEpochSecond(ZoneOffset.UTC);
        //当前时间戳减去开始时间戳
        long timestamp = second - BEGIN_TIMESTAMP;
        //记录当天生成的id个数,作为序列号
        String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        Long count = stringRedisTemplate.opsForValue().increment("inc:" + keyPrefix + ":" + data);
        if (count == null){
            return timestamp << COUNT_BITS;
        }
        return timestamp << COUNT_BITS | count;

    }
}

2. 秒杀业务

2.1 核心业务分析

        秒杀业务的核心其实就是购买逻辑,总体来说就是用户发出订单,服务端修改库存,但秒杀业务一般都会有时限,因此还需要判断秒杀活动是否已开启,库存是否充足。流程图如下

 2.2 代码实现

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

    return Result.ok(orderId);

}

2.3 超卖问题

        在进行扣减库存前,虽然进行库存数量的判断,但由于未考虑到并发问题导致的都现成安全问题,可能会出现超卖现象。

        例如,库存恰好只剩下一个,但此时来了多个并发请求订单,这几个请求先后到达,第一个到达的请求判断,库存为1,通过,进行后续处理。在第一个请求还未提交事务之前,其他请求仍然可以通过库存判断。但真实情况是库存只能满足一个请求,这就出现了问题。

         这种由多线程并发引起的线程安全问题,解决方案往往就是进行加锁处理,常见的锁有悲观锁和乐观锁。

乐观锁:存在一个版本号,每次处理数据对应的版本号+1,再次提交数据时会进行校验版本号是否与之前的版本号大1,是,则进行操作成功,不是则证明数据被修改过,数据无法提交。乐观锁认为线程安全问题不一定会发生,因此不加锁,只是去判断其他线程是否对数据进行了修改。

悲观锁:悲观锁可以实现数据的串行化执行,比如syn和lock。悲观锁认为线程安全问题一定会发生,因此在进行数据操作之前要申请锁,确保线程串行执行。

2.4 乐观锁解决超卖问题

乐观锁的关键是判断之前查询得到的数据是否被修改过。

方案一:直接判断处理之前查询得到的数据是否和处理时得到的数据相等。

修改库存方案修改:直接在sql查找的时候进行判断。

boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1") //set stock = stock -1
            .eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update(); //where id = ? and stock = ?

 这种方案有明显的缺陷,就是支持单线程,因为在多线程环境下,基本上只有一个线程可以通过。因为同时得到相同的库存后,但一个线程提交数据后,其他线程就查询不到数据。

方案二:查询时添加库存大于零的判断条件。

boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0

   这种方案就可以有效解决上述问题。只查询库存大于零的,在多线程环境下,每次提交后更新库存,当库存降为零时,此时其他线程再想进行数据更新时就查不到这个数据,操作就会失败。      

2.5  实现一人一单

        需求:在秒杀活动中,一个人只能下一个订单。

2.5.1 逻辑分析

先判断时间是否充足,时间充足,判断库存是否足够,最后判断当前用户是否已经下单。

2.5.2 判断订单是否存在代码实现

        直接根据用户ID和商品ID查询数据库中订单数据的订单数量(count),如果count>0(订单存在),直接返回 。否则,执行扣减库存和创建订单的业务。

    // 一人一单逻辑
    // 用户id
    Long userId = UserHolder.getUser().getId();
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    // 判断是否存在
    if (count > 0) {
        // 用户已经购买过了
        return Result.fail("用户已经购买过一次!");
    }

2.5.3 问题分析

        上述代码实现了一人一单的基本逻辑实现,但是还是存在很多问题,和超卖问题一样,在并发环境下,查询数据库都不存在订单。因此还是需要加锁。插入数据一般使用悲观锁,因为乐观锁无法进行判断(此时数据还未进行插入,根本就不存在乐观锁的version)。

        这里加锁是使用synchronized锁,考虑到锁的粒度问题,这里只对userId进行加锁,这样相同的id进入是才需要进行申请锁,不同id进入则不需要。同时,由于当前方法是在spring的事务中,如果在方法中加锁,可能会导致当前方法事务还没提交,锁就已经被释放。因此将存在需要事务处理部分的代码提取出来,作为一个新方法createVoucherOrder(),对这个方法进行以userId加锁处理。

2.5.4 最终代码

    public Result addSeckillVoucherOrder(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("库存不足");
        }
        //设置悲观锁,一人一订单。
        Long userId = UserHolder.getUser().getId();
        //释放锁需要再事务提交之后,因此环绕加锁方法。并且以用户id加锁,调用intern()方法确保获取的userid是同一个对象
        synchronized (userId.toString().intern()) {
            //设置代理,因为@Transactional事务需要动态代理才能实行,如果用this调用无法实现事务。
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
    }
@Transactional
public  Result createVoucherOrder(Long voucherId) {
	Long userId = UserHolder.getUser().getId();
    // 5.1.查询订单
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    // 5.2.判断是否存在
    if (count > 0) {
        // 用户已经购买过了
        return Result.fail("用户已经购买过一次!");
    }

    // 6.扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1") // set stock = stock - 1
            .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
            .update();
    if (!success) {
        // 扣减失败
        return Result.fail("库存不足!");
    }

    // 7.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 7.1.订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 7.2.用户id
    voucherOrder.setUserId(userId);
    // 7.3.代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);
    // 7.返回订单id
    return Result.ok(orderId);    
}

        上述代码已经解决了单体架构下的秒杀并发问题,但却不适用与分布式集群架构。因为分布式环境下,我们可能拥有多个服务器,也就是多个jvm,此时不同jvm下的线程是不能别锁住的,因此就需要分布式锁来解决这个问题。

基于reids实现分布式锁:http://t.csdnimg.cn/qc1mz

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值