全局ID生成器

image.png
每个店铺都可以发布优惠券:当用户抢购时,就会生成订单并保存到tb_voucher_order(优惠券订单)这张表中,而订单表如果使用数据库自增ID就存在一些问题:

  1. id的规律性太明显
  2. 受单表数据量的限制

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
image.png

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
image.png
ID的组成部分:

  • 符号位:1bit,永远为0
  • 时间戳:31bit,以秒为单位,可以使用69年
  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
  1. @Component
  2. public class RedisIdWorkerTest {
  3. @Autowired
  4. private StringRedisTemplate redisTemplate;
  5. public static final Long BEGIN_TIMESTAMP = 1640995200L;
  6. private static final int COUNT_BITS = 32;
  7. public long nextId(String keyPrefix) {
  8. // 1. 31位时间戳
  9. Long timestamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) - BEGIN_TIMESTAMP;
  10. // 2. 32位自增序列
  11. String format = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
  12. Long value = redisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + format);
  13. // 3. 拼接
  14. return timestamp << COUNT_BITS | value;
  15. }
  16. }

测试

@SpringBootTest
class HmDianPingApplicationTests {

    @Autowired
    RedisIdWorkerTest redisIdWorker;

    @Test
    public void idWork() throws InterruptedException {
        ExecutorService pool = Executors.newFixedThreadPool(300);
        CountDownLatch latch = new CountDownLatch(300);
        long begin = System.currentTimeMillis();
        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                System.out.println(redisIdWorker.nextId("id"));
            }
            latch.countDown();
        };
        for (int i = 0; i < 300; i++) {
            pool.submit(task);
        }
        latch.await();
        System.out.println(System.currentTimeMillis() - begin);
    }
}

全局唯一ID生成策略:

  • UUID
  • Redis自增
  • snowflake算法
  • 数据库自增

Redis自增ID策略:

  • 每天一个key,方便统计订单量
  • ID构造是 时间戳 + 计数器

实现优惠券秒杀下单

下单时需要判断两点:

  1. 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  2. 库存是否充足,不足则无法下单

image.png

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

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    @Transactional
    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);
        //7.返回订单id
        return Result.ok(orderId);
    }
}

超卖问题

情景:通过JMeter做高并发测试,100张优惠券卖出去变成负数,生成了两百来个订单
解决方案:
image.png

乐观锁:

乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:

  1. 版本号法:通过增加版本号字段,对比版本号是否是查询到的版本号,是才做修改

image.png

  1. cas法:通过比较数据是否是原来查到的数据,是才做修改

image.png

// 5.扣减库存
boolean success = update().setSql("stock = stock -1").eq("voucher_id", voucherId).eq("stock", seckillVoucher.getStock()).update();

该cas没有自旋的操作,所以失败率比较高,这个在业务满足的基础上做个优化

// 5.扣减库存
boolean success = update().setSql("stock = stock -1").eq("voucher_id", voucherId).gt("stock", 0).update();

一人一单

需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单
image.png

// 用户id
Long userId = UserHolder.getUser().getId();
// 查询订单
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
    return Result.fail("用过已经购买过一次了");
}

查询订单和下订单操作非原子性,存在线程安全问题,需要加锁,由于是新增的数据,无法使用乐观锁

@Override
@Transactional
public synchronized Result seckillVoucher(Long voucherId) {
//...
}

一开始想到的肯定是给整个方法加锁,但是这样加锁粒度太大,几乎串行执行,效率低,通过业务分析,给用户纬度加锁是最合适的

@Transactional
public Result createVoucherOrder(Long voucherId) {
    Long userId = UserHolder.getUser().getId();

    //一人一单
    synchronized (userId.toString().intern()) {

        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            //用户已经购买过
            return Result.fail("用户已经购买过!");
        }
        //5.扣减库存
        boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId).gt("stock", 0)
            .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
        voucherOrder.setUserId(userId);
        //6.3代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

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

由于userId.toString()每次都是返回的新对象,而我们只需要比较值,所以将该对象放到常量池中
这里还有一个问题,由于事务是方法执行前后由spring代理对象处理的,所以这里会存在方法执行完了,锁释放了,但是事务没提交的情况,所以,这里需要把锁的范围放大

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

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    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("库存不足!");
        }

        //7.返回订单id
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            // 获取代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
    }

    @Transactional
    @Override
    public Result createVoucherOrder(Long voucherId) {
        Long userId = UserHolder.getUser().getId();

        //一人一单
        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            //用户已经购买过
            return Result.fail("用户已经购买过!");
        }
        //5.扣减库存
        boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId).gt("stock", 0)
            .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
        voucherOrder.setUserId(userId);
        //6.3代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

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

}

这里由于调用本类的方法是使用的this当前对象,所以需要获取spring的代理对象才能保证事务的正常执行。

一人一单的并发安全问题

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

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

image.png

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

image.png

现在,同一个用户请求会在这两个节点上负载均衡,再次测试下是否存在线程安全问题。
image.png
集群模式下,有多个JVM存在,每个JVM内部都有自己的锁,导致每个锁都可以有一个线程获取,变成并行运行。