跳至主要內容

黑马点评3

项目实战黑马点评项目实战黑马点评大约 9 分钟约 2654 字全民制作人ikun

黑马点评3

优惠卷秒杀

自增的id存在一些问题:id规律太明显,受单表数据量限制,因此需要全局ID生成器

全局ID生成器

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

1653363172079.png
具体代码实现:
获取2022-1-1的时间戳为1640995200L

    public static void main(String[] args) {
        LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
        long second = time.toEpochSecond(ZoneOffset.UTC);
        System.out.println(second);
    }

获取全局ID

@Component
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    public static final long BEGIN_TIMESTAMP = 1640995200L;
    /**
     * 序列号的位数
     */
    public static final int COUNT_BITS = 32;

    private StringRedisTemplate stringRedisTemplate;

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

    public long nextId(String keyPrefix) {
        // 获取当前时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;

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

测试是否有效:

    @Resource
    private RedisIdWorker redisIdWorker;

    private ExecutorService es = Executors.newFixedThreadPool(500);

    @Test
    void testIdWorker() throws Exception {
        CountDownLatch countDownLatch = new CountDownLatch(300);
        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                long id = redisIdWorker.nextId("order");
                System.out.println("id = " + id);
            }
            countDownLatch.countDown();
        };
        long begin = System.currentTimeMillis();
        for (int i = 0; i < 300; i++) {
            es.submit(task);
        }
        countDownLatch.await();
        long end = System.currentTimeMillis();
        System.out.println("(end-begin) = " + (end - begin));
    }

创建优惠卷

表结构:
tb_voucher 优惠卷的基本信息

create table tb_voucher
(
    id           bigint unsigned auto_increment comment '主键'
        primary key,
    shop_id      bigint unsigned                            null comment '商铺id',
    title        varchar(255)                               not null comment '代金券标题',
    sub_title    varchar(255)                               null comment '副标题',
    rules        varchar(1024)                              null comment '使用规则',
    pay_value    bigint unsigned                            not null comment '支付金额,单位是分。例如200代表2元',
    actual_value bigint                                     not null comment '抵扣金额,单位是分。例如200代表2元',
    type         tinyint unsigned default '0'               not null comment '0,普通券;1,秒杀券',
    status       tinyint unsigned default '1'               not null comment '1,上架; 2,下架; 3,过期',
    create_time  timestamp        default CURRENT_TIMESTAMP not null comment '创建时间',
    update_time  timestamp        default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
    collate = utf8mb4_general_ci
    row_format = COMPACT;

tb_voucher_order:

create table tb_voucher_order
(
    id          bigint                                     not null comment '主键'
        primary key,
    user_id     bigint unsigned                            not null comment '下单的用户id',
    voucher_id  bigint unsigned                            not null comment '购买的代金券id',
    pay_type    tinyint unsigned default '1'               not null comment '支付方式 1:余额支付;2:支付宝;3:微信',
    status      tinyint unsigned default '1'               not null comment '订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款',
    create_time timestamp        default CURRENT_TIMESTAMP not null comment '下单时间',
    pay_time    timestamp                                  null comment '支付时间',
    use_time    timestamp                                  null comment '核销时间',
    refund_time timestamp                                  null comment '退款时间',
    update_time timestamp        default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
    collate = utf8mb4_general_ci
    row_format = COMPACT;

tb_seckill_voucher 秒杀卷

create table tb_seckill_voucher
(
    voucher_id  bigint unsigned                     not null comment '关联的优惠券的id'
        primary key,
    stock       int                                 not null comment '库存',
    create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间',
    begin_time  timestamp default CURRENT_TIMESTAMP not null comment '生效时间',
    end_time    timestamp default CURRENT_TIMESTAMP not null comment '失效时间',
    update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
    comment '秒杀优惠券表,与优惠券是一对一关系' collate = utf8mb4_general_ci
                                                row_format = COMPACT;

控制器:

    /**
     * 新增秒杀券
     * @param voucher 优惠券信息,包含秒杀信息
     * @return 优惠券id
     */
    @PostMapping("seckill")
    public Result addSeckillVoucher(@RequestBody Voucher voucher) {
        voucherService.addSeckillVoucher(voucher);
        return Result.ok(voucher.getId());
    }

业务逻辑:

    @Override
    @Transactional
    public void addSeckillVoucher(Voucher voucher) {
        // 保存优惠券
        save(voucher);
        // 保存秒杀信息
        SeckillVoucher seckillVoucher = new SeckillVoucher();
        seckillVoucher.setVoucherId(voucher.getId());
        seckillVoucher.setStock(voucher.getStock());
        seckillVoucher.setBeginTime(voucher.getBeginTime());
        seckillVoucher.setEndTime(voucher.getEndTime());
        seckillVoucherService.save(seckillVoucher);
    }

实现秒杀下单

流程图如下:
1653366238564.png

具体实现:
控制器:

    @Resource
    private IVoucherOrderService voucherOrderService;

    @PostMapping("seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {
        return voucherOrderService.seckillVoucher(voucherId);
    }

逻辑层:

    @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 update = seckillVoucherService.update().setSql("stock=stock-1").eq("voucher_id", voucherId).update();
        if (!update){
            return Result.fail("库存不足");
        }
        //6.生成订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //订单id
        long orderId = redisIdWorker.nextId("order");
        //用户id
        Long userId = UserHolder.getUser().getId();
        //优惠券id
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setUserId(userId);
        voucherOrder.setId(orderId);
        this.save(voucherOrder);
        //7.返回订单id
        return Result.ok(orderId);

    }

库存超卖问题

多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
1653368335155.png

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
1653368562591.png
悲观锁(Pessimistic Locking)、乐观锁(Optimistic Locking)、CAS(Compare-and-Swap)是并发控制机制,用于处理多个线程或进程同时访问共享资源的情况。它们的作用是确保数据的一致性和避免竞态条件(race conditions)。

悲观锁

  • 悲观锁的核心思想是在访问共享资源之前,先获取锁来阻止其他线程或进程的访问。
  • 当一个线程获取了悲观锁,其他线程必须等待,直到锁被释放。
  • 常见的实现方式包括数据库中的行级锁或表级锁,以及编程中的互斥锁(Mutex)。
  • 悲观锁通常会导致并发性能较差,因为它阻止了多个线程同时访问资源,可能会导致性能瓶颈。

乐观锁

  • 乐观锁的核心思想是假定在大多数情况下,共享资源的访问是不会发生冲突的。
  • 线程在读取数据时不会加锁,但在更新数据时会检查数据的版本号或标记。
  • 如果在更新时发现数据已经被其他线程修改,就会放弃本次更新,或者进行冲突解决操作。
  • 乐观锁通常用于减小锁的争用,提高并发性能。
    乐观锁 版本号法:
    1653369268550.png

CAS自旋锁

  • CAS 是一种乐观锁的实现方式,它是一种原子操作,通常由硬件提供支持。
  • CAS 操作包括三个参数:要更新的内存位置、预期值和新值。
  • CAS 操作会比较内存位置的当前值和预期值,如果相符,则将新值写入内存位置;否则,操作失败。
  • CAS 可用于实现乐观锁,通过原子比较和更新来确保在多线程环境下数据的一致性。
    自旋锁实现:
    var5 是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值
int var5;
do {
    var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;

用库存代替版本号,就实现了CAS自旋锁。
代码如下:

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

核心含义:只要我扣减库存时的库存和之前我查询到的库存是一样的,就意味着没有人在中间修改过库存,那么此时就是安全的,但是以上这种方式通过测试发现会有很多失败的情况,失败的原因在于:在使用乐观锁过程中假设100个线程同时都拿到了100的库存,然后大家一起去进行扣减,但是100个人中只有1个人能扣减成功,其他的人在处理时,他们在扣减时,库存已经被修改过了,所以此时其他线程都会失败
修改:

        boolean update = seckillVoucherService.update()
                .setSql("stock=stock-1")
                .eq("voucher_id", voucherId).gt("stock", 0)
                .update();

一人一单

一个人只能抢购一个优惠卷

        // 一人一单
        Long userId = UserHolder.getUser().getId();
        int count = this.query().eq("user_id", userId)
                .eq("voucher_id", voucherId).count();
        if (count > 0) {
            return Result.fail("您已经抢购过了");
        }

但是这样还是会存在一个人买多个优惠券的情况
乐观锁适合用在更新数据的情况下面,这里是插入数据,考虑使用悲观锁

    @Transactional()
    public synchronized Result createVoucherOrder(Long voucherId) {
        // 一人一单
        Long userId = UserHolder.getUser().getId();
        int count = this.query().eq("user_id", userId)
                .eq("voucher_id", voucherId).count();
        if (count > 0) {
            return Result.fail("您已经抢购过了");
        }
        //5.扣减库存
        boolean update = seckillVoucherService.update()
                .setSql("stock=stock-1")
                .eq("voucher_id", voucherId).gt("stock", 0)
                .update();
        if (!update) {
            return Result.fail("库存不足");
        }
        //6.生成订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //订单id
        long orderId = redisIdWorker.nextId("order");
        //优惠券id
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setUserId(userId);
        voucherOrder.setId(orderId);
        this.save(voucherOrder);
        //7.返回订单id
        return Result.ok(orderId);
    }

但是如果按照上面 这种方式加锁,锁的力度太大了,因为如果锁的力度 太大,会导致每个线程进来都会锁住,所以我们需要去控制锁的力度,

intern() 这个方法是从常量池中拿到数据,如果我们直接使用userId.toString() 他拿到的对象实际上是不同的对象,new
来的对象,我们使用锁必须保证锁必须是同一把,所以我们需要使用intern()方法

    @Transactional
    public  Result createVoucherOrder(Long voucherId) {
        // 一人一单
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            int count = this.query().eq("user_id", userId)
                    .eq("voucher_id", voucherId).count();
            if (count > 0) {
                return Result.fail("您已经抢购过了");
            }
            //5.扣减库存
            boolean update = seckillVoucherService.update()
                    .setSql("stock=stock-1")
                    .eq("voucher_id", voucherId).gt("stock", 0)
                    .update();
            if (!update) {
                return Result.fail("库存不足");
            }
            //6.生成订单
            VoucherOrder voucherOrder = new VoucherOrder();
            //订单id
            long orderId = redisIdWorker.nextId("order");
            //优惠券id
            voucherOrder.setVoucherId(voucherId);
            voucherOrder.setUserId(userId);
            voucherOrder.setId(orderId);
            this.save(voucherOrder);
            //7.返回订单id
            return Result.ok(orderId);
        }
    }

以上代码还是存在问题,问题的原因在于当前方法被spring的事务控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题,所以我们选择将当前方法整体包裹起来,确保事务不会出现问题:

        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            return this.createVoucherOrder(voucherId);
        }

但是以上做法依然有问题,因为你调用的方法,其实是this.的方式调用的,事务想要生效,还得利用代理来生效,所以这个地方,我们需要获得原始的事务对象, 来操作事务
image.png

导入依赖:

        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
        </dependency>

开启:

@MapperScan("com.hmdp.mapper")
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class HmDianPingApplication {

    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }
}

使用代理:

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

集群并发

复制一份配置 ,修改端口为8082:
修改VM参数

-Dserver.port=8082
image.png
image.png

形成集群:
image.png

修改 nginx配置如下 :
image.png

加载nginx配置

nginx -s reload
image.png
image.png

此时可以达到负载均衡的效果

此时会发现在分布式情况下,依然会发生 并发问题,同一个人还是可以抢到两个优惠卷,因为synchronized是在两台不同的jvm里面,要解决这个问题,就需要用到分布式锁。

上次编辑于:
贡献者: yunfeidog