乐观锁、悲观锁

优惠券超卖
超卖场景复现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService SeckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
//1.根据id查询优惠卷
SeckillVoucher voucher = SeckillVoucherService.getById(voucherId);
//2.判断是否开始或结束
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
//3.判断库存
if (voucher.getStock()<1){
return Result.fail("库存不足");
}
//4.扣减库存 防止超卖,加乐观锁,扣减库存前再查询一次库存判断
boolean b = SeckillVoucherService.update()
.setSql("stock=stock-1").
eq("voucher_id", voucherId).gt("stock", voucher.getStock()).update();
if (!b) {
return Result.fail("库存不足");
}
//5.创建订单,并插入数据库
VoucherOrder order = new VoucherOrder();
Long id = UserHolder.getUser().getId();
order.setVoucherId(voucherId);
//生成订单的全局唯一ID
long orderID = redisIdWorker.nextId("voucherOrder");
order.setId(orderID);
order.setUserId(id);
save(order);
//7.返回订单
return Result.ok("orderID");
}
}
|
上述代码适用于非高并发情况下,然而真实的情况是很多用户同时下单,是并发问题,那么此时就会出现超卖问题。 可以使用 apache JMeter 或者 ApiFox 模拟多线程并发场景:
在数据库中设置秒杀券库存为 100,JMeter 设置 200 并发线程模拟 200 个用户同时下单。
由图二可以看到异常值是 45.5%,但是实际应该是 50%,因为只有 100 个库存,200 个用户应该只有 100 个用户能抢到秒杀券 ,打开数据库发现:
秒杀券数量由最初的 100 变为-9,而不是 0,此时就是超卖问题!
原因分析
超卖问题就是线程并发安全问题 ,在一个线程修改数据的同时插入其他线程并发操作,进而出现数据错误,那么解决方法就是加锁,这里加乐观锁!
加乐观锁
乐观锁其实并不是真的加锁,而是在最后要更新数据库数据时做版本判断,若此时的数据版本和最初的数据版本不一致,则认为在该线程执行过程中有其他线程插入做了数据修改,此时就认为数据不是安全的,就要报异常或者重试!而如果版本一致,则认为数据安全,那么就会更新数据。
乐观锁在最后执行数据更新的时候进行判断,不用加锁,因此性能比悲观锁高。
解决方法
这里采用第二种 CAS 法,利用库存代替版本号。
根据以上分析,解决方法就是在扣减库存进行数据更新时多加一步操作:判断此时更新时的库存是否和上一步判断库存是否充足时的库存是否一致,一致则认为没有线程并发
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService SeckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
//1.根据id查询优惠卷
SeckillVoucher voucher = SeckillVoucherService.getById(voucherId);
//2.判断是否开始或结束
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
//3.判断库存
if (voucher.getStock()<1){
return Result.fail("库存不足");
}
//4.扣减库存 防止超卖,加乐观锁,扣减库存前再查询一次库存判断
boolean b = SeckillVoucherService.update()
.setSql("stock=stock-1").
eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update();
//使用setSql方法设置了更新语句"stock=stock-1",接着使用eq方法添加了两个条件:"voucher_id"等于voucherId和"stock"等于voucher.getStock()
//条件1:voucher_id=voucherId指当前操作的优惠卷的id=数据库中的优惠卷id,即通过优惠卷id指明了要修改哪个优惠卷的库存
//条件2:stock=voucher.getStock,说明该线程修改库存期间没有其他线程来插队修改库存,那么数据是安全的
if (!b) {
return Result.fail("秒杀失败");
}
//5.创建订单
VoucherOrder order = new VoucherOrder();
Long id = UserHolder.getUser().getId();
order.setVoucherId(voucherId);
long orderID = redisIdWorker.nextId("voucherOrder");
order.setId(orderID);
order.setUserId(id);
save(order);
//7.返回订单
return Result.ok(orderID);
}
}
|
重新使用 JMeter 模拟 200 个用户并发抢购 100 库存的秒杀券:
发现异常率提升到 89%,说明有 89%的用户抢购失败!
数据库库存由 100 变为 79,只卖出 21 个。
原因在于上述代码在扣减库存时加的乐观锁,我们的判断方法是:
1
|
eq("stock",voucher.getStock())
|
也就是说即使在库存充足的情况下,只要库存数量上前后判断不对等,就会抢购失败,所以要对乐观锁进行改进!
我们实际业务是只要库存>0 就可以抢购,因此只需要把 eq 换成 gt 即可!
重新模拟并发测试,异常值是 50%,说明 200 个用户抢购,只有 100 个抢购成功,库存正好清零,问题解决!

总结
然而在实际的高并发场景下,对数据库的大量更新操作是不可取的 ,后续进行优化!
一人一单

一人多单场景复现
一人一单的代码实现,是在上述代码的基础上,在扣减库存之前,根据用户的 id 和要下单的秒杀券 id 去数据库查询,如果没查到就可以购买,如果查到就说明已经下过单,不能重复下单。如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService SeckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
//1.根据id查询优惠卷
SeckillVoucher voucher = SeckillVoucherService.getById(voucherId);
//2.判断是否开始或结束
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
//3.判断库存
if (voucher.getStock()<1){
return Result.fail("库存不足");
}
//一人一单,根据优惠卷id和用户id去数据库查询是否已经存在该优惠卷
Long id = UserHolder.getUser().getId();
int count = query().eq("user_id", id).eq("voucher_id", voucherId).count();
if (count == 1) {
return Result.fail("不能重复购买优惠卷");
}
//4.扣减库存 防止超卖,加乐观锁,扣减库存前再查询一次库存判断,只要>0就可以修改
boolean b = SeckillVoucherService.update()
.setSql("stock=stock-1").
eq("voucher_id", voucherId).gt("stock", voucher.getStock()).update();
if (!b) {
return Result.fail("库存不足");
}
//5.创建订单,插入数据库
VoucherOrder order = new VoucherOrder();
order.setVoucherId(voucherId);
long orderID = redisIdWorker.nextId("voucherOrder");
order.setId(orderID);
order.setUserId(id);
save(order);
//7.返回订单
return Result.ok(orderID);
}
}
|
使用 JMeter 模拟一个用户 200 次下单,结果如下,发现异常值是 95%,数据库中一个用户下了 10 个单。

原因分析
先查询再判断,多个线程并发,count == 1 都判断为假,因为此时都查询为 0 还没有下单,所以后面可以重复下单。此处解决方法是加悲观锁,在更新数据时加乐观锁,插入数据时加悲观锁。
加悲观锁
从一人一单开始到最后的保存订单这部分提取成方法 createVoucherOrder,对该方法加锁,即
1
|
public synchronized Result createVoucherOrder(Long voucherId, SeckillVoucher voucher)
|
但是这样是对整个方法加锁,这样该方法只能串行执行,即一个用户占用执行该方法时其他用户无法执行该方法,会极大影响性能,而我们加锁,是要判断是否是同一个用户,因此要对用户的 ID 加锁,这样同一个用户加同一把锁,缩小了锁的范围,提升了性能,并且可以并行执行方法。
对同一个用户 ID 加锁:
1
|
synchronized (id.toString()){.....}
|
注意!每次新的请求,Long id = UserHolder.getUser().getId() 也都是新的对象,那么每次的锁都是新的,也就是同一个用户多次请求,id 对象不是同一个,那么最后加的锁也不是同一个,这样违背了我们的想法,我们是对 ID 的值加锁而不是对 id 这个对象加锁!因此要写成这样:
1
|
synchronized (id.toString().intern()){....}
|
intern 方法返回字符串对象的规范表示,调用该方法时,如果常量池中已经包含一个等于这个 string 对象的字符串(由 equals(object)方法确定),则返回池中的字符串引用,而不是新创建!
这样就确保了用户 id 值一样,锁也就一样。代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
|
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService SeckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
// @Transactional
@Override
public Result seckillVoucher(Long voucherId) {
//1.根据id查询优惠卷
SeckillVoucher voucher = SeckillVoucherService.getById(voucherId);
//2.判断是否开始或结束
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
//3.判断库存
if (voucher.getStock()<1){
return Result.fail("库存不足");
}
//库存充足就创建订单
return createVoucherOrder(voucherId, voucher);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId, SeckillVoucher voucher) {
//一人一单,根据优惠卷id和用户id去数据库查询是否已经存在该优惠卷
Long id = UserHolder.getUser().getId();
synchronized(id.toString.intern()){
//为用户id加锁而不是对整个createVoucherOrder方法加锁,减小锁范围,提升性能
int count = query().eq("user_id", id).eq("voucher_id", voucherId).count();
if (count == 1) {
return Result.fail("不能重复购买优惠卷");
}
//4.扣减库存 防止超卖,加乐观锁,扣减库存前再查询一次库存判断,最后库存判断,只要>0就可以修改
boolean b = SeckillVoucherService.update()
.setSql("stock=stock-1").
eq("voucher_id", voucherId).gt("stock", voucher.getStock()).update();
if (!b) {
return Result.fail("库存不足");
}
//5.创建订单
VoucherOrder order = new VoucherOrder();
order.setVoucherId(voucherId);
long orderID = redisIdWorker.nextId("voucherOrder");
order.setId(orderID);
order.setUserId(id);
save(order);
}
//7.返回订单
return Result.ok(orderID);
}
}
|
事务失效
上述代码仍有问题,那就是事务失效,createVoucherOrder 方法加了事务注解@Transactional,那么事务的提交是在整个 createVoucherOrder 方法执行后由 spring 提交的,而锁的释放是在锁代码块执行后释放,上述代码的结果是锁先释放,然后方法执行完提交事务,这样是不安全的,因为锁释放之后,可能该事务还未提交,也就是该用户的订单还未写入数据库,那么此时该用户在数据库层面上还没有下单,那么另一个线程就进来判断后就可以下新的订单,出现重复下单。
所以这里我们锁的范围又小了,应该对调用 createVoucherOrder 方法的代码块加锁,使得先提交事务,后释放锁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
|
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService SeckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
// @Transactional
@Override
public Result seckillVoucher(Long voucherId) {
//1.根据id查询优惠卷
SeckillVoucher voucher = SeckillVoucherService.getById(voucherId);
//2.判断是否开始或结束
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
//3.判断库存
if (voucher.getStock()<1){
return Result.fail("库存不足");
}
//库存充足就创建订单,锁加到这里
Long id = UserHolder.getUser().getId();
synchronized(id.toString.intern()){
return createVoucherOrder(voucherId, voucher);
}
}
}
@Transactional
public Result createVoucherOrder(Long voucherId, SeckillVoucher voucher) {
//一人一单,根据优惠卷id和用户id去数据库查询是否已经存在该优惠卷
Long id = UserHolder.getUser().getId();
//为用户id加锁而不是对整个createVoucherOrder方法加锁,减小锁范围,提升性能
int count = query().eq("user_id", id).eq("voucher_id", voucherId).count();
if (count == 1) {
return Result.fail("不能重复购买优惠卷");
}
//4.扣减库存 防止超卖,加乐观锁,扣减库存前再查询一次库存判断,最后库存判断,只要>0就可以修改
boolean b = SeckillVoucherService.update()
.setSql("stock=stock-1").
eq("voucher_id", voucherId).gt("stock", voucher.getStock()).update();
if (!b) {
return Result.fail("库存不足");
}
//5.创建订单
VoucherOrder order = new VoucherOrder();
order.setVoucherId(voucherId);
long orderID = redisIdWorker.nextId("voucherOrder");
order.setId(orderID);
order.setUserId(id);
save(order);
//7.返回订单
return Result.ok(orderID);
}
}
|
BUT!!!seckillVoucher 方法没有加事务注解,该方法的 return createVoucherOrder(voucherId, voucher),实际是 return this.createVoucherOrder(voucherId, voucher),即使用 VoucherOrderServiceImpl 对象调用的 createVoucherOrder 方法,而 createVoucherOrder 方法的事务之所以能生效,是因为该方法的调用是由 VoucherOrderServiceImpl 的代理对象调用的。
也就是说 VoucherOrderServiceImpl 调用 createVoucherOrder 事务不生效,VoucherOrderServiceImpl 的代理对象调用 createVoucherOrder 事务才生效
解决方法
既然 VoucherOrderServiceImpl 调用 createVoucherOrder 事务不生效,那我们就使用 VoucherOrderServiceImpl 的代理对象调用 createVoucherOrder,在 seckillVoucher 方法的加锁代码中,通过如下代码拿到代理对象,并使用代理对象调用 createVoucherOrder 方法:
1
2
|
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();//拿到动态代理对象
return proxy.createVoucherOrder(voucherId, voucher);
|
然而要获取代理对象,还需要如下操作:
1.添加依赖
1
2
3
4
5
|
<!--VoucherOrderServiceImpl类获取动态代理类对象 -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
|
2.启动类加注解
1
2
|
//暴露代理对象。这样 VoucherOrderServiceImpl 中才可以获得动态代理对象
@EnableAspectJAutoProxy(exposeProxy = true)
|
最终代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
|
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService SeckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
// @Transactional
@Override
public Result seckillVoucher(Long voucherId) {
//1.根据id查询优惠卷
SeckillVoucher voucher = SeckillVoucherService.getById(voucherId);
//2.判断是否开始或结束
if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
if (voucher.getEndTime().isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
//3.判断库存
if (voucher.getStock()<1){
return Result.fail("库存不足");
}
Long id = UserHolder.getUser().getId();
synchronized (id.toString().intern()) {
//锁加到这里,事务提交后才释放锁
//获取事务的动态代理对象,需要在启动类加注解暴漏出对象
IVoucherOrderService proxy = (IVoucherOrderService)AopContext.currentProxy();//拿到动态代理对象
return proxy.createVoucherOrder(voucherId, voucher);
//使用动态代理类的对象,事务可以生效
}
//TODO 这里提交的事务是由类对象提交的,不是动态代理对象,因此出现事务失效
//因此需要拿到该类的动态代理对象
}
//TODO spring对该类做了动态代理,用动态代理的对象提交的事务
@Transactional
public Result createVoucherOrder(Long voucherId, SeckillVoucher voucher) {
//一人一单,根据优惠卷id和用户id去数据库查询是否已经存在该优惠卷
Long id = UserHolder.getUser().getId();
//为用户id加锁而不是对整个createVoucherOrder方法加锁,减小锁范围,提升性能,这样每个用户就有不同的锁
//锁加在函数内部,锁内的代码执行完后就会释放锁,而事务的提交是在整个方法执行后提交的,也就是事务的提交在锁释放之后。
//但是锁释放后其他线程就可以进来,此时事务可能还没有提交,可能出现并发问题,重复购买
//所以要扩大锁的范围,把锁加到seckillVoucher方法后面,在事务提交后才能释放锁!
int count = query().eq("user_id", id).eq("voucher_id", voucherId).count();
if (count == 1) {
return Result.fail("不能重复购买优惠卷");
}
//4.扣减库存 防止超卖,加乐观锁,扣减库存前再查询一次库存判断
boolean b = SeckillVoucherService.update()
.setSql("stock=stock-1").
eq("voucher_id", voucherId).gt("stock", voucher.getStock()).update();
if (!b) {
return Result.fail("库存不足");
}
//5.创建订单
VoucherOrder order = new VoucherOrder();
order.setVoucherId(voucherId);
long orderID = redisIdWorker.nextId("voucherOrder");
order.setId(orderID);
order.setUserId(id);
save(order);
//7.返回订单
return Result.ok(orderID);
}
}
|
仍然 200 个线程模拟一个用户多次下单
异常率 99%,数据库中库存减 1,说明用户一人一单代码成功!
总结
- 秒杀优惠券业务,更新库存时,加乐观锁而不是悲观锁,可以提升性能
- 一人一单数据库插入订单时,加悲观锁,原本对整个方法加锁,不能并行化,现在对用户 ID 加锁,减小锁范围,提高性能,用户得以并行化
- 锁代码块执行完先释放锁,方法执行完后提交事务,出现了先释放锁后提交事务的情况,不安全,因此要扩大锁范围。因此对调用事务方法的代码块加锁
- 事务的提交由 spring 创建的代理类对象提交,普通的类对象无法提交事务,那么为了避免出现事务失效的情况,要获取类的代理类对象,由代理类对象去调用事务方法