作者:李玉亮
引言
数据库事务与大多数后端软件开发人员的工作密不可分,本文从事务理论、事务技术、事务实践等方面对常用的相关事务知识进行整理总结,供大家参考。
事务理论介绍
事务定义
在数据库管理系统中,事务是单个逻辑或工作单元,有时由多个操作组成,在数据库中以一致模式完成的逻辑处理称为事务。一个例子是从一个银行账户转账到另一个账户:完整的交易需要减去从一个账户转账的金额,然后将相同的金额添加到另一个账户。
事务特性
原子性( atomicty)
事务中的全部操作在数据库中是不可分割的,要么全部完成,要么全部不执行。
一致性(consistency)
事务的执行不能破坏数据库数据的完整性和一致性。一致性指数据满足所有数据库的条件,比如字段约束、外键约束、触发器等,事务从一致性开始,以一致性结束。
隔离性( isolation)
事务的执行不受其他事务的干扰,事务执行的中间结果对其他事务是透明的。
持久性(durability)
对于提交事务,系统必须保证该事务对数据库的改变不被丢失,即使数据库出现故障。
注:DBMS一般采用日志来保证事务的原子性、一致性和持久性。
事务隔离级别
并发事务带来的问题
问题 | 说明 |
脏读Dirty read | 当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,然后使用了这个数据。因为这个数据是还没有提交的数据,那么另外一个事务读到的这个数据是“脏数据”,依据“脏数据”所做的操作可能是不正确的。 |
丢失修改Lost to modify | 指在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据,这样第一个事务内的修改结果就被丢失,因此称为丢失修改。 例如:事务1读取某表中的数据A=20,事务2也读取A=20,事务1修改A=A-1并提交,事务2也修改A=A-1并提交,最终结果A=19,事务1的修改被丢失。 |
不可重复读Unrepeatable read | 指在一个事务内多次读同一数据,在这个事务还没有结束时,另一个事务也访问该数据。在第一个事务中的两次读数据之间,由于第二个事务的修改并提交导致第一个事务两次读取的数据可能不一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。 |
幻读Phantom read | 幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据并提交。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。 |
不可重复读的重点是数据修改场景,幻读的重点在于新增或者删除场景。
事务隔离级别
SQL92标准定义了4种隔离级别的事务
隔离级别 | 说明 | 脏读 | 不可重复读 | 幻读 |
读未提交Read uncommitted | 事务可以读取未提交的数据。事务中的修改,即使没有提交,对其他事务也都是可见的 | √ | √ | √ |
读已提交Read committed | 一个事务开始后,只能看到已经提交的事务所做的修改 | × | √ | √ |
可重复读Repeatable read | 在同一个事务中多次读取同样的记录的结果是一致的。它解决了脏读和不可重复读问题,但还是无法解决幻读问题 | × | × | √ |
可串行化Serializable | 通过强制事务串行,避免了前面说的幻读问题,实际应用中很少 | × | × | × |
大多数数据库系统如oracle的默认隔离级别都是 Read committed,mysql默认为可重复读,InnoDB 和 XtraDB 存储引擎通过多版并发控制(MVCC,Multivesion Concurrency Control)解决了幻读问题,Repeatable read 是 Mysql 默认的事务隔离级别,其中 InnoDB主 要通过使用 MVVC 获得高并发,使用一种被称为 next-key-locking 的策略来避免幻读。
事务模型
事务提交模型
显式事务:又称自定义事务,是指用显式的方式定义其开始和结束的事务,当使用start transaction和 commit语句时表示发生显式事务。
隐式事务:隐式事务是指每一条数据操作语句都自动地成为一个事务,事务的开始是隐式的,事务的结束有明确的标记。即当用户进行数据操作时,系统自动开启一个事务,事务的结束则需手动调用 commit或 rollback语句来结束当前事务,在当前事务结束后又自动开启一个新事务。
自动事务:自动事务是指能够自动开启事务并且能够自动结束事务。在事务执行过程中,如果没有出现异常,事务则自动提交;当执行过程产生错误时,则事务自动回滚;一条SQL语句一个事务。
事务编程模型
本地事务模型:事务由本地资源管理器来管理。简单理解就是直接使用JDBC的事务API。
connection.setAutoCommit(false);// 自动提交关闭
//XXXX数据库的增删改查操作
connection.commit(); //提交事务
编程式事务模型:事务通过JTA以及底层的JTS实现来管理,对于开发人员而言,管理的是“事务”,而非“连接”。简单理解就是使用事务的API写代码控制事务。
示例一、JTA的API编程
UserTransaction txn = sessionCtx.getUserTransaction();
txn.begin();
txn.commit();
示例二、Spring的事务模版
transactionTemplate.execute(
new TransactionCallback<Object>() {
@Override
public Object doInTransaction(TransactionStatus status) {
// 事务相关处理
return null;
}
}
);
声明式事务:事务由容器进行管理,对于开发人员而言,几乎不管理事务。简单理解就是加个事务注解或做个AOP切面。
@Transactional(rollbackFor = Exception.class)
public void updateStatus(String applyNo){
cashierApplyMapper.updateStatus(applyNo, CANCEL_STATUS, CANCEL_STATUS_DESC);
}
比较
模型 | 优点 | 缺点 | 说明 |
本地事务模型 | 自主可控 | 侵入性较大,开发人员时刻关注事务边界,写大量commit代码,不支持XA | 无 |
编程式事务模型 | 模型高了一个层级,自主可控性强 | 需要写代码,不优雅 | 无 |
声明式事务模型 | 简单易用 | 事务边界必须是方法级别,依赖AOP的机制 | 常用的实践做法 |
附:SQL相关小知识
SQL的全称:Structured Query Language。中文翻译:结构化查询语言。
关系数据库理论之父:埃德加·科德。是一位计算机的大牛,他凭借关系数据模型理论获得了图灵奖,核心思想就两个:关系代数和关系演算,发表了一篇牛逼的论文“A Relational Model of Data for Large Shared Data Banks”。
写第一句SQL的人:Donald D. Chamberlin 和 Raymond F. Boyce。埃德加·科德的两个同事Donald D. Chamberlin和Raymond F. Boyce根据论文,发明出了简单好用的SQL语言。
SQL 标准:有两个主要的标准,分别是 SQL92 和 SQL99 。92 和 99 代表了标准提出的时间。除了 SQL92 和 SQL99 以外,还存在 SQL-86、SQL-89、SQL:2003、SQL:2008、SQL:2011 和 SQL:2016 等其他的标准。
事务技术介绍
以Spring+Mybatis+JDBC+Mysql为例,常见的事务类请求的调用链路如下图。请求调用应用服务,应用服务中开启事务并进行业务操作,操作过程中调用Mybatis进行数据库类操作,Mybatis通过JDBC驱动与底层数据库交互。
因此接下来先按Mysql、JDBC、Mybatis、Spring来介绍各层的事务相关知识;最后进行全链路的调用分析。
Mysql事务相关
Mysql逻辑架构
架构图如下(InnoDB存储引擎):
MySQL事务是由存储引擎实现的,MySQL支持事务的存储引擎有InnoDB、NDB Cluster等,其中InnoDB的使用最为广泛,其他存储引擎如MyIsam、Memory等不支持事务。
Mysql的事务保证
Mysql的4个特性中有3个与 WAL(Write-Ahead Logging,先写日志,再写磁盘)有关系,需要通过 Redo、Undo 日志来保证等,而一致性需要通过DBMS的功能逻辑及原子性、隔离性、持久性共同来保证。
MVCC
MVCC最大的好处是读不加锁,读写不冲突,在读多写少的系统应用中,读写不冲突是非常重要的,可极大提升系统的并发性能,这也是为什么现阶段几乎所有的关系型数据库都支持 MVCC 的原因,目前MVCC只在 Read Commited 和 Repeatable Read 两种隔离级别下工作。它是通过在每行记录的后面保存两个隐藏列来实现的,这两个列, 一个保存了行的创建时间,一个保存了行的过期时间, 存储的并不是实际的时间值,而是系统版本号。MVCC在mysql中的实现依赖的是undo log与read view。
read view
在 MVCC 并发控制中,读操作可以分为两类: 快照读(Snapshot Read)与当前读 (Current Read)。
•快照读:读取的是记录的快照版本(有可能是历史版本)不用加锁(select)。
•当前读:读取的是记录的最新版本,并且当前读返回的记录,都会加锁,保证其他事务不会再并发修改这条记录(select… for update 、lock或insert/delete/update)。
redo log
redo log叫做重做日志。mysql 为了提升性能不会把每次的修改都实时同步到磁盘,而是会先存到Buffer Pool(缓冲池)里,当作缓存来用以提升性能,使用后台线程去做缓冲池和磁盘之间的同步。那么问题来了,如果还没来及的同步的时候宕机或断电了怎么办?这样会导致丢部分已提交事务的修改信息!所以引入了redo log来记录已成功提交事务的修改信息,并且会把redo log持久化到磁盘,系统重启之后再读取redo log恢复最新数据。redo log是用来恢复数据的,保障已提交事务的持久化特性。
undo log
undo log 叫做回滚日志,用于记录数据被修改前的信息。他正好跟前面所说的重做日志所记录的相反,重做日志记录数据被修改后的信息。undo log主要记录的是数据的逻辑变化。为了在发生错误时回滚之前的操作,需要将之前的操作都记录下来,然后在发生错误时才可以回滚。undo log 记录事务修改之前版本的数据信息,假如由于系统错误或者rollback操作而回滚的话可以根据undo log的信息来进行回滚到没被修改前的状态。undo log是用来回滚数据的,保障未提交事务的原子性。
示例
假设 F1~F6 是表中字段的名字,1~6 是其对应的数据。后面三个隐含字段分别对应该行的隐含ID、事务号和回滚指针,如下图所示。
具体的更新过程如下:
假如一条数据是刚 INSERT 的,DB_ROW_ID 为 1,其他两个字段为空。当事务 1 更改该行的数据值时,会进行如下操作,如下图所示。
•用排他锁锁定该行,记录 Redo log;
•把该行修改前的值复制到 Undo log,即图中下面的行;
•修改当前行的值,填写事务编号,并回滚指针指向 Undo log 中修改前的行。
如果再有事务2操作,过程与事务 1 相同,此时 Undo log 中会有两行记录,并且通过回滚指针连在一起,通过当前记录的回滚指针回溯到该行创建时的初始内容,如下图所示,这里的undolog不会一直增加,purge thread在后面会进行undo page的回收,也就是清理undo log。
JDK事务相关
JDBC规范
java定义了统一的JDBC驱动API,各数据库厂商按规范实现。jdbc驱动相关包在java.sql包下:
使用示例:
// 创建数据库连接
Connection connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/easyflow", "root", "12345678");
// 自动提交设置
connection.setAutoCommit(false);
// 只读设置
connection.setReadOnly(false);
// 事务隔离级别设置
connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
// 创建查询语句
PreparedStatement statement = connection.prepareStatement("update config set cfg_value='1' where id=11111");
// 执行SQL
int num = statement.executeUpdate();
System.out.println("更新行数:" + num);
// 事务提交
connection.commit();
JDBC驱动注册机制
之前需要调用Class.forName或其他方式显式加载驱动,现在有了SPI机制后可不写。
public class DriverManager {
// List of registered JDBC drivers
private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
private static volatile int loginTimeout = 0;
private static volatile java.io.PrintWriter logWriter = null;
private static volatile java.io.PrintStream logStream = null;
// Used in println() to synchronize logWriter
private final static Object logSync = new Object();
/* Prevent the DriverManager class from being instantiated. */
private DriverManager(){}
/**
* Load the initial JDBC drivers by checking the System property
* jdbc.properties and then use the {@code ServiceLoader} mechanism
*/
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
……
JTA规范
JTA 全称 Java Transaction API,是 X/OPEN CAE 规范中分布式事务 XA 规范在 Java 中的映射,是 Java 中使用事务的标准 API,同时支持单机事务与分布式事务。
作为 J2EE 平台规范的一部分,JTA 与 JDBC 类似,自身只提供了一组 Java 接口,需要由供应商来实现这些接口,与 JDBC 不同的是这些接口需要由不同的供应商来实现。
相关代码在jta jar的javax.transaction包下。
Mybatis事务相关
Mybatis核心是提供了sql查询方法、结果集与应用方法及对象之间的映射关系,便于开发人员进行数据库操作。
整体模块如下:
各模块与下面的各子包一一对应:
Mybatis执行的核心类如下:
Mysql的核心入口类为SqlSession,事务相关的操作通过TransactionFactory来处理,可选择使用Spring事务(SpringManagedTransaction)还是内置事务管理。
事务相关的控制处理可见SqlSessionInterceptor类,主要逻辑如下:
源码见下:
private class SqlSessionInterceptor implements InvocationHandler {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
SqlSession sqlSession = getSqlSession(SqlSessionTemplate.this.sqlSessionFactory,
SqlSessionTemplate.this.executorType, SqlSessionTemplate.this.exceptionTranslator);
try {
Object result = method.invoke(sqlSession, args);
if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
// force commit even on non-dirty sessions because some databases require
// a commit/rollback before calling close()
sqlSession.commit(true);
}
return result;
} catch (Throwable t) {
Throwable unwrapped = unwrapThrowable(t);
if (SqlSessionTemplate.this.exceptionTranslator != null && unwrapped instanceof PersistenceException) {
// release the connection to avoid a deadlock if the translator is no loaded. See issue #22
closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
sqlSession = null;
Throwable translated = SqlSessionTemplate.this.exceptionTranslator
.translateExceptionIfPossible((PersistenceException) unwrapped);
if (translated != null) {
unwrapped = translated;
}
}
throw unwrapped;
} finally {
if (sqlSession != null) {
closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
}
}
}
}
Spring事务相关
spring事务相代码主要位于spring-tx包,如TransactionInterceptor。spring-jdbc包中有spring jdbc对事务的相关支持实现,如JdbcTransactionManager。核心类如下图,主要有三大部分:事务管理器(TransactionManager)、事务定义(TransactionDefinition)、事务状态(TtransactionStatus),这也是经常见的一种架构思维,将功能模块抽象为配置态定义、运行态实例和执行引擎,在开源组件jd-easyflow(
https://github.com/JDEasyFlow/jd-easyflow) 中也是此种设计理念。从下面的类图可以Spring的设计非常有层次化,很有美感。
Spring编程式事务
常用类为TransacitonTemplate,执行逻辑为:获取事务状态->在事务中执行业务->提交或回滚,源码见下:
@Override
@Nullable
public <T> T execute(TransactionCallback<T> action) throws TransactionException {
Assert.state(this.transactionManager != null, "No PlatformTransactionManager set");
if (this.transactionManager instanceof CallbackPreferringPlatformTransactionManager) {
return ((CallbackPreferringPlatformTransactionManager) this.transactionManager).execute(this, action);
}
else {
TransactionStatus status = this.transactionManager.getTransaction(this);
T result;
try {
result = action.doInTransaction(status);
}
catch (RuntimeException | Error ex) {
// Transactional code threw application exception -> rollback
rollbackOnException(status, ex);
throw ex;
}
catch (Throwable ex) {
// Transactional code threw unexpected exception -> rollback
rollbackOnException(status, ex);
throw new UndeclaredThrowableException(ex, "TransactionCallback threw undeclared checked exception");
}
this.transactionManager.commit(status);
return result;
}
}
Spring声明式事务
声明式事务实现原理就是通过AOP/动态代理。
在Bean初始化阶段创建代理对象:Spring容器在初始化每个单例bean的时候,会遍历容器中的所有BeanPostProcessor实现类,并执行其
postProcessAfterInitialization方法,在执行AbstractAutoProxyCreator类的postProcessAfterInitialization方法时会遍历容器中所有的切面,查找与当前实例化bean匹配的切面,这里会获取事务属性切面,查找@Transactional注解及其属性值,然后根据得到的切面创建一个代理对象,默认是使用JDK动态代理创建代理,如果目标类是接口,则使用JDK动态代理,否则使用Cglib。
在执行目标方法时进行事务增强操作:当通过代理对象调用Bean方法的时候,会触发对应的AOP增强拦截器,声明式事务是一种环绕增强,对应接口为MethodInterceptor,事务增强对该接口的实现为TransactionInterceptor,类图如下:
事务拦截器TransactionInterceptor在invoke方法中,通过调用父类TransactionAspectSupport的invokeWithinTransaction方法进行事务处理,包括开启事务、事务提交、异常回滚 。
声明式事务有5个配置项,说明如下:
事务配置一、事务隔离级别
配置该事务的隔离级别,一般情况数据库或应用统一设置,不需要单独设值。
事务配置二、事务传播属性
事务传播属性是spring事务模块的一个重要属性。简单理解,他控制一个方法在进入事务时,在外层方法有无事务的场景下,自己的事务的处理策略,如是复用已有事务还是创建新事务。
spring支持的传播属性有7种,如下:
PROPAGATION_REQUIRED=0 | 如果当前没有事务,就新建一个事务,如果已经存在一个事务中,加入到这个事务中。这是最常见的选择,也是默认的选择。 |
PROPAGATION_SUPPORTS=1 | 支持当前事务,如果当前没有事务,就以非事务方式执行。 |
PROPAGATION_MANDATORY=2 | 使用当前的事务,如果当前没有事务,就抛出异常。 |
PROPAGATION_REQUIRES_NEW=3 | 新建事务,如果当前存在事务,把当前事务挂起。 |
PROPAGATION_NOT_SUPPORTED=4 | 以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。 |
PROPAGATION_NEVER=5 | 以非事务方式执行,如果当前存在事务,则抛出异常。 |
PROPAGATION_NESTED=6 | 如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则执行与PROPAGATION_REQUIRED类似的操作。 |
事务配置三、事务超时
事务的超时设置是为了解决什么问题呢?
在数据库中,如果一个事务长时间执行,这样的事务会占用不必要的数据库资源,还可能会锁定数据库的部分资源,这样在生产环境是非常危险的。这时就可以声明一个事务在特定秒数后自动回滚,不必等它自己结束。
事务超时时间的设置
由于超时时间在一个事务开启的时候创建的,因此,只有对于那些具有启动一个新事务的传播行为(PROPAGATION_REQUIRES_NEW、PROPAGATION_REQUIRED、ROPAGATION_NESTED),声明事务超时才有意义。
事务配置四、事务只读
如果一个事务只对数据库进行读操作,数据库可以利用事务的只读特性来进行一些特定的优化。我们可以通过将事务声明为只读,让数据库对我们的事务操作进行优化。
事务配置五、回滚规则
回滚规则,就是程序发生了什么会造成回滚,这里我们可以进行设置RuntimeException或者Error。
默认情况下,事务只有遇到运行期异常时才会回滚,而在遇到检查型异常时不会回滚。
我们可以声明事务在遇到特定的异常进行回滚。同样,我们也可以声明事务遇到特定的异常不回滚,即使这些异常是运行期异常。
声明式事务失效的场景
序号 | 场景 | 说明 |
1 | 同一个类中方法调用导致@Transactional失效 | 类内部访问:A 类的 a1 方法没有标注 @Transactional,a2 方法标注 @Transactional,在 a1 里面调用 a2,则不生效。因为只有当事务方法被当前类以外的代码调用时,才会由Spring生成的代理对象来管理。 |
2 | 多线程 | 声明式事务是通过ThreadLocal来保持状态的,线程切换会导致事务传播属性失效 |
3 | @Transactional 注解属性 rollbackFor设置错误 | rollbackFor可以指定能够触发事务回滚的异常类型。Spring默认抛出了未检查unchecked异常(继承自 RuntimeException的异常)或者 Error才回滚事务,其他异常不会触发回滚事务。 |
4 | 异常被try catch掉 | 无 |
5 | @Transactional非public修饰的方法上 | AOP时会检查目标方法的修饰符是否为 public,不是 public则不会获取@Transactional 的属性配置信息 |
6 | @Transactional 注解属性propagation设置错误 | 无 |
7 | 数据库引擎不支持事务 | 无 |
8 | 没有定义spring bean | 无 |
事务同步管理器
Spring中有一个事务同步管理器类
TransactionSynchronizationManager,它提供了事务提交后处理等相关回调注册的方法。当我们有业务需要在事务提交过后进行某一项或者某一系列的业务操作时候我们就可以使用
TransactionSynchronizationManager。
事务请求处理链路示例
下图为全链路的从应用发起到开启事务,到业务逻辑处理(SQL执行),最后关闭事务的正向链路。
事务实践相关
数据一致性
同一个数据源的操作在一个事务内可保证一致,但实际场景中会因为不同事务或不同数据源(不同关系数据库、缓存或远端服务)而导致数据不能强一致。在CAP理论框架下,我们一般是保证可用性、分区容错性,基于BASE理论达到最终一致性。但如何达到数据的最终一致性需要合理设计。
数据库的提交、缓存的更新、RPC的执行、消息的发送的先后顺序
一般我们以数据库数据为准,先数据库提交,再更新缓存或发送消息,通过异步轮询补偿的方式保证异常情况下的最终一致性。
不建议用法:
1、事务回滚会导致缓存和数据库不一致
2、事务回滚会导致消息接收方收到的数据状态错误
建议用法:
1、先更新数据库,事务提交后再更新缓存或发送消息
2、通过异步异常重试或批处理同步来保证数据的最终一致性
3、核心交易以数据库数据为准
系统健壮性增强,但编程模型复杂一些
长事务
如果事务中有耗时长的SQL或有RPC操作可能会导致事务时间变长,会导致并发量大的情况下数据库连接池被占满,应用无法获取连接资源,在主从架构中会导致主从延时变大。
建议事务粒度尽量小,事务中尽量少包含RPC操作。事务尽量放在下层。
不建议用法
建议用法
这种方式需要应用程序保证多个事务操作的最终一致性,一般可通过异常重试来实现。
事务代码层级
事务该加在哪一层?放在上层的优点是编程简单,放在底层则需要需要在一个事务的操作封装在一起沉淀到底层。
对于传统架构(如下图),建议在DAO层和Manager层加事务。Service层可以有,但重的Service或有rpc的Service操作慎用。
对于领域设计类架构(如下图),从DDD的思想上,建议放在APP层(基础设施不应是领域层关注的),但考虑到长事务问题,不建议放在APP层,更建议优先放在基础设施层,domain的service层也可有。
总结
以上对事务的常用知识进行了总结整理,相关实践规范有的并无完美固定答案,需要结合实际而论,欢迎大家留言沟通!