高并发减库存之当锁遇到了数据库事务

高并发减库存之当锁遇到了数据库事务

高并发的场景有很多,比如:抢购、抢票、抢红包等等 总之,就是在短时间内有大量的请求。

我们都知道,这种系统设计的大方向就是限流,即通过层层过滤,最终只让相对较少的请求进入到核心业务处理层。

这里不谈秒杀设计,不谈Redis原子操作,不谈使用队列等使请求串行化,就谈下怎么用锁来保证数据正确,就是已经到减库存那一步了,在这一步中如果保证不超卖,重点是扣减库存。

一、业务代码分析

对减库存操作大致代码如下,查询库存、库存减一、更新库存。假如库存100,使用JMeter模拟每秒一百个请求。

代码如下:

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
/**
* @Description 减库存操作
* @Author jie.zhao
* @Date 2019/9/17 14:12
*/
@Transactional(rollbackFor = Exception.class)
public void stock() {
String sql = "SELECT * FROM user WHERE name = 'zz'";
User user = jdbcTemplate.queryForObject(sql, null, new BeanPropertyRowMapper<>(User.class));

//模拟减库存
int newNum = user.getNum() - 1;
user.setNum(newNum);
jdbcTemplate.update("UPDATE user SET num = ? WHERE name = 'zz'", user.getNum());

//操作其他表。。。。。。
System.out.println(user);
}

//执行结果

User(name=zz, num=99)
User(name=zz, num=99)
User(name=zz, num=99)
User(name=zz, num=99)
User(name=zz, num=98)
User(name=zz, num=97)
User(name=zz, num=97)
User(name=zz, num=97)
User(name=zz, num=97)
....

对这种情况,一般都会想到加上锁。如java自带的锁(比如:synchronized、Lock)、Redis分布式锁。

其实,锁就是将多线程请求转成单线程请求,因为每次只有一个线程获得锁并执行,其余都被阻塞了。

改造后代码如下:

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
/**
* @Description 减库存操作 加锁
* @Author jie.zhao
* @Date 2019/9/17 14:12
*/
@Transactional(rollbackFor = Exception.class)
public synchronized void stock() {
String sql = "SELECT * FROM user WHERE name = 'zz'";
User user = jdbcTemplate.queryForObject(sql, null, new BeanPropertyRowMapper<>(User.class));

//模拟减库存
int newNum = user.getNum() - 1;
user.setNum(newNum);
jdbcTemplate.update("UPDATE user SET num = ? WHERE name = 'zz'", user.getNum());

//操作其他表。。。。。。
System.out.println(user);
}

//执行结果

User(name=zz, num=99)
User(name=zz, num=99)
User(name=zz, num=98)
User(name=zz, num=98)
User(name=zz, num=97)
User(name=zz, num=97)
User(name=zz, num=96)
User(name=zz, num=95)
User(name=zz, num=95)
User(name=zz, num=94)
User(name=zz, num=94)
......

第一眼看起来挺好的,没问题啊,但仔细实践证明是由问题的。这就很让人不解了,试着把@Transactional去掉再测试,发现就没问题了。

这就找到问题了,锁和数据库事务不能在一起!!!

二、原因分析

原因mysql默认的事务隔离级别是REPEATABLE-READ(可重复读),在这种隔离级别下,同一个事务中多次读取,返回的数据是一样的。 即在同一个事务内的查询都是事务开始时刻一致的,InnoDB默认级别,该隔离级别消除了不可重复读,但是还存在幻读。

同时,在@Transactional注解中默认的传播特性REQUIRED,即业务方法需要在一个事务中运行,如果方法运行时,已处在一个事务中,那么就加入该事务,否则自己创建一个新的事务。这是spring默认的传播行为。

image

Spring声明式事务是Spring AOP最好的例子,Spring是通过AOP代理的方式来实现事务的,也就是说在调用reduceStock()方法的之前就已经开启了事务。

那么,在并发情况下可能会存在这样的情况,假设线程T1和T2都执行到这里,于是它们都开启了事务S1和S2,T1先执行,T2后执行,由于T2执行的时候事务已经创建了,根据隔离级别,这个时候事务S2读取不到S1已提交的数据,于是就会出现T1和T2读取到的值是一样的,即T2读取的是T1更新前的库存数据。

PS : 2019-9-17 16:31:10
Qth 黄浩:
应该是提交事务延迟导致的
可以锁在方法外面
别扯淡了,就是事务没提交写这老长

解决方案一: 锁方法上移

将锁放到调用减库存方法的上一层,这样的话就不会有事务问题。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@GetMapping("/apiUser")
private String apiUser() {
try {
lock.lock();
//执行减库存
userService.stock();
} finally {
lock.unlock();
}

return "SUCCESS";
}

@Transactional(rollbackFor = Exception.class)
public void stock() {
//省略。。。
}
解决方案二: update行锁

简单易用直接利用数据库UPDATE语句自带的锁去更新数据,WHERE条件里库存数量大于0即可。

1
UPDATE user SET num = num -1 WHERE name = 'zz' AND num >0
解决方案三: 数据库的悲观锁

当我们要对一个数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。

这种借助数据库锁机制在修改数据之前先锁定,再修改的方式被称之为悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)。

共享锁和排它锁是悲观锁的不同的实现,它俩都属于悲观锁的范畴。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* @Description 减库存操作 悲观锁
* @Author jie.zhao
* @Date 2019/9/17 14:12
*/
@Transactional(rollbackFor = Exception.class)
public void stock() {
// 悲观锁
// 使用排它锁 可以不加synchronized
String sql = "SELECT * FROM user WHERE name = 'zz' FOR UPDATE";
// 使用共享锁
// 如果代码不加synchronized锁的话会报错: Deadlock found when trying to get lock; try restarting transaction
// String sql = "SELECT * FROM user WHERE name = 'zz' lock in share mode";
User user = jdbcTemplate.queryForObject(sql, null, new BeanPropertyRowMapper<>(User.class));

//模拟减库存
int newNum = user.getNum() - 1;
user.setNum(newNum);
jdbcTemplate.update("UPDATE user SET num = ? WHERE name = 'zz'", user.getNum());

//操作其他表。。。。。。
System.out.println(user);
}

不足:数据库记录始终处于加锁状态,可以想见,如果面对几百上千个并发,这样的情况将导致怎样的后果?

解决方案四: 数据库的乐观锁

CAS(compare and swap)比较并交换

在Java中,一个线程想修改某个变量的值,那么第一步是将变量的值从主内存中读取到自己工作内存中,然后修改,最后写回主内存。这个过程可以归结为:读取——修改——写入,在写回内存的时候可能当前内存中那个值已经发生了变化,这个时候如果继续写则会覆盖别人的数据,只有当内存中的那个值和它修改之前读到的那个值一样,才可以写入。这个跟数据库是一样的。Java中通过Unsafe中compareAndSwapObject这样的方法类实现的,它直接调用CPU指令。

数据库乐观锁就是一种CAS。乐观锁不是数据库自带的,需要我们自己去实现。数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。

更新的时候带上版本号,只有当前版本号与更新之前查询时的版本一致,才会更新。不一致不能更新,执行自己的重试逻辑,这里不做代码示例。

1
UPDATE user SET num = ? AND version = version + 1 WHERE name = 'zz' AND version =  1

参考文档:

https://www.cnblogs.com/cjsblog/p/9135118.html

https://www.cnblogs.com/lichihua/p/10803305.html


PS:注意用锁来解决带事务的方法本身就不妥 O(∩_∩)O哈哈~

-------------已经触及底线 感谢您的阅读-------------

本文标题:高并发减库存之当锁遇到了数据库事务

文章作者:趙小傑~~

发布时间:2019年08月29日 - 13:31:13

最后更新:2019年09月17日 - 19:28:13

原始链接:https://cnsyear.com/posts/760077e1.html

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

0%