三、实战篇 优惠券秒杀

源码仓库地址:git@gitee.com:chuangchuang-liu/hm-dingping.git

1、全局唯一ID

数据库默认自增的存在的问题:

  • id增长规律明显
  • 受单表数据量的限制

场景一分析:id如果增长规律归于明显,容易被用户或者商业对手猜测出一些敏感信息,比如早上出的第一个单子的id是1,晚上再查看出的单子的id是1001,那别人就很容易猜测出你这一天的销售情况。
场景二分析:Mysql数据库的由于查询性能的考虑,单表数据量不建议超过500W。数据量更大时,需要进行分库分表,但从逻辑上来讲这两张表是同一个表,需要保证id的唯一性,增添了一定的维护成本。

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

特性含义
唯一性需要保证id全局唯一
高可用生成器需要保证所有线程来调用时都能提供服务来生成id
高性能很多业务要求执行时间不能过长如缓存重建,所以对生成器生成id的性能有一定要求
递增性为了不重复、便于管理和查询以及提高系统的性能和效率
安全性生成的id不易被猜测或篡改

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

符号位:永远为0
时间戳:31bit,以秒为单位,可以使用69年
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

  • 编码实现

全局唯一id生成器

@Component
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    /**
     * 序列号的位数
     */
    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.生成序列号
        // 2.1.获取当前日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        // 2.2.自增长
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

        // 3.拼接并返回
        return timestamp << COUNT_BITS | count;
    }
}

单元测试

@SpringBootTest
class HmDianPingApplicationTests {
    @Autowired
    private RedisIdWorker redisIdWorker;

    private ExecutorService es;
    /**
     * 初始化线程池
     */
    @BeforeEach
    void setUp() {
        es = Executors.newFixedThreadPool(10);
    }

    @Test
    void testIdWorker() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(300);

        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                long id = redisIdWorker.nextId("order");
                System.out.println("id = " + id);
            }
            latch.countDown();
        };
        long begin = System.currentTimeMillis();
        for (int i = 0; i < 300; i++) {
            es.submit(task);
        }
        latch.await();
        long end = System.currentTimeMillis();
        System.out.println("time = " + (end - begin));
    }
}

2、实现优惠券秒杀下单

秒杀需要思考的点:

  • 秒杀活动是否开始或结束
  • 库存是否足够

思路分析:
image.png

  • 编码实现
/**
 * 下单购买秒杀券
 * @param voucherId 秒杀券id
 * @return 订单id
 */
@Override
@Transactional
public Result order(Long voucherId) {
    // 查询优惠券信息
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    if (ObjectUtil.isEmpty(voucher)) {
        return Result.fail("id为" + 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("秒杀券已售罄");
    }
    // 扣减库存
    boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
    if (!success) {
        return Result.fail("秒杀券已售罄");
    }
    // 生成订单
    VoucherOrder order = new VoucherOrder();
    long orderID = redisIdWorker.nextId("order");
    order.setId(orderID);
    order.setVoucherId(voucherId);
    order.setUserId(UserHolder.getUser().getId());
    save(order);
    return Result.ok(orderID);
}

3、超卖问题

超卖问题原因分析:假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁,而对于加锁通常有两种方案:

方案名称含义例子
悲观锁认为线程安全问题一定会发生,因此在操作数据前,添加互斥锁,保证操作是串行的Synchronized、Lock
乐观锁认为线程安全问题不一定会发生,因此就不添加锁,只有在做更新操作时,判断其他线程是否做过更新。如果没有改过,才去做更新CAS

什么是CAS?
CAS(Compare And Swap)是一种用于管理并发数据访问的无锁算法。CAS操作包含三个参数:内存位置(V)、预期原值(A)和新值(B)。执行CAS操作的基本步骤是:如果V的值和预期原值A相同,那么就用新值B替换V的值;如果V的值和预期原值A不相同,就不做任何操作。

乐观锁的两种常见方式:

  • 版本号法(version)
  • 利用业务数据本身来充当版本号

1653369268550.png

  • 编码实现解决超卖问题

一般是CAS+自旋组合来解决超卖问题:如果CAS失败,但只要库存仍大于0,就允许其继续尝试购买秒杀券
但这里可以简化为只要当前库存量大于0,就允许其继续尝试购买秒杀券

// 扣减库存,添加乐观锁
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherId)
        // 这种方式反而会增加下单的失败率
//                .eq("stock", voucher.getStock())
        // 只要库存还大于0,就认为下单成功
        .gt("stock", 0)
        .update();

4、一人一单

需求: 要求一个用户只能购买一张秒杀券
目前存在的问题: 一个用户可以无限制次数的抢优惠券。应当加一层判断逻辑,当用户成功下完单后,不允许其再次抢优惠券。
判断逻辑: 查询该用户和优惠券在订单表里是否已经存在,如果存在,说明其之前已经抢过优惠券了

  • 编码实现
// 保证一人一单
Integer count = query().eq("voucher_id", voucherId)
        .eq("user_id", userId).count();
if (count > 0) {
    return Result.fail("id为:" + userId + "的用户已经购买过该秒杀券");
}

存在问题: 在并发情况下仍然会出现一人多单的问题
分析: 该问题和超卖的问题是一致的,因为在查询时,多个线程都进来查询,发现该用户没有下过单,因此都做创建订单操作。
解决方案: 和之前一样,通过添加锁来实现。乐观锁比较适合更新数据,而这里是插入数据操作,适合悲观锁。
注:添加悲观锁这里,存在诸多问题,一个个来分析:

  • 锁的存放位置:在方法上添加同步锁。这种方式下,锁的粒度太粗了,导致每一个线程进来都会被锁住,性能太差
@Transactional
/*
    将锁放在方法体上,那么这个方法就是一个同步方法,只有一个线程能够进入,会导致性能问题
 */
public synchronized Result oneUserAndOrder(Long voucherId) {
    Long userId = UserHolder.getUser().getId();
    // 保证一人一单
    Integer count = query().eq("voucher_id", voucherId)
            .eq("user_id", userId).count();
    if (count > 0) {
        return Result.fail("id为:" + userId + "的用户已经购买过该秒杀券");
    }

    // 扣减库存,添加乐观锁
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId)
            // 这种方式反而会增加下单的失败率
//                .eq("stock", voucher.getStock())
            // 只要我库存还大于0,就允许用户继续下单
            .gt("stock", 0)
            .update();
    if (!success) {
        return Result.fail("秒杀券已售罄");
    }
    // 生成订单
    VoucherOrder order = new VoucherOrder();
    long orderID = redisIdWorker.nextId("order");
    order.setId(orderID);
    order.setVoucherId(voucherId);
    order.setUserId(UserHolder.getUser().getId());
    save(order);
    return Result.ok(orderID);
}
  • 锁的存放位置:在方法体内添加同步锁,且以用户id进行加锁,这样锁的粒度更细,同一个用户线程进来后会去争锁资源,而不会导致所有线程都被锁住。

存在的问题: 在方法体内添加同步代码块,代码块执行完毕后立即释放锁,但事务又是由Spring管理的,此时事务还未提交。其他线程进来后,查询用户未下单,执行创建订单操作。因此这种方式仍会出现多线程并发问题。
解决方案: 事务提交后再释放锁

  • 编码实现
<!--引入aspectJ-->
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>
@Override
public Result order(Long voucherId) {
    // 查询优惠券信息
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    if (ObjectUtil.isEmpty(voucher)) {
        return Result.fail("id为" + 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();
    // 3、事务提交后,锁会被释放
    synchronized (userId.toString().intern()) {
        // 获取代理对象,代理对象才具备事务功能
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.oneUserAndOrder(voucherId);
    }
}
@Transactional
public Result oneUserAndOrder(Long voucherId) {
    Long userId = UserHolder.getUser().getId();
    // 保证一人一单
    Integer count = query().eq("voucher_id", voucherId)
            .eq("user_id", userId).count();
    if (count > 0) {
        return Result.fail("id为:" + userId + "的用户已经购买过该秒杀券");
    }

    // 扣减库存,添加乐观锁
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherId)
            // 这种方式反而会增加下单的失败率
//                .eq("stock", voucher.getStock())
            // 只要我库存还大于0,就允许用户继续下单
            .gt("stock", 0)
            .update();
    if (!success) {
        return Result.fail("秒杀券已售罄");
    }
    // 生成订单
    VoucherOrder order = new VoucherOrder();
    long orderID = redisIdWorker.nextId("order");
    order.setId(orderID);
    order.setVoucherId(voucherId);
    order.setUserId(UserHolder.getUser().getId());
    save(order);
    Result.ok(orderID);
}
@MapperScan("com.hmdp.mapper")
// 启动aop,否则service获取不到aop代理类
@EnableAspectJAutoProxy(exposeProxy = true)
@SpringBootApplication
public class HmDianPingApplication {
    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }
}
  • JMeter进行并发安全测试

image.png
image.png
image.png
image.png

可以发现,至此已成功添加了一人一单的限制!

5、集群环境下的并发问题

通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。
1、我们将服务启动两份,端口分别为8081和8082:
image.png
2、修改nginx的配置文件

upstream backend {
    server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;
    server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;
}  

具体操作:
1、通过IDEA克隆一份应用配置
image.png
2、添加 vm options
image.png
image.png
3、重启nginx

nginx -s reload

集群环境下锁失效的原因分析:
现在部署了多套tomcat服务器,每个tomcat内部都有一个jvm,jvm内部多个线程间可以实现锁互斥,但jvm间的线程的锁并不互斥,从而导致互斥锁失效,出现一人多单的问题,这就是集群环境下syn锁失效的原因,在这种情况下就需要使用分布式锁来解决该问题!
1653374044740.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值