目录

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)。

redis 级联锁 redis锁实现_redis

 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 核心业务分析

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

redis 级联锁 redis锁实现_缓存_02

 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,通过,进行后续处理。在第一个请求还未提交事务之前,其他请求仍然可以通过库存判断。但真实情况是库存只能满足一个请求,这就出现了问题。

redis 级联锁 redis锁实现_java_03

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

乐观锁:存在一个版本号,每次处理数据对应的版本号+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 逻辑分析

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

redis 级联锁 redis锁实现_数据库_04

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下的线程是不能别锁住的,因此就需要分布式锁来解决这个问题。