版权声明:本文的主要内容均来自于「深入学习MySQL事务:ACID特性的实现原理」和「MySQL(二)|深入理解MySQL的四种隔离级别及加锁实现原理」这两篇文章,在此向原作者表示感谢。
事务(Transaction
),一般是指要做的或所做的事情。在计算机术语中是指访问并可能更新数据库中各种数据项的一个程序执行单元。事务通常由高级数据库操作语言或编程语言(如 SQL,C++ 或 Java)书写的用户程序的执行所引起,并用形如begin transaction
和end transaction
语句(或函数调用)来界定。事务由事务开始(begin transaction
)和事务结束(end transaction
)之间执行的全部操作组成。
对于 MySQL 数据库来说,事务是指以执行start transaction
命令开始,到执行commit
或者rollback
命令结束之间的全部 SQL 操作,如果这些 SQL 操作全部执行成功,则执行commit
命令提交事务,表示事务执行成功;如果这些 SQL 操作中任一操作执行失败,则执行rollback
命令回滚事务,表示事务执行失败,并将数据库回滚到执行start transaction
命令之前的状态。特别地,在现阶段的 MySQL 数据库中,仅 InnoDB 和 NDB 两个存储引擎是支持事务的。
以 MySQL 的 InnoDB 存储引擎为例,其默认是开启autocommit
配置的,即自动提交事务。在自动提交模式下,如果没有以start transaction
显式地开始一个事务,那么每条 SQL 语句都会被当做一个事务执行提交操作。通过set autocommit = 0
命令可以关闭自动提交模式,如果关闭了autocommit
,则所有的 SQL 语句都在一个事务中,直到执行commit
或rollback
,该事务结束,并同时开始另外一个新的事务。在此,需要我们注意的是,autocommit
参数是针对连接的,在一个连接中修改了参数,不会对其他连接产生影响。
除此之外,在 MySQL 中,还存在一些特殊的命令,如果在事务中执行了这些命令,则会强制执行commit
命令提交事务,如 DDL 语句(create table
/drop table
/alter table
)、lock tables
语句等。不过,我们常用的select
、insert
、update
和delete
命令,都不会强制提交事务。
通过上面的内容,我们已经知道了什么是事务,但实际上,事务还具有以下四个特性,即:
Atomicity
)Consistency
)Isolation
)Durability
)按照严格的标准,只有同时满足 ACID 特性才是事务,但是在各大数据库厂商的实现中,真正满足 ACID 的事务少之又少。例如,MySQL 的 NDB 事务不满足持久性和隔离性;InnoDB 默认的事务隔离级别是“可重复读”,不满足隔离性;Oracle 默认的事务隔离级别为“读提交”,不满足隔离性等等,因此与其说 ACID 是事务必须满足的条件,不如说它们是衡量事务的四个维度。
我们刚刚提到的“隔离级别”在后文中会进行详细的讲解,下面我们先详细介绍 ACID 特性及其实现原理。
原子性,是指一个事务是一个不可分割的工作单位,其中的操作要么都做,要么都不做;如果事务中一个 SQL 语句执行失败,则已执行的语句也必须回滚,数据库回退到事务开始前的状态。
在说明原子性的实现原理之前,我们先来了解一下 MySQL 的事务日志。MySQL 的日志有很多种,如二进制日志、错误日志、查询日志、慢查询日志等,此外 InnoDB 存储引擎还提供了两种事务日志:redo log
(重做日志)和undo log
(回滚日志)。其中,redo log
用于保证事务持久性;undo log
则是事务原子性和隔离性实现的基础。
实现原子性的关键,是当事务回滚时能够撤销所有已经成功执行的 SQL 语句。InnoDB 实现回滚,靠的是undo log
:当事务对数据库进行修改时,InnoDB 会生成对应的undo log
;如果事务执行失败或调用了rollback
,导致事务需要回滚,便可以利用undo log
中的信息将数据回滚到修改之前的样子。
undo log
属于逻辑日志,它记录的是 SQL 执行的相关信息。当发生回滚时,InnoDB 会根据undo log
的内容做与之前相反的工作:对于每个insert
,回滚时会执行delete
;对于每个delete
,回滚时会执行insert
;对于每个update
,回滚时会执行一个相反的update
,把数据改回去。
以update
操作为例:当事务执行update
时,其生成的undo log
中会包含被修改行的主键(以便知道修改了哪些行)、修改了哪些列、这些列在修改前后的值等信息,回滚时便可以使用这些信息将数据还原到update
之前的状态。
持久性,是指事务一旦提交,它对数据库的改变就应该是永久性的,接下来的其他操作或故障不应该对其有任何影响。
redo log
和undo log
都属于 InnoDB 的事务日志。下面先聊一下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
进行刷盘。如果 MySQL 宕机,重启时可以读取redo log
中的数据,对数据库进行恢复。redo log
采用的是 WAL(Write-ahead logging
,预写式日志),所有修改先写入日志,再更新到Buffer Pool
,保证了数据不会因 MySQL 宕机而丢失,从而满足了持久性要求。
既然redo log
也需要在事务提交时将日志写入磁盘,为什么它比直接将Buffer Pool
中修改的数据写入磁盘(即刷脏)要快呢?主要有以下两方面的原因:
redo log
是追加操作,属于顺序 IO。Page
)为单位的,MySQL 默认页大小是 16 KB,一个Page
上一个小修改都要整页写入;而redo log
中只包含真正需要写入的部分,无效 IO 大大减少。我们知道,在 MySQL 中还存在binlog
(二进制日志)也可以记录写操作并用于数据的恢复,但二者是有着根本的不同的:
redo log
是用于crash recovery
的,保证 MySQL 宕机也不会影响持久性;binlog
是用于point-in-time recovery
的,保证服务器可以基于时间点恢复数据,此外binlog
还用于主从复制。redo log
是 InnoDB 存储引擎实现的,而binlog
是 MySQL 的服务器层实现的,同时支持 InnoDB 和其他存储引擎。redo log
是物理日志,内容基于磁盘的Page
;binlog
的内容是二进制的,根据binlog_format
参数的不同,可能基于 SQL 语句、基于数据本身或者二者的混合。binlog
在事务提交时写入;redo log
的写入时机相对多元: fsync
对redo log
进行刷盘,这是默认情况下的策略,修改innodb_flush_log_at_trx_commit
参数可以改变该策略,但事务的持久性将无法保证。master thread
每秒刷盘一次redo log
等,这样的好处是不一定要等到commit
时刷盘,commit
速度大大加快。与原子性、持久性侧重于研究事务本身不同,隔离性研究的是不同事务之间的相互影响。隔离性,是指事务内部的操作与其他事务是隔离的,并发执行的各个事务之间不能互相干扰。严格的隔离性,对应了事务隔离级别中的Serializable
(可串行化),但实际应用中出于性能方面的考虑很少会使用可串行化。
隔离性追求的是并发情形下事务之间互不干扰。简单起见,我们仅考虑最简单的读操作和写操作(暂时不考虑带锁读等特殊操作),那么隔离性的探讨,主要可以分为两个方面:
首先来看两个事务的写操作之间的相互影响。隔离性要求同一时刻只能有一个事务对数据进行写操作,InnoDB 通过锁机制来保证这一点。
锁机制的基本原理可以概括为:事务在修改数据之前,需要先获得相应的锁;获得锁之后,事务便可以修改数据;该事务操作期间,这部分数据是锁定的,其他事务如果需要修改数据,需要等待当前事务提交或回滚后释放锁。
按照粒度,锁可以分为表锁、行锁以及其他位于二者之间的锁。表锁在操作数据时会锁定整张表,并发性能较差;行锁则只锁定需要操作的数据,并发性能好。但是由于加锁本身需要消耗资源(获得锁、检查锁、释放锁等都需要消耗资源),因此在锁定数据较多情况下使用表锁可以节省大量资源。MySQL 中不同的存储引擎支持的锁是不一样的,例如 MyIsam 只支持表锁,而 InnoDB 同时支持表锁和行锁,且出于性能考虑,绝大多数情况下使用的都是行锁。
如何查看锁信息:有多种方法可以查看 InnoDB 中锁的情况,例如
select * from information_schema.innodb_locks;
# 查询锁的概况show engine innodb status;
# 查询 InnoDB 整体状态,其中包括锁的情况下面来看一个例子:
# 在事务 A 中执行
start transaction;
update account SET balance = 1000 where id = 1;
# 在事务 B 中执行
start transaction;
update account SET balance = 2000 where id = 1;
此时,查看锁的情况:
使用show engine innodb status
语句查看锁相关的部分:
通过上述命令可以查看事务24052
和24053
占用锁的情况,其中lock_type
为RECORD
,代表锁为行锁(记录锁);lock_mode
为X
,代表排它锁(写锁)。除了排它锁(写锁)之外,MySQL 中还有共享锁(读锁)的概念。
介绍完写操作之间的相互影响,下面讨论写操作对读操作的影响。
首先来看并发情况下,读操作可能存在的三类问题。
A
)中可以读到其他事务(B
)未提交的数据(脏数据),这种现象是脏读。举例如下(以账户余额表为例):
A
中先后两次读取同一个数据,两次读取的结果不一样,这种现象称为不可重复读。脏读与不可重复读的区别在于:前者读到的是其他事务未提交的数据,后者读到的是其他事务已提交的数据。举例如下:
A
中按照某个条件先后两次查询数据库,两次查询结果的条数不同,这种现象称为幻读。不可重复读与幻读的区别可以通俗的理解为:前者是数据变了,后者是数据的行数变了。举例如下:
SQL 标准中定义了四种隔离级别,并规定了每种隔离级别下上述几个问题是否存在。一般来说,隔离级别越低,系统开销越低,可支持的并发越高,但隔离性也越差。隔离级别与读问题的关系如下:
在实际应用中,读未提交在并发时会导致很多问题,而性能相对于其他隔离级别提高却很有限,因此使用较少。可串行化强制事务串行,并发效率很低,只有当对数据一致性要求极高且可以接受没有并发时使用,因此使用也较少。因此在大多数数据库系统中,默认的隔离级别是读已提交(如 Oracle)或可重复读(后文简称RR
)。
可以通过如下两个命令分别查看全局隔离级别和本次会话的隔离级别:
select @@global.tx_isolation
# 查询全局隔离级别select @@tx_isolation
# 查询本次会话隔离级别InnoDB 默认的隔离级别是RR
,后文会重点介绍RR
。需要注意的是,在 SQL 标准中,RR
是无法避免幻读问题的,但是 InnoDB 实现的RR
避免了幻读问题。
RR
解决脏读、不可重复读、幻读等问题,使用的是 MVCC:MVCC 全称Multi-Version Concurrency Control
,即多版本的并发控制协议。下面的例子很好的体现了 MVCC 的特点:在同一时刻,不同的事务读取到的数据可能是不同的(即多版本)—— 在 T5 时刻,事务A
和事务C
可以读取到不同版本的数据。
MVCC 最大的优点是读不加锁,因此读写不冲突,并发性能好。InnoDB 实现 MVCC,多个版本的数据可以共存,主要是依靠数据的隐藏列(也可以称之为标记位)和undo log
。其中,数据的隐藏列包括了该行数据的版本号、删除时间、指向undo log
的指针等等;当读取数据时,MySQL 可以通过隐藏列判断是否需要回滚并找到回滚需要的undo log
,从而实现 MVCC;隐藏列的详细格式不再展开。
下面结合前文提到的几个问题分别说明。
当事务A
在 T3 时间节点读取zhangsan
的余额时,会发现数据已被其他事务修改,且状态为未提交。此时事务A
读取最新数据后,根据数据的undo log
执行回滚操作,得到事务B
修改前的数据,从而避免了脏读。
当事务A
在 T2 节点第一次读取数据时,会记录该数据的版本号(数据的版本号是以row
为单位记录的),假设版本号为1
;当事务B
提交时,该行记录的版本号增加,假设版本号为2
;当事务A
在 T5 再一次读取数据时,发现数据的版本号2
大于第一次读取时记录的版本号1
,因此会根据undo log
执行回滚操作,得到版本号为1
时的数据,从而实现了可重复读。
InnoDB 实现的RR
通过next-key lock
机制避免了幻读现象。next-key lock
是行锁的一种,实现相当于record lock(记录锁) + gap lock(间隙锁)
;其特点是不仅会锁住记录本身(record lock
的功能),还会锁定一个范围(gap lock
的功能)。当然,这里我们讨论的是不加锁读:此时的next-key lock
并不是真的加锁,只是为读取的数据增加了标记(标记内容包括数据的版本号等),准确起见姑且称之为类next-key lock
机制。还是以前面的例子来说明:
当事务A
在 T2 节点第一次读取0<id<5
数据时,标记的不只是id=1
的数据,而是将范围(0, 5)
进行了标记,这样当 T5 时刻再次读取0<id<5
数据时,便可以发现id=2
的数据比之前标记的版本号更高,此时再结合undo log
执行回滚操作,避免了幻读。
概括来说,InnoDB 实现的RR
,通过锁机制、数据的隐藏列、undo log
和类next-key lock
,实现了一定程度的隔离性,可以满足大多数场景的需要。不过需要说明的是,RR
虽然避免了幻读问题,但是毕竟不是Serializable
,不能保证完全的隔离,下面是一个例子,大家可以自己验证一下。
一致性,是指事务执行结束后,数据库的完整性约束没有被破坏,事务执行的前后都是合法的数据状态。数据库的完整性约束包括但不限于:实体完整性(如行的主键存在且唯一)、列完整性(如字段的类型、大小、长度要符合要求)、外键约束、用户自定义完整性(如转账前后,两个账户余额的和应该不变)。
可以说,一致性是事务追求的最终目标:前面提到的原子性、持久性和隔离性,都是为了保证数据库状态的一致性。此外,除了数据库层面的保障,一致性的实现也需要应用层面进行保障。实现一致性的措施包括:
下面总结一下 ACID 特性及其实现原理:
undo log
日志;redo log
日志;RR
,RR
的实现主要基于锁机制、数据的隐藏列、undo log
日志和类next-key lock
机制;在上文中,我们已经大致介绍了事务的四种隔离级别,下面我们在来看看这四种隔离级别的实现原理。
我们都知道事务的四种性质,数据库为了维护这些性质,尤其是一致性和隔离性,一般使用加锁这种方式。同时数据库又是个高并发的应用,同一时间会有大量的并发访问,如果加锁过度,会极大的降低并发处理能力。所以,对于加锁的处理,可以说就是数据库对于事务处理的精髓所在。
因为有大量的并发访问,为了预防死锁,一般应用中推荐使用一次封锁法,就是在方法的开始阶段,已经预先知道会用到哪些数据,然后全部锁住,在方法运行之后,再全部解锁。这种方式可以有效的避免循环死锁,但在数据库中却不适用,因为在事务开始阶段,数据库并不知道会用到哪些数据。
数据库遵循的是两段锁协议,将事务分成两个阶段,分别为:
S
锁(共享锁,其它事务可以继续加共享锁,但不能加排它锁),在进行写操作之前要申请并获得X
锁(排它锁,其它事务不能再获得任何锁)。加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。这种方式虽然无法避免死锁,但是两段锁协议可以保证事务的并发调度是串行化(串行化很重要,尤其是在数据恢复和备份的时候)的。
在数据库操作中,为了有效保证并发读取数据的正确性,提出的事务隔离级别,而数据库锁,就是为了构建这些隔离级别存在的。
Read Uncommitted
,虽然其也是隔离级别中的一种,但因为其可能引发的问题比较多,所以数据库一般都不会使用这种隔离级别,而且其任何操作都不会加锁,这里就不讨论了。
Read Committed
,在这种隔离级别中,数据的读取都是不加锁的,但是数据的写入、修改和删除是需要加锁的。
Repeatable Read
,这是 MySQL 中 InnoDB 存储引擎默认的隔离级别。我们姑且分“读”和“写”两个模块来讲解。
读就是可重复读,其解决了脏读和不可重复读的问题,但却可能引发幻读的问题。
讲到这里,我们先来好好地说明下不可重复读和幻读的区别:
update
和delete
操作,而幻读的重点则在于insert
操作。insert
的数据,所以当事务A
先前读取了数据,或者修改了全部数据,事务B
还是可以insert
数据提交,这时事务A
就会发现莫名其妙多了一条之前没有的数据,这就是幻读,不能通过行锁来避免。Serializable
隔离级别 ,读用读锁,写用写锁,读锁和写锁互斥,这么做可以有效的避免幻读、不可重复读、脏读等问题,但却会极大的降低数据库的并发能力。所以说,不可重复读和幻读最大的区别,就在于如何通过锁机制来解决他们产生的问题。MySQL、Oracle、PostgreSQL 等成熟的数据库,出于性能考虑,都是使用了以乐观锁为理论基础的 MVCC(多版本并发控制)来避免这两种问题。
这里继续扩展下悲观锁和乐观锁的知识。
version
)记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个version
字段来实现。需要说明的是,MVCC 的实现没有固定的规范,每个数据库都会有不同的实现方式,这里讨论的是 InnoDB 的 MVCC。接下来,讲解 MVCC 在 MySQL 的 InnoDB 中的实现:
RR
事务隔离级别下: insert
时,保存当前事务版本号为行的创建版本号;delete
时,保存当前事务版本号为行的删除版本号;select
时,读取创建版本号<=
当前事务版本号,删除版本号为空或>
当前事务版本号;update
时,插入一条新纪录,保存当前事务版本号为行创建版本号,同时保存当前事务版本号到原来删除的行。通过 MVCC,虽然每行记录都需要额外的存储空间、更多的行检查工作以及一些额外的维护工作,但可以减少锁的使用,大多数读操作都不用加锁,读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行,也只锁住必要行。
事务的隔离级别其实都是对于读数据的定义,但到了这里,就被拆成了读和写两个模块来讲解。这主要是因为 MySQL 中的读,和事务隔离级别中的读,是不一样的。
我们且看,在RR
级别中,通过 MVCC 机制,虽然让数据变得可重复读,但我们读到的数据可能是历史数据,是不及时的数据,不是数据库当前的数据。这在一些对于数据的时效特别敏感的业务中,就很可能出问题。
对于这种读取历史数据的方式,我们叫它快照读(snapshot read
),而读取数据库当前版本数据的方式,叫当前读(current read
)。很显然,在 MVCC 中:
select
操作;insert
、update
和delete
操作,属于当前读,处理的都是当前的数据,需要加锁。事务的隔离级别实际上都是定义了当前读的级别,MySQL 为了减少锁处理(包括等待其它锁)的时间,提升并发能力,引入了快照读的概念,使得select
不用加锁。而update
、insert
这些“当前读”,就需要另外的模块来解决了。因为更新数据、插入数据是针对当前数据的,所以不能以快照的历史数据为参考,此处就是这个意思。
事务的隔离级别中虽然只定义了读数据的要求,实际上这也可以说是写数据的要求。上文的“读”,实际是讲的快照读,而这里说的“写”就是当前读了。
为了解决当前读中的幻读问题,MySQL 事务使用了next-key lock
锁,我们在上文中已经介绍过了,它是行锁和间隙锁的组合。
行锁可以防止不同事务版本的数据修改提交时造成数据冲突的情况。但如何避免别的事务插入数据就成了问题。行锁防止别的事务修改或删除,间隙锁防止别的事务新增,行锁和间隙锁结合形成的的next-key lock
锁就共同解决了RR
级别在写数据时的幻读问题。
Serializable
,这个级别很简单,读加共享锁,写加排他锁,读写互斥。使用悲观锁的理论,实现简单,数据更加安全,但是并发能力非常差。如果你的业务并发的特别少或者没有并发,同时又要求数据及时可靠的话,可以使用这种模式。
在这里需要注意改变一个观念,不要看到select
就说不会加锁了,在Serializable
这个级别中,select
还是会加锁的。
参考资料: