很多关系型数据库的教材中都会有这么一句话:
事务是数据库系统区别于文件系统的根本属性
事务是数据库中一个很重要的概念,简单的说事务就是一组数据库操作的集合。事务实现起来比较复杂,包括的知识体系也比较庞大,本文将以MySQL的InnoDB引擎为例,详细描述关系型数据库的事务体系。
1. 事务的特性
事务的四大特性在每一本数据库教材中都会出现,现在列举如下:
- 原子性(Atomicity):即事务是原子的,一个事务中的操作,要么全部成功,要么全部不成功,不存在部分成功部分失败的情况;
- 一致性(Consistency):事务执行前后,整体系统保持稳定,即事务不破坏数据完整性和业务逻辑一致性;
- 隔离性(Isolation):不同事务之间不会相互影响,这是实现并发能力的前提;
- 持久性(Durability):事务成功结束,它对数据库做出的更改必须永久保存。
以上是事务的四个基本属性,流行度较高的关系型数据库系统都是支持者四种属性的。
下面将一一讲述这四种股属性。
1.1 原子性和一致性
提到原子性,我们总会举这么一个例子,当我们去银行的ATM机上取钱的时候,会经过以下步骤:
- 插卡输入密码,银行校验密码成功,进入操作界面;
- 点击取款按钮,输入金额;
- 银行校验账户余额是否足够取现;
- 银行校验成功,吐钞,扣除账户相等金额,继续下一步,或者银行校验失败,提示余额不足并退卡,则流程结束;
- 用户收钱,选择退卡,流程结束。
这一步骤中,如果一个环节出现了问题,所有操作都要回滚,否则就有可能出现两种情况:
- 吐钞成功,账户没有成功扣款;
- 吐钞失败,账户异常扣款。
两种情况都会造成损失,第一种情况还会造成一定的法律问题。因此这些操作一定要是事务的,要满足事务的原子性,即要么全部成功,要么全部回滚。
所谓原子性即是如此,虽然简单,却是事务实现的最基本属性。
具体到InnoDB引擎中,事务的原子性是由Undo log技术保证的。每一次数据库的操作时,都会记录一个Undo log,这里记录了所有操作的逆操作,一旦需要回滚的时候,就会把Undo log中记录的操作全部执行一次,保证事务回到开始时的状态。
数据库是用来保存数据的,在一个正常提供服务的系统中,数据库中的数据是完整的,合法的,同时也存在各种各样的约束,比如主键约束,唯一键约束,外键约束等等。
事务的一致性要求所有操作完成之后,数据库的数据完整性不受破坏,仍旧是合法的数据。
除了这种一致性之外,现在分布式数据库系统还提出了CAP理论,因此一致性有时候也表示分布式数据库系统中的C。但是这并不是本文论述的重点,因此在此仅仅是提及,并不深入讲述。
1.2 隔离性
隔离性是事务比较难以理解的部分,涉及到的技术比较多。首先需要了解到的知识点是事务的隔离级别,事务的隔离级别决定了数据库系统的并发能力:
- 未提交读(Read Uncommitted):未提交读是一个基本不可用的隔离级别,事务A对数据的更改,在提交之前就可以被事务B读取到,这种隔离级别产生了脏读的概念,在生产环境上基本没有人使用;
- 提交读(Read Committed):事务A提交的事务,事务B可以读取到,但是事务B绝对读取不到没有提交的事务,这是一种常见的隔离级别,Oracle采用这个级别,这个级别解决了脏读问题;
- 可重复读(Read Repeatable):一个事务生命周期内,读取到的数据永远是一样的,其他事务对数据的修改,无论提交与否,都看不到;
- 串行化(Serializable):即牺牲并发能力,一个事务必须等前一个事务完成才能继续执行。
下面将用一个表格来说明所谓的RR和RC隔离级别:
| 时间点 | 事务A | 事务B |
|---|---|---|
| T1 | start | start |
| T2 | select col1 from table1; | |
| T3 | update table1 set col1=2; | |
| T4 | commit; | |
| T5 | select col1 from table1; | |
| T6 | commit; |
在RR级别下,假设T2时间点上事务B读取的值是1,那么T5时间点上事务B读取到的值仍旧是1;
在RC级别下,还是保持之前的假设,在T2时间点,事务B读取到的值是1,但是在T5时间点,事务B读取到的值就变成了2。
不论什么隔离级别,都绕不开一种叫做多版本并发控制的技术(MVCC)。我们知道,update操作是要给记录加上X锁的,这种锁是排他的,不兼容S锁的,因此正常情况下另外一个事务是无法获取已经加了X锁的记录上的S锁的。但是上表中事务B可以在T5时间点上正常的读取数据,其实读取到的并不是真实的数据,而是一份快照。
实现这种快照的技术就称为MVCC,MVCC基于Undo log产生。
1.3 锁和锁算法
事务的隔离性和锁是一对不能分开来讲的概念,隔离性提供了数据库并发能力的理论基础,锁则具体实现了隔离性,为数据库提供了并发处理能力。
关系型数据库中普遍提供了两种锁,即X锁(排它锁)和S锁(共享锁),下面是两种锁的兼容关系:
| X锁 | S锁 | |
|---|---|---|
| X锁 | 不兼容 | 不兼容 |
| S锁 | 不兼容 | 兼容 |
一个事务要访问一条或者多条记录,首先需要获得这些记录上的锁,如果事务发现记录上已经有的锁和自己需要请求的锁不兼容,则需要等待,直到之前的事务释放锁。这就是锁的兼容性。
锁的实现算法有两种:
- Record Lock即记录锁,只会锁住某条或者某几条记录;
- Gap Lock即间隙锁,锁住的是一个区间
这里提及一个概念——幻读。所谓幻读,很多资料上都给出了不同的概念,我们在此选择MySQL官方文档中提供的一个例子来解释幻读。
child表只有id为90和102的记录,执行这样一条查询语句:
select * from child where id > 100 for update;
如果此时没有锁锁定90到102这个范围的话,另一个线程可能会成功插入一条id为101的数据,那么再次执行这个查询,就会出现一条id为101的记录,这就是幻读问题。
事实上RR隔离级别是没有幻读保护的,但是实际操作中InnoDB却在RR级别下实现了幻读保护,这里利用一种叫做Next-key Lock的算法实现的。
Next-key Lock是之前两种算法的结合。
有这样一张表:
CREATE TABLE `test` (`id` int(11) primary key auto_increment,`xid` int, KEY `xid` (`xid`) )ENGINE=InnoDB DEFAULT CHARSET=utf8;insert into test(xid) values (1), (3), (5), (8), (11);
Next-key Lock算法可能会锁住的范围如下:
(-∞, 1], (1, 3], (3, 5], (5, 8], (8, 11], (11, +∞)
接下来执行下图的测试:

Session A执行后会锁住的范围除了8之外,还会锁住下一个区间,那么实际锁住的范围则是:
(5,8],(8,11]
同时,系统还会对主键索引上锁。
根据这种推测,Session B的7,8步会被阻塞,但是实际测试显示第八步不会被阻塞,并且第六步会被阻塞。
观察下面的索引示意图:

因为B+树索引的有序性,不允许在(8,4)之前再写入一个(5,x),其中x>3。同理,如果写入一个(11,6)则不破坏这种有序性,因此不会被阻塞。
需要注意的是,如果XID列有唯一索引,则不会进行Next-key Lock,只会给记录加上Record Lock。
InnoDB虽然是行锁,但是行锁是基于索引的,因此,对一个没有索引的列进行更新,很可能出现表锁,严重影响效率,这是在表设计时应该高度关注的。
InnoDB中还有一个叫做意向锁的概念,意向锁包括两种,IS和IX,称为意向共享锁和意向排他锁。加上这两个概念后,锁的兼容性表应该变成下面这样:
| X | IX | S | IS | |
|---|---|---|---|---|
| X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
| IX | 不兼容 | 兼容 | 不兼容 | 兼容 |
| S | 不兼容 | 不兼容 | 兼容 | 兼容 |
| IS | 不兼容 | 兼容 | 兼容 | 兼容 |
意向锁是一种更粗粒度的锁,在给表加锁以前,首先要对表加意向锁。
- 当一个事务请求一个记录的S锁之前,应该首先获得这个表上的IS锁;
- 当一个事务请求一个记录的X锁之前,应该首先获得这个表上的IX锁
更粗粒度的锁,能够提升并发能力。
1.4 持久性
简单的说,持久性就是数据不丢失。一个运行中的数据库,可能遇到的问题有很多,比如常见的断电,火灾,空调故障。这些事故都有可能造成数据在物理上的灭失,这些并不在我们的讨论范围。我们这里关注的持久化,仅仅说的是断电等可恢复情况下的数据不丢失。
现代操作系统有一个经典设计即CPU-内存-外存的三级设计,采用这种设计是因为外存的读写速度非常低下,和CPU的处理速度严重不匹配导致的。
但是读取在内存中的缓冲数据面对的一个问题就是内存是易失存储,这个问题也是数据库需要面对的。
数据库的设计中借鉴了缓冲思想,在内存中开辟了一片独占的区域作为缓冲池,将热数据块缓存在内存中,以期提升读写效率。这部分数据块的持久化,就是本节需要讲述的问题。
InnoDB有一个很重要的日志文件叫做Redo Log,其作用是保证持久性。下面描述一下一个update语句是如何执行的:
- 首先检查要修改的数据块是否在缓冲池中,如果没有则将其读取到缓冲池里;
- 修改缓冲池中的数据块;
- 提交事务,系统会自动的将已经修改的数据块写入Redo Buffer,并且将Buffer中的内容刷入Redo Log中;
- 流程结束。
观察上面的步骤会发现,数据并没有写入数据文件中,而是写入了Redo Log中,这种方式称为WAL(Write Ahead Log),另外有一个线程,会异步的,慢慢的将脏数据库块写入到数据文件中。
这种方式并不是InnoDB特有的,Oracle其实也采用了WAL的方式,MongoDB的WiredTiger引擎也采用了这种方式。Oracle甚至可以将所有的Redo Log都归档起来,理论上Oracle就有了把数据库恢复到任一时刻的能力。
InnoDB有相关的选项可以调整Redo Log文件的大小,但是没有办法保存从开始到现在的所有Redo,这些Log文件是循环利用的。
在遇到故障异常宕机再启动以后,缓冲池中的数据块都会丢失,此时InnoDB会对比Redo Log和数据文件中的差异,只会恢复差异的部分,这种方式也大大降低了crash recovery的消耗时间。
WAL还有很复杂的checkpoint技术,如果以后有时间,会详细描述该技术,此处不再展开来讲。
2. Spring的事务管理
2.1 事务传播行为
当我们调用一个Spring的Service接口方法时,它将运行于Spring管理的事务环境中,Service接口方法可能会在内部调用其他的Service接口以共同完成一个完整的业务操作,因此就会产生服务接口方法嵌套调用的情况,Spring通过事务传播行为控制当前的事务如何传播到被嵌套调用的目标服务接口方法中。
下表列举了事务传播行为类型:
| 事务传播行为类型 | 说明 |
|---|---|
| PROPAGATION_REQUIRED | 如果当前没有事务,则新建一个事务;如果已经存在一个事务,则加入到这个事务中。这是最常见的选择。 |
| PROPAGATION_SUPPORTS | 支持当前事务。如果当前没有事务,则以非事务方式执行。 |
| PROPAGATION_MANDATORY | 使用当前的事务。如果当前没有事务,则抛出异常。 |
| PROPAGATION_REQUIRED_NEW | 新建事务。如果当前存在事务,则将事务挂起。 |
| PROPAGATION_NOT_SUPPORTED | 以非事务方式执行操作。如果当前存在事务,则将当前事务挂起。 |
| PROPAGATION_NEVER | 以非事务方式执行,如果当前存在事务,则抛出异常 |
| PROPAGATION_NESTED | 当前如果存在事务,则在嵌套事务内执行;如果当前没有事务,则执行与PROPAGATION_REQUIRED类似的操作。 |
2.2 @Transactional注解
Spring支持以注解方式实现事务配置。可以自定义配置事务的属性,也可以采用默认值,属性列举如下:
- propagation,事务传播行为,枚举类型
- isolation,事务隔离级别,枚举类型
- readOnly,事务读写性,boolean型
- timeout,超时时间,int型
- rollbackFor,该属性用于设置需要进行回滚的异常类数组,当方法中抛出指定异常数组中的异常时,则进行事务回滚
- rollbackForClassName,该属性用于设置需要进行回滚的异常类名称数组,当方法中抛出指定异常名称数组中的异常时,则进行事务回滚
- noRollbackFor,该属性用于设置不需要进行回滚的异常类数组,当方法中抛出指定异常数组中的异常时,不进行事务回滚
- noRollbackForClassName,该属性用于设置不需要进行回滚的异常类名称数组,当方法中抛出指定异常名称数组中的异常时,不进行事务回滚
在何处标记@Transactional注解是我们要关注的问题。@Transactional注解可以被应用于接口定义和接口方法,类定义和类的public方法上。
这种代码仍旧是合法的:
@Transactionalpublic class Test {@Transactionalpublic void Function1() {...}}
具体方法上的@Transactional注解会覆盖类级注解。
根据实际测试的结果,这种代码实际上是在一个事务中的:
@Transactionalpublic class Test {public void Function1() {Function1_1();Function1_2();}private void Funciton1_1() {...}private void Function1_2() {...}
Function1中所有private方法都会包裹在一个事务中。
