多线程下的事务数据问题
前几天遇到的线上问题,防止以后犯这样的错误,特别写下来记录下!😒
现场问题:一个业务流程的接口,包含五个左右的操作步骤(a,b,c,d,e,每个步骤都是原子性),第三方调用这个接口,其中有两次请求(两次请求间隔几十毫秒),A请求没问题,B请求的d步骤没有执行!
d步骤的sql执行了,但是更新结果为0,这是为什么呢?
d步驟更新sql:
UPDATE tb_accounts_receivable SET amountReceivable=?, amountReceived=?, beLongType=?, beLongId=?, isActive=?, creator=?, createTime=?, modifier=?, modifyTime=? WHERE id=? AND modifyTime=? AND isDelete=0
d步骤更新返回结果
@Transactional
public void business() {
a();
b();
c();
// b方法加了锁
d();
e();
}
public void b() {
int a = get();
...
set(a);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
我想会不会A,B线程同时竞争b()方法的锁,A线程获取到锁,然后修改了记录,之后释放了锁,之后继续往下执行,
然后B线程获取到锁,先获取值,再设置值,由于A线程还没有提交事务,mysql的默认隔离级别是可重复读
,B线程获取的值并不是A线程修改后的值,而是修改之前的值,然后B线程继续修改值,因为A线程的事务没有结束,mysql中存储引擎InnoDB默认锁的级别是行锁,B线程修改值会阻塞在这,知道A线程事务提交,释放锁,B线程才会继续往下执行,此时B线程修改的值不是建立在A线程修改后的基础上。可能文字有点难理解,还是画图吧。
A线程获取到锁,然后修改了记录,之后释放了锁,继续往下执行。
然后B线程获取到锁,先获取值,再设置值,由于A线程还没有提交事务,mysql的默认隔离级别是可重复读
,B线程获取的值并不是A线程修改后的值,而是修改之前的值,然后B线程继续修改值,因为A线程的事务没有结束,mysql中存储引擎InnoDB默认锁的级别是行锁,B线程修改值会阻塞在这。
A线程事务提交,释放a记录行锁,B线程执行更新操作,将a更新为100。
最后A,B线程都执行结束,a的值为110。
测试代码
@Transactional
public void multiTran(Integer num) {
try {
Thread.sleep(1000 * 2);
updateStudentName(num);
Thread.sleep(1000 * 2);
Teacher teacher = new Teacher();
teacher.setName("ss");
teacher.setId(-1L);
teacher.insert();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
@Transactional
@Klock(waitTime = 2,lockTimeoutStrategy = LockTimeoutStrategy.FAIL_FAST)
public void updateStudentName(Integer num) {
String threadName = Thread.currentThread().getName();
Student student = new Student();
student.setId(1L);
Student studentOld = student.selectById(1L);
studentOld.setNum(studentOld.getNum() + num);
System.out.println("threadName= "+threadName+"num=" +num+ "studentOld = " + studentOld);
boolean update = studentOld.updateById();
log.info("update:{}",update);
}
@Test
void updateStudentNameTest() throws InterruptedException {
final CountDownLatch countDownLatch = new CountDownLatch(3);
for (int i = 1; i < 4; i++) {
int finalI = i;
Thread thread = new Thread(new Runnable() {
@SneakyThrows
@Override
public void run() {
// 测试任务
testLockTime.multiTran(finalI);
countDownLatch.countDown();
}
});
thread.start();
}
// countDownLatch减为0,才会往下执行,否则一直阻塞
countDownLatch.await();
}
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
测试结果
threadName= Thread-4num=3studentOld = Student{id=1, name='a学生', tId=1, num=3}
threadName= Thread-3num=2studentOld = Student{id=1, name='a学生', tId=1, num=2}
threadName= Thread-2num=1studentOld = Student{id=1, name='a学生', tId=1, num=1}
2022-02-17 22:57:52.628 INFO 14892 --- [ Thread-3] com.zhu.klock_test.service.TestLockTime : update:true
2022-02-17 22:57:54.646 INFO 14892 --- [ Thread-4] com.zhu.klock_test.service.TestLockTime : update:true
2022-02-17 22:57:56.654 INFO 14892 --- [ Thread-2] com.zhu.klock_test.service.TestLockTime : update:true
2
3
4
5
6
数据库记录以最后一次修改为准,前面两次均失效了。
咦~,但是这和我的情况还是不一样呀,我的问题是B线程的d操作压根就没执行呀,这上面分明是都执行了,只是以最后一次的为准。
再来看下现场的sql
UPDATE tb_accounts_receivable SET amountReceivable=?, amountReceived=?, beLongType=?, beLongId=?, isActive=?, creator=?, createTime=?, modifier=?, modifyTime=? WHERE id=? AND modifyTime=? AND isDelete=0
WHERE id=? AND modifyTime=? AND isDelete=0
这个地方好奇怪,更新怎么会带上modifyTime
的条件,而且更新操作不只是更改金额,还会修改modifyTime
,这个字段感觉像是乐观锁
,防止别的线程此前更新过记录。
乐观锁顾名思义就是在操作时很乐观,认为操作不会产生并发问题(不会有其他线程对数据进行修改),因此不会上锁。但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般会使用
版本号机制
或CAS(compare and swap)算法
实现
翻看下代码,果然modifyTime
是作为版本号
所以之前的分析少了一个时间字段,A线程更新记录会更新modifyTime
值,之后提交事务,释放完行锁,B线程执行更新操作,而B线程还是拿着A线程更新之前的modifyTime
值作为查询条件,那肯定查询不到呀!!附上流程图
测试代码修改,添加修改时间字段,再次测试!!
@Transactional
public void multiTran(Integer num) {
try {
Thread.sleep(1000 * 2);
// updateStudentName(num);
updateStudentNameByUpdateTimeAndId(num);
Thread.sleep(1000 * 2);
Teacher teacher = new Teacher();
teacher.setName("ss");
teacher.setId(-1L);
teacher.insert();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
@Transactional
@Klock(waitTime = 2,lockTimeoutStrategy = LockTimeoutStrategy.FAIL_FAST)
public void updateStudentNameByUpdateTimeAndId(Integer num) {
String threadName = Thread.currentThread().getName();
Student student = new Student();
student.setId(1L);
Student studentOld = student.selectById(1L);
System.out.println("threadName= "+threadName+"num=" +num+ "studentOld = " + studentOld);
boolean update = studentMapper.updateByIdAndUpdate(studentOld.getId(),studentOld.getNum() + num,studentOld.getUpdateTime());
log.info("update:{}",update);
}
@Test
void updateStudentNameTest() throws InterruptedException {
final CountDownLatch countDownLatch = new CountDownLatch(3);
for (int i = 1; i < 4; i++) {
int finalI = i;
Thread thread = new Thread(new Runnable() {
@SneakyThrows
@Override
public void run() {
testLockTime.multiTran(finalI);
countDownLatch.countDown();
}
});
thread.start();
}
// countDownLatch减为0,才会往下执行,否则一直阻塞
countDownLatch.await();
}
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
测试结果
threadName= Thread-3num=2studentOld = Student{id=1, name='a学生', tId=1, num=0, updateTime=Fri Feb 18 00:01:39 CST 2022}
threadName= Thread-4num=3studentOld = Student{id=1, name='a学生', tId=1, num=0, updateTime=Fri Feb 18 00:01:39 CST 2022}
threadName= Thread-2num=1studentOld = Student{id=1, name='a学生', tId=1, num=0, updateTime=Fri Feb 18 00:01:39 CST 2022}
2022-02-18 00:23:22.120 INFO 18080 --- [ Thread-4] com.zhu.klock_test.service.TestLockTime : update:true
2022-02-18 00:23:24.144 INFO 18080 --- [ Thread-3] com.zhu.klock_test.service.TestLockTime : update:false
2022-02-18 00:23:26.154 INFO 18080 --- [ Thread-2] com.zhu.klock_test.service.TestLockTime : update:false
2
3
4
5
6
7
果然第一次执行条数大于0了,后面线程更新条数为0。
结局方案:
- 在整个业务上加锁,同一时间只能有一个线程访问(当时没分析出来,临时先用)
- 写原生sql,只根据id更新金额
总结:锁不是越多越好,还是要分析好逻辑,这个代码里面有redis实现的锁,InnoDB数据引擎的行锁,版本控制的乐观锁,这么多锁,看问题看的眼花缭乱,还是适合就好。