基于数据库实现乐观锁
- 一 乐观锁与悲观锁介绍
- 二 乐观锁实践案例
- 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 是为了在操作失败时,再次进行自旋操作,即把之前的逻辑再操作一次。
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;