基于数据库实现乐观锁

一 乐观锁与悲观锁介绍

在这里插入图片描述

悲观锁:

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

乐观锁:

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

  • 版本号机制

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

  • CAS 算法

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

int var5;
do {
    
    
   var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;

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

二 乐观锁实践案例

2.1 库存超卖问题复现

2.1.1 模拟秒杀下单分析

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

下单时需要判断两点:

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

下单核心逻辑分析:

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

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

2.1.2秒杀代码

@Override
    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 success = seckillVoucherService.update()
                .setSql("stock= stock -1")
                .eq("voucher_id", voucherId).update();
        if (!success) {
    
    
            //扣减库存
            return Result.fail("库存不足!");
        }
        //6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 6.1.订单id
        Random ra =new Random();
        long orderId = ra.nextLong();
        voucherOrder.setId(orderId);
        // 6.2.用户id
        Long userId =  ra.nextLong();
        voucherOrder.setUserId(userId);
        // 6.3.代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        return Result.ok(orderId);

    }

2.1.3单元测试结果

@SpringBootTest
class LockApplicationTests {
    
    

    //实际项目中应使用自定义的线程池
    ExecutorService threadPool = Executors.newFixedThreadPool(100);
    @Autowired
    private IVoucherOrderService voucherOrderService;


    @Test
    void testIdWorker() throws InterruptedException {
    
    

        CountDownLatch latch = new CountDownLatch(100);

        Runnable task = new Runnable() {
    
    
            @Override
            public void run() {
    
    
                Result result = voucherOrderService.seckillVoucher(2L);
                System.out.println("result = " + result);
            latch.countDown();
            }
        } ;
        long begin = System.currentTimeMillis();
        for (int i = 0; i < 100; i++) {
    
    
            threadPool.execute(task);
        }
        latch.await();
        long end = System.currentTimeMillis();
        System.out.println("time = " + (end - begin));
    }
}

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

观察表中数据

在这里插入图片描述

2.2 库存超卖问题分析

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

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

假设线程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 在扣减库存时,改为:

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

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

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

修改代码方案二、

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

boolean success = seckillVoucherService.update()
                .setSql("stock= stock -1")
                .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;

猜你喜欢

转载自blog.csdn.net/weixin_43811057/article/details/130816043