7. MySQL InnoDB的事务原理与实现

专栏地址:

MySQL系列文章专栏


1. 概念

理论上来说,事务是指满足ACID特性的操作,可以将数据库从一个一致性状态转移到另一个一致性状态。但是数据库实现上可能并不会严格的去满足ACID标准。

对于InnoDB存储引擎而言,其默认的隔离级别可重复读,完全满足和遵循事务的ACID特性。

1.1 ACID

ACID之间的关系并不是正交的,原子性、隔离性、持久性都是为了得到最终的一致性

Atomicity 原子性

原子性是指一个事务是不可分割的最小执行单元,要么全部执行成功,要么全部执行失败。

Consistency 一致性

事务将数据库从一个一致性状态转移到另一个一致性状态。这种一致性是语义上的,等同于数据的正确性。

Isolation 隔离性

隔离性又称并发控制,是指事务之间相互隔离,在事务提交之前其修改对其它事务不可见。

Durability 持久性

事务一旦提交,其修改就是永久性的,即使发生宕机,也能够进行故障恢复。这是一种高可靠性而不是高可用性的要求,也就是说如果硬盘发生损坏,数据仍然可能丢失。

1.2 隔离性与隔离级别

当数据库同时有多个事务执行的时候,就有可能会出现 脏读、不可重复读、幻读等问题。

1.2.1 并发问题

丢失更新

两个事务同时对一个数据进行来修改,后一个事务覆盖了前者的修改。在数据库层面,任何隔离级别下都不会导致丢失更新。因为对行的DML操作,InnoDB会利用锁进行并发控制。

但是在应用层面仍然会存在丢失更新,比如应用程序在SELECT和UPDATE之间发生了并发:

1. 事务 1 SELECT
2. 事务 2 SELECT
3. 事务 1 UPDATE
4. 事务 2 UPDATE

解决丢失更新的思路有两种:

  1. 使用 SELECT FOR UPDATE,对即将要修改的数据加上排他锁,以阻塞其它事务的读写。
  2. 使用基于版本号的乐观锁。

脏读

脏读是指读取到了未提交的数据。

比如,事务A修改了一个数据,但未提交,随后被事务B读取,若A撤销了修改,那么B读取到的为脏数据。

不可重复读

不可重复读是指对同一个数据先后读取到了不同的值。

比如,事务A先读取了数据,随后事务B修改了数据并进行了提交,此时A再次读取该数据却拿到了修改后的值。

幻读

幻读是指对同一范围的查询先后读取到了不同的数据集合,即其中一次查询读取到了另一次查询没有的记录。

比如,A事务读取了某个范围的数据,随后B事务在该范围进行了插入操作并进行了提交,此时A再次读取该范围,拿到的数据集合与前一次不同。

不可重复读的重点在于Update和Delete,而幻读在于Insert。

1.2.2 隔离级别

为了解决这些问题,SQL标准定义了4种隔离级别:

未提交读 Read Uncommitted

即使事务没有提交,其修改也对其它事务可见。

提交读 Read Committed

事务在提交之后,其修改才对其它事务可见。

提交读可以避免脏读问题。

可重复读 Repeatable Read

在同一个事务中,多次读取同样的数据,其值总是和第一次相同。

可重复读可以避免脏读和不可重复读。

与标准的SQL隔离级别不同的是,InnoDB在可重复读级别下,利用Next-Key Lock解决了幻读问题,能够完全保证事务的隔离性,达到了串行化级别。

串行化

事务串行执行,互不干扰。

可以避免脏读、不可重复读和幻读。

总结

隔离级别 脏读 不可重复读 幻影读
未提交读
提交读 ×
可重复读 × ×
可串行化 × × ×

例题

在不同的隔离级别下,V值分别如下:

未提交读 提交读 可重复读 串行化
2 1 1 1
2 2 1 1
2 2 2 2

1.3 分类

事务一般可以分为以下几类:

  • 扁平事务
  • 带有保存点的扁平事务
  • 嵌套事务
  • 链事务
  • 分布式事务

InnoDB支持除嵌套事务外的所有事务类型。

1.4 事务的启动方式

事务的使用方式

  1. 显式启动语句,提交使用commit,回滚使用rollback:
    • begin、start transaction:这两条命令并不是事务的起点,一致性视图将在第一次执行快照读时创建;
    • start transaction with consistent snapshot:立即创建一个一致性视图。
  2. autocommit:
    • = 1(默认值):开启自动提交,每条语句都会被当作一个事务执行提交;
    • = 0:关闭自动提交,所有语句在一个事务中执行,直到显式调用commit或者rollback。

长事务

长事务意味着系统中会存在很老的事务视图,由于这些事务可能会访问数据库的任何数据,所以可能会用到的回滚记录undo log都必须保留,占用大量的存储空间。

其次,由于两阶段锁的加锁方式,长事务会长时间占用锁资源

最佳实践

autocommit = 1,开启自动提交,并通过显式语句开启事务。

将锁竞争最大的语句放在事务的尾部,减少锁的持有时间。

1.5 事务在InnoDB中的实现

在MySQL中,事务是在存储引擎层实现的。对于InnoDB而言:

  • 原子性代表着可回滚,这一特性主要有undo log实现;
  • 隔离性需要在效率上作出平衡,在不同的隔离级别下主要由MVCC和锁实现;
  • 持久性主要由redo log和double write实现,redo log是一种Write Ahead Log(WAL)策略,用于对数据页进行重做;double write则用于防止脏页刷盘时部分写失效导致的数据丢失。

原子性、隔离性、持久性最终实现了一致性。

2 隔离性的实现

InnoDB实现事务隔离的机制主要有MVCC和锁。

MVCC(Multiversion concurrency control,多版本并发控制协议),是一种提高系统并发的技术,在很多情况下避免了加锁操作。MVCC通过undo log来构建数据的历史版本,通过视图来定义数据版本的可见性。并由此构建数据库在某一个时间点的全库快照(一致性视图),来实现一致性非锁定读,保障事务的隔离性和一致性。

在没有MVCC的情况下,只有读读是可以并发的,引入MVCC后只有写写之间是阻塞的,其它都可以并发。这解决读写锁造成的多个、长时间读操作饿死写操作的的问题。

在InnoDB中,只有普通查询使用的是一致性非锁定读,其它DML等操作则采用当前读。

2.1 版本

数据版本是MVCC中的一个逻辑概念,在物理上并不真实存在。InnoDB在当前行记录的基础之上,利用undo log链来构建出记录的历史版本。 当版本链很长时,可能比较耗时。

具体实现是,在InnoDB的行记录格式中,有两个隐藏列:事务ID和回滚指针。事务ID是由InnoDB在事务开始前分配的,是一个严格递增的唯一ID。行记录上的事务ID等于更新这条记录的事务ID。回滚指针指向当前记录的undo log。

当事务对记录进行更新时,会先将当前记录上更新前的值、事务ID、回滚指针一起记录在undo log中,随后更新记录的值及其事务ID,并将回滚指针指向生成的undo log。于是,通过undo log可以将当前记录恢复到上一个状态。那么,通过回滚指针串联成的undo log链表,可以将记录恢复到任意历史版本。

在MVCC中,每一行记录都有多个版本,每一次更新都会产生一个新的版本,版本的事务ID row trx_id 为生成这个版本事务的ID。

图中,V1~V4表示记录的四个版本,其中V4为最新版本,由ID为25的事务所更新,所以其 row trx_id = 25。虚线U1~U3为undo log,根据当前版本V4和U1~U3依次计算出版本V1~V3。

2.2 视图

有了历史版本,就可以构建整个库在某一时刻的数据视图。

MVCC使用一致性视图(consistent read view)来定义事务中数据的可见性,在事务中的数据访问以视图的逻辑结果为准。在不同的隔离级别下,视图的创建时机不同:

  • 未提交读:直接返回记录的最新值(Buffer Pool),没有视图概念;
  • 提交读:在每条SQL开始执行时创建视图;
  • 可重复读:在事务启动时创建视图,在整个事务中始终使用这个视图,期间只能看到事务启动前已提交的事务结果和自己更新产生的版本
  • 串行化:直接使用锁来避免并发冲突。

在访问行记录时,都从当前最新版本开始,如果不可见就访问上个版本,直至可见或者达到最老版本。

视图数组、高/低水位

视图数组和高/低水位共同定义了事务的一致性视图。

视图数组m_ids保存了视图创建时当前活跃的事务ID,活跃事务指的是当前启动了但还未提交的事务。

低水位up_limit_id为视图数组的最小值,即当前最小活跃事务ID。

高水位low_limit_id为视图创建时,当前系统中已分配事务ID的最大值+1。

版本可见性

数据版本的可见性是基于一致性视图和版本的事务ID row trx_id对比得到的。

如果数据版本的事务ID:

  • 小于低水位,则表明这个版本是由视图创建前已提交事务创建的,可见;
  • 大于等于高水位,则表明这个版本是由将来启动的事务创建的,不可见;
  • 位于高低水位之间,则包含两种情况:
    • 位于视图数组中,则表明该版本是由视图创建前未提交事务创建的,不可见;
    • 不位于视图数组中,则表明该版本是由视图创建前已提交事务创建的,可见;
  • 等于当前事务ID,可见。

2.3 当前读和快照读(一致性非锁定读)

普通查询为一致性非锁定读(快照读),利用视图控制数据版本的可见性,读取当前或者历史版本,不会产生锁操作。

而对于UPDATE等DML数据变更操作,则采用当前读:读取最新版本,并对行记录进行加锁,如果与当前行锁产生冲突,则进入锁等待。

如果对查询语句进行加锁的话,那么也采用当前读:

SELECT ... IN SHARE MODE; # 读锁(S锁,共享锁)
SELECT ... FOR UPDATE; # 写锁(X锁,排他锁)

2.4 可重读读下的事务隔离

在可重复读隔离级别下,假设有以下表结构:

CREATE TABLE t (   
    id int(11) NOT NULL,   
    k int(11) DEFAULT NULL,   
    PRIMARY KEY (id)
) ENGINE=InnoDB; 

insert into t(id, k) values(1,1),(2,2)

设置自动提交autocommit = 1,那么以下事务中读取的值分别为多少?

分析

start transaction with consistent snapshot立即启动一个事务并创建一个一致性视图,对于事务A、B而言,id = 1这行记录快照读的可见版本为 (1, 1)。

事务C采用当前读将 (1, 1) 更新成了 (1, 2),由于没有显式地使用事务语句且设置了自动提交,所以更新完成之后立即进行了提交,并且释放了行锁。最新版本变成了(1, 2)。

事务B的UPDATE语句同样采用当前读,将 (1, 2) 更新成了 (1, 3)。普通SELECT采用的是快照读,由于最新版本 (1, 3) 是由当前事务更新的,所以该SELECT的值为3。最新版本变成了(1, 3),由于事务尚未提交,行锁不释放。

事务A的SELECT为快照读,根据事务开始前视图确认的可见版本,其值为1。

详解

假设:

  • 事务A开始前当前活跃的事务ID只有99;
  • 事务A、B 、C的ID分别为100,101, 102;
  • 三个事务开始前,id = 1这行记录上的事务ID为90,即最新版本的事务ID为90。已提交。

于是,事务A的视图数组为 [99, 100],高低水位分别为 99/101;事务B的视图数组为 [99, 100, 101],高低水位为 99/ 102;事务C的视图数组为 [99, 100, 101, 102], 高低水位为 99/ 103。

第一个有效更新是事务C产生的,将 (1, 1) 更新成了 (1, 2),此时数据最新版本的事务ID row trx_id 为102,90变成了历史版本。

第二个有效更新是事务B,事务B在进行更新时,采用当前读,避免丢失更新,所以UPDATE读取到了 (1, 2) 并更新成了 (1, 3),该版本的事务ID row trx_id为101。

随后事务B进行查询时,最新版本的事务ID为101与自己相同,所以读取到的值为3。

在事务A进行查询时,从当前最新版本开始读取数据:

  1. (1, 3) 版本的事务ID为101,大于等于高水位101,位于红色区域,不可见;
  2. 接着访问上一个版本(利用undo log 计算)(1, 2),仍然比高水位大,不可见;
  3. 继续向前访问到 (1, 1),该版本的事务ID为90,比低水位99小,可见。

所以,事务A读取到的值为1。

此外,假设事务C更新完没有立即提交,在没有提交之前,事务B先发起了UPDARE操作:

由于两阶段锁的加锁方式,事务C的行锁在提交之后才会释放。所以,事务B的UPDATE会被阻塞住。

3 原子性的实现

事务是不可分割的最小执行单元,要么全部执行成功,要么全部执行失败。原子性意味着可回滚,InnoDB对事务过程中的数据变更总是记录了undo log,利用undo log可将记录恢复到历史版本。

3.1 undo log基本概念

undo log是逻辑日志,可以将数据库逻辑地而不是物理地恢复到原来的状态,撤销事务对行记录所作出的修改。undo log只作用于聚簇索引的变更。

除了回滚操作,undo log的另一个重要作用是在MVCC中用以构建数据的历史版本,以实现非锁定一致性读。

undo log 存储于共享表空间的回滚段中,与普通数据页的管理类似,也会先缓存于buffer pool之中,因此也需要进行持久性的保证——undo log的生成会随之生成对应的redo log,用于对undo log页进行重做

3.2 undo log格式

InnoDB中的undo log主要有两种格式:

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

两种undo log中均有:

  • n_unique_index:记录着所有主键的列及其值,用以快速定位到行记录。
  • undo_no:记录生成该undo log的事务ID。
  • next、start:记录下一条undo log的位置以及当前undo log的起始位置。

undate undo log记录的内容更多,有:

  • DATA_TRX_ID:旧记录的事务ID,即生成旧记录的事务ID。
  • DATA_ROLL_PTR:旧记录的回滚指针。
  • n_undate_field:更新的记录的列及其旧值。

其中,旧记录的事务ID和回滚指针用以在MVCC中构建历史版本。

3.3 undo log的生成

当事务进行更新时,会生成能将更新操作进行回滚的undo log(redo log前),更新操作有主要有两种情况:

  • 原地更新

    直接在当前行记录上应用更新,一般对于非主键的字段更新采用该方式。首先先将当前记录上更新前的值、事务ID、回滚指针一起记录在undo log中,随后更新记录的值及其事务ID,并将回滚指针指向生成的undo log。

  • delete mark + insert

    对于主键列的更新,采用先删除再插入的方式。先将原记录标记为删除(delete flag),然后再插入一条新纪录,同时生成两种类型的undo log。

3.4 undo log的清理

由于InnoDB支持MVCC,所以对记录的删除操作、undo log的回收操作等不能立刻进行,而是需要在这些旧版本没有被事务引用时再进行清理操作。

update undo log会按照顺序放到history list中,后台Purge Thread会在定期扫描,清理无用的undo log。另外,还会对标记为删除行记录(delete flag)进行彻底的删除,即该记录占用的空间放入PAGE_FREE链表中,以便复用。

insert undo log,由于只对当前事务生效,所以在事务提交后可以直接删除,不需要purge操作。

4 持久性的实现

InnoDB中,事务的持久性主要由redo log和double write实现。

redo log是一种Write Ahead Log(WAL)策略,用于对数据页进行重做;double write则用于防止脏页刷盘时部分写失效导致的数据丢失。

4.1 redo log

4.1.1 redo log的作用

InnoDB利用缓存池buffer pool来提高系统性能,缓存池中脏页刷新的IO成本很高,并不是实时刷新的。为了防止脏页异步刷新导致的数据丢失,InnoDB利用redo log来记录对数据页的修改,以便在故障发生后对数据页进行重做,提供Crash-Safe能力,保证事务的ACID中的D持久性。

redo log是一种WAL(Write-Ahead Logging,日志先行)策略:在对数据进行修改前,先记录修改日志,即先写日志,再将写磁盘。

为了保证事务不丢失,在默认情况下,每次事务提交前均会持久化redo log。

WAL策略应用的前提:redo log的持久化成较脏页刷新成本低

redo log需要持久化后才有意义,所以要保证redo log的持久化成本远低于脏页刷新成本,才有应用的价值。

redo log的持久化成本低主要体现在:

  1. redo log为顺序写,较脏页刷新的随机IO成本低很多。
  2. 组提交 Group Commit,redo log 和 binlog 都具有组提交特性,在刷盘时通过等待一段时间来收集多个事务日志同时进行刷盘。

PS:

Crash-Safe

指在故障恢复后:

  • 已提交的事务数据不会丢失
  • 未提交的事务自动回滚

4.1.2 redo log的实现

循环追加写

redo log在磁盘上以循环写的方式操作日志组,write pos表示当前日志记录的位置,边写边向后移动。checkpoint表示脏页已经刷盘的日志编号(LSN),随着脏页不断的刷新到磁盘,checkpoint也在不断地向后推进,而在checkpoint前的redo log可以被复用。

redo log 缓存

redo log先写缓存,默认在以下情况下会刷新到磁盘上:

  1. Master Thread 线程每秒刷新一次;
  2. 事务提交时,由innodb_flush_log_at_trx_commit控制,默认为1,即每次事务提交时均持久化redo log;
  3. 当缓存空间小于1/2时。

脏页刷新

InnoDB会在以下情况将缓存池中的脏页刷新到磁盘:

  1. 系统空闲时
  2. 缓存池空间不足时
  3. redo log 空间不足时
    脏页刷新由Page Cleaner Thread线程负责。

4.1.3redo log和binlog的XA

在进行事务提交时,MySQL利用内部的XA事务来协调引擎层和Sercer层的一致性。XA事务是两阶段提交(2PC,Two-Phase Commit Protocol)的一种实现。

为什么要使用XA

MySQL的架构中,引擎层和Server层具有各自的日志系统,XA即为了保证这两套日志系统的逻辑一致性。

倘若没有2PC在系统崩溃时可能会造成Server层和引擎层的日志不一致,最常见的异常就是主从不一致

假设不使用2PC,若先写redo log再写binlog,在redo log写入成功后系统崩溃,则原库可以正常恢复,而利用binlog的主从复制和数据恢复等操作则与原库不一致。

若先写binlog在写redo log,并在此期间发生崩溃,则从库和利用binlog恢复出的临时库正常,而原库则丢失了一部分更新。

事务的提交流程

具体而言,在更新操作中,InnoDB先仅在缓存中对数据页进行修改,随后事务提交时利用内部XA事务来保证引擎层日志和Server层日志的一致性,Server层作为协调者:

  1. Server层发起prepare,此时:
    1. Server层binlog什么都不做
    2. InnoDB将redo log设置为prepare状态,并将redo log 实时落盘
  2. 若所有引擎均prepare成功,则发起commit
    1. Server层写binlog 实时落盘
    2. InnoDB将redo log设置为commit

事务提交完成,脏页将在后续适时刷新,整体流程如下:

PS

控制redo log 和 binlog 持久化策略的参数:

sync_binlog:默认为1,即每次事务提交均持久化binlog;

innodb_flush_log_at_trx_commit:默认为1,即每次事务提交时均持久化redo log。

若非全1,则在极端情况下可能导致redo log和binlog不一致。

4.1.4 故障恢复流程

主库恢复流程

读取redo log和binlog,提交redo log处于commit状态和虽处于prepared状态但binlog成功落盘的事务。

具体而言,CheckPoint前的脏页已经成功落盘,主库故障恢复时从CheckPoint开始:

  1. 首先读取redo log:
    • 对于处于commit的事务正常提交;
    • 对于处于未prepared和commit的事务进行回滚;
    • 对于处于prepared但尚未commit的事务暂时挂起。
  2. 读取binlog:
    • 判断redo log中处于prepared但尚未commit的事务是否存在与binlog中,若存在则提交,否则回滚;
    • 对于binlog尾部不完整的事务进行回滚。

redo log 和 binlog通过XID联系。

对于需要提交的事务,从磁盘上读取数据页到缓存池中,然后应用redo log更新页。

对于需要回滚的事务,利用undo log进行回滚。

MySQL在binlog和redo log的保存期限内可以恢复到任意一秒的状态

首先找到目标日期最近的一次binlog备份恢复出临时库,然后在临时库上重放redo log至目标时刻。

使用mysqldump和mysqlbinlog可以制作全量和增量备份。

4.1.5 redo log、binlog 和 undo log的区别

redo log是InnoDB层的物理日志,记录事务对数据页的修改,用于数据库的故障恢复时对数据页进行重做,保证事务ACID中的D持久性。

binlog是Server层的逻辑归档日志,记录事务SQL语句的原始逻辑,用于备份和主从同步。

undo log是InnoDB层的逻辑日志,记录事务对行记录修改的回滚操作(比如UPDATE前行记录的旧值),可以将数据库逻辑地而不是物理地恢复到原来的状态,用于撤销事务对行记录所作出的修改。此外,undo log还用于在MVCC中构建记录的历史版本。undo log位于共享表空间的回滚段,其持久化也需要redo log的保证。

redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。

故障恢复必须利用redo log,binlog是逻辑日志不能用于恢复数据页,临时库(从库)只能利用binlog。

4.2 double write

Double Write主要是为了提升数据页可靠性,防止部分写失效导致的数据丢失。因为InnoDB数据页一般为16K,而文件系统的页大小为4K,所以操作系统可能无法保证InnoDB数据页的原子写入。倘若在刷新页的过程中,服务器宕机了,则会导致原始数据页的损毁。重放redo log也无法解决部分写失效问题,因为重做日志记录的是对数据页的修改操作,如果这个数据页本身就是损坏的,对其应用redo log中的修改也是没有意义的。

InnoDB的解决方案是:拷贝一份数据页的副本,即Double Write。当脏页要刷新时,Doube Write的主要流程是:

  1. 先将Buffer Pool中的脏页拷贝到Double Write Buffer中。
  2. 将Double Write Buffer以顺序追加写的方式实时刷盘(系统表空间),同步IO。
  3. 再将Double Write Buffer中的页写到相应的表空间,此时为随机IO,异步IO。异步IO回调函数中会进行完整性校验。

Double Write Buffer不仅仅存在于内存中,是一个内存/磁盘的两层结构

当发生了部分写失效时,可以通过系统表空间的Double Write Buffer进行恢复,随后再应用redo log重做日志,完成完整的故障恢复流程。

如果文件系统能够提供页大小的原子写入,提供防范部分写失效的解决方案,那么可以关闭Double Write。

参考

MySQL的MVCC机制—淘宝数据库内核日报

MySQL · 引擎特性 · InnoDB 事务子系统介绍—淘宝数据库内核日报

MySQL · 引擎特性 · InnoDB undo log 漫游—淘宝数据库内核日报

《MySQL实战45讲》极客时间

《高性能MySQL》

《MySQL技术内幕(InnoDB存储引擎)》

猜你喜欢

转载自blog.csdn.net/cooper20/article/details/108637534