点评day03优惠卷秒杀-库存超卖,一人一单(单机模式)

复制代码
1.
全局唯一ID

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

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

场景分析:如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天时间内,卖出了多少单,这明显不合适。

场景分析二:随着我们商城规模越来越大,mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的id是不能一样的,但拆表的话id又得从一开始自增,于是乎我们需要保证id的唯一性。
复制代码
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性
复制代码
ID的组成部分:符号位:1bit,永远为0

时间戳:31bit,以秒为单位,可以使用69年

序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
java 复制代码
@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;
    }
}

首先long nowSecond = now.toEpochSecond(ZoneOffset.UTC);

toEpochSecond(ZoneOffset.UTC)是一个方法,能返回指定时区(ZoneOffset.UTC)目前到最开始时间搓的那个秒数,做差就可以生成时间搓了。

然后为什么这样涉及从天开始生成序列号,因为前面是秒,你同一秒可能有大量线程去申请这个id,所以就需要后面的序列号控制:

为什么要按 "天" 来划分?

这里用天(yyyy:MM:dd)来做 Redis Key 的一部分,主要有两个核心原因:

  • 避免序列号无限膨胀如果不按天划分,Redis 的 Key 会一直自增,理论上可以到 263−1,但:

    • 占用 Redis 内存,Key 会越来越大;
    • 一旦超过 long 类型上限(虽然 32 位序列号上限是 42 亿,单日很难打满),会出现溢出风险;
    • 按天重置,每天从 1 开始,既安全又节省内存。
  • 便于统计和排查

    • 按天生成 Key(如 icr:order:2026:02:18),可以方便地统计每天生成了多少 ID;
    • 出现问题时,也能快速定位是哪一天的 ID 序列出了问题

而那个自增长的话在redis中能够在分布式实现唯一:

原子性 :在分布式环境下,多个线程 / 服务同时调用 increment,Redis 会保证每次返回的 count 都是唯一且递增的,不会出现重复;

测试

private ExecutorService es = Executors.newFixedThreadPool(500);

  • ExecutorService:Java 并发包中用于管理线程池的核心接口,提供了提交任务、关闭线程池等能力。
  • Executors.newFixedThreadPool(500) :这是一个工厂方法,用于创建一个固定线程数 的线程池:
    • 线程池里始终有 500 个核心线程,不会被回收;
    • 当任务提交时,如果线程池里有空闲线程,就立即执行;
    • 如果所有线程都在忙,新任务会进入一个无界队列中等待。
    • 要使线程执行某个任务,使用es.submit(task)即可,task是一个线程任务,day02咱们有写过,只不过是直接写的函数式接口
复制代码
Countdownlatch名为信号枪:主要的作用是同步协调在多线程的等待于唤醒问题:CountDownLatch 中有两个最重要的方法

1、countDown

2、await

await 方法 是阻塞方法,我们担心分线程没有执行完时,main线程就先执行(就是这里要统计我们那id的耗时,所以希望那300个线程先执行完再执行那个统计时间的逻辑),所以使用await可以让main线程阻塞,那么什么时候main线程不再阻塞呢?当CountDownLatch内部维护的变量变为0时,就不再阻塞,直接放行,那么什么时候CountDownLatch 维护的变量变为0 呢,我们只需要调用一次countDown ,内部变量就减少1,我们让分线程和变量绑定(CountDownLatch latch = new CountDownLatch(300)就是在设置内部变量), 执行完一个分线程就减少一个变量,当分线程全部走完,CountDownLatch 维护的变量就是0,此时await就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。
java 复制代码
   private ExecutorService es = Executors.newFixedThreadPool(500);
    @Test
    void testIdWorker() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(300);

        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                long id = redisWorker.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.添加优惠卷

首先分析基本结构:

复制代码
tb_voucher:优惠券的基本信息,优惠金额、使用规则等
tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息

平价卷由于优惠力度并不是很大,所以是可以任意领取

而代金券由于优惠力度大,所以像第二种卷,就得限制数量,从表结构上也能看出,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段(即特价卷也是优惠卷,在tb_voucher表里也有)基本的初始化我就不写了,就是添加基础卷,添加秒杀卷并且保存秒杀卷信息到redis里。

3.实现秒杀下单

复制代码
下单时需要判断两点:
* 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
* 库存是否充足,不足则无法下单

下单核心逻辑分析:
当用户开始进行下单,我们应当去查询优惠卷信息,查询到优惠卷信息,判断是否满足秒杀条件

比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。这是最基本的框架。。
java 复制代码
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    /*
    * 秒杀券下单
    * */
    @Resource
    private VoucherOrderMapper voucherOrderMapper;
    @Resource
    private RedisWorker redisWorker;
    @Resource
    private SeckillVoucherMapper seckillVoucherMapper;
    @Transactional
    @Override
    public Result seckillVoucher(Long voucherId) {
        //1. 查询优惠券
        Voucher voucher = seckillVoucherMapper.getById(voucherId);
        //2.如果优惠券不存在,返回失败
        if (voucher == null){
            return Result.fail("优惠券不存在");
        }
        //3.存在,查看活动是否开始,查看活动是否结束
        LocalDateTime beginTime = voucher.getBeginTime();
        LocalDateTime endTime = voucher.getEndTime();
        if (beginTime.isAfter(LocalDateTime.now()) || endTime.isBefore(LocalDateTime.now())){
            return Result.fail("优惠券未开始");
        }
        //4.判断是否库存充足,不足返回
        Integer stock = voucher.getStock();
        if (stock <= 0){
            return Result.fail("库存不足");
        }
        //5.扣减库存
        int update = seckillVoucherMapper.update(voucherId,stock-1);
        //6.生成订单
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setId(redisWorker.nextId("order"));
        voucherOrder.setUserId(UserHolder.getUser().getId());
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setCreateTime(LocalDateTime.now());
        voucherOrderMapper.insert1(voucherOrder);
        //7.返回订单
        return Result.ok(voucherOrder.getId());
    }

4.库存超卖问题:

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


(1)悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等
(2)乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,
那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过

当然乐观锁还有一些变种的处理方式比如cas

(3)

课程中的使用方式是没有像cas一样带自旋的操作,也没有对version的版本号+1 ,他的操作逻辑是在操作时,对版本号进行+1 操作,然后要求version 如果是1 的情况下,才能操作,那么第一个线程在操作后,数据库中的version变成了2,但是他自己满足version=1 ,所以没有问题,此时线程2执行,线程2 最后也需要加上条件version =1 ,但是现在由于线程1已经操作过了,所以线程2,操作时就不满足version=1 的条件了,所以线程2无法执行成功

但其实可以把库存当作版本号,只要现在where stock = 原来的stock这样就可以修改,如果已经有人改过就不会成功!所以只需要在扣减库存时

java 复制代码
<update id="update">
        UPDATE tb_seckill_voucher
        SET stock = #{stock1}
        WHERE voucher_Id = #{voucherId};
    </update>

改变为:这里stock2是需要传入的,表示之前我们查到的库存

java 复制代码
    <update id="update">
        UPDATE tb_seckill_voucher
        SET stock = #{stock1}
        WHERE voucher_Id = #{voucherId} AND stock = #{stock2}
    </update>
复制代码
只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败,这很明显是不对的,应该在库存满足的前提下,尽可能多的成功。不然我抢票的时候我点的是应该能拿到的,但是因为有人和我一块,导致我失败了,然而比我靠后的还能强到,这不对,哈哈哈,其实改动很简单,只要判断库存大于0即可。我们让只要有库存就可以拿到。
java 复制代码
    <update id="update">
        UPDATE tb_seckill_voucher
        SET stock = #{stock1}
        WHERE voucher_Id = #{voucherId} AND stock > 0
    </update>

这样就完成了超卖现象的处理。但是还有一个问题,就是一人多单的问题,既然是秒杀卷,是为了推广店面,自然不可能只让一个人抢完,这里我们完成一人一单的功能,可以在这个基础上完成一人限买两单或者几单。

5.一人一单

基础代码就是加上一个判断,去判断一下这个人是不是买了一单。

加上这个代码即可

java 复制代码
        //一人一单
        Long userId = UserHolder.getUser().getId();
        Long count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0){
            return Result.fail("一人一单");
        }

同样这仅仅是一个基础代码,这样写是存在问题的,同样也是多个线程进来,一个线程判断当前用户没下单,然后就去下单,修改数据库,但是中间又有一个线程进来,由于第一个线程还没改完于是又下单。所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作。我们使用synchronized修饰一个方法,这样就达到了悲观锁的效果

改进一

java 复制代码
@Override
    public Result seckillVoucher(Long voucherId) {
        //1. 查询优惠券
        Voucher voucher = seckillVoucherMapper.getById(voucherId);
        //2.如果优惠券不存在,返回失败
        if (voucher == null){
            return Result.fail("优惠券不存在");
        }
        //3.存在,查看活动是否开始,查看活动是否结束
        LocalDateTime beginTime = voucher.getBeginTime();
        LocalDateTime endTime = voucher.getEndTime();
        if (beginTime.isAfter(LocalDateTime.now()) || endTime.isBefore(LocalDateTime.now())){
            return Result.fail("优惠券未开始");
        }
        //4.判断是否库存充足,不足返回
        Integer stock = voucher.getStock();
        if (stock <= 0){
            return Result.fail("库存不足");
        }
        Result voucherOrder = this.createVoucherOrder(voucherId);
        return voucherOrder;
    }

    @Transactional
    public synchronized 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("用户已经购买过一次!");
        }

        //5.扣减库存
        int update = seckillVoucherMapper.update(voucherId,stock-1);
        if (update <= 0){
            return Result.fail("库存不足");
        }
        //6.生成订单
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setId(redisWorker.nextId("order"));
        voucherOrder.setUserId(UserHolder.getUser().getId());
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setCreateTime(LocalDateTime.now());
        voucherOrderMapper.insert1(voucherOrder);
        //7.返回订单
        return Result.ok(voucherOrder.getId());
    }
复制代码
但是这样添加锁,锁的粒度太粗了,每个线程每个用户都得加锁,这很明显是不对的,我李四进来锁了,我张三不应该锁呀,所以在使用锁过程中,控制锁粒度是一个非常重要的事情,因为如果锁的粒度太大,会导致每个线程进来都会锁住,所以合适的不应该锁这个方法,而是锁每个用户,这可以从拦截器哪里得到。

所以应该是这样的

改进二

但注意

复制代码
synchronized(userId.toString())是有问题的,因为你看这个tostring源码,还是返回一个字符串对象,这样不同线程进来虽然userid一样,但是创建的对象是不同的,这样锁对象是不能完成的,我们可以调用:

意思就是这个就直接看常量池有没有一样的东西,就是值一样锁就一样。所以i就是这样的。

改进三

复制代码
但是以上代码还是存在问题,问题的原因在于当前方法被spring的事务控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,因为你锁在事务里,所以流程是先开启事务,获取锁,然后释放锁,然后提交事务,可能你释放锁了以后,事务还没有提交,又进来一个线程,这是因为锁有点小了,我们可以先锁再事务这样万无一失。

改进四

但注意这又有一个致命问题,这里事务会失效,就是在类内部调用事务方法,这里是this.方法,事务是基于动态代理的,这样调用事务会失效,所以这个地方,我们需要获得原始的事务对象, 来操作事务。

使用这个要引入依赖

java 复制代码
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
        </dependency>

还要在启动类上面加一个注解去暴露动态代理,默认是不暴露的,加上这个就可以获得IVoucherOrderService代理对象。

这是最终代码:

java 复制代码
public Result seckillVoucher(Long voucherId) {
        //1. 查询优惠券
        Voucher voucher = seckillVoucherMapper.getById(voucherId);
        //2.如果优惠券不存在,返回失败
        if (voucher == null){
            return Result.fail("优惠券不存在");
        }
        //3.存在,查看活动是否开始,查看活动是否结束
        LocalDateTime beginTime = voucher.getBeginTime();
        LocalDateTime endTime = voucher.getEndTime();
        if (beginTime.isAfter(LocalDateTime.now()) || endTime.isBefore(LocalDateTime.now())){
            return Result.fail("优惠券未开始");
        }
        //4.判断是否库存充足,不足返回
        stock = voucher.getStock();
        if (stock <= 0){
            return Result.fail("库存不足");
        }
        userId = UserHolder.getUser().getId();
        synchronized(userId.toString().intern()){
            IVoucherOrderService o = (IVoucherOrderService)AopContext.currentProxy();
            Result voucherOrder = o.createVoucherOrder(voucherId);
            return voucherOrder;
        }
    }

    @Transactional
    public  Result createVoucherOrder(Long voucherId) {
        // 5.1.查询订单
        Long count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2.判断是否存在
        if (count > 0) {
            // 用户已经购买过了
            return Result.fail("用户已经购买过一次!");
        }

        //5.扣减库存
        int update = seckillVoucherMapper.update(voucherId, stock - 1);
        if (update <= 0) {
            return Result.fail("库存不足");
        }
        //6.生成订单
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setId(redisWorker.nextId("order"));
        voucherOrder.setUserId(UserHolder.getUser().getId());
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setCreateTime(LocalDateTime.now());
        voucherOrderMapper.insert1(voucherOrder);
        //7.返回订单
        return Result.ok(voucherOrder.getId());
    }
相关推荐
zh_xuan1 小时前
React Native页面加载流程
android·react native
yuezhilangniao2 小时前
从Next.js到APK:Capacitor跨平台(安卓端)打包完全指南
android·开发语言·javascript
WoodyPhang2 小时前
Android开机动画修改完全指南:从原理到实战
android
城东米粉儿2 小时前
Android Native Crash 监控 笔记
android
城东米粉儿3 小时前
Android 线程池 笔记
android
zh_xuan3 小时前
React Native 开发环境准备
android·react native
冬奇Lab16 小时前
PMS核心机制:应用安装与包管理深度解析
android·源码阅读
城东米粉儿18 小时前
Android 计算滑动帧率 笔记
android
城东米粉儿19 小时前
Android Choreographer 和 looper 结合使用 监控
android