起因是在使用mybatis-plus的乐观锁插件的时候 , 遇到了问题

mybatis-plus插件官网: https://baomidou.com/pages/0d93c0/#optimisticlockerinnerinterceptor

项目中的配置做法是:

  1. 在IOC中注入乐观锁插件Bean
  2. 在实体类以及数据库表中添加version字段 , 同时在实体类字段中加上 @Version注解
  3. 实现 VersionService , 继承com.baomidou.mybatisplus.extension.service.impl.ServiceImpl , 重写 updateById方法
  4. 其他的Service或者Manager层在实现的时候, 继承VersionServiceImpl而不是 mybatis的 ServiceImpl

这样做可以保证其他的 manager 更新数据的时候使用乐观锁进行更新

VersionServiceImpl代码框架如下 , 这里隐含了一个问题 , 等到后面进行说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Slf4j
public class VersionServiceImpl<M extends BaseMapper<T>, T> extends ServiceImpl<M, T> {
private Integer maxRetryTimes;
@Transactional(isolation = Isolation.READ_COMMITTED)
@Override
public boolean updateById(T entity) {
//...
try {
for (int i = 0; i <= this.maxRetryTimes; i++) {
// do retry
if (super.updateById(entity)) {
return true;
}
}
}
//....
throw new OptimisticLockException("更新失败,重试%d次后仍然失败".formatted(this.maxRetryTimes));
}
}

乐观锁

乐观锁 , 乐观的认为不需要加锁

在关系型数据库中,乐观锁通常通过版本控制来实现。

具体而言,每条记录都有一个版本号或时间戳属性,当数据被更新时,版本号会被自动递增或者时间戳会被更新

当进行更新操作时,乐观锁会先比较当前的版本号或时间戳与更新前的值是否一致,如果一致则允许更新,否则就表示数据版本冲突,并且需要进行相应的处理。

在使用乐观锁时,不需要显式地进行加锁或解锁操作,相比使用悲观锁(如数据库的行级锁或表级锁)来说,乐观锁的并发性更高,因为它假设并发冲突是不常见的。

通过乐观锁可以提高系统的并发性能,并且在合适的情形下,能够保证数据的一致性。


版本号思想的使用场景有很多, 比如ZK中我们可以利用在添加 or 删除文件的时候加上版本号参数来实现分布式锁

MySQL事务隔离级别

MySQL 事务都是指在 InnoDB 引擎下,MyISAM 引擎是不支持事务的。

数据库事务指的是一组数据操作,事务内的操作要么就是全部成功。

隔离级别 描述
Read Uncommitted 可能读取到其他事务未提交的数据,存在脏读、不可重复读和幻读的问题。
Read Committed 只能读取到其他事务已经提交的数据,避免脏读,但仍可能出现不可重复读和幻读的问题。
Repeatable Read 事务执行过程中多次读取同一数据,其间其他事务对该数据的修改不会影响该事务的读取结果。避免脏读和不可重复读,可能出现幻读问题。
Serializable 所有事务按照顺序执行,事务之间不会相互影响。避免脏读、不可重复读和幻读的问题,但并发性能较低。一般用于对一致性要求非常高的场景。

RU的隔离级别最低, 会存在各种问题, 基本上很少使用。

Spring中事务的默认隔离级别为RR

关于RC 与 RR 只要记住 RC是可以读取到其他事务已经提交的数据, RR是只能读取到在当前事务开启之前就已经提交的事务的数据

其实现原理是MVCC(Multi-Version Concurrency Control , 比如RR会在事务开启的时候生成一系列的数据版本快照 , 保证只会读取到之前事务的提交的数据 。

Spring事务的传播级别

传播属性 描述
PROPAGATION_REQUIRED 如果当前没有事务,则创建一个新的事务;如果当前存在事务,则加入该事务。
PROPAGATION_REQUIRES_NEW 无论当前是否存在事务,始终会启动一个新的事务,并在新事务中执行。
PROPAGATION_SUPPORTS 如果当前存在事务,则加入该事务;如果当前不存在事务,则以非事务方式执行。
PROPAGATION_NOT_SUPPORTED 当前方法不应该在事务中执行,如果有事务正在运行,则将该事务挂起。
PROPAGATION_MANDATORY 如果当前存在事务,则加入该事务;如果当前不存在事务,则抛出异常。
PROPAGATION_NEVER 当前方法不应该在事务中执行,如果有事务正在运行则抛出异常。
PROPAGATION_NESTED 如果有事务正在运行,则当前方法应该在该事务的嵌套事务中执行;如果没有事务正在运行,则会启动一个新事务,并在新事务中执行。

这里给出代码 , 可用于实际测试

下面提到的事务失效的问题 , 也可以通过编程式事务来解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Transactional(isolation = Isolation.REPEATABLE_READ, propagation = Propagation.REQUIRED)
@Test
public void transactionalTest() {
TaskDO task = createTask();
Long taskId = task.getId();
// 执行一些读取操作
System.out.println(taskManager.getById(taskId).getTitle());
taskManager.update().set(TaskDO.TITLE, "this is TRANSACTION title").eq(TaskDO.ID, taskId).update();
transactionTemplate.setPropagationBehavior(Propagation.NOT_SUPPORTED.value());
transactionTemplate.setIsolationLevel(Isolation.READ_COMMITTED.value());
transactionTemplate.execute(status -> {
System.out.println("tt: " + taskManager.getById(taskId).getTitle());
taskManager.update().set(TaskDO.TITLE, "this is new tt update").eq(TaskDO.ID, taskId).update();
return true;
});
System.out.println("TRANSACTION: " + taskManager.getById(taskId).getTitle());
}

实际上先不必管事务的隔离级别 , 只要我们可以确定当前是否是新的事务(是嵌套事务还是两个独立的事务) , 就可以自行推断出运行的结果。之后将理论付诸实践即可。

@Transactional注解失效的问题

失效主要有三种场景 :

  1. @Transactional注解标注方法修饰符为非public
  2. 在类内部调用调用类内部@Transactional标记的方法 (开头中我们提到的问题就在于这里)
  3. 事务方法内部捕捉了异常,没有抛出新的异常,导致事务操作不会进行回滚。

org.springframework.transaction.interceptor

这里先不探究为什么失效, 先来找一找 @Transactional注解声明式事务是如何实现的

首先, 在Spring或者Springboot项目中, 我们会大量使用到其提供的注解, 这固然非常方便, 但是过多的校验操作也提高了源码的复杂度以及我们阅读理解源码的难度。

我们知道 , 声明式事务是通过动态代理以及拦截器来进行实现的 , 一般代理的方式有两种 :

  • JDK动态代理 (需要类实现接口)
  • CGLIB动态代理

由于service并没有实现接口, 因此可以推测这里的动态代理方式是CGLIB动态代理

1
2
3
4
@Service
@Slf4j
public class TaskService {
}

但是CGLIB动态代理有一个问题 : 其只能代理公有方法 , 具体的demo可以查看 https://github.com/adorabled4/dem0828/commit/9799d2ff6e3ce0b5a5bfc873a11256afc5815521

之前通过 RabbitMQ Listener 不小心设置成了 private方法, 导致在使用Spring Retry进行重试的时候报错。

到这里第一种事务失效的场景也就可以理解了。

仍然是代理的问题 : 声明式事务的实现方式是AOP切面编程 , 在我们第一次调用 @Transactional标记的方法的时候, 事务是可以生效的, 但是如果在内部调用了 类中其他的被@Transactional标记的方法 , 是直接通过当前的代理对象进行方法调用, 不会再次生成新的代理对象 , 此时的调用与方法直接调用相差无几, 不会触发AOP的增强逻辑, 导致事务失效 。

那么第二种事务失效的场景也解释完毕.

接着我们来看看Spring是如何增强我们的方案的 :

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

protected Object invokeWithinTransaction(Method method, Class<?> targetClass, final InvocationCallback invocation)
protected Object invokeWithinTransaction(方法方法,类<?>targetClass,最终InvocationCallback调用)
throws Throwable {

// If the transaction attribute is null, the method is non-transactional.
final TransactionAttribute txAttr = getTransactionAttributeSource().getTransactionAttribute(method, targetClass);
final PlatformTransactionManager tm = determineTransactionManager(txAttr);
final String joinpointIdentification = methodIdentification(method, targetClass);
final String joinpointIdentification=methodIdentification(method,targetClass);

if (txAttr == null || !(tm instanceof CallbackPreferringPlatformTransactionManager)) {
// Standard transaction demarcation with getTransaction and commit/rollback calls.
//使用getTransaction和commit/rerollback调用进行标准事务划分。
//开启事务
TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);
Object retVal = null;
对象retVal=null
try {
// This is an around advice: Invoke the next interceptor in the chain.
//这是一个绕过建议:调用链中的下一个拦截器。
// This will normally result in a target object being invoked.
//反射调用业务方法
retVal = invocation.proceedWithInvocation();
retVal=调用.proceedWithInvocation();
}
catch (Throwable ex) {
// target invocation exception
//异常时,在catch逻辑中回滚事务
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
}
finally {
cleanupTransactionInfo(txInfo);
}
//提交事务
commitTransactionAfterReturning(txInfo);
return retVal;
}

else {
//....................
}
}

可以看到 , 在被修饰的方法运行出现异常的时候 , 会执行 completeTransactionAfterThrowing(txInfo, ex);来回滚事务 , 接着继续抛出异常

加入我们在方法中添加了类似于这样的逻辑, 没有把异常抛出去 , 导致Spring的增强方法 根本就不知道事务中出现了异常 , 此时就出现了第三种事务失效的情况

1
2
3
4
5
try{
//........
}catch(RuntimeException e){
log.info(e);
}

问题解决

在弄清楚问题的由来以及Spring事务的实现原理之后,问题也就迎刃而解了。

只需要将VersionServiceImpl中的声明式事务改成编程式事务,同时注意设置事务的传播级别为REQIRES_NEW,隔离级别为RC

这里注意不能设置为RR的隔离级别,否则会导致乐观锁没有用(只能读取到开启事务时候的数据 , 也就无法读取到最新的Version)

修改之后的代码框架如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Resource
TransactionTemplate transactionTemplate;
@Override
public boolean updateById(T entity) {
Boolean execute = transactionTemplate.execute(status -> {
//....
try {
for (int i = 0; i <= this.maxRetryTimes; i++) {
if (i != 0) {
log.debug("乐观锁更新失败,开始第{}次重试", i);
Thread.sleep(20 + RandomUtil.randomInt(0, 20));
}
//....
if (super.updateById(entity)) {
return true;
}
}
throw new OptimisticLockException("更新失败,重试%d次后仍然失败".formatted(this.maxRetryTimes));
} catch (IllegalAccessException | InvocationTargetException | InterruptedException e) {
throw new RuntimeException(e);
}
});
return BooleanUtil.isTrue(execute);
}

Reference