一文理解MySQL的事务原则与事务隔离

318

事务是一组不可分组的操作集合,这些操作要么都成功执行,要么都取消执行。最典型的需要事务的场景是银行账户间的转账:假如 A 账户要给 B 账户转账 100 元,那么 A 账户要扣减 100 元,B 账户要增加 100 元,这两个账户的数据变更都成功才可算作转账成功。

本文涉及到MySQL锁机制的很多知识,对这方面不了解的可以先看博客《快速理解MySQL的锁机制与死锁排查

ACID原则

事务具有四个原则:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。这四个特性简称为ACID原则。

  1. 原子性(Atomictiy):事务是一个包含一系列操作的原子操作。事务的原子性确保这些操作全部完成或者全部失败。

  2. 一致性(Consistency):事务的执行不能破坏数据库数据的完整性和一致性,一个事务在执行之前和执行之后,数据库都必须处于一致性状态。

如果数据库系统在运行过程中发生故障,有些事务尚未完成就被迫中断,这些未完成的事务对数据库所作的修改有一部分已写入物理数据库,此时数据库就处于不一致的状态。(在合格的数据库产品中,数据库重启后,会按照一定规则回滚所有导致不一致的事务)

  1. 隔离性(Isolation):数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。

  2. 持久性(Durabilit):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。

1. 原子性

原子性(Atomicity)的实现离不开MySQL的事务日志undo log日志类型,当事务需要回滚的时候需要将数据库状态回滚到事务开始前,即需要撤销所有已经成功执行的sql语句。undo log起了关键性作用。

当事务对数据库进行修改时,InnoDB会生成对应的undo log;如果事务执行失败或调用了rollback,导致事务需要回滚,便可以利用undo log中的信息将数据回滚到修改之前的样子。

undo log

undo log主要为事务的回滚服务。在事务执行的过程中,除了记录redo log,还会记录一定量的undo log。undo log记录了数据在每个操作前的状态,如果事务执行过程中需要回滚,就可以根据undo log进行回滚操作。单个事务的回滚,只会回滚当前事务做的操作,并不会影响到其他的事务做的操作。

undo log的作用:

  1. 为了保证数据的原子性,记录事务发生之前的一个版本,用于回滚,

  2. 通过mvcc和undo log实现innodb事务可重复读和读取已提交隔离级别。

其中,undo log分为:

  • insert undo log:insert操作中产生的undo log。

  • update undo log:delete和update操作产生的undo log。

  1. insert操作

因为insert操作的记录,只对事务本身可见,对其他事务不可见。故undo log可以在事务提交后直接删除,不需要进行purge操作,

  1. delete操作

Delete操作在事务中并不是真正的删除掉数据行,而是一种Delete Mark操作,在记录上标识Delete_Bit,而不删除记录。是一种"假删除",之后通过purge线程删除。(原因是为了支持MVCC)

  1. update操作

update分为两种情况:update的列是否是主键索引。

如果不是主键索引,在undo log中直接反向记录是如何update的。即update是直接进行的。

如果是主键索引,update分两部执行:先删除该行,再插入一行目标行。

与insert undo log不同的,update undo log日志,当事务提交的时候,innodb不会立即删除undo log, 会将该事务对应的undo log放入到删除列表中,之后通过purge线程删除。

因为后续还可能会用到undo log,例如隔离级别为repeatable read时的MVCC,事务读取的都是开启事务时的最新提交行版本,只要该事务不结束,该行版本就不能删除(即undo log不能删除),且undo log分配的页可重用减少存储空间和提升性能。

purge线程两个主要作用是:清理undo页和清除page里面带有Delete_Bit标识的数据行。

2. 一致性

一致性是通过事务的原子性、持久性和隔离性来保证的。

  • 原子性:语句要么全执行,要么全不执行,是事务最核心的特性,事务本身就是以原子性来定义的。实现主要基于undo log。

  • 持久性:保证事务提交后不会因为宕机等原因导致数据丢失。实现主要基于redo log。

  • 隔离性:保证事务执行尽可能不受其他事务影响。InnoDB默认的隔离级别是RR,RR实现隔离性主要基于锁机制(包含next-key lock)、MVCC(包括数据的隐藏列、基于undo log的版本链、ReadView)。

3. 隔离性

隔离性是指事务内部的操作与其他事务是隔离的,详情可以看下面章节“事务并发问题”与“解决事务并发问题”。

4. 持久性

持久性是指事务提交之后,所做的修改就会永久保存,不会因为系统故障导致数据丢失,

redo log

在innoDB的存储引擎中,事务日志通过redo log和日志缓冲实现。事务开启时,事务中的操作,都会先写入存储引擎的日志缓冲中,在事务提交之前,这些缓冲的redo log都需要提前刷新到磁盘上持久化,这就是日志先行机制(Write-Ahead Logging)。当事务提交之后,在日志缓冲中映射的数据文件才会慢慢刷新到磁盘。此时如果数据库崩溃或者宕机,那么当系统重启进行恢复时,就可以根据redo log中记录的日志,把数据库恢复到崩溃前的一个状态。未完成的事务,可以继续提交,也可以选择回滚,这基于恢复的策略而定。

在系统启动的时候,存储引擎就已经为redo log分配一块连续的存储空间,以顺序追加的方式记录Redo Log,通过顺序IO来改善性能。redo log日志的大小是固定的,即记录满了以后就从头循环写。所有的事务共享redo log的存储空间,Redo Log按语句的执行顺序,依次交替的记录在一起。

缓冲的redo log刷新到磁盘上持久化,刷新频率由innodb_flush_log_at_trx_commit
变量来控制的:

  • 0 :每秒刷新写入到磁盘中的,当系统崩溃,会丢失1秒钟的数据。

  • 1:事务每次提交都写入磁盘。

  • 2:每秒刷新写入到磁盘中的,但跟0是有区别的。

事务并发问题

1. 脏读

脏读是指一个事务中访问到了另外一个事务未提交的数据。

如果会话2更新age为 10,但是在commit之前,会话1此时获取age,那么会获得的值就是会话2中尚未commit的值。如果会话2再次更新了age或者执行了rollback,而会话 1已经拿到了age=10的值,这就是脏读。

2. 不可重复读

不可重复读是一个事务读取同一条记录2次,得到的结果不一致。

由于在读取中间变更了数据,所以会话1事务查询期间的得到的结果就不一样了。

3. 幻读

幻读是一个事务读取2次,得到的记录条数不一致。

由于在会话1之间插入了一个新的数据,所以得到的两次数据就不一样了。

事务隔离级别

为了解决上面提到的事务并发问题,SQL标准规定了四个隔离水平:

  1. 读未提交(Read Uncommited)

  2. 读已提交(Read Commited)

  3. 可重复读(Repeatable Read)

  4. 串行化(Serializable)

隔离级别脏读不可重复读幻读
读未提交存在存在存在
读已提交不存在存在存在
可重复读不存在不存在存在
串行化不存在不存在不存在

1. 读未提交(Read Uncommited)

该隔离级别安全最低,允许脏读。

2. 读已提交(Read Commited)

存在不可重复读取,但不允许脏读。读已提交只允许获取已经提交的数据。

3. 可重复读(Repeatable Read)

保证在事务处理过程中,多次读取同一个数据时,其值都和事务开始时刻是一致的,因此该事务级别禁止不可重复读取和脏读,但是有可能出现幻读。

4. 串行化(Serializable)

是最严格的事务隔离级别,它要求所有事务被串行执行,即事务只能一个接一个的进行处理,不能并发执行。

下面是主流数据库的默认隔离级别:

DatabaseDefault isolation Level
OracleREAD_COMMITTED
MySQLREPETABLE_READ
Microsoft SQL ServerREAD_COMMITTED
PostgreSQLREAD_COMMITTED
DB2CURSOR STABILITY (a.k.a READ_COMMITTED)

隔离级别越高,越能保证数据的完整性和一致性,但是对并发性能的影响也越大。对于多数应用程序,可以优先考虑把数据库系统的隔离级别设为Read Committed。它能够避免脏读取,而且具有较好的并发性能。尽管它会导致不可重复读、幻读和第二类丢失更新这些并发问题,在可能出现这类问题的业务场景,可以采用悲观锁或乐观锁来控制。

解决事务并发问题

MySQL、Oracle这样的数据库中,为了性能的考虑并不是完全按照上面的SQL标准来实现的。

数据库实现事务隔离的方式,基本可以分为以下两种。

  1. 在读取数据前,对其加锁,阻止其他事务对数据进行修改。

  2. 不用加任何锁,通过一定机制生成一个数据请求时间点的一致性数据快照(Snapshot),并用这个快照来提供一定级别(语句级或事务级)的一致性读取。从用户的角度来看,数据库可以提供同一数据的多个版本,因此,这种技术叫做数据多版本并发控制(MultiVersion Concurrency Control,简称MVCC或MCC)

MVCC

仅通过锁来控制实现事务隔离级别会存在一些问题,比如要实现 RC(读已提交)级别,事务a更新一行数据,需要对行(实际是索引记录)加X锁,阻塞其它事务对该行的读写,事务b想要读取该行必须等到a提交或回滚释放锁,这样的话就会很大程度上限制读写的并发能力。

MVCC是一种无锁方案,用以解决事务读-写并发的问题,能够极大提升读-写并发操作的性能。

为了理解MVCC,需要先了解下版本链与ReadView,业务场景如下:

创建一个表book,就三个字段,分别是主键book_id, 名称book_name, 库存stock。然后向表中插入一些数据:

INSERT INTO book VALUES(1, '数据结构', 100);
INSERT INTO book VALUES(2, 'C++指南', 100);
INSERT INTO book VALUES(3, '精通Java', 100);

版本链

对于使用InnoDB存储引擎的表,其主键索引记录中包含了两个重要的隐藏列:

  1. 事务ID(DB_TRX_ID):每当事务对主键索引中的记录进行修改时,都会把当前事务的事务id记录到DB_TRX_ID中。

  2. 回滚指针(DB_ROLL_PTR):每当事务对主键索引中的记录进行修改时,都会把该记录的旧版本记录到undo日志中,通过DB_ROLL_PTR这个指针可以用来获取该记录旧版本的信息。

如果在一个事务中多次对记录进行修改,则每次修改都会生成undo日志,并且这些undo日志通过DB_ROLL_PTR指针串联成一个版本链,版本链的头结点是该记录最新的值,尾结点是事务开始时的初始值。

例如在表book中做以下修改:

BEGIN;
UPDATE book SET stock = 200 WHERE id = 1;
UPDATE book SET stock = 300 WHERE id = 1;

那么id=1的记录此时的版本链就如下图所示:

ReadView

对于Read Uncommitted隔离级别的事务来说,只需要读取版本链上最新版本的记录即可;对于Serializable隔离级别的事务来说,InnoDB使用加锁的方式来访问记录;而对于Read Committed和Repeatable Read隔离级别来说,都需要读取已经提交的事务所修改的记录,也就是说如果版本链中某个版本的修改没有提交,那么该版本的记录时不能被读取的。所以需要确定在Read Committed和Repeatable Read隔离级别下,版本链中哪个版本是能被当前事务读取的。于是ReadView的概念被提出以解决这个问题。

ReadView相当于某个时刻表记录的一个快照,在这个快照中能获取到与当前记录相关的事务中,哪些事务是已提交的稳定事务,哪些是正在活跃的事务,哪些是生成快照之后才开启的事务。由此就能根据可见性比较算法判断出版本链中能被读取的最新版本记录。

在MySQL中,Read Committed和Repeatable Read隔离级别下的区别就是它们生成ReadView的时机不同。

在Read Committed隔离级别下,每次读取数据时都会生成ReadView;而在Repeatable Read隔离级别下只会在事务首次select数据时生成ReadView,之后的读操作都会使用相同的ReadView。

可见性比较算法

可见性比较算法是基于事务ID的比较算法。事务id是递增分配的,从ReadView中能获取到生成快照时刻系统中活跃的事务中最小和最大的事务id(最大的事务id实际上是系统中将要分配给下一个事务的id值),这样就得到一个活跃事务id的范围,称之为ACTIVE_TRX_ID_RANGE
。那么小于这个范围的事务id对应的事务都是已提交的稳定事务,大于这个范围的事务都是在快照生成之后才开启的事务,而在ACTIVE_TRX_ID_RANGE
范围内的事务中除了正在活跃的事务,也都是已提交的稳定事务。

具体的可见性比较算法过程如下:

  1. 首先判断版本记录的DB_TRX_ID字段与ReadView的creator_trx_id字段是否相等。如果相等,那就说明该版本的记录是在当前事务中生成的,自然也就能够被当前事务读取;否则进行第2步。

  2. 如果版本记录的DB_TRX_ID字段小于范围ACTIVE_TRX_ID_RANGE,表明该版本记录是已提交事务修改的记录,即对当前事务可见;否则进行下一步。

  3. 版本记录的DB_TRX_ID字段位于范围ACTIVE_TRX_ID_RANGE内:如果该事务ID对应的不是活跃事务(已经提交了),即对当前事务可见;如果该事务ID对应的是活跃事务(还未提交了),那么对当前事务不可见,则读取版本链中下一个版本记录。重复以上步骤,直到找到对当前事务可见的版本。

creator_trx_id字段:ReadView中的属性,记录创建这条记录/最后一次修改该记录的事务ID。

如果某个版本记录经过以上步骤判断确定其对当前事务可见,则查询结果返回此版本记录;否则读取下一个版本记录继续按照上述步骤进行判断,直到版本链的尾结点。如果遍历完版本链没有找到对当前事务可见的版本,则查询结果为空。

Read Committed下的MVCC

假设在Read Committed隔离级别下,有如下事务在执行,事务id为10:

BEGIN;  开启Transaction 10
UPDATE book SET stock = 200 WHERE id = 2;
UPDATE book SET stock = 300 WHERE id = 2;

此时该事务尚未提交,id为2的记录版本链如下图所示:

然后开启一个事务对id为2的记录进行查询:

BEGIN;
SELECT * FROM book WHERE id = 2; 此时Transaction 10 未提交

当执行SELECT语句时会生成一个ReadView,该ReadView中的ACTIVE_TRX_ID_RANGE为[10, 11),creator_trx_id为0(因为事务中当执行写操作时才会分配一个单独的事务id,否则事务id为0)。按照之前所述ReadView的工作原理,查询到的id=2的book,stock值应为100。

然后将事务id为10的事务提交:

BEGIN;  开启Transaction 10
UPDATE book SET stock = 200 WHERE id = 2;
UPDATE book SET stock = 300 WHERE id = 2;
COMMIT;

同时开启执行另一事务id为11的事务,但不提交:

BEGIN;  开启Transaction 11
UPDATE book SET stock = 400 WHERE id = 2;

此时id为2的记录版本链如下图所示: 

然后回到刚才的查询事务中再次查询id为2的记录:

BEGIN;
SELECT * FROM book WHERE id = 2; // 此时Transaction 10 未提交
SELECT * FROM book WHERE id = 2; // 此时Transaction 10 已提交

当第二次执行SELECT语句时会再次生成一个ReadView,该ReadView中的ACTIVE_TRX_ID_RANGE为[11, 12),当前事务IDcreator_trx_id依然为0。按照ReadView的工作原理进行分析,查询到的id=2的book,stock值为300。

从上述分析可以发现,因为每次执行查询语句都会生成新的ReadView,所以在Read Committed隔离级别下的事务读取到的是查询时刻表中已提交事务修改之后的数据。

Repeatable Read下的MVCC

Repeatable Read隔离级别下的事务只会在第一次执行查询时生成ReadView,该事务中后续的查询操作都不会生成新的ReadView,因此Repeatable Read隔离级别下,一个事务中多次执行同样的查询,其结果都是一样的,这样就实现了可重复读。

补充

redo/undo log和binlog的区别:

  1. 层次不同。redo/undo是innodb引擎层维护的,而binlog是mysql server层维护的,跟采用何种引擎没有关系,记录的是所有引擎的更新操作的日志记录。

  2. 记录内容不同。redo/undo记录的是每个页/每个数据的修改情况,属于物理日志+逻辑日志结合的方式(redo log是物理日志,undo log 是逻辑日志);binlog是逻辑日志,其记录是对应的SQL语句。

  3. 记录时机不同。redo/undo在事务执行过程中会不断的写入,而binlog是在事务最终提交前写入的。binlog什么时候刷新到磁盘跟参数sync_binlog
    相关。

当前读

SELECT xxx LOCK IN SHARE MODE 语句为当前读,加S锁和元数据读锁,直到事务结束。

SELECT xxx FOR UPDATE 语句为当前读,加X锁和元数据读锁,直到事务结束

这种读取方式读取的是记录的当前最新版本,称为当前读。另外对于INSERT、DELETE、UPDATE操作,也需要先读取记录,获取记录的X锁,这个过程也是一个当前读。由于需要对记录进行加锁,会阻塞其他事务的写操作,因此也叫加锁读或阻塞读。

在MySQL的Repeatable Read隔离级别下,当前读使用临键锁解决幻读问题。

快照读

在Read Committed和Repeatable Read隔离级别下,普通的SELECT查询(不包括 select ... lock in share mode, select ... for update)都是读取MVCC版本链中的一个版本,相当于读取一个快照,因此称为快照读。这种读取方式不会加锁,因此读操作时非阻塞的,也叫非阻塞读。

在标准的Repeatable Read隔离级别下读操作会加S锁,直到事务结束,因此可以阻止其他事务的写操作;但在MySQL的Repeatable Read隔离级别下读操作没有加锁,不会阻止其他事务对相同记录的写操作。

注意:快照读的时间点是以第一个select来确认的。所以即使事务开始后先update再select,那么select获取的数据则是对应update动作之后生成的ReadView。

在MySQL的Repeatable Read隔离级别下,快照读使用MVCC解决幻读问题。


文章转载自全菜工程师小辉,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论