基于数据库实现乐观锁

水深无声 2023-10-10 20:16 59阅读 0赞

基于数据库实现乐观锁

  • 一 乐观锁与悲观锁介绍
  • 二 乐观锁实践案例
    • 2.1 库存超卖问题复现
      • 2.1.1 模拟秒杀下单分析
      • 2.1.2秒杀代码
      • 2.1.3单元测试结果
    • 2.2 库存超卖问题分析
    • 2.3 乐观锁解决超卖问题
      • 2.3.1版本号方式
  • 案例源码
  • 案例中sql脚本

一 乐观锁与悲观锁介绍

在这里插入图片描述

悲观锁:

悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等

乐观锁:

乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。

  • 版本号机制

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

  • CAS 算法

乐观锁的典型代表:就是cas,利用cas进行无锁化机制加锁,var5 是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值 其中do while 是为了在操作失败时,再次进行自旋操作,即把之前的逻辑再操作一次。

  1. int var5;
  2. do {
  3. var5 = this.getIntVolatile(var1, var2);
  4. } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
  5. return var5;

基于数据库方式和Redis方法实现乐观锁
乐观锁与悲观锁

二 乐观锁实践案例

2.1 库存超卖问题复现

2.1.1 模拟秒杀下单分析

秒杀下单应该思考的内容:

下单时需要判断两点:

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

下单核心逻辑分析:

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

比如时间是否充足,如果时间充足,则进一步判断库存是否足够,如果两者都满足,则扣减库存,创建订单,然后返回订单id,如果有一个条件不满足则直接结束。
在这里插入图片描述

2.1.2秒杀代码

  1. @Override
  2. public Result seckillVoucher(Long voucherId) {
  3. // 1.查询优惠券
  4. SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
  5. // 2.判断秒杀是否开始
  6. if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
  7. // 尚未开始
  8. return Result.fail("秒杀尚未开始!");
  9. }
  10. // 3.判断秒杀是否已经结束
  11. if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
  12. // 尚未开始
  13. return Result.fail("秒杀已经结束!");
  14. }
  15. // 4.判断库存是否充足
  16. if (voucher.getStock() < 1) {
  17. // 库存不足
  18. return Result.fail("库存不足!");
  19. }
  20. //5,扣减库存
  21. boolean success = seckillVoucherService.update()
  22. .setSql("stock= stock -1")
  23. .eq("voucher_id", voucherId).update();
  24. if (!success) {
  25. //扣减库存
  26. return Result.fail("库存不足!");
  27. }
  28. //6.创建订单
  29. VoucherOrder voucherOrder = new VoucherOrder();
  30. // 6.1.订单id
  31. Random ra =new Random();
  32. long orderId = ra.nextLong();
  33. voucherOrder.setId(orderId);
  34. // 6.2.用户id
  35. Long userId = ra.nextLong();
  36. voucherOrder.setUserId(userId);
  37. // 6.3.代金券id
  38. voucherOrder.setVoucherId(voucherId);
  39. save(voucherOrder);
  40. return Result.ok(orderId);
  41. }

2.1.3单元测试结果

  1. @SpringBootTest
  2. class LockApplicationTests {
  3. //实际项目中应使用自定义的线程池
  4. ExecutorService threadPool = Executors.newFixedThreadPool(100);
  5. @Autowired
  6. private IVoucherOrderService voucherOrderService;
  7. @Test
  8. void testIdWorker() throws InterruptedException {
  9. CountDownLatch latch = new CountDownLatch(100);
  10. Runnable task = new Runnable() {
  11. @Override
  12. public void run() {
  13. Result result = voucherOrderService.seckillVoucher(2L);
  14. System.out.println("result = " + result);
  15. latch.countDown();
  16. }
  17. } ;
  18. long begin = System.currentTimeMillis();
  19. for (int i = 0; i < 100; i++) {
  20. threadPool.execute(task);
  21. }
  22. latch.await();
  23. long end = System.currentTimeMillis();
  24. System.out.println("time = " + (end - begin));
  25. }
  26. }

观察ideal控制台
在这里插入图片描述

观察表中数据

在这里插入图片描述

2.2 库存超卖问题分析

有关超卖问题分析:在我们原有代码中是这么写的

  1. if (voucher.getStock() < 1) {
  2. // 库存不足
  3. return Result.fail("库存不足!");
  4. }
  5. //5,扣减库存
  6. boolean success = seckillVoucherService.update()
  7. .setSql("stock= stock -1")
  8. .eq("voucher_id", voucherId).update();
  9. if (!success) {
  10. //扣减库存
  11. return Result.fail("库存不足!");
  12. }

假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
在这里插入图片描述

2.3 乐观锁解决超卖问题

高并发情况下使用悲观锁来解决线程安全问题,有些太重了,咔嚓一下整个业务锁住,只能抢到锁的线程独享,其他线程只能等待,这明显有些霸道了;使用乐观锁的方式,在更新数据时去判断有没有其他线程对数据做修改,不影响高并发情况下的效率;

2.3.1版本号方式

操作逻辑是在操作时,对版本号进行+1 操作,然后要求version 如果是1 的情况下,才能操作,那么第一个线程在操作后,数据库中的version变成了2,但是他自己满足version=1 ,所以没有问题,此时线程2执行,线程2 最后也需要加上条件version =1 ,但是现在由于线程1已经操作过了,所以线程2,操作时就不满足version=1 的条件了,所以线程2无法执行成功
在这里插入图片描述
由于我们案例中的场景是减库存,方案有些特殊,正好可以用库存字段stock来代替这个版本号;在进行更新的时候,添加条件判断:查询到的版本号和表中的版本号进行比较,相等则更新成功,不等则更新失败

修改代码方案一、

VoucherOrderServiceImpl 在扣减库存时,改为:

  1. boolean success = seckillVoucherService.update()
  2. .setSql("stock= stock -1") //set stock = stock -1
  3. .eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update(); //where id = ? and stock = ?

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

在这里插入图片描述
在其他场景了使用版本号方案成功率低完全没问题;但是这是秒杀场景,出现成功率太低有点不太符合业务要求;

修改代码方案二、

之前的方式要修改前后都保持一致,但是这样我们分析过,成功的概率太低,所以我们的乐观锁需要变一下,改成stock大于0 即可

  1. boolean success = seckillVoucherService.update()
  2. .setSql("stock= stock -1")
  3. .eq("voucher_id", voucherId).gt("stock", 0).update(); //where id = ? and stock > 0

在这里插入图片描述
库存卖完之后,其他线程再来秒杀就会返回库存不足提示!
在这里插入图片描述
知识小扩展:

针对cas中的自旋压力过大,我们可以使用Longaddr这个类去解决

Java8 提供的一个对AtomicLong改进后的一个类,LongAdder

大量线程并发更新一个原子性的时候,天然的问题就是自旋,会导致并发性问题,当然这也比我们直接使用syn来的好

所以利用这么一个类,LongAdder来进行优化

如果获取某个值,则会对cell和base的值进行递增,最后返回一个完整的值
知识小扩展:

针对cas中的自旋压力过大,我们可以使用LongAdder这个类去解决Java8 提供的一个对AtomicLong改进后的一个类,LongAdder大量线程并发更新一个原子性的时候,天然的问题就是自旋,会导致并发性问题,当然这也比我们直接使用syn来的好
所以利用这么一个类,LongAdder来进行优化
如果获取某个值,则会对cell和base的值进行递增,最后返回一个完整的值
在这里插入图片描述
LongAdder原理浅析

案例源码

案例源码

案例中sql脚本

tb_voucher_order:订单表
tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。

  • tb_seckill_voucher

    DROP TABLE IF EXISTS tb_seckill_voucher;
    CREATE TABLE tb_seckill_voucher (
    voucher_id bigint(20) UNSIGNED NOT NULL COMMENT ‘关联的优惠券的id’,
    stock int(8) NOT NULL COMMENT ‘库存’,
    create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ‘创建时间’,
    update_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ‘更新时间’,
    begin_time datetime NOT NULL COMMENT ‘生效时间’,
    end_time datetime NOT NULL COMMENT ‘失效时间’,
    PRIMARY KEY (voucher_id) USING BTREE
    ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = ‘秒杀优惠券表,与优惠券是一对一关系’ ROW_FORMAT = COMPACT;


    — Records of tb_seckill_voucher


    INSERT INTO tb_seckill_voucher VALUES (2, 100, ‘2022-01-04 10:09:17’, ‘2023-05-23 03:07:53’, ‘2023-05-23 09:09:04’, ‘2023-05-31 10:09:17’);

    SET FOREIGN_KEY_CHECKS = 1;

  • tb_voucher_order

    DROP TABLE IF EXISTS tb_voucher_order;
    CREATE TABLE tb_voucher_order (
    id bigint(20) NOT NULL COMMENT ‘主键’,
    user_id bigint(20) UNSIGNED NOT NULL COMMENT ‘下单的用户id’,
    voucher_id bigint(20) UNSIGNED NOT NULL COMMENT ‘购买的代金券id’,
    pay_type tinyint(1) UNSIGNED ZEROFILL NULL DEFAULT NULL COMMENT ‘支付方式 1:余额支付;2:支付宝;3:微信’,
    status tinyint(1) UNSIGNED NULL DEFAULT 1 COMMENT ‘订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款’,
    create_time timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT ‘下单时间’,
    pay_time timestamp NULL DEFAULT NULL COMMENT ‘支付时间’,
    use_time timestamp NULL DEFAULT NULL COMMENT ‘核销时间’,
    refund_time timestamp NULL DEFAULT NULL COMMENT ‘退款时间’,
    update_time timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ‘更新时间’,
    PRIMARY KEY (id) USING BTREE
    ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = COMPACT;


    — Records of tb_voucher_order


    SET FOREIGN_KEY_CHECKS = 1;

发表评论

表情:
评论列表 (有 0 条评论,59人围观)

还没有评论,来说两句吧...

相关阅读

    相关 乐观实现分布式

    \\ 乐观锁 \\ 就是说特别乐观,比如说每次去吃饭的时候,都认为窗口没有人,只有到了吃饭的窗口才看有没有人,如果有人则去别的地方吃饭。就像系统认为数据的更新在大多

    相关 乐观实现

    什么场景下需要使用锁? -------------------- 前言 在多线程多线程执行时,同一个时间可能有多个线程更新查询相同数据,会产生冲突,这就是并发问题

    相关 数据库设置乐观--作用

    Hibernate支持乐观锁。当多个事务同时对数据库表中的同一条数据操作时,如果没有加锁机制的话,就会产生脏数据(duty data)。Hibernate有2种机制可以解决这个

    相关 MybatisPlus实现乐观

    > 乐观锁只要是为了解决数据库中写操作中的丢失更新问题,在多人同时修改同一数据的时候,最后提交的要将之前提交的覆盖掉,为了解决这个问题在此条记录中添加一个version的字段,