事务(Transaction)在数据库中一般是指包含了一组原子性操作的步骤组合,这些操作要么都成功,要么都失败,事务一般包含了ACID四大特性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。这也是数据库区别于一般文件系统的重要特性之一。
事务按照类别可以划分为以下几种类型:
事务类型中最为简单的一种,也是使用最频繁的一种,通常伴随着BEGIN...COMMIT/ROLLBACK语句,所有操作都是原子性的。
相比于扁平事务,带有保存点的扁平事务再事务内部维护了一个可以递增的保存点,在事务进行回滚时可以指定回滚到某个保存点,如果需要回滚到最初状态,需要再次执行ROLLBACK语句。
说明:执行过程类似于START TRANSACTION...SAVEPOINT a...ROLLBACK TO SAVEPOINT a...ROLLBACK /COMMIT
链事务可以看作为带有保存点的扁平事务的一种变种。链事务将带有保存点的扁平事务中一个较长的操作链路转化为较短链路的事务链,这样一定程度保障了数据丢失的可能性。链事务同带有保存点的扁平事务类型相比,前者只能回滚到最近一个操作事务内的保存点,而后者可以回滚到任意保存点,并且链事务在COMMIT后就释放了当前事务所拥有的锁。
嵌套事务是一个多层次架构的事务树,由顶层事务控制子事务,子事务既可以是嵌套事务也可以是扁平事务,且叶子节点只能是扁平事务,每个子事务从根到叶节点的距离可以是不同的。
子事务既可以提交也可以回滚。但是它的提交操作并不马上生效,除非其父事务已经提交。因此可以推论出,任何子事物都在顶层事务提交后才真正的提交。树中的任意一个事务的回滚会引起它的所有子事务一同回滚,故子事务仅保留A、C、I特性,不具有D的特性。
分布式事务通常指在分布式环境中运行的扁平事务,一般出现在跨数据库实例的访问链路中。分布式事务同样要求满足ACID特性。单依靠数据库特性并不一定可以完美解决,一般需要引入第三方工具进行中间协调,如Seata。
MySQL中对于InnoDB存储引擎来说,其支持扁平事务、带有保存点的事务、链事务、分布式事务。对于嵌套事务,其并不原生支持,但用户仍可以通过带有保存点的事务来模拟串行的嵌套事务。
事务所谓四大特性特点如下:
原子指的是不可拆分的特性,例如Java中AtomicInteger原子性操作。同样在数据库中事务是操作的最小单元,整个事务所包含的操作中全部成功或失败,不存在中间状态。在MySQL中与原子性相关的设置或者操作包含 autocommit设置、 COMMIT 语句、ROLLBACK语句。
数据库中的状态总是会从一种一致性的状态转换到另外一种一致性状态。而事务的原子性操作特点,也保证了一致性的必然结果。与一致性相关的特点InnoDB Crash Recovery、 Doublewrite Buffer。
事务隔离性通常指的是一个事务对于数据的操作未提交前,对于另外一个事务产生的结果是不可见的。这主要取决于事务的隔离级别,在主流数据库中默认的隔离级别都是READ COMITTED(或称不可重复读),这种设置主要是考虑到性能影响,因为事务的实现是通过锁来实现的,过度的安全性并不会影响结果,但会导致数据库性能下降。与隔离性相关的特点Transaction Isolation Levels。
事务的持久性对事务的操作结果负责,一旦事务提交,操作结果会永久保存到本地磁盘中。即使由于某种原因导致系统崩溃,但是存储的数据文件依然可以对数据进行找回,当然根据crash-safe的设置机制,系统崩溃也有可能导致很小部分的数据丢失。持久性通过sync_binlog等实现。
不同存储引擎中对于事务的支持程度也不一样,如在MySQL中InnoDB支持事务特性,而MyISAM、Memory不支持事务。MySQL中默认的存储引擎为InnoDB。
InnoDB中实现了四种隔离级别,这四种级别的隔离属性逐渐增强。
READ UNCOMMITTED 读未提交(脏读)在该隔离级别中,一个未提交的事务,对数据的操作,其他事务也可以读取到。事务可以读取到未提交事务操作的数据称为脏读(Dirty Read)。从性能角度而言,READ UNCOMMITTED并没有比其他隔离级别好多少,但是脏读带来的问题会相当严重。
READ COMMITTED 读已提交(不可重复读)在该隔离级别下,某种程度上已经解决了读隔离性的问题,这也是大多数数据库所采用的隔离级别。相比于READ UNCOMMITTED并不会带来脏读的结果,但是会出现不可重复读现象,同一事务中,两次读取的结果会不相同。
REPEATABLE READ 可重复读(默认值,幻读)相比于READ COMMITTED,可重复读解决了READ COMMITTED不可重复读的问题,这也是MySQL中默认的隔离级别,但是单纯依赖于事务的隔离级别无法解决幻读(Phantom Read)问题。所谓幻读,指的是当某个事务在读取某个范围内的记录时,另一个事务又在该范围内插入了新的记录,当前一个事务再次进行读取时会出现幻影行(Phantom Row)。关于如何解决幻读,后面会进行说明。
SERIALIZABLE 串行化串行化是事务隔离级别中最高的隔离级别,通过更细粒度的锁,强制事务串行执行,该隔离级别解决了事务的幻读问题。在读取的每一行数据都进行加锁,会导致大量的时间都在等待或者争抢锁,对数据库的性能影响相当大,实际生产中很少使用该隔离级别。对数据强一致性且对性能要求较低场景才有可能考虑此隔离级别。
总而言之事务的隔离级别解决的是数据在操作过程中一致性的问题,关于事务的隔离级别以及出现的数据一直性问题如下表所示:
| 隔离级别 / 存在的问题 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 存在 | 存在 | 存在 |
| READ COMMITTED | 存在 | 存在 | |
| REPEATABLE READ | 存在 | ||
| SERIALIZABLE |
关于事务隔离级别通过测试数据进行演示,关闭事务自动提交功能,创建测试数据:
- set autocommit = 0;
- create table u(id int primary key,name varchar(10)) engine=innodb;
-
- insert into u values(1,'zs');
- insert into u values(2,'ls');
- insert into u values(3,'ww');
- commit;
- mysql> set session transaction isolation level read uncommitted;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> begin;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> update u set name='张三' where id=1;
- Query OK, 1 row affected (0.00 sec)
- Rows matched: 1 Changed: 1 Warnings: 0
-
- mysql> rollback;
- Query OK, 0 rows affected (0.01 sec)
- mysql> set session transaction isolation level read uncommitted;
- Query OK, 0 rows affected (0.01 sec)
-
- mysql> select * from u where id=1;
- +----+------+
- | id | name |
- +----+------+
- | 1 | zs |
- +----+------+
- 1 row in set (0.00 sec)
-
- mysql> select * from u where id=1;
- +----+--------+
- | id | name |
- +----+--------+
- | 1 | 张三 |
- +----+--------+
- 1 row in set (0.00 sec)
-
- mysql> select * from u where id=1;
- +----+------+
- | id | name |
- +----+------+
- | 1 | zs |
- +----+------+
- 1 row in set (0.00 sec)
流程图如下:

- mysql> set session transaction isolation level read committed;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> begin;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> update u set name='张三' where id=1;
- Query OK, 1 row affected (0.00 sec)
- Rows matched: 1 Changed: 1 Warnings: 0
-
- mysql> commit;
- Query OK, 0 rows affected (0.01 sec)
- mysql> set session transaction isolation level read committed;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> select * from u where id=1;
- +----+------+
- | id | name |
- +----+------+
- | 1 | zs |
- +----+------+
- 1 row in set (0.00 sec)
-
- mysql> select * from u where id=1;
- +----+------+
- | id | name |
- +----+------+
- | 1 | zs |
- +----+------+
- 1 row in set (0.00 sec)
-
- mysql> select * from u where id=1;
- +----+--------+
- | id | name |
- +----+--------+
- | 1 | 张三 |
- +----+--------+
- 1 row in set (0.00 sec)
流程图如下:
- mysql> set session transaction isolation level repeatable read;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> begin;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> update u set name='zs' where id=1;
- Query OK, 1 row affected (0.00 sec)
- Rows matched: 1 Changed: 1 Warnings: 0
-
- mysql> commit;
- Query OK, 0 rows affected (0.01 sec)
- mysql> set session transaction isolation level repeatable read;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> begin;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> select * from u where id=1;
- +----+--------+
- | id | name |
- +----+--------+
- | 1 | 张三 |
- +----+--------+
- 1 row in set (0.00 sec)
-
- mysql> select * from u where id=1;
- +----+--------+
- | id | name |
- +----+--------+
- | 1 | 张三 |
- +----+--------+
- 1 row in set (0.00 sec)
-
- mysql> select * from u where id=1;
- +----+--------+
- | id | name |
- +----+--------+
- | 1 | 张三 |
- +----+--------+
- 1 row in set (0.00 sec)
-
- mysql> commit;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> select * from u where id=1;
- +----+------+
- | id | name |
- +----+------+
- | 1 | zs |
- +----+------+
- 1 row in set (0.00 sec)
- mysql> begin;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> insert into u values(4,'zl');
- Query OK, 1 row affected (0.01 sec)
-
- mysql> commit;
- Query OK, 0 rows affected (0.01 sec)
-
- mysql> select * from u where id=4 for update;
- +----+------+
- | id | name |
- +----+------+
- | 4 | zl |
- +----+------+
- mysql> begin;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> select * from u where id=4;
- Empty set (0.00 sec)
-
- mysql> insert into u values(4,'zl');
- ERROR 1062 (23000): Duplicate entry '4' for key 'PRIMARY'
- mysql> select * from u where id=4;
- Empty set (0.00 sec)
-
- mysql> commit;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> select * from u where id='4';
- +----+------+
- | id | name |
- +----+------+
- | 4 | zl |
- +----+------+
- 1 row in set (0.01 sec)
流程图如下:
- mysql> set session transaction isolation level serializable;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> begin;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> select * from u where id='5';
- Empty set (0.00 sec)
- mysql> set session transaction isolation level serializable;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> begin;
- Query OK, 0 rows affected (0.00 sec)
-
- mysql> insert into u values(5,'zb');
- ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
如图所示,当使用SERIALIZABLE隔离级别时,数据库会对读写进行强制重排序,保证事务有序进行。
流程图如下:
数据库中事务包含了ACID四大特性,其中的隔离性通过读写锁+MVCC实现,原子性通过undo log实现,持久性则通过redo log实现,一致性通过AID三者共同实现,事务的最终目的也就是保证数据的一致性。
需要注意的是redo和undo并不是互逆操作,两种都是一种恢复操作,其中redo恢复的是提交事务修改的页操作,undo是回滚行记录到某个特定版本;此外两者记录内容不同redo通常是物理日志,记录的页物理操作,undo是逻辑日志,记录每行操作。
MySQL中的日志文件主要包含binlog、redo log、Undo Log等,其中binlog是MySQL Server层面的日志,关于服务端日志更多内容。而redo log、undo log是存储引擎层面的日志,并且只存在于存储引擎为InnoDB时。
binlog全称为binary log,也就是说该日志的存储格式为二进制。存储了MySQL中所有数据,binlog主要用来进行数据恢复以及主从复制时进行数据同步。binlog是逻辑日志,记录了MySQL中的操作信息,采用顺序写入,追加文件的形式。
MySQL 8.x版本中binlog是默认开始的,可以使用命令查看开启状态。
select @@log_bin
从表面看binlog与redo log有相似之处,二者本质上存在较大差别,前者的产生与存储引擎无关,任何存储引擎都会产生binlog,其次binlog是逻辑日志,且只在事务提交后记录SQL操作的语句,而后者只存在于InnoDB存储引擎中,且为物理格式的日志,记录的为实际页修改,并且需要记录事务进行中操作的内容。
redo log又称为重做日志,主要用来实现数据库事务中的持久性。重做日志主要由两部组成,第一部分是存储在内存中的重做日志缓冲(redo log buffer),由于存在内存中因此存在丢失风险,另外一部分是重做日志文件(redo log file),存储于磁盘中,是持久化的不易丢失。
redo log的存在另一方面还提高了数据库的更新效率,数据库中的数据持久化在磁盘文件中,如果每次对于数据的操作都要进行磁盘IO读写,会大大消耗性能。因此采用了一种称为WAL(Write Ahead Logging)的策略,这种策略关键点在于先写日志再写磁盘。这里的先写日志指的是会现在内存的Log Buffer中完成redo log及Undo Log的写入,然后在合适的时机调用fsync操作把内存中的Log Buffer更新到磁盘文件中。

Log Buffer如何从内存写入到磁盘日志文件中如下图所示:

日志内容会从MySQL的Buffer Pool汇总到Log Buffer中,并会根据不同的策略写入OS Buffer中,最终调用fsync操作写入磁盘文件中,这种缓冲的操作减少了IO次数提高了数据库的性能。
InnoDB存储引擎在事务COMMIT时,会通过Force Log at Commit机制要求先对日志文件进行写入磁盘。关于何时将Log Buffer内容写到磁盘,可通过innodb_flush_log_at_trx_commit配置,该参数值包含以下三种情况:
redo log文件大小是固定的,是一个物理日志(记录的是在某个数据页上做了什么样的修改),使用的是循环写入的过程。如图所示:

在redo log中包含了两个指针,write pos表示下一次写入数据的位置,可以一直往后写直到check point(已经刷新到磁盘的日志序列号,即LSN)位置,此外,每个页上也会记录一个LSN,用于表示已经刷新的数据。当写完一个事务时,write pos会往前移动,在redo log中的记录被更新到数据库中时,checkpoint会往前移动。
check point和write pos之间表示当前已经写入的数据,write pos和 checkpoint 之间的记录,可以被擦除。当write pos和check point重合时,write pos会回到最开头的文件,重新开始写。
2pc指的是两阶段提交,是为了保证MySQL Server层和存储引擎InnoDB层两部分日志所记录数据的一致性。当恢复数据时,无论根据redo log还是binlog,得到的数据是一致的。两阶段提交的过程如下图所示:

如果redo log和binlog分别提交,可能会造成用日志恢复出来的数据和原来数据不一致的情况:
由此可见,redo log和binlog的两阶段提交是非常必要的。
MySQL Server层的binlog主要用于数据恢复,在数据误删时,可以将数据根据上次的全量备份恢复到某个时间点。而存储引擎InnoDB是以插件的形式引入,redo log的功能更主要是为了实现crash safe能力。
redo log和binlog的存在使得MySQL可以保证即使数据库发生异常重启,根据日志文件中记录的每个事务的状态,实现已提交的事务数据不丢失、未提交的事务数据进行回滚,这种机制叫做crash-safe,保证了事务的持久性特点。
为了实现无差错的crash-safe,在写入redo log和binlog文件时必须进行一次fsync操作将日志刷入磁盘,这样保证掉电时两个日志数据都写入了磁盘且数据一致,因此redolog必需设置innodb_flush_log_at_trx_commit=1。binlog也有决定何时进行fsync的配置sync_binlog,当值为0时代表刷新binlog_cache中的信息到磁盘由os决定。当值大于0为N时,代表每N次事务提交后就刷新binlog cache中的信息到磁盘,因此还要设置sync_binlog=1。两个参数值都得为1,俗称"双1",是保证crash-safe的根本,官方建议在需要保证事务持久性和一致性场景下设置成双1。
关于使用crash-safe进行数据恢复的场景如下所示:
undo log用来保证事务的原子性,InnoDB在写日志的时,在Log Buffer中不仅包含redo log buffer,还存在undo log buffer,也就意味着同时还会写入undo log file,与redo log file不同的是,undo log数据,位于一个特殊的段中(rollback segment),位于共享表空间中。undo log是一份逻辑日志,存储了与实际操作相反的记录,如下面几种场景:
当事务执行出现了错误或者用户手动调用rollback时,就可以进行数据恢复。undo log主要包含两种:
说明:purge线程
- 为了实现InnoDB的MVCC机制,更新或者删除操作都只是设置一下老记录的deleted_bit,并不真正将过时的记录删除
- 为了节省磁盘空间,InnoDB有专门的purge线程来清理deleted_bit为true的记录。为了不影响MVCC的正常工作,purge线程自己也维护了一个read view(这个read view相当于系统中最老活跃事务的read view);如果某个记录的deleted_bit为true,并且DB_TRX_ID相对于purge线程的read view可见,那么这条记录一定是可以被安全清除的。
MVCC全称为多版本并发控制,一种并发控制的方法,实现对数据库的并发访问。简单的说,MVCC就是为了实现读-写冲突不加锁,这里的读指的就是快照读,而非当前读(实际上是一种加锁的操作,是悲观锁的实现)。
MySQL中对于数据读取的操作包含两种形式:
MVCC的实现原理主要是依赖三部分内容:隐式字段、undo log、Read View。
隐式字段
MySQL每行记录除了自定义字段外,还有数据库隐式定义的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段,具体作用如下:
此外实际还有一个删除flag隐藏字段,当记录被更新或删除并不代表真的删除,而是删除flag变了。
undo log
对MVCC有帮助的实质是update undo log,undo log实际上就是存在rollback segment中旧记录链。不同事务或者相同事务对同一记录的修改,会导致该记录的undo log成为一条记录版本链表,undo log的链首就是最新的旧记录,链尾就是最早的旧记录。
当执行update或者delete时,这条数据并不会被直接处理,例如在执行删除时,只是将隐式字段标志标记为删除,真正删除动作的执行是通过purge来延时操作完成的,也正基于此种设计使得通过undo log实现了MVCC多版本共存的能力。
Read View
Read View是事务在进行快照读操作时产生的读视图,也就意味着这个数据是在读的时刻产生的快照副本,记录并维护了一个当前活跃事务的自增ID。
Read View主要是用来做可见性判断的,当某个事务执行快照读的时候,对该记录创建一个Read View,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。
Read View需要确保处于这样一个状态:当前视图里的数据是一部分或者全部事务提交后的结果,不存在未提交事务(即当前活跃事务)的部分数据。如何做到?
Read View遵循一个可见性算法,主要是将要被修改的数据的最新记录中的DB_TRX_ID(即当前事务ID)取出来,与系统当前其他活跃事务的ID去对比(由Read View维护),如果DB_TRX_ID跟Read View的属性做了某些比较,不符合可见性,那就通过DB_ROLL_PTR回滚指针去取出Undo Log中的DB_TRX_ID再比较,即遍历链表的DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的DB_TRX_ID, 那么这个DB_TRX_ID所在的旧记录就是当前事务能看见的最新老版本
如何判断DB_TRX_ID对Read View是否符合可见性?简化一下Read View结构,假设有这三个全局属性
trx_list:一个数值列表,用来维护Read View生成时刻系统正活跃的事务ID
up_limit_id:记录trx_list列表中事务ID最小的ID
low_limit_id:ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1
判断逻辑如下:
RR是如何在RC级的基础上解决不可重复读的?
总结,在RC隔离级别下,每个快照读都会生成最新的Read View;在RR下,同一个事务中只会在第一次快照读时创建Read View,并且之后的快照读也是读的先前的Read VIew。
什么是幻读?
MySQL官方给出的幻读解释是:当某个事务在读取某个范围内的记录时,另一个事务又在该范围内插入了新的记录,当前一个事务再次进行读取时会出现幻影行(Phantom Row)
简单来说就是只要在一个事务中,第二次SELECT多出了Row就算幻读。举例说明:
A事务先SELECT,B事务此时进行INSERT,数据库会加Gap Lock,当B事务执行COMMIT,锁就会释放(释放后A事务也可以进行DML操作),A事务再进行SELECT出来的结果在MVCC下与第一次SELECT一样,如果A事务不使用for update进行读,当前读会作用在所有行上(包括B事务COMMIT的数据),这时A事务就会SELECT出B事务中操作的新行。如图所示:
在这个案例中,A事务使用快照读查询结果没有读出新行,使用当前读,可以读出B事务操作的新行。MySQL如何解决幻读?
关于如何解决幻读,这里需要再次明确快照读和当前读两种形式下的区别。
不能把快照读和当前读得到的结果不一样这种情况认为是幻读,这是两种不同的使用方式。
说明:关于间隙锁简单概念
- 间隙锁(Gap Lock):锁住两行纪录之间的空隙,以避免数据新增、删除和更新操作。只在RR级别下有用,解决幻读问题。
- Next-Key Lock:单行锁(Record Lock)+ 间隙锁(Gap Lock)合起来称为Next-Key Lock。
通过参数innodb_locks_unsafe_for_binlog配置为0,代表开启间隙锁;值为1代表关闭间隙锁,在MySQL8.0之前默认值为0,该参数在 8.0 版本被移除,说明一直是打开的。
参考资料: