事务是MySQL等关系型数据库区别于NoSQL的重要方面,是保证数据一致性的重要手段。
1、前置知识
1.1 事务的ACID特性
事务是由一系列对系统中数据进行访问或者更新操作组成的一个程序执行逻辑单元(Unit),也就是说,事务是要求这一组程序要么都执行成功,要么都不执行(执行失败)。
ACID,是指数据库管理系统在写入或更新资料的过程中,为保证事务是正确可靠的,所必须具备的四个特性:
- 原子性 Atomicity :要保证事务中包裹的逻辑,要么全部执行成功,要么全部都不执行。
- 一致性 Consistency:要保证事务在执行前后,数据库都要处于正确状态,满足完整性约束。
- 隔离性 Isolation:多个事务并发执行的时候,一个事务不应该影响另一个事务,保证所有事务都好像在独立运行。
- 持久性 Durability:事务处理完成,对数据的修改是永久性的,即使系统故障,都不会丢失。
我的理解中,AID是为了保证C存在的,保证了AID和业务层面代码逻辑正确,就能得到C的结果。
当然,也可以把C解释成与AID一致的层面,比如:数据一致性就是数据要满足一定约束条件,如果事务的执行违反了这个约束条件,那么事务应该失败,这样理解的话,就是ACID是一个层面的特性了。
不管是“AID 是特性,C是目的”,还是“ACID 都是 特性”,都看个人理解,然而这种个人理解无论是哪种,都是有利于“事务”的制定和发展的。
1.2 MySQL基础架构
上图为MySQL的基础架构,主要由Server层和存储引擎层构成。具体来说,由下面几部分构成:
- 连接器: 身份认证和权限相关(登录 MySQL 的时候)。
- 查询缓存: 执行查询语句的时候,会先查询缓存(MySQL 8.0 版本后移除,因为这个功能不太实用)。
- 分析器: 没有命中缓存的话,SQL 语句就会经过分析器,分析器说白了就是要先看你的 SQL 语句要干嘛,再检查你的 SQL 语句语法是否正确。
- 优化器: 按照 MySQL 认为最优的方案去执行。
- 执行器: 执行语句,然后从存储引擎返回数据。 执行语句之前会先判断是否有权限,如果没有权限的话,就会报错。
- 插件式存储引擎:主要负责数据的存储和读取,采用的是插件式架构,支持 InnoDB、MyISAM、Memory 等多种存储引擎。
2、原子性(Atomicity)
2.1 定义
原子性是指一个事务是一个不可分割的工作单位,其中的操作要么都做,要么都不做;如果事务中的一个sql语句执行失败,则已执行的语句也必须回滚,数据库退回到事务前的状态。
2.2 实现原理:undo log
在说明原子性原理之前,首先介绍一下MySQL的事务日志。
MySQL服务器层的日志有很多种,如binlog、errorlog、slowquerylog等,此外InnoDB存储引擎还提供了两种日志:redo log(重做日志)和undo log(回滚日志)。其中redo log用于保证事务持久性;undo log则用于实现事务原子性和隔离性。
下面说回undo log。实现原子性的关键,是当事务回滚时能够撤销所有已经成功执行的sql语句。
InnoDB依赖undolog实现事务回滚:当事务对数据库进行修改(增删改)时,InnoDB会生成对应的undo log;如果事务执行失败或调用了rollback,导致事务需要回滚,便可以利用undo log中的信息将数据回滚到修改之前的样子。
undo log属于逻辑日志,它记录的是sql执行相关的信息。每当 InnoDB 引擎对一条记录进行操作(修改、删除、新增)时,要把回滚时需要的信息都记录到 undolog里,比如:
- 插入一条记录时,要把这条记录的主键值记下来,这样之后回滚时只需要把这个主键值对应的记录删掉就好了;
- 在删除一条记录时,要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了;
- 在更新一条记录时,要把被更新的列的旧值记下来,这样之后回滚时再把这些列更新为旧值就好了。
在发生回滚时,就读取 undo log 里的数据,然后做原先相反操作。比如当 delete 一条记录时,undo log中会把记录中的内容都记下来,然后执行回滚操作的时候,就读取 undo log 里的数据,然后进行 insert操作。
undo log的实现依赖于Mysql的隐藏字段:trx_id事务id、roll_pointer指针,形成事务回滚的版本链。如下图所示:
- 通过trx_id 可以知道该记录是被哪个事务修改的;
- 通过 roll_pointer 指针可以将这些 undo log串成一个链表,这个链表就被称为版本链;
当事务需要回滚时,通过记录的undolog来回滚该事务之前已执行的SQL语句。
另外,undolog还有一个作用,通过undolog版本链+readview实现MVCC,从而保证事务之间的隔离性,这一点将在隔离性中详细介绍。
3、持久性(Durability)
3.1 定义
持久性是指事务一旦提交,它对数据库的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。
3.2 实现原理:redo log
首先介绍一下redo log存在的背景。
InnoDB作为MySQL的存储引擎,数据是存放在磁盘中的,但如果每次读写数据都需要磁盘IO,效率会很低。为此,InnoDB提供了缓存(Buffer Pool),Buffer Pool中包含了磁盘中部分数据页的映射,作为访问数据库的缓冲:当从数据库读取数据时,会首先从Buffer Pool中读取,如果Buffer Pool中没有,则从磁盘读取后放入Buffer Pool;当向数据库写入数据时,会首先写入Buffer Pool,Buffer Pool中修改的数据会定期刷新到磁盘中(这一过程称为刷脏页)。
Buffer Pool的使用大大提高了读写数据的效率,但是也带了新的问题:如果MySQL宕机,而此时Buffer Pool中修改的数据还没有刷新到磁盘,就会导致数据的丢失,事务的持久性无法保证。
于是,redo log被引入来解决这个问题:
- 当数据修改时,除了修改Buffer Pool中的数据,还会在redo log记录这次操作。
- 默认情况下,当事务提交时,会调用fsync接口对redo log进行刷盘。【此处可以通过配置innodb_flush_log_at_trx_commit参数来控制redo log的刷盘时机】
- 如果MySQL宕机,重启时可以读取redo log中的数据,对数据库进行恢复。
redo log采用的是WAL(Write-ahead logging,预写式日志),所有修改先写入日志,再更新到Buffer Pool,保证了数据不会因MySQL宕机而丢失,从而满足了持久性要求。
WAL 机制的原理:修改并不直接写入到数据库文件中,而是写入到另外一个称为 WAL 的文件中;如果事务失败,WAL
中的记录会被忽略,撤销修改;如果事务成功,它将在随后的某个时间被写回到数据库文件中,提交修改。
既然redo log也需要刷盘,为什么它比直接将Buffer Pool中修改的数据写入磁盘(即刷脏页)要快呢?主要有以下两方面的原因:
- Buffer Pool刷脏页是随机IO,因为读取的数据页分布是不确定的、每次修改的数据位置也是随机的;但写redo log刷盘是追加操作,属于顺序IO;
- 刷脏页是以数据页(Page)为单位的,MySQL默认页大小是16KB,一个Page上一个小修改都要整页写入;而redo log中只包含真正需要写入的部分,无效IO大大减少。
3.3 redo log与bin log
3.3.1 二者区别
我们知道,在MySQL中还存在binlog(二进制日志),也可以记录修改操作并用于数据的恢复,但二者是有着根本的不同的:
(1)作用不同:redo log是用于崩溃恢复的,保证MySQL宕机也不会影响持久性;binlog是用于时间点恢复的,保证服务器可以基于时间点恢复数据,此外binlog还用于主从复制。
(2)层次不同:redo log是InnoDB存储引擎实现的,而binlog是MySQL的服务器层实现的,同时支持InnoDB和其他存储引擎。
(3)内容不同:redo log是物理日志,内容基于磁盘的Page;binlog是逻辑日志,内容是二进制的,根据binlog_format参数的不同,可能基于sql语句、基于数据本身或者二者的混合。
(4)写入时机不同:binlog在事务提交时写入;redo log的写入时机相对多元:
- 前面曾提到:当事务提交时会调用fsync对redo log进行刷盘;这是默认情况下的策略,修改innodb_flush_log_at_trx_commit参数可以改变该策略,但事务的持久性将无法保证。
- 除了事务提交时,还有其他刷盘时机:如master thread每秒刷盘一次redo log等,这样的好处是不一定要等到commit时刷盘,commit速度大大加快。
3.3.2 两阶段提交
需要注意的是,binlog用于主从复制。默认配置下事务提交后,redo log 和 binlog 都要持久化到磁盘,但是这两个是独立的逻辑,可能出现半成功的状态,这样就造成两份日志之间的逻辑不一致。
举个例子,假设id=1这行数据的字段 name 的值原本是tuanzi,然后执行 UPDATE user SET name =‘datuanzi’ WHERE id = 1;
如果在持久化redo log 和 binlog 两个日志的过程中,出现了半成功状态,那么就有两种情况:
- 如果在将redo log刷入到磁盘之后,MySQL突然宕机了,而binlog 还没有来得及写入。MySQL 重启后,通过 redo log 能将 Buffer Pool 中id =1这行数据的 name 字段恢复到新值 xiaolin,但是 binlog里面没有记录这条更新语句,在主从架构中,binlog 会被复制到从库,由于 binlog 丢失了这条更新语句,从库的这一行 name 字段是旧值jay,与主库的值不一致性;
- 如果在将 binlog刷入到磁盘之后,MySQL突然宕机了,而redo log 还没有来得及写入。由于 redolog 还没写,崩溃恢复以后这个事务无效,所以id=1这行数据的name 字段还是旧值 jay,而 binlog里面记录了这条更新语句,在主从架构中,binlog 会被复制到从库,从库执行了这条更新语句,那么这一行 name 字段是新值 xiaolin,与主库的值不一致性;
可以看到,在持久化 redo log 和binlog这两份日志的时候,如果出现半成功的状态,就会造成主从环境的数据不一致性。这是因为redo log 影响主库的数据,binlog 影响从库的数据,所以redo log 和 binlog必须保持一致,才能保证主从数据一致。
MySQL 为了避免出现两份日志之间的逻辑不一致的问题,使用了「两阶段提交」来解决,两阶段提交其实是分布式事务一致性协议,它可以保证多个逻辑操作要不全部成功,要不全部失败,不会出现半成功的状态。
两阶段提交把单个事务的提交拆分成了2个阶段,分别是「准备(Prepare)阶段」和「提交(Commit)阶段」,每个阶段都由协调者(Coordinator)和参与者(Participant)共同完成。
从图中可看出,事务的提交过程有两个阶段,就是将 redo log 的写入拆成了两个步骤:prepare和commit,中间再穿插写入binlog,具体如下:
- prepare 阶段:将ID(内部XA事务的ID)写入到 redo log,同时将 redo log 对应的事务状态设置为 prepare,然后将 redo log 持久化到磁盘(innodb_flush_log_at_trx_commit =1的作用);
- commit 阶段:把 XID 写入到 binlog,然后将 binlog 持久化到磁盘(sync_binlog =1的作用),接着调用引擎的提交事务接口,将redo log状态设置为commit,此时该状态并不需要持久化到磁盘,只需要 write 到文件系统的page cache 中就够了,因为只要 binlog写磁盘成功,就算 redo log 的状态还是 prepare 也没有关系,一样会被认为事务已经执行成功;
异常重启会出现什么现象?
我们来看看在两阶段提交的不同时刻,MySQL 异常重启会出现什么现象?下图中有时刻A和时刻B都有可能发生崩溃:
不管是时刻A(redo log 已经写入磁盘,binlog还没写入磁盘)崩溃,还是时刻B(redo log和 binlog 都已经写入磁盘,还没写入commit 标识)崩溃,此时的redo log都处于 prepare 状态。
在 MySQL 重启后会按顺序扫描 redo log 文件,碰到处于 prepare 状态的 redo log,就会拿着着 redo log 中的事务ID 去 binlog 查看是否存在此事务ID:
- 如果 binlog中没有当前事务ID,说明redolog完成刷盘,但是binlog 还没有刷盘,则回滚事务。对应时刻 A 崩溃恢复的情况。
- 如果 binlog 中有当前事务的ID,说明redolog 和 binlog 都已经完成了刷盘,则提交事务。对应时刻B崩溃恢复的情况。
所以说,两阶段提交是以 binlog 写成功为事务提交成功的标识,因为 binlog写成功了,就意味着能在binlog 中查找到与 redo log 相同的事务ID。
处于prepare阶段的redo log加上完整binlog,重启就提交事务,MySQL 为什么要这么设计?
binlog已经写入了,之后就会被从库(或者用这个binlog恢复出来的库)使用。所以,在主库上也要提交这个事务。采用这个策略,主库和备库的数据就保证了一致性。
事务没提交的时候,redo log 会被持久化到磁盘吗?
会的。
事务执行中间过程的redo log 也是直接写在 redo log buffer 中的,这些缓存在 redo log buffer 里的
redo log 也会被「后台线程」每隔一秒一起持久化到磁盘。也就是说,事务没提交的时候,redo log 也是可能被持久化到磁盘的。
4、隔离性(Isolation)
4.1 定义
与原子性、持久性侧重于研究事务本身不同,隔离性研究的是不同事务之间的相互影响。
隔离性是指,事务内部的操作与其他事务是隔离的,并发执行的各个事务之间不能互相干扰。严格的隔离性,对应了事务隔离级别中的Serializable (可串行化),但实际应用中出于性能方面的考虑很少会使用可串行化。
隔离性追求的是并发情形下事务之间互不干扰。简单起见,我们主要考虑最简单的读操作和写操作(加锁读等特殊读操作会特殊说明),那么隔离性的探讨,主要可以分为两个方面:
- (一个事务)写操作对(另一个事务)写操作的影响:锁机制保证隔离性
- (一个事务)写操作对(另一个事务)读操作的影响:MVCC保证隔离性
4.2 锁机制
首先来看两个事务的写操作之间的相互影响。隔离性要求同一时刻只能有一个事务对数据进行写操作,InnoDB通过锁机制来保证这一点。
锁机制的基本原理可以概括为:事务在修改数据之前,需要先获得相应的锁;获得锁之后,事务便可以修改数据;该事务操作期间,这部分数据是锁定的,其他事务如果需要修改数据,需要等待当前事务提交或回滚后释放锁。
按照粒度,锁可以分为表锁、行锁以及其他位于二者之间的锁。表锁在操作数据时会锁定整张表,并发性能较差;行锁则只锁定需要操作的数据,并发性能好。
MySQL中不同的存储引擎支持的锁是不一样的,例如MyIsam只支持表锁,而InnoDB同时支持表锁和行锁,且出于性能考虑,绝大多数情况下使用的都是行锁。
除了排它锁(写锁)之外,MySQL中还有共享锁(读锁)的概念。由于本文重点是MySQL事务的实现原理,因此对锁的介绍到此为止。
介绍完写操作之间的相互影响,下面讨论写操作对读操作的影响。
4.3 脏读、不可重复读和幻读
首先来看并发情况下,读操作可能存在的三类问题:
- 脏读:当前事务(A)中可以读到其他事务(B)未提交的数据(脏数据),这种现象是脏读。举例如下(以账户余额表为例):
时间 | 事务A | 事务B |
---|---|---|
T1 | 开始事务 | 开始事务 |
T2 | 修改tuanzi的余额,将余额由200改为100 | |
T3 | 查询tuanzi的余额,结果为100【脏读】 | |
T4 | 回滚事务 |
- 不可重复读:在事务A中先后两次读取同一个数据,两次读取的结果不一样,这种现象称为不可重复读。脏读与不可重复读的区别在于:前者读到的是其他事务未提交的数据,后者读到的是其他事务已提交的数据。举例如下:
时间 | 事务A | 事务B |
---|---|---|
T1 | 开始事务 | 开始事务 |
T2 | 查询tuanzi的余额,结果为200 | |
T3 | 修改tuanzi的余额,将余额由200改为100 | |
T4 | 提交事务 | |
T5 | 查询tuanzi的余额,结果为100【不可重复读】 |
- 幻读:在事务A中按照某个条件先后两次查询数据库,两次查询结果的条数不同,这种现象称为幻读。不可重复读与幻读的区别可以通俗的理解为:前者是数据变了,后者是读取数据的行数变了。举例如下:
时间 | 事务A | 事务B |
---|---|---|
T1 | 开始事务 | 开始事务 |
T2 | 查询0<id<5的所有用户的余额信息, 结果为一条数据{tuanzi, 100} | |
T3 | 账户余额表中插入一条新数据 {tuanzi2, 500} | |
T4 | 提交事务 | |
T5 | 查询0<id<5的所有用户的余额信息, 结果为两条数据{tuanzi, 100},{tuanzi2, 500} 【幻读】 |
4.4 事务隔离级别
SQL标准中定义了四种隔离级别。一般来说,隔离级别越低,系统开销越低,可支持的并发越高,但隔离性也越差。
不同隔离级别与可能发生的并发问题,对应关系如下:
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
Read - Uncommitted 读未提交 | √ | √ | √ |
Read - Committed 读已提交 | × | √ | √ |
Repeated - Read 可重复读 | × | × | √ |
Serializable 可串行化 | × | × | × |
在实际应用中,读未提交在并发时会导致很多问题,而性能相对于其他隔离级别提高却很有限,因此使用较少。可串行化强制事务串行,并发效率很低,只有当对数据一致性要求极高且可以接受没有并发时使用,因此使用也较少。
因此在大多数数据库系统中,默认的隔离级别是读已提交(如Oracle)或可重复读(后文简称RR)。在Oracle数据库中默认是RC,MySQL数据库中默认是RR。
可以通过如下两个命令分别查看全局隔离级别和本次会话的隔离级别:
InnoDB默认的隔离级别是RR,后文会重点介绍RR。需要注意的是,在SQL标准中,RR是无法避免幻读问题的,但是InnoDB实现的RR避免了幻读问题。
4.5 MVCC
4.5.1 MVCC实现原理
RR解决脏读、不可重复读、幻读等问题,使用的是MVCC:MVCC全称Multi-Version Concurrency Control,即多版本并发控制协议。
下面的例子体现了MVCC的特点:在同一时刻,不同的事务读取到的数据可能是不同的(即多版本)——在T5时刻,事务A和事务C可以读取到不同版本的数据。
时间 | 事务A | 事务B | 事务C |
---|---|---|---|
T1 | 开始事务 | 开始事务 | 开始事务 |
T2 | 查询tuanzi的余额,结果为200 | ||
T3 | 修改tuanzi的余额,将余额由200改为100 | ||
T4 | 提交事务 | ||
T5 | 查询tuanzi的余额,结果为200 | 查询tuanzi的余额,结果为100 |
MVCC最大的优点是读不加锁,因此读写不冲突,并发性能好。其实现主要基于以下技术及数据结构:
- 隐藏列:InnoDB中每行数据都有隐藏列,隐藏列中包含了本行数据的trx_id事务id、roll_pointer指向undo log的指针等。
- 基于undo log的版本链:前面说到每行数据的隐藏列中包含了指向undo log的指针,而每条undo log也会指向更早版本的undo log,从而形成一条版本链。
- ReadView:通过隐藏列和版本链,MySQL可以将数据恢复到指定版本;但是具体要恢复到哪个版本,则需要根据ReadView来确定。所谓ReadView,是指事务(记做事务A)在某一时刻给整个事务系统(trx_sys)打快照,之后再进行读操作时,会将读取到的数据中的隐藏数据事务id与trx_sys快照比较,从而判断数据对该ReadView是否可见,即对事务A是否可见。trx_sys中的主要内容,以及可见性算法如下:
- low_limit_id:表示生成ReadView时系统中应该分配给下一个事务的id。如果数据的事务id大于等于low_limit_id,则此条数据对该ReadView不可见。
- up_limit_id:表示生成ReadView时当前系统中活跃的读写事务中最小的事务id。如果数据的事务id小于up_limit_id,则此条数据对该ReadView可见。
- rw_trx_ids:表示生成ReadView时当前系统中活跃的读写事务的事务id列表。如果数据的事务id在low_limit_id和up_limit_id之间,则需要判断事务id是否在rw_trx_ids中:如果在,说明生成ReadView时事务仍在活跃中,则此条数据对ReadView不可见;如果不在,说明生成ReadView时事务已经提交了,则此条数据对ReadView可见。
4.5.2 避免并发问题
下面以RR隔离级别为例,结合前文提到的几个问题避免分别说明。
- 脏读
时间 | 事务A | 事务B |
---|---|---|
T1 | 开始事务 | 开始事务 |
T2 | 修改tuanzi的余额,将余额由200改为100 | |
T3 | 查询tuanzi的余额,结果为200【避免了脏读】 | |
T4 | 回滚事务 |
当事务A在T3时刻读取tuanzi的余额前,会生成ReadView。由于此时事务B没有提交仍然活跃,因此其事务id一定在ReadView的rw_trx_ids中,因此根据前面介绍的规则,事务B的修改对ReadView不可见。接下来,事务A根据指针指向的undo log查询上一版本的数据,得到zhangsan的余额为200。这样事务A就避免了脏读。
- 不可重复读
时间 | 事务A | 事务B |
---|---|---|
T1 | 开始事务 | 开始事务 |
T2 | 查询tuanzi的余额,结果为200 | |
T3 | 修改tuanzi的余额,将余额由200改为100 | |
T4 | 提交事务 | |
T5 | 查询tuanzi的余额,结果为200【避免不可重复读】 |
当事务A在T2时刻读取zhangsan的余额前,会生成ReadView。
此时事务B分两种情况讨论,一种是如图中所示,事务已经开始但没有提交,此时其事务id在ReadView的rw_trx_ids中;另一种是事务B还没有开始,此时其事务id大于等于ReadView的low_limit_id。无论是哪种情况,根据前面介绍的规则,事务B的修改对ReadView都不可见。
当事务A在T5时刻再次读取zhangsan的余额时,会根据T2时刻生成的ReadView对数据的可见性进行判断,从而判断出事务B的修改不可见;因此事务A根据指针指向的undo log查询上一版本的数据,得到zhangsan的余额为200,从而避免了不可重复读。
- 幻读
快照读和当前读
按照是否加锁,MySQL的读可以分为两种:
一种是快照读。沿用之前的ReadView,读取快照数据。
另一种是当前读,也称加锁读。会产生最新的ReadView,读取当前版本的数据。查询语句有所不同,如下所示:
– 快照读
select…
– 当前读
update , delete , insert
select…for update
select…lock in share mode
当前读在查询时会对查询的数据加锁(共享锁或排它锁)。由于锁的特性,当某事务对数据进行加锁读后,其他事务无法对数据进行写操作。
在都是快照读的查询条件下,MVCC是可以避免幻读问题的。其机制和避免不可重复读类似。
时间 | 事务A | 事务B |
---|---|---|
T1 | 开始事务 | 开始事务 |
T2 | 查询0<id<5的所有用户的余额信息,(快照读) 结果为一条数据{tuenzi, 100} | |
T3 | 账户余额表中插入一条新数据 {tuenzi2, 500} | |
T4 | 提交事务 | |
T5 | 查询0<id<5的所有用户的余额信息,(快照读) 结果仍为一条数据{tuenzi, 100}【避免幻读】 |
当事务A在T2时刻读取0<id<5的用户余额前,会生成ReadView。此时事务B分两种情况讨论,一种是如图中所示,事务已经开始但没有提交,此时其事务id在ReadView的rw_trx_ids中;一种是事务B还没有开始,此时其事务id大于等于ReadView的low_limit_id。无论是哪种情况,根据前面介绍的规则,事务B的修改对ReadView都不可见。
当事务A在T5时刻再次读取0<id<5的用户余额时,会根据T2时刻生成的ReadView对数据的可见性进行判断,从而判断出事务B的修改不可见。因此对于新插入的数据lisi(id=2),事务A根据其指针指向的undo log查询上一版本的数据,发现该数据并不存在,从而避免了幻读。
4.5.3 MVCC+Next-Key lock解决当前读情况的幻读
前面介绍的MVCC解决幻读问题,是基于所有查询都是"快照读"的基础上。而当"快照读"和"当前读"同时使用时,仍可能发生幻读问题。举例如下:
事务1两次提交事务,会发现第二次查询的时候突然多出了一条记录。好像幻觉一样,这就是幻读问题。
所以说单独的MVCC是不能解决当前读情况下的幻读,要想解决还是需要锁。
事务1不发生幻读问题的重点就是,其它事务不能插入数据并提交成功。要解决这个问题也很简单,就是事务A先获得这个排它锁。
我们可以在事务A第一次查询的时候加一个排他锁Next-Key Lock。Next-Key lock是行锁的一种,实现相当于record lock(记录锁) + gap lock(间隙锁);其特点是不仅会锁住记录本身(record lock的功能),还会锁定一个范围(gap lock的功能)。因此,当前读同样可以避免脏读、不可重复读和幻读,保证隔离性。
// 加排他锁Next-Key Lock
select * from account_balance for update;
当事务1使用加锁读之后,事务2insert操作会阻塞,直到事务1commit之后释放锁,事务2才能插入数据。
4.5.4 RC隔离级别
上面介绍的是RR隔离级别下实现隔离性的原理,对于读已提交(RC)隔离级别,首先RC与RR一样,都使用了MVCC,其主要区别在于:
RR是在事务开始后第一次执行select前创建ReadView,直到事务提交都不会再创建。根据前面的介绍,RR可以避免脏读、不可重复读和"快照读"的幻读问题。
RC每次执行select前都会重新建立一个新的ReadView,因此如果事务A第一次select之后,事务B对数据进行了修改并提交。那么事务A第二次select时会重新建立新的ReadView,因此事务B的修改对事务A是可见的。因此RC隔离级别可以避免脏读,但是无法避免不可重复读和幻读。
4.6 总结
概括来说,InnoDB实现的RR,通过MVCC(包括数据的隐藏列、基于undo log的版本链、ReadView)+Next-Key Lock,实现了一定程度的隔离性,避免脏读、不可重复读和一定情况的幻读,可以满足大多数场景的需要。
不过需要说明的是,RR虽然避免了幻读问题,但是毕竟不是Serializable,不能保证完全的隔离,在快照读和当前读混用的时候,仍可能发生并发性问题。
如果在事务中第一次读取采用非加锁读,第二次读取采用加锁读,并且在两次读取之间有别的事务添加了数据并commit。则两次读取的数据条数会发生变化,因为加锁读时会重新生成readview,读取最新的数据。
5、一致性(Consistency)
5.1 基本概念
一致性是指事务执行结束后,数据库的完整性约束没有被破坏,事务执行的前后都是合法的数据状态。数据库的完整性约束包括但不限于:实体完整性(如行的主键存在且唯一)、列完整性(如字段的类型、大小、长度要符合要求)、外键约束、用户自定义完整性(如转账前后,两个账户余额的和应该不变)。
5.2 实现
可以说,一致性是事务追求的最终目标:前面提到的原子性、持久性和隔离性,都是为了保证数据库状态的一致性。此外,除了数据库层面的保障,一致性的实现也需要应用层面进行保障。
实现一致性的措施包括:
- 保证原子性、持久性和隔离性。ADI -> C
- 数据库本身提供保障,例如不允许向整形列插入字符串值、字符串长度不能超过列的限制等
- 应用层面进行保障,例如如果转账操作只扣除转账者的余额,而没有增加接收者的余额,无论数据库实现的多么完美,也无法保证状态的一致
6、总结
最后总结一下ACID特性及其实现原理:
- 原子性:一个事务中的语句要么全执行,要么全不执行,是事务最核心的特性。实现基于undo log进行事务回滚。
- 持久性:保证事务提交后不会因为宕机等原因导致数据丢失。实现主要基于redo log进行崩溃恢复。
- 隔离性:保证事务执行尽可能不受其他事务影响。InnoDB默认的隔离级别是RR,RR的实现主要基于锁机制(包含next-key lock)、MVCC(包括数据的隐藏列、基于undo log的版本链、ReadView)
- 一致性:事务追求的最终目标。一致性的实现既需要数据库层面的保障,也需要应用层面的保障。