蹲厕所的熊

benjaminwhx

谈谈MySQL InnoDB存储引擎事务的ACID特性

2018-04-25 作者: 吴海旭


  1. 1、前言
  2. 2、Redo log
    1. 对比binlog
    2. redo log block
    3. crash recovery
  3. 3、Undo log
    1. 基本文件结构
    2. Undo log的格式
    3. purge
  4. 4、多版本控制MVCC
    1. InnoDB存储引擎的行结构
    2. 事务链表
    3. ReadView
    4. RC和RR隔离级别ReadView的实现方式
  5. 5、总结
    1. 为什么InnoDB能够保证原子性A?用的什么方式?
    2. 为什么InnoDB能够保证持久性?用的什么方式?
    3. 为什么InnoDB能够保证一致性?用的什么方式?
    4. 为什么RU级别会发生脏读,而其他的隔离级别能够避免?
    5. 为什么RC级别不能重复读,而RR级别能够避免?
    6. 为什么InnoDB的RR级别能够防止幻读?

1、前言

相信工作了一段时间的同学肯定都用过事务,也都听说过事务的4大特性ACID。ACID表示原子性、一致性、隔离性和持久性。一个很好的事务处理系统,必须具备这些标准特性:

  • 原子性(Atomicity):一个事务必须被视为一个不可分割的最小工作单元,整个事务中的所有操作要么全部提交成功,要么全部失败回滚。
  • 一致性(consistency):数据库总是从一个一致性的状态转换到另一个一致性的状态。(其实原子性和隔离性间接的保证了一致性)
  • 隔离性(isolation):通常来说,一个事务所做的修改在最终提交以前,对其他事务是不可见的。
  • 持久性(durability):一旦事务提交,则其所做的修改就会永久保存到数据库中。

而我们最常说的隔离性其实有对应的隔离级别,MySQL规定的隔离级别有4种,分别是:

  • READ UNCOMMITTED(读未提交):在此级别里,事务的修改,即使没有提交,对其他事务也都是可见的。事务可以读取未提交的数据,也就是会产生脏读,在实际应用中一般很少使用。
  • READ COMMITTED(读已提交):大多数数据库系统的默认隔离级别都是它,但是MySQL不是。它能够避免脏读问题,但是在一个事务里对同一条数据的多次查询可能会得到不同的结果,也就是会产生不可重复读问题。
  • REPEATABLE READ(可重复读):该隔离级别是MySQL默认的隔离级别,看名字就知道它能够防止不可重复读问题,但是在一个事务里对一段数据的多次读取可能会导致不同的结果,也就是会有幻读的问题(注:这里说的无法解决是MySQL定义层面,对于InnoDB引擎则完美的解决了幻读的问题,如果你正在使用InnoDB引擎,可忽略)
  • SERIALIZABLE(可串行化):该隔离级别是级别最高的,它通过锁来强制事务串行执行,避免了前面说的所有问题。在高并发下,可能导致大量的超时和锁争用问题。实际应用中也很少用到这个隔离级别,因为RR级别解决了所有问题。

可以看到隔离级别里最重要的只有两个隔离级别:RC和RR。那么问题来了,我们知道上面说的ACID以及隔离级别的实现原理吗?无论是平时工作还是面试,这部分的问题都重中之重,接下来,我会抛出几个问题,大家可以带着问题来看此文:

ACID问题:

  • 为什么InnoDB能够保证原子性?用的什么方式?
  • 为什么InnoDB能够保证一致性?用的什么方式?
  • 为什么InnoDB能够保证持久性?用的什么方式?

隔离性里隔离级别的问题:

  • 为什么RU级别会发生脏读,而其他的隔离级别能够避免?
  • 为什么RC级别不能重复读,而RR级别能够避免?
  • 为什么InnoDB的RR级别能够防止幻读?

解决这些问题之前,我们要首先知道Redo log、Undo log以及MVCC都是什么。

2、Redo log

redo log(重做日志)用来实现事务的持久性,即事务ACID中的D。其由两部分组成,一是内存中的重做日志缓冲(redo log buffer),其实易失的。二是重做日志文件(redo log file),其是持久的。

在一个事务中的每一次SQL操作之后都会写入一个redo log到buffer中,在最后COMMIT的时候,必须先将该事务的所有日志写入到redo log file进行持久化(这里的写入是顺序写的),待事务的COMMIT操作完成才算完成。

MySQL-Lock8

由于重做日志文件打开没有使用O_DIRECT选项,因此重做日志缓冲先写入文件系统缓存。为了确保重做日志写入磁盘,必须进行一次fsync操作。由于fsync的效率取决于磁盘的性能,因此磁盘的性能决定了事务提交的性能,也就是数据库的性能。由此我们可以得出在进行批量操作的时候,不要for循环里面嵌套事务。

参数 innodb_flush_log_at_trx_commit 用来控制重做日志刷新到磁盘的策略,该参数有3个值:0、1和2。

  • 0:表示事务提交时不进行写redo log file的操作,这个操作仅在master thread中完成(master thread每隔1秒进行一次fsync操作)。
  • 1:默认值,表示每次事务提交时进行写redo log file的操作。
  • 2:表示事务提交时将redo log写入文件,不过仅写入文件系统的缓存中,不进行fsync操作。

我们可以看到0和2的设置都比1的效率要高,但是破坏了数据库的ACID特性,不建议使用!

对比binlog

在MySQL数据库中还有一种二进制日志(binlog),从表面上来看它和redo log很相似,都是记录了对数据库操作的日志,但是,它们有着非常大的不同。

首先,redo log是在MySQL的InnoDB引擎层产生,而binlog则是在MySQL的上层产生,它不仅针对InnoDB引擎,其他任何引擎对于数据库的更改都会产生binlog。

其次,两种日志记录的内容形式不同,binlog是一种逻辑日志,其记录的是对应的SQL语句。而redo log则是记录的物理格式日志,其记录的是对于每个页的修改。

此外,两种日志记录写入磁盘的时间点不同,binlog只在事务提交完成后一次性写入,而redo log在上面也说了是在事务进行中不断被写入,这表现为日志并不是随事务提交的顺序进行写入的。

MySQL-Lock9

redo log block

在InnoDB引擎中,redo log都是以512字节进行存储的(和磁盘扇区的大小一样,因此redo log写入可以保证原子性,不需要double write),也就是重做日志缓存和文件都是以块的方式进行保存的,称为redo log block,每个block占512字节。

重做日志除了日志本身之外,还由日志块头(log block header)及日志块尾(log block tailer)两部分组成。

MySQL-Lock10

下面我来解释一下组成Log Block header的4个部分各自的含义:

  • LOG_BLOCK_HDR_NO:它主要用来标记所处Redo Log Buffer中Log Block的位置。
  • LOG_BLOCK_HDR_DATA_LEN:它表示Log Block所占用的大小。当Log Block被写满时,该值为0x200,表示使用全部Log Block空间,即占用512字节。
  • LOG_BLOCK_FIRST_REC_GROUP:表示Log Block中第一个日志所在的偏移量,如果该值大小和LOG_BLOCK_HDR_DATA_LEN相同,则表示当前Log Block不包含新的日志,如果事务的日志大小超过一个Log Block的大小,剩余的将会接着保存到一个新的Log Block中。
  • LOG_BLOCK_CHECKPOINT_NO:表示该Log Block最后被写入时的检查点第4字节的值。

Log Block tailer只包含一个LOG_BLOCK_TRL_NO,它的值和LOG_BLOCK_HDR_NO相同,并在函数log_block_init中被初始化。

crash recovery

前面提到了redo log是用来实现ACID的持久性的,也就是只要事务提交成功后,事务内的所有修改都会保存到数据库,哪怕这时候数据库crash了,也要有办法来进行恢复。也就是Crash Recovery。

说到恢复,我们先来了解一个概念:什么是LSN

LSN(log sequence number) 用于记录日志序号,它是一个不断递增的 unsigned long long 类型整数,占用8字节。它代表的含义有:

  • redo log写入的总量。
  • checkpoint的位置。
  • 页的版本,用来判断是否需要进行恢复操作。

checkpoint:它是redo log中的一个检查点,这个点之前的所有数据都已经刷新回磁盘,当DB crash后,通过对checkpoint之后的redo log进行恢复就可以了。

我们可以通过命令show engine innodb status来观察LSN的情况:

---
LOG
---
Log sequence number 33646077360
Log flushed up to   33646077360
Last checkpoint at  33646077360
0 pending log writes, 0 pending chkp writes
49687445 log i/o's done, 1.25 log i/o's/second

Log sequence number表示当前的LSN,Log flushed up to表示刷新到redo log文件的LSN,Last checkpoint at表示刷新到磁盘的LSN。如果把它们三个简写为 A、B、C 的话,它们的值的大小肯定为 A>=B>=C

InnoDB引擎在启动时不管上次数据库运行时是否正常关闭,都会进行恢复操作。因为重做日志记录的是物理日志,因此恢复的速度比逻辑日志,如二进制日志要快很多。恢复的时候只需要找到redo log的checkpoint进行恢复即可。

MySQL-Lock11

3、Undo log

重做日志记录了事务的行为,可以很好的通过其对页进行“重做”操作。但是事务有时候还需要进行回滚操作,也就是ACID中的A(原子性),这时就需要Undo log了。因此在数据库进行修改时,InnoDB存储引擎不但会产生Redo,还会产生一定量的Undo。这样如果用户执行的事务或语句由于某种原因失败了,又或者用户一条ROLLBACK语句请求回滚,就可以利用这些Undo信息将数据库回滚到修改之前的样子。

Undo log是InnoDB MVCC事务特性的重要组成部分。当我们对记录做了变更操作时就会产生Undo记录,Undo记录默认被记录到系统表空间(ibdata)中,但从5.6开始,也可以使用独立的Undo 表空间。

Undo记录中存储的是老版本数据,当一个旧的事务需要读取数据时,为了能读取到老版本的数据,需要顺着undo链找到满足其可见性的记录。当版本链很长时,通常可以认为这是个比较耗时的操作。

基本文件结构

为了保证事务并发操作时,在写各自的undo log时不产生冲突,InnoDB采用回滚段(Rollback Segment,简称Rseg)的方式来维护undo log的并发写入和持久化。回滚段实际上是一种 Undo 文件组织方式,每个回滚段又有多个undo log slot。具体的文件组织方式如下图所示:

MySQL-Lock12

上图展示了基本的Undo回滚段布局结构,其中:

  • rseg0预留在系统表空间ibdata中。
  • rseg 1~rseg 32 这32个回滚段存放于临时表的系统表空间中,用于临时表的undo。
  • rseg33~rseg 128 则根据配置(InnoDB >= 1.1默认128,可通过参数 innodb_undo_logs 设置)存放到独立undo表空间中(如果没有打开独立Undo表空间,则存放于ibdata中,独立表空间可以通过参数 innodb_undo_directory 设置),用于普通事务的undo。

如图所示,每个回滚段维护了一个段头页,在该page中又划分了1024个slot(TRX_RSEG_N_SLOTS),每个slot又对应到一个undo log对象,因此理论上InnoDB最多支持 96 * 1024个普通事务。

Undo log的格式

在InnoDB引擎中,undo log分为:

  • insert undo log
  • update undo log

insert undo log是指在insert操作中产生的undo log,因为insert操作的记录,只对事务本身可见,对其他事务不可见(这是事务隔离性的要求),故该undo log可以在事务提交后直接删除,不需要进行purge操作。而update undo log记录的是delete和update操作产生的undo log。该undo log可能需要提供MVCC机制,因此不能在事务提交时就进行删除,提交时放入undo log链表,等待purge线程进行最后的删除。下面是两种undo log的结构图。

MySQL-Lock13

purge

对于一条delete语句 delete from t where a = 1,如果列a有聚集索引,则不会进行真正的删除,而只是在主键列等于1的记录delete flag设置为1,即记录还是存在在B+树中。而对于update操作,不是直接对记录进行更新,而是标识旧记录为删除状态,然后新产生一条记录。那这些旧版本标识位删除的记录何时真正的删除?怎么删除?

其实InnoDB是通过undo日志来进行旧版本的删除操作的,在InnoDB内部,这个操作被称之为purge操作,原来在srv_master_thread主线程中完成,后来进行优化,开辟了purge线程进行purge操作,并且可以设置purge线程的数量。purge操作每10s进行一次。

为了节省存储空间,InnoDB存储引擎的undo log设计是这样的:一个页上允许多个事务的undo log存在。虽然这不代表事务在全局过程中提交的顺序,但是后面的事务产生的undo log总在最后。此外,InnoDB存储引擎还有一个history列表,它根据事务提交的顺序,将undo log进行连接,如下面的一种情况:

MySQL-Lock14

在执行purge过程中,InnoDB存储引擎首先从history list中找到第一个需要被清理的记录,这里为trx1,清理之后InnoDB存储引擎会在trx1所在的Undo page中继续寻找是否存在可以被清理的记录,这里会找到事务trx3,接着找到trx5,但是发现trx5被其他事务所引用而不能清理,故再去history list中取查找,发现最尾端的记录时trx2,接着找到trx2所在的Undo page,依次把trx6、trx4清理,由于Undo page2中所有的记录都被清理了,因此该Undo page可以进行重用。

InnoDB存储引擎这种先从history list中找undo log,然后再从Undo page中找undo log的设计模式是为了避免大量随机读操作,从而提高purge的效率。

4、多版本控制MVCC

MVCC 多版本并发控制技术,用于多事务环境下,对数据读写在不加读写锁的情况下实现互不干扰,从而实现数据库的隔离性,在事务隔离级别为Read Commit 和 Repeatable Read中使用到,今天我们就用最简单的方式,来分析下MVCC具体的原理,先解释几个概念。

InnoDB存储引擎的行结构

InnoDB表数据的组织方式为主键聚簇索引,二级索引中采用的是(索引键值, 主键键值)的组合来唯一确定一条记录。

InnoDB表数据为主键聚簇索引,mysql默认为每个索引行添加了4个隐藏的字段,分别是:

  • DB_ROW_ID:InnoDB引擎中一个表只能有一个主键,用于聚簇索引,如果表没有定义主键会选择第一个非Null的唯一索引作为主键,如果还没有,生成一个隐藏的DB_ROW_ID作为主键构造聚簇索引。
  • DB_TRX_ID:最近更改该行数据的事务ID。
  • DB_ROLL_PTR:undo log的指针,用于记录之前历史数据在undo log中的位置。
  • DELETE BIT:索引删除标志,如果DB删除了一条数据,是优先通知索引将该标志位设置为1,然后通过(purge)清除线程去异步删除真实的数据。

MySQL-Lock15

整个MVCC的机制都是通过DB_TRX_ID,DB_ROLL_PTR这2个隐藏字段来实现的。

事务链表

当一个事务开始的时候,会将当前数据库中正在活跃的所有事务(执行begin,但是还没有commit的事务)保存到一个叫trx_sys的事务链表中,事务链表中保存的都是未提交的事务,当事务提交之后会从其中删除。

MySQL-Lock16

ReadView

有了前面隐藏列和事务链表的基础,接下去就可以构造MySQL实现MVCC的关键——ReadView。

ReadView说白了就是一个数据结构,在事务开始的时候会根据上面的事务链表构造一个ReadView,初始化方法如下:

// readview 初始化
// m_low_limit_id = trx_sys->max_trx_id; 
// m_up_limit_id = !m_ids.empty() ? m_ids.front() : m_low_limit_id;
ReadView::ReadView()
    :
    m_low_limit_id(),
    m_up_limit_id(),
    m_creator_trx_id(),
    m_ids(),
    m_low_limit_no()
{
    ut_d(::memset(&m_view_list, 0x0, sizeof(m_view_list)));
}

总共做了以下几件事:

  1. 活跃事务链表(trx_sys)中事务id最大的值被赋值给m_low_limit_id
  2. 活跃事务链表中第一个值(也就是事务id最小)被赋值给m_up_limit_id
  3. m_ids 为事务链表。

MySQL-Lock17

通过该ReadView,新的事务可以根据查询到的所有活跃事务记录的事务ID来匹配能够看见该记录,从而实现数据库的事务隔离,主要逻辑如下:

  1. 通过聚簇索引的行结构中DB_TRX_ID隐藏字段可以知道最近被哪个事务ID修改过。
  2. 一个新的事务开始时会根据事务链表构造一个ReadView。
  3. 当前事务根据ReadView中的数据去跟检索到的每一条数据去校验,看看当前事务是不是能看到这条数据。

那么问题来了,怎么来判断可见性呢?我们来通过源码一探究竟:

// 判断数据对应的聚簇索引中的事务id在这个readview中是否可见
bool changes_visible(
        trx_id_t        id, // 记录的id
    const table_name_t& name) const
MY_ATTRIBUTE((warn_unused_result))
{
    ut_ad(id > 0);
    // 如果当前记录id < 事务链表的最小值或者等于创建该readview的id就是它自己,那么是可见的
    if (id < m_up_limit_id || id == m_creator_trx_id) {
        return(true);
    }

    check_trx_id_sanity(id, name);
    // 如果该记录的事务id大于事务链表中的最大值,那么不可见
    if (id >= m_low_limit_id) {
        return(false);
        // 如果事务链表是空的,那也是可见的
    } else if (m_ids.empty()) {
        return(true);
    }

    const ids_t::value_type*    p = m_ids.data();

    //判断是否在ReadView中,如果在说明在创建ReadView时 此条记录还处于活跃状态则不应该查询到,否则说明创建ReadView是此条记录已经是不活跃状态则可以查询到
    return(!std::binary_search(p, p + m_ids.size(), id));
}

总结一下可见性判断逻辑:

  1. 当检索到的数据的事务ID小于事务链表中的最小值(数据行的DB_TRX_ID < m_up_limit_id)表示这个数据在当前事务开启前就已经被其他事务修改过了,所以是可见的。
  2. 当检索到的数据的事务ID表示的是当前事务自己修改的数据(数据行的DB_TRX_ID = m_creator_trx_id) 时,数据可见。
  3. 当检索到的数据的事务ID大于事务链表中的最大值(数据行的DB_TRX_ID >= m_low_limit_id) 表示这个数据在当前事务开启后到下一次查询之间又被其他的事务修改过,那么就是不可见的。
  4. 如果事务链表为空,那么也是可见的,也就是当前事务开始的时候,没有其他任意一个事务在执行。
  5. 当检索到的数据的事务ID在事务链表中的最小值和最大值之间,从m_low_limit_id到m_up_limit_id进行遍历,取出DB_ROLL_PTR指针所指向的回滚段的事务ID,把它赋值给 trx_id_current ,然后从步骤1重新开始判断,这样总能最后找到一个可用的记录。

RC和RR隔离级别ReadView的实现方式

我们知道,RC隔离级别是能看到其他事务提交后的修改记录的,也就是不可重复读,但是RR隔离级别完美的避免了,但是它们都是使用的MVCC机制,那又为何有两种截然不同的结果呢?其实我们看一下他们创建ReadView的区别就知道了。

  • 在RC事务隔离级别下,每次语句执行都关闭ReadView,然后重新创建一份ReadView。
  • 在RR下,事务开始后第一个读操作创建ReadView,一直到事务结束关闭。

上面的总结英文版为:With REPEATABLE READ isolation level, the snapshot is based on the time when the first read operation is performed. With READ COMMITTEDisolation level, the snapshot is reset to the time of each consistent read operation.

来源自MySQL官网:MySQL Glossary-glos_consistent_read

因为RC每次查询语句都创建一个新的ReadView,所以活跃的事务列表一直在变,也就导致如果事务B update提交了后事务A才进行查询,查询的结果就是最新的行,也就是不可重复读咯。而RR则一直用的事务开始时创建的ReadView。

5、总结

还记得开头提到的问题吗?现在应该能够全部解决了。

为什么InnoDB能够保证原子性A?用的什么方式?

其实这个在上面Undo log中已经提及了。在事务里任何对数据的修改都会写一个Undo log,然后进行数据的修改,如果出现错误或者用户需要回滚的时候可以利用Undo log的备份数据恢复到事务开始之前的状态。

为什么InnoDB能够保证持久性?用的什么方式?

这个在上面Redo log中已经提及了。在一个事务中的每一次SQL操作之后都会写入一个redo log到buffer中,在最后COMMIT的时候,必须先将该事务的所有日志写入到redo log file进行持久化(这里的写入是顺序写的),待事务的COMMIT操作完成才算完成。即使COMMIT后数据库有任何的问题,在下次重启后依然能够通过redo log的checkpoint进行恢复。也就是上面提到的crash recovery。

为什么InnoDB能够保证一致性?用的什么方式?

在事务处理的ACID属性中,一致性是最基本的属性,其它的三个属性都为了保证一致性而存在的。

首先回顾一下一致性的定义。所谓一致性,指的是数据处于一种有意义的状态,这种状态是语义上的而不是语法上的。最常见的例子是转帐。例如从帐户A转一笔钱到帐户B上,如果帐户A上的钱减少了,而帐户B上的钱却没有增加,那么我们认为此时数据处于不一致的状态。

在数据库实现的场景中,一致性可以分为数据库外部的一致性和数据库内部的一致性。前者由外部应用的编码来保证,即某个应用在执行转帐的数据库操作时,必须在同一个事务内部调用对帐户A和帐户B的操作。如果在这个层次出现错误,这不是数据库本身能够解决的,也不属于我们需要讨论的范围。后者由数据库来保证,即在同一个事务内部的一组操作必须全部执行成功(或者全部失败)。这就是事务处理的原子性。(上面说过了是用Undo log来保证的)

但是,原子性并不能完全保证一致性。在多个事务并行进行的情况下,即使保证了每一个事务的原子性,仍然可能导致数据不一致的结果,比如丢失更新问题。

为了保证并发情况下的一致性,引入了隔离性,即保证每一个事务能够看到的数据总是一致的,就好象其它并发事务并不存在一样。用术语来说,就是多个事务并发执行后的状态,和它们串行执行后的状态是等价的。

为什么RU级别会发生脏读,而其他的隔离级别能够避免?

RU级别的操作其实就是对事务内的每一条更新语句对应的行记录加上读写锁来操作,而不把一个事务当成一个整体来加锁,所以会导致脏读。但是RC和RR能够通过MVCC来保证记录只有在最后COMMIT后才会让别的事务看到。

为什么RC级别不能重复读,而RR级别能够避免?

这个在上面的MVCC的最后说到了,在RC事务隔离级别下,每次语句执行都关闭ReadView,然后重新创建一份ReadView。而在RR下,事务开始后第一个读操作创建ReadView,一直到事务结束关闭。

为什么InnoDB的RR级别能够防止幻读?

这个是因为RR隔离级别使用了Next-key Lock这么个东东,也就是Gap Lock+Record Lock的方式来进行间隙锁定,具体原理本章不深入讨论,可以参考我的另一篇文章。



坚持原创技术分享,您的支持将鼓励我继续创作!



分享

评论