最近在做旧代码重构中经常会遇事务相关的问题,比如事务范围与有效性,事务同步,事务与其他中间件协同,事务粒度控制等问题,本篇系统地梳理下事务相关的知识内容。
1.事务的相关概念
事务的概念来自于两个独立的需求:并发数据库访问,系统错误恢复。在数据库操作中,一个事务可以被看作一个独立操作单元的一系列SQL语句的集合。
1.1 事务的特性(ACID)
-
A (atomacity 原子性) : 事务必须是原子工作单元;对于其数据修改,要么全都执行,要么全都不执行。
-
C (consistency 一致性) : 事务将数据库从一种一致状态转变为下一个一致状态。事务在完成时,必须使所有的数据都保持一致状态。
-
I (isolation 隔离性) : 由并发事务所作的修改必须与任何其它并发事务所作的修改隔离。事务查看数据时数据所处的状态,要么是另一并发事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看中间状态的数据。
-
D (durability 持久性) : 事务完成之后,它对于系统的影响是永久性的。该修改即使出现致命的系统故障也将一直保持。
1.2 事务相关行为
事务的行为包括开启事务、提交事务和回滚事务。在MySQL的InnoDB存储引擎中所有的用户SQL执行都在事务控制之内。
- 在默认情况下,autocommit设置为true,单条SQL执行成功后,MySQL会自动提交事务,或者如果SQL执行出错,则根据异常类型执行事务提交或者回滚。
- 可以使用START TRANSACTION或者BEGIN开启事务,使用COMMIT和ROLLBACK提交和回滚事务;
- 当设置autocommit为false时,其后执行的多条SQL语句将在一个事务内,直到执行COMMIT或者ROLLBACK事务才会提交或者回滚。
2.MySQL实现事务原理
在业务中常采用MySQL中间件作为关系型数据库,要实现事务的有效性,就需要解决MySQL解决可靠性和并发性的问题,可靠性是要保证当insert或update操作时抛异常或者数据库crash的时候需要保障数据的操作前后的一致;并发性要保证当有多个事务并发执行时,能避免读到脏数据,需要对事务之间的读写进行隔离。MySQL中使用日志文件(redo log 和 undo log),锁技术以及MVCC等技术来实现事务。
2.1 日志文件
在业务中常见的数据库MySQL是靠redo和undo来实现事务的重做和恢复。
- redo log: 用于记录已成功提交事务的修改信息。为了提升性能不会把每次的修改都实时同步到磁盘,而是会先存到Boffer Pool(缓冲池),后台线程去做缓冲池和磁盘之间的同步,所以引入了redo log来记录已成功提交事务的修改信息,会把redo log持久化到磁盘,如系统重启之后会再读取redo log恢复最新数据。
- undo log: 用于记录数据被修改前的信息。 为了在发生错误时回滚之前的操作,需要将之前的操作都记录下来,然后在发生错误时才可以回滚,undo log不用持久化,在提交完事务后则将删除该事务的undo log。
如下是一个简单的更新用户账户信息的例子,首先开启一个新的事务,在该事务中先先读取到某个用户的账户信息然后进行修改,包括了两条更新sql语句的执行,可以看到每次执行都会写redo log和undo log,一个记录执行前结果,一个记录执行后结果。当事务提交后, redo log将会同步到磁盘中,而undo log将被删除。
2.2 锁与MVCC
当有多个请求来读取表中的数据时可以不采取任何操作,但是多个请求里有读请求,又有修改请求时必须有一种措施来进行并发控制。读写锁解决上述问题很简单,只需用下面两种锁的组合来对读写请求进行控制即可。
- 共享锁(shared lock),又叫做"读锁",读锁是可以共享的,或者说多个读请求可以共享一把锁读数据,不会造成阻塞。
- 排他锁(exclusive lock),又叫做"写锁",写锁会排斥其他所有获取锁的请求,一直阻塞,直到写入完成释放锁。
InnoDB存储引擎的MVCC,是通过在每行记录的后面保存两个隐藏的列来实现的。这两个列, 一个保存了行的创建时间,一个保存了行的过期时间, 当然存储的并不是实际的时间值,而是系统版本号,主要实现思想是通过数据多版本来做到读写分离。从而实现不加锁读进而做到读写并行。MVCC在mysql中的实现依赖的是undo log与read view
- undo log: undo log 中记录某行数据的多个版本的数据。
- read view : 用来判断当前版本数据的可见性
总结下,MySQL事务的的原子性是通过 undo log 来实现的,持久性性是通过 redo log 来实现的,隔离性是通过 (读写锁+MVCC)来实现的。
3. Spring框架事务实现原理
3.1 Spring-AOP增强原理
Spring使用AOP(面向切面编程)来实现声明式事务,后续再讲Spring事务具体实现的时候会详细说明,关于AOP的概念可参考Spring AOP概念理解,这里不再细说。说下动态代理和AOP增强。
动态代理是Spring实现AOP的默认方式,分为两种:JDK动态代理和CGLIB动态代理。JDK动态代理面向接口,通过反射生成目标代理接口的匿名实现类;CGLIB动态代理则通过继承,使用字节码增强技术为目标代理类生成代理子类。Spring默认对接口实现使用JDK动态代理,对具体类使用CGLIB,同时也支持配置全局使用CGLIB来生成代理对象。详细实操可参见juejin.cn/post/684790…
我们在切面配置中会使用到@Aspect注解,这里用到了Aspectj的切面表达式。Aspectj是java语言实现的一个AOP框架,使用静态代理模式,拥有完善的AOP功能,与Spring AOP互为补充。Spring采用了Aspectj强大的切面表达式定义方式,但是默认情况下仍然使用动态代理方式,并未使用Aspectj的编译器和织入器,当然也支持配置使用Aspectj静态代理替代动态代理方式。Aspectj功能更强大,比方说它支持对字段、POJO类进行增强,与之相对,Spring只支持对Bean方法级别进行增强。
Spring对方法的增强有五种方式:
- 前置增强(org.springframework.aop.BeforeAdvice):在目标方法执行之前进行增强;
- 后置增强(org.springframework.aop.AfterReturningAdvice):在目标方法执行之后进行增强;
- 环绕增强(org.aopalliance.intercept.MethodInterceptor):在目标方法执行前后都执行增强;
- 异常抛出增强(org.springframework.aop.ThrowsAdvice):在目标方法抛出异常后执行增强;
- 引介增强(org.springframework.aop.IntroductionInterceptor):为目标类添加新的方法和属性。
声明式事务的实现就是通过环绕增强的方式,在目标方法执行之前开启事务,在目标方法执行之后提交或者回滚事务,事务拦截器的继承关系图可以体现这一点:
3.2 spring事务切面实现原理
Spring事务采用AOP的方式实现,主要通过TransactionAspectSupport类实现,可以看到这个类中关于事务操作的主要实现流程如下所示:
//1. 获取@Transactional注解的相关参数
TransactionAttributeSource tas = getTransactionAttributeSource();
// 2. 获取事务管理器
final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
final PlatformTransactionManager tm = determineTransactionManager(txAttr);
final String joinpointIdentification = methodIdentification(method, targetClass, txAttr);
if (txAttr == null || !(tm instanceof CallbackPreferringPlatformTransactionManager)) {
// 3. 获取TransactionInfo,包含了tm和TransactionStatus
TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);
Object retVal = null;
try {
// 4.执行目标方法
retVal = invocation.proceedWithInvocation();
} catch (Throwable ex) {
//5.出现异常,回滚事务
completeTransactionAfterThrowing(txInfo, ex);
throw ex;
} finally {
// 6. 清理当前线程的事务相关信息
cleanupTransactionInfo(txInfo);
}
// 7.提交事务
commitTransactionAfterReturning(txInfo);
return retVal;
}
TransactionManager保存着当前的数据源连接,对外提供对该数据源的事务提交回滚操作接口,同时实现了事务相关操作的方法。其中DataSource具有的核心方法如下:
- public commit 提交事务
- public rollback 回滚事务
- public getTransaction 获得当前事务状态
- protected doBegin 开始事务,主要是执行了JDBC的con.setAutoCommit(false)方法。同时处理了很多和数据库连接相关的ThreadLocal变量。
- protected doSuspend 挂起事务
- protected doCommit 提交事务
- protected doRollback 回滚事务
- protected doGetTransaction() 获取事务信息
3.3 Spring事务传播机制
Spring事务的传播级别描述的是多个使用了@Transactional注解的方法互相调用时,Spring对事务的处理逻辑,传播级别有:
- REQUIRED, 如果当前线程已经在一个事务中,则加入该事务,否则新建一个事务。
- SUPPORT, 如果当前线程已经在一个事务中,则加入该事务,否则不使用事务。
- MANDATORY(强制的),如果当前线程已经在一个事务中,则加入该事务,否则抛出异常。
- REQUIRES_NEW,无论如何都会创建一个新的事务,如果当前线程已经在一个事务中,则挂起当前事务,创建一个新的事务。
- NOT_SUPPORTED,如果当前线程在一个事务中,则挂起事务。
- NEVER,如果当前线程在一个事务中则抛出异常。
- NESTED, 执行一个嵌套事务,有点像REQUIRED,但是有些区别,在Mysql中是采用SAVEPOINT来实现的。
挂起事务,指的是将当前事务的属性如事务名称,隔离级别等属性保存在一个变量中,同时将当前线程中所有和事务相关的ThreadLocal变量设置为从未开启过线程一样。Spring维护着一个当前线程的事务状态,用来判断当前线程是否在一个事务中以及在一个什么样的事务中,挂起事务后,当前线程的事务状态就好像没有事务一样。
4.避坑指南
4.1 事务传播级别
以下是在业务开发中经常会碰到的一些事务传播级别的情况,特别是在多个事务交织的时候,更是一团乱麻,熟悉传播级别的具体表现是避坑的首要条件。如下是针对与事务之间的REQUIRED/REQUIRED_NEW/NESTED三种传播级别下的不同表现做的测试。
针对REQURED传播级别的测试如下,在外围方法未开启事务的情况下REQUIRED修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰,在外围方法开启事务的情况下REQUIRED修饰的内部方法会加入到外围方法的事务中,所有REQUIRED修饰的内部方法和外围方法均属于同一事务,只要一个方法回滚,整个事务均回滚。
@Service
public class TestServiceAImpl implements TestServiceA {
/**
* 外围未开启事务,B和C均在自己的事务中独立运行,
* 外围方法异常不影响内部插入,B和C的插入都正常
*/
public void saveApp1() {
serviceB.saveB_required();
serviceC.saveC_required();
throw new RuntimeException("test");
}
/**
* 外围未开启事务,B和C均在自己的事务中独立运行,
* 其中C方法中抛出异常,B正常插入,C回滚
*/
public void saveApp2() {
serviceB.saveB_required();
serviceC.saveC_required_throw_exception();
}
/**
* 外围开启事务,B和C都加入到外围事务中,
* 外围抛异常回滚,则BC内部方法也回滚
*/
@Transactional(propagation = REQUIRED)
public void saveApp3() {
serviceB.saveB_required();
serviceC.saveC_required();
throw new RuntimeException("test");
}
/**
* 外围开启事务,B和C都加入到外围事务中
* 当C内部方法抛异常时被外围捕捉,导致整体事务回滚,B也会回滚
*/
@Transactional(propagation = REQUIRED)
public void saveApp4() {
serviceB.saveB_required();
serviceC.saveC_required_throw_exception();
}
/**
* 外围开启事务,B和C都加入到外围事务中,
* 当C内部方法抛异常时已被捕获,即使外围感知不到,
* 也会造成B和C回滚
*/
@Transactional(propagation = REQUIRED)
public void saveApp5() {
serviceB.saveB_required();
try {
serviceC.saveC_required_throw_exception();
} catch (Exception ex) {
}
}
}
针对于REQUIRED_NEW传播级别的测试如下,在外围方法未开启事务的情况下REQUIRES_NEW修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰,在外围方法开启事务的情况下REQUIRES_NEW修饰的内部方法依然会单独开启独立事务,且与外部方法事务也独立,内部方法之间、内部方法和外部方法事务均相互独立,互不干扰。
@Service
public class TestServiceAImpl implements TestServiceA {
/**
* 外围未开启事务,B和C均在自己的事务中独立运行
* 外围方法异常不影响内部插入,B和C的插入都正常
*/
public void saveApp6() {
serviceB.saveB_new();
serviceC.saveC_new();
throw new RuntimeException("test");
}
/**
* 外围未开启事务,B和C均在自己的事务中独立运行,
* 其中C方法中抛出异常后回滚,B正常插入
*/
public void saveApp7() {
serviceB.saveB_new();
serviceC.saveC_new_throw_exception();
}
/**
* 外围开启事务,B_required跟外围事务属于同一个,
* 外围会受到抛出异常影响造成回滚,则B_required也回滚的,
* B_new和C_new则是两个新的事务不受影响,正常插入
*/
@Transactional(propagation = REQUIRED)
public void saveApp8() {
serviceB.saveB_required();
serviceB.saveB_new();
serviceC.saveC_new();
throw new RuntimeException("test");
}
/**
* 外围开启事务,B_required跟外围事务属于同一个,
* C_new开启新的事务抛了异常后回滚,并的影响到了外围事务造成了B_required的回滚,
* B_new是新的事务,插入不受影响,
*/
@Transactional(propagation = REQUIRED)
public void saveApp9() {
serviceB.saveB_required();
serviceB.saveB_new();
serviceC.saveC_new_throw_exception();
}
/**
* 外围开启事务,B_required跟外围事务属于同一个,
* 虽然C_new开启新的事务但抛了异常,被catch到了,并不会影响到外围事务,
* 所以B_required和C_new插入正常,C_new_throw_exception回滚
*/
@Transactional(propagation = REQUIRED)
public void saveApp10() {
serviceB.saveB_required();
serviceC.saveC_new();
try {
serviceC.saveC_new_throw_exception();
} catch (Exception ex) {
}
}
}
针对NESTED传播级别测试如下,在外围方法未开启事务的情况下NESTED和REQUIRED作用相同,修饰的内部方法都会新开启自己的事务,且开启的事务相互独立,互不干扰。在外围方法开启事务的情况下NESTED修饰的内部方法属于外部事务的子事务,外围主事务回滚,子事务一定回滚,而内部子事务可以单独回滚而不影响外围主事务和其他子事务。
@Service
public class TestServiceAImpl implements TestServiceA {
/**
* 外围未开启事务,B和C均在自己的事务中独立运行,
* 外围方法异常不影响独立事务,B和C的插入都正常
*/
public void saveApp11() {
serviceB.saveB_nest();
serviceC.saveC_nest();
throw new RuntimeException("test");
}
/**
* 外围开启事务,B和C均在自己的事务中独立运行,
* B正常插入,C回滚
*/
public void saveApp12() {
serviceB.saveB_nest();
serviceC.saveC_nest_throw_exception();
}
/**
* 外围开启事务,内部事务均为外围事务的子事务,
* 外围方法回滚,子事务也要回滚,B和C均插入失败
*/
@Transactional(propagation = REQUIRED)
public void saveApp13() {
serviceB.saveB_nest();
serviceC.saveC_nest();
throw new RuntimeException("test");
}
/**
* 外围开启事务,内部事务为子事务,
* 内部事务抛出异常后,外围方法感知导致整体事务回滚,B和C均插入失败
*/
@Transactional(propagation = REQUIRED)
public void saveApp14() {
serviceB.saveB_nest();
serviceC.saveC_nest_throw_exception();
}
/**
* 外围开启事务,内部事务为子事务,
* C插入抛出异常,则单独对C子事务回滚,
* 但异常已被捕获,B子事务则插入成功
*/
@Transactional(propagation = REQUIRED)
public void saveApp15() {
serviceB.saveB_nest();
try {
serviceC.saveC_nest_throw_exception();
} catch (Exception ex) {
}
}
}
总结下,NESTED、REQUIRED、REQUIRED_NEW三种事务隔离级别的相关特性表现如下:
- NESTED和REQUIRED修饰的内部方法都属于外围方法事务,如果外围方法抛出异常,这两种方法的事务都会被回滚。
- REQUIRED加入外围方法事务,所以和外围事务同属于一个事务,一旦REQUIRED事务抛出异常被回滚,外围方法事务也将被回滚。
- NESTED是外围方法的子事务,有单独的保存点,所以NESTED方法抛出异常被回滚,不会影响到外围方法的事务。
- NESTED和REQUIRES_NEW都可以做到内部方法事务回滚而不影响外围方法事务。但是因为NESTED是嵌套事务,所以外围方法回滚之后,作为外围方法事务的子事务也被回滚。
- 而REQUIRES_NEW是通过开启新的事务实现的,内部事务和外围事务是两个事务,外围事务回滚不会影响内部事务。
4.2 事务有效性
在业务开发中会经常遇到事务失效的情况,其中要考虑到数据库、aop技术、spring的@Transactional注解、应用的数据源配置等功能特性,以下是总结的一些踩坑经验:
- 在MySQL数据库中Innodb存储引擎才支持事务,而MyISAM存储引擎是不支持事务的;
- 没有指定rollbackFor参数,默认捕捉抛出来的Throwable类型包括了Exception和Error两个子类型,
- 在有多个数据源的时候,比如做分库分表/读写分离的时候会经常遇到一个应用里面配置了多个数据源,没有指定transactionManager参数,默认的transactionManager可能就不是
- 如果AOP使用了JDK动态代理,对象内部方法互相调用不会被Spring的AOP拦截,@Transactional注解无效。
- 如果AOP使用了CGLIB代理,事务方法或者类不是public,无法被外部包访问到,或者是final无法继承,@Transactional注解无效。
4.3 事务的粒度控制
在平时业务开发中,经常遇到在一个很大的方法上加了@Transactional注解,从调用方法开始就一直打开并占用了一条连接资源,在这个大方法中调用了很多外部接口和逻辑,里面会消耗大量的时间,等到这些都做完了才会去执行最后一步的入库操作,这样极大地影响了数据库的吞吐量等性能表现,通常表现为数据库连接不够用。可通过抓MySQL数据库服务端与应用端的通信包分析可得到。整个事务范围过大,可视情况而定将事务范围适当所小,到使用的时候才去获取连接资源,这样使用更加合理,我们团队中就有事务范围过大的血的教训,
@Transactional
public void big_transaction() {
//调用外部接口获取用户列表
List<User> users = findUser(classUid);
//调用外部接口获取班级信息
Map<String, String> classNameMap = findName(classUid);
//检查用户列表是否正确
check(users);
......
//真正执行sql入库操作
saveUser(users);
}
4.4 与不同中间件的事务同步
在实际业务开发中,我们经常会有一些比较重要的操作需要在DB事务提交之后在再执行操作的, 比如发送MQ或清理缓存。举个实际的业务场景:更新用户信息,与之相关的是两个接口
- 查询用户信息:从缓存里面查询用户信息,如果查不到,则从DB加载,再放到缓存中
- 更新用户信息:更新db信息、删除缓存
比如某个事务内,代码顺序是 A - B - C - D,我们指定B和D是在事务提交之后再执行的。
- 事务提交成功,执行顺序是:A - C - 事务提交 - B - D
- 事务回滚,执行顺序是:A - C - 事务回滚
我们团队对此情况下进行了事务的增强,在同一个事务中既更新了数据库记录,还清空了缓存,发送了消息。如果不使用增强组件的话,整个事务过程会非常长,而且会受到缓存连接处理与发送消息处理等影响,影响了数据库的吞吐量。在使用组件之后就会控制事务的提交和回滚。
@Service
@InvokeAfterCommitted
public class UserServiceAfterImpl {
/**
* 作息相关更新MQ
*/
public void after() {
//清除缓存
clearCache();
//发送mq
sendMq();
}
}
@Service
public clsss ScheduleServiceImpl {
@Autowired
privated UserServiceAfterImpl afterInvokerImpl;
@Transactional
@Ovveride
public void updateSchedule() {
//执行update sql
scheduleManager.udpate();
//入库后发送更新MQ,删除缓存
afterInvokerImpl.after();
}
如下所示,是实现上述功能的部分代码。
public class CustomTransactionManager extends DataSourceTransactionManager {
@Override
protected DefaultTransactionStatus newTransactionStatus(TransactionDefinition definition, Object transaction, boolean newTransaction, boolean newSynchronization, boolean debug, Object suspendedResources) {
DefaultTransactionStatus transactionStatus =
super.newTransactionStatus(definition, transaction,
newTransaction, newSynchronization, debug, suspendedResources);
return DefaultTransactionStatusProxy.proxyTransaction(transactionStatus,
transaction, newTransaction, transactionStatus.isNewSynchronization(),
definition.isReadOnly(), debug, suspendedResources);
}
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
super.doBegin(transaction, definition);
InvokerHolder.init();
}
@Override
protected void doResume(Object transaction, Object suspendedResources) {
super.doResume(transaction, suspendedResources);
Object messageTxHolderObj = TransactionSynchronizationManager.unbindResource(suspendedResources);
if (messageTxHolderObj instanceof InvokerHolder) {
InvokerHolder.resume((InvokerHolder) messageTxHolderObj);
} else {
...
}
}
@Override
protected void doCommit(DefaultTransactionStatus status) {
super.doCommit(status);
List<Invoker> logicList = InvokerHolder.clear();
for (Invoker logic : logicList) {
try {
logic.doInvoke();
} catch (Exception e) {
...
}
}
}
@Override
protected void doRollback(DefaultTransactionStatus status) {
super.doRollback(status);
List<Invoker> logicList = InvokerHolder.clear();
for (Invoker logic : logicList) {
...
}
}
}
5.总结
本篇主要围绕日常业务开发中事务的使用展开,首先介绍了事务的基本知识,并介绍了MySQL和Spring实现事务的一些技术原理,并总结了我们团队日常开发中发现到的一些问题与相应的实践,希望能给大家带来一些收获!
参考文献
cloud.tencent.com/developer/a… mysql事务实现原理
segmentfault.com/a/119000001… spring事务传播行为详解
blog.csdn.net/qq\_3852657… spring传播行为
-----------------------------------------------------------------------------------------
如果觉得这个文章写得还不错,点个赞呗!
求点赞👍 求关注❤️ 求分享👥