「黑马点评」三、优惠券秒杀

29 天前
1

「黑马点评」三、优惠券秒杀

基于 Redis 的全局唯一 ID 生成器

分布式系统下用来生成唯一性 ID 的工具:唯一性、高可用、高性能、递增性、安全性

image.png|500

image.png|500

示例

@Component
public class RedisIdWorker {

    private static final long BEGIN_TIMESTAMP = 1735689600L;

    private static final int COUNT_BITS = 32;

    private StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public long nextId(String keyPrefix) {
        // 1. 生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;

        // 2. 生成序列号
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
        return timestamp << COUNT_BITS | count;
    }
}

测试生成速度,利用线程池,提交任务,用 CountDownCountDownLatch 计数线程,以 await 到真正结束时间

image.png|500

image.png|500

实现优惠券秒杀下单

业务背景

平价券:随意抢,不限制 特价券:优惠大,有限制

image.png|500

image.png|500

秒杀券下单流程

image.png|500

image.png|500
@Override
public Result<Long> seckillVoucher(Long voucherId) {
    // 1. 查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2. 判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
        return Result.error("秒杀尚未开始");
    }
    // 3. 判断秒杀是否结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
        return Result.error("秒杀已结束");
    }
    // 4. 判断库存是否充足
    if (voucher.getStock() <= 0) {
        return Result.error("库存不足");
    }
    // 5. 扣减库存
    boolean isSuccess = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        .update();
    if (!isSuccess) {
        return Result.error("库存不足");
    }
    // 6. 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setId(redisIdWorker.nextId("order"));
    voucherOrder.setUserId(UserHolder.getUser().getId());
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setPayType(1); // 设置支付方式,1:余额支付
    voucherOrder.setStatus(1);  // 设置订单状态,1:未支付
    voucherOrderMapper.insert(voucherOrder);
    return Result.success(voucherOrder.getId());
}

超卖分析

问题:同时查到了符合数量的库存数并同时扣减库存,出现负库存,实际上就是线程安全问题

image.png|500

image.png|500

加锁解决

悲观锁:线程安全问题必定发生,只要操作数据就加锁 乐观锁:线程安全问题不一定发生,只在操作数据时判断数据有没有被其他线程修改

image.png|500

image.png|500

乐观锁如何判断数据有没有被操作过

版本号方案:数据只要被修改,就修改版本号,确保修改时的版本号与查询时的版本号一致

image.png|500

image.png|500

CAS 方案:简化版本号,在本案例中用 stock 本身有没有变化替代版本号

image.png|500

image.png|500

示例:CAS 方案

// 5. 扣减库存
boolean isSuccess = seckillVoucherService.update()
    .setSql("stock = stock - 1")
    .eq("voucher_id", voucherId).eq("stock", voucher.getStock())
    .update();
if (!isSuccess) {
    return Result.error("库存不足");
}

问题:成功率太低,不应每次都判断 stock 与查询得到的一致

改进:只需要修改时的 stock 大于 0 即可

// 5. 扣减库存
boolean isSuccess = seckillVoucherService.update()
    .setSql("stock = stock - 1")
    .eq("voucher_id", voucherId)
    .gt("stock", 0)  // 使用大于0的条件替代等于当前库存的条件
    .update();
if (!isSuccess) {
    return Result.error("库存不足");
}

一人一单

背景:同一个用户一种优惠券只能下一单

image.png|500

image.png|500

示例

 // 一人一单
Long userId = UserHolder.getUser().getId();
long count = voucherOrderMapper.selectCount(userId, voucherId);
if (count > 0) {
    return Result.error("您已经购买过该优惠券");
}

结果:出现了线程安全问题,同一个用户的大量请求同时经过了一人一单的检验

解决:要加锁,但是没有数据,所以需要加悲观锁,从一人一单的检验开始到创建订单加上悲观锁

将一人一单、扣减库存、创建订单提取成一个逻辑函数,并为方法加上 synchronized,不妥,因为一人一单是针对同一个用户的,并非所有用用户,如果给方法上锁,这就直接退化为串行

所以应该为用户 id 上锁,userId.toString().intern(),如果只是 toString 那么返回的都是新创建出的 String 对象,所以使用 .intern 返回字符串对象池中的对象

那为什么锁不加在抽离出的 createVoucherOrder 方法内呢,因为这个方法带了 @Transactional,如果锁加在方法内部,就是先释放锁后提交事务,锁的范围太小还是会出现线程安全问题

所以把锁改到了调用 createVoucherOrder 的地方,先提交事务再释放用户锁

那又为什么调用的是从 AopContext 获取到的 proxy 的 createVoucherOrder 方法呢,是因为 @Transactional 是由 Spring 管理的,不通过代理对象的话,事务管理会失效,所以要从 AopContext 中获取到代理对象,才能让事务生效

同时要代理对象暴露出来,需要再 Application 中添加注解 @EnableAspectJAutoProxy(exposeProxy = true),并添加依赖 aspectjweaver

@Override
public Result<Long> seckillVoucher(Long voucherId) {
    // 1. 查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2. 判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
        return Result.error("秒杀尚未开始");
    }
    // 3. 判断秒杀是否结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
        return Result.error("秒杀已结束");
    }
    // 4. 判断库存是否充足
    if (voucher.getStock() <= 0) {
        return Result.error("库存不足");
    }

    Long userId = UserHolder.getUser().getId();
    synchronized (userId.toString().intern()) {
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(voucherId);
    }
}

@Transactional
public Result<Long> createVoucherOrder(Long voucherId) {
    Long userId = UserHolder.getUser().getId();
    long count = voucherOrderMapper.selectCount(userId, voucherId);
    if (count > 0) {
        return Result.error("您已经购买过该优惠券");
    }
    // 扣减库存
    boolean isSuccess = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        .gt("stock", 0)  // 使用大于0的条件替代等于当前库存的条件
        .update();
    if (!isSuccess) {
        return Result.error("库存不足");
    }
    // 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    voucherOrder.setId(redisIdWorker.nextId("order"));
    voucherOrder.setUserId(userId);
    voucherOrder.setVoucherId(voucherId);
    voucherOrder.setPayType(1); // 设置支付方式,1:余额支付
    voucherOrder.setStatus(1);  // 设置订单状态,1:未支付
    voucherOrderMapper.insert(voucherOrder);
    return Result.success(voucherOrder.getId());
}

集群下的一人一单

前提:先在 services 中新建一个应用

image.png|500

image.png|500

重新运行,并修改 nginx 配置

image.png|500

image.png|500

但是还是有问题,集群中的每个 JVM 只维护他内部的锁,原先的写法无法管理集群情况下的锁

image.png|500

image.png|500

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...