面试题:MySQL事务的ACID如何实现?

Ki1tFJ

大家好,我是【码老思】,事务是一个数据库绕不开的话题,今天和大家一起聊聊。

事务是什么?

事务(Transaction)是并发控制的基本单位。所谓的事务呢,它是一个操作序列,这些操作要么都执行,要么都不执行,它是一个不可分割的工作单位。

在介绍事务的特性之前,我们先看下MySQL的逻辑架构,

WrntnJ

如上图所示,MySQL服务器逻辑架构从上往下可以分为三层:

  1. 第一层:处理客户端连接、授权认证等。
  2. 第二层:服务器层,负责查询语句的解析、优化、缓存以及内置函数的实现、存储过程等。
  3. 第三层:存储引擎,负责MySQL中数据的存储和提取。MySQL 中服务器层不管理事务,事务是由存储引擎实现的。**MySQL支持事务的存储引擎有InnoDB、NDB Cluster等,其中InnoDB的使用最为广泛;其他存储引擎不支持事务,如MyISAM、Memory等。

后续讨论主要以InnoDB为主。

事务有什么特征?

事务的特性,可以总结为如下4个方面:

  • 原子性(Atomicity):原子性是指整个数据库的事务是一个不可分割的工作单位,在每一个都应该是原子操作。当我们执行一个事务的时候,如果在一系列的操作中,有一个操作失败了,那么需要将这一个事务中的所有操作恢复到执行事务之前的状态,这就是事务的原子性。

  • 一致性(Consistency): 一致性呢是指事务将数据库从一种状态转变成为下一种一致性的状态,也就是说是在事务的执行前后,这两种状态应该是一样的,也就是在数据库的完整性约束不会被破坏。另外的话,还需要注意的是一致性不关注中间的过程是发生了什么。

  • 隔离性(lsolation): Mysql数据库可以同时的话启动很多的事务,但是呢,事务跟事务之间他们是相互分离的,也就是互不影响的,这就是事务的隔离性。下面有介绍事务的四大隔离级别。

  • 持久性(Durability): 事务的持久性是指事务一旦提交,就是永久的了。说白了就是发生了问题,数据库也是可以恢复的。因此持久性保证事务的高可靠性。

谈到事务的四大特性,不得不说一下MySQL事务的隔离机制,在不同的数据库连接中,一个连接的事务并不会影响其他连接,这是基于事务隔离机制实现的。在MySQL中,事务隔离机制分为了四个级别:

  • Read uncommitted / RU:读未提交,就是一个事务可以读取另一个未提交事务的数据。毫无疑问,这样会造成大量的脏读,所以数据库一般不会采用这种隔离级别。

  • Read committed / RC:读已提交,就是一个事务读到的数据必须是其他事务已经提交的数据,这样就避免了脏读的情况。但是如果有两个并行的事务A和B,处理同一批的数据,如果事务A在这个过程中,修改了数据并提交;那么在事务B中可能前后看到两个不一样的数据,这就造成不可重复读的情况。

  • Repeatable read / RR:可重复读,就是在开始读取数据(事务开启)时,不再允许修改操作。这样就解决了不可重复读的问题,但是需要注意的是,不可重复读对应的是修改,即UPDATE操作。但是可能还会有幻读问题。因为幻读问题对应的是插入INSERT操作,而不是UPDATE操作。

  • Serializable:序列化/串行化。它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个读的数据行上加上共享锁。这种情况下所有事务串行执行,可以避免上面的出现的各种问题,但是在大并发场景下会导致大量的超时现象和锁竞争,所以一般也很少采用。

上述四个级别,越靠后并发控制度越高,也就是在多线程并发操作的情况下,出现问题的几率越小,但对应的也性能越差,MySQL的事务隔离级别,默认为第三级别:Repeatable read可重复读。

按照严格的标准,只有同时满足ACID特性才是事务;但是目前各大数据库厂商的实现中,真正满足ACID的事务很少。例如MySQL的NDB Cluster事务不满足持久性;Oracle默认的事务隔离级别为READ COMMITTED,不满足隔离性;InnoDB默认事务隔离级别是可重复读,完全满足ACID的特性。因此与其说ACID是事务必须满足的条件,不如说它们是衡量事务的四个维度。

**MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象,解决的方案有两种:

  • 针对快照读(普通 select 语句),是通过 MVCC 方式解决了不可重复读和幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的。MVVC在下面会仔细介绍。

Read Committed隔离级别:每次select都生成一个快照读。
Read Repeatable隔离级别:开启事务后第一个select语句才是快照读的地方,而不是一开启事务就快照读。

  • 针对当前读(select ... for update, delete, insert; select...lock in share mode (共享读锁) 等语句),是通过 next-key lock(行记录锁+间隙锁)方式解决了幻读,因为当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。对主键或唯一索引,如果select查询时where条件全部精确命中(=或者in),这种场景本身就不会出现幻读,所以只会加行记录锁。关于锁这块,后续有专门的章节进行介绍。

总结:事务的隔离性由MVCC和锁来实现,而原子性、一致性、持久性通过数据库的redo和undo日志来完成。接下来会详细介绍其实现原理。

MVVC如何实现事务的隔离?

MVCC,全称Multi-Version Concurrency Control,即多版本并发控制。MVCC是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问。MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。

MVVC是一种用来解决读-写冲突的无锁并发控制,简单总结就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。 所以MVCC可以为数据库解决以下问题:在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能;同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题。

MVVC的实现,依赖4个隐式字段undo日志 ,Read View 来实现的。

隐式字段

每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段

  • DB_ROW_ID 6byte, 隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引
  • DB_TRX_ID 6byte, 最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID
  • DB_ROLL_PTR 7byte, 回滚指针,指向这条记录的上一个版本(存储于rollback segment里)
  • DELETED_BIT 1byte, 记录被更新或删除并不代表真的删除,而是删除flag变了。

jwlfZm
如上图,DB_ROW_ID是数据库默认为该行记录生成的唯一隐式主键;DB_TRX_ID是当前操作该记录的事务ID; 而DB_ROLL_PTR是一个回滚指针,用于配合undo日志,指向上一个旧版本;delete flag没有展示出来。

undo log

InnoDB把这些为了回滚而记录的这些东西称之为undo log。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的undo log。undo log主要分为3种:

  • Insert undo log :插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。
  • Update undo log:修改一条记录时,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。
  • Delete undo log:删除一条记录时,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。删除操作都只是设置一下老记录的DELETED_BIT,并不真正将过时的记录删除。

这里举一个例子,比如我们想更新Person表中的数据,有两个事务先后对同一行数据进行了修改,那么undo log中,不会仅仅只保存最近修改的旧版本记录,而是通过链表的方式将不同版本连接起来。在下面的例子中,

  1. Person表中有一行数据,name为Jerry,age是24岁。
  2. 事务A将name修改为Tom,数据修改完成之后,会把旧记录拷贝到undo log中,并将隐藏字段的事务ID修改为当前事务ID,这里假设从1开始,回滚指针指向undo log的副本记录,说明上一个版本就是它。
  3. 事务B将年龄修改为30,相同的方式,A事务修改过后的记录会被放到undo log,而事务B会把事务ID修改为2,同时回滚指针指向undo log中A事务修改过后的数据。
  4. 最后的形成的回滚链路如下。
    TTwcrp

ReadView

在上面介绍undo log的时候可以看到,undo log中维护了每条数据的多个版本,如果新来的一个事务也访问这同一条数据,如何判断该读取这条数据的哪个版本呢?此时就需要ReadView来做多版本的并发控制,根据查询的时机来选择一个当前事务可见的旧版本数据读取。

当一个事务启动后,首次执行select操作时,MVCC就会生成一个数据库当前的ReadView,通常而言,一个事务与一个ReadView属于一对一的关系(不同隔离级别下也会存在细微差异),ReadView一般包含四个核心内容:

  • creator_trx_id:代表创建当前这个ReadView的事务ID。
  • trx_ids:表示在生成当前ReadView时,系统内活跃的事务ID列表。
  • up_limit_id:活跃的事务列表中,最小的事务ID。
  • low_limit_id:表示在生成当前ReadView时,系统中要给下一个事务分配的ID值。

可以通过如下的示意图进一步理解ReadView,

b6VgN5

假设目前数据库中共有T1~T5这五个事务,T1、T2、T4还在执行,T3已经回滚,T5已经提交,此时当有一条查询语句执行时,就会利用MVCC机制生成一个ReadView,由于前面讲过,单纯由一条select语句组成的事务并不会分配事务ID,因此默认为0,所以目前这个快照的信息如下:

{ "creator_trx_id" : "0", "trx_ids" : "[1,2,4]", "up_limit_id" : "1", "low_limit_id" : "6" }

当我们拿到ReadView之后,如何判断当前的事务能够看到哪些版本的数据,这里会遵循一个可见性算法,简单来讲就是将要被修改数据的最新记录的DB_TRX_ID(即当前事务ID),与ReadView维护的其他事务ID进行比较,来确定当前事务能看到的最新老版本。

这里结合MySQL的算法实现来看,下面是MySQL 8.1里面关于这个可见性算法的实现。可以看到,整体流程如下:

  1. 首先判断 DB_TRX_ID < up_limit_id,此时说明该事务已经结束,所以DB_TRX_ID对应的旧版本对ReadView可见。如果 DB_TRX_ID = creator_trx_id,说明ReadView是当前事务中生成的,当然可以看到自己的修改,所以也是可见的。
  2. 接着判断 DB_TRX_ID >= low_limit_id,则代表DB_TRX_ID 所在的记录在Read View生成后才出现的,那对当前事务肯定不可见。但是如果DB_TRX_ID < low_limit_id,并且当前无活跃的事务id,说明所有事务已经提交了,因此该条记录也是可见的。
  3. 判断DB_TRX_ID 是否在活跃事务之中。如果在,则代表Read View生成时刻,这个事务还在活跃,还没有Commit,因此这个事务修改的数据,我当前事务也是看不见的;如果不在,则说明,你这个事务在Read View生成之前就已经Commit了,你修改的结果,我当前事务是能看见的。
// https://dev.mysql.com/doc/dev/mysql-server/latest/read0types_8h_source.html

/** Check whether the changes by id are visible.
  @param[in]    id      transaction id to check against the view
  @param[in]    name    table name
  @return whether the view sees the modifications of id. */
  [[nodiscard]] bool changes_visible(trx_id_t id, const table_name_t &name) const {
    ut_ad(id > 0);
 
    if (id < m_up_limit_id || id == m_creator_trx_id) {
      return (true);
    }
 
    check_trx_id_sanity(id, name);
 
    if (id >= m_low_limit_id) {
      return (false);
    } else if (m_ids.empty()) {
      return (true);
    }
 
    const ids_t::value_type *p = m_ids.data();
 
    return (!std::binary_search(p, p + m_ids.size(), id));
  }

MVCC原理总结

MVCC主要由下面两个核心功能组成,undo log实现数据的多版本,ReadView实现多版本的并发控制。

  1. 当一个事务尝试改动某条数据时,会将原本表中的旧数据放入undo log中。
  2. 当一个事务尝试查询某条数据时,MVCC会生成一个ReadView快照。

这里举一个例子回顾下整个流程:

假设有A和B两个并发事务,其中事务A在修改第一行的数据,而事务B准备读取这条数据,那么B在具体执行过程中,当出现SELECT语句时,会根据MySQL的当前情况生成一个ReadView。

  1. 判断数据行中的隐藏列TRX_ID与ReadView中的creator_trx_id是否相同,如果相同表示是同一个事务,数据可见。
  2. 判断TRX_ID是否小于up_limit_id,也就是最小活跃事务ID,如果小的话,说明改动这行数据的事务在ReadView生成之前就结束了,所以是可见的;如果大于的话,继续往下走。
  3. 判断TRX_ID是否小于low_limit_id,也就是当前ReadView生成时,下一个会分配的事务ID。如果大于或等于low_limit_id,说明修改该数据的事务是生成ReadView之后才开启的,当然是不可见的。如果小于low_limit_id,则进行下一步判断。
  4. 如果TRX_ID在trx_ids中,说明该数据行对应的事务还在执行,因此对于当前事务而言,该数据不可见;如果TRX_ID不在trx_ids中,说明该事务在生成ReadView时已经结束,因此是可见的。

如果undo log中存在某行数据的多个版本,那么在实际中会根据隐藏列roll_ptr依次遍历整个链表,按照上面的流程,找到第一条满足条件的数据并返回。

RC、RR不同级别下的MVVC机制

ReadView是一个事务中只生成一次,还是每次select时都会生成呢?这个问题和MySQL的事务隔离机制有关,RC和RR下的实现有些许不同。

  • RC(读已提交):每个快照读都会生成并获取最新的Read View,保证已经提交事务的修改对当前事务可见。
  • RR(可重复读):同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是使用同一个Read View;这样整个事务期间读到的记录都是事务启动前的记录。

undo log和redo log在事务里面有什么用?

上面介绍了事务隔离性的实现原理,即通过多版本并发控制(MVCC,Multiversion Concurrency Control )解决不可重复读问题,加上间隙锁(也就是并发控制)解决幻读问题。保证了较好的并发性能。

而事务的原子性、一致性和持久性则是通过事务日志实现,主要就是redo log和undo log。了解完下面这些内容,那就明白了其中的原理和实现。

1. redo log

为什么需要redo log

在 MySQL 中,如果每一次的更新要写进磁盘,这么做会带来严重的性能问题:

  • 因为 Innodb 是以页为单位进行磁盘交互的,而一个事务很可能只修改一个数据页里面的几个字节,这时将完整的数据页刷到磁盘的话,太浪费资源了。
  • 一个事务可能涉及修改多个数据页,并且这些数据页在物理上并不连续,使用随机 IO 写入性能太差

因此每当有一条新的数据需要更新时,InnoDB 引擎就会先更新内存(同时标记为脏页),然后将本次对这个页的修改以 redo log 的形式记录下来,这个时候更新就算完成了。之后,InnoDB 引擎会在适当的时候,由后台线程将缓存在 Buffer Pool 的脏页刷新到磁盘里,这就是 WAL (Write-Ahead Logging)技术

WAL 技术指的是, MySQL 的写操作并不是立刻写到磁盘上,而是先写日志,然后在合适的时间再写到磁盘上。

整个过程如下:

sOkpW5

什么是redo log

redo log 是物理日志,记录了某个数据页做了什么修改,比如对A表空间中的B数据页C偏移量的地方做了D更新,每当执行一个事务就会产生这样的一条或者多条物理日志。

在事务提交时,只要先将 redo log 持久化到磁盘即可,可以不需要等到将缓存在 Buffer Pool 里的脏页数据持久化到磁盘。当系统崩溃时,虽然脏页数据没有持久化,但是 redo log 已经持久化,接着 MySQL 重启后,可以根据 redo log 的内容,将所有数据恢复到最新的状态。

redo log有什么好处

总结来看,有一下两点:

  • 将写数据的操作,由随机写变成了顺序写。在写入redo log时,使用的是追加操作,所以对应磁盘是顺序写。而直接写数据,需要先找到数据的位置,然后才能写磁盘,所以磁盘操作是随机写。因此直接写入redo log比直接写入磁盘效率高很多。
  • 实现事务的持久性。 使用redo log之后,虽然每次修改数据之后,数据处于缓冲中,如果MySQL重启,缓冲中的数据会丢失,但是我们可以根据redo log的内容将数据恢复到最新的状态;保证了事务修改的数据,不会丢失,也就是实现了持久性。

redo log如何写入磁盘?

redo log并不是每次写入都会刷新到数据页,而是采取一定的策略周期性的刷写到磁盘上。所以,它其实包括了两部分,分别是内存中的日志缓冲(redo log buffer)和磁盘上的日志文件(redo log file)

由于MySQL处于用户空间,而用户空间下的缓冲区数据是无法直接写入磁盘的,因为中间必须经过操作系统的内核空间缓冲区(OS Buffer)。所以,redo log buffer 写入 redo logfile 实际上是先写入 OS Buffer,然后操作系统调用 fsync() 函数将日志刷到磁盘。过程如下:

x6JECP

MySQL支持用户自定义在commit时如何将log buffer中的日志刷log file中。这种控制通过变量 innodb_flush_log_at_trx_commit 的值来决定。该变量有3种值:0、1、2,默认为1。但注意,这个变量只是控制commit动作是否刷新log buffer到磁盘。

参数值 含义
0(延迟写) 事务提交时不会将 redo log buffer 中日志写到 os buffer,而是每秒写入os buffer 并调用 fsync() 写入到 redo logfile 中。也就是说设置为 0 时是(大约)每秒刷新写入到磁盘中的,当系统崩溃,会丢失1秒钟的数据。
1(实时写、实时刷新) 事务每次提交都会将 redo log buffer 中的日志写入 os buffer 并调用 fsync() 刷到 redo logfile 中。这种方式即使系统崩溃也不会丢失任何数据,但是因为每次提交都写入磁盘,IO的性能差。
2(实时写、延迟刷新) 每次提交都仅写入到 os buffer,然后是每秒调用 fsync() 将 os buffer 中的日志写入到 redo log file。

三种方案总结如下:

nMdcL9

  • 针对参数 0 :会把缓存在 redo log buffer 中的 redo log ,通过调用 write() 写到系统缓存,然后调用 fsync() 持久化到磁盘。所以参数为 0 的策略,MySQL 进程的崩溃会导致上一秒钟所有事务数据的丢失;
  • 针对参数 2 :调用 fsync,将缓存在系统缓存里的 redo log 持久化到磁盘。所以参数为 2 的策略,较取值为 0 情况下更安全,因为 MySQL 进程的崩溃并不会丢失数据,只有在操作系统崩溃或者系统断电的情况下,上一秒钟所有事务数据才可能丢失

在主从复制结构中,要保证事务的持久性和一致性,需要对日志相关变量设置为如下:

  1. 如果启用了二进制日志,则设置sync_binlog=1,即每提交一次事务同步写到磁盘中。
  2. 总是设置innodb_flush_log_at_trx_commit=1,即每提交一次事务都写到磁盘中。
    上述两项变量的设置保证了:每次提交事务都写入二进制日志和事务日志,并在提交时将它们刷新到磁盘中。

redo log file结构是怎么样的?

InnoDB 的 redo log 是固定大小的。比如可以配置为一组 4 个文件,每个文件的大小是 1GB,那么 redo log file 可以记录 4GB 的操作。从头开始写。写到末尾又回到开头循环写。如下图:

Uebok6

上图中,write pos 表示 redo log 当前记录的 LSN (逻辑序列号) 位置,一边写一遍后移,写到第 3 号文件末尾后就回到 0 号文件开头; check point 表示数据页更改记录刷盘后对应 redo log 所处的 LSN(逻辑序列号) 位置,也是往后推移并且循环的。

write pos 到 check point 之间的部分是 redo log 的未写区域,可用于记录新的记录;check point 到 write pos 之间是 redo log 已写区域,是待刷盘的数据页更改记录。

当 write pos 追上 check point 时,表示 redo log file 写满了,这时候有就不能执行新的更新。得停下来先擦除一些记录(擦除前要先把记录刷盘),再推动 check point 向前移动,腾出位置再记录新的日志。

2. undo log

undo log有两个作用:提供回滚和多个行版本控制(MVCC)。

在数据修改的时候,不仅记录了redo,还记录了相对应的undo,如果因为某些原因导致事务失败或回滚了,可以借助该undo进行回滚。

cEK3S1

undo log和redo log记录物理日志不一样,它是逻辑日志。可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录。

当执行rollback时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚。有时候应用到行版本控制的时候,也是通过undo log来实现的:当读取的某一行被其他事务锁定时,它可以从undo log中分析出该行记录以前的数据是什么,从而提供该行版本信息,让用户实现非锁定一致性读取。

undo log 和数据页的刷盘策略是一样的,都需要通过 redo log 保证持久化。
buffer pool 中有 undo 页,对 undo 页的修改也都会记录到 redo log。redo log 会每秒刷盘,提交事务时也会刷盘,数据页和 undo 页都是靠这个机制保证持久化的。

总结回顾

InnoDB通过MVVC、undo log和redo log实现了事务的ACID特性,

  • MVCC 是通过 ReadView + undo log 实现的。undo log 为每条记录保存多份历史数据,MySQL 在执行快照读(普通 select 语句)的时候,会根据事务的 Read View 里的信息,顺着 undo log 的版本链找到满足其可见性的记录。实现了事务的隔离性。
  • undo log记录了每行数据的历史版本,当现了错误或者用户执 行了 ROLLBACK 语句,MySQL 可以利用 undo log 中的历史数据将数据恢复到事务开始之前的状态。保证了事务的一致性和原子性。
  • 使用redo log之后,虽然每次修改数据之后,数据处于缓冲中,如果MySQL重启,缓冲中的数据会丢失,但是我们可以根据redo log的内容将数据恢复到最新的状态;保证了事务修改的数据,不会丢失,也就是实现了事务的持久性。

参考:


欢迎关注公众号【码老思】,第一时间获取最通俗易懂的原创技术干货。

posted @ 2023-10-28 23:08  码老思  阅读(324)  评论(0编辑  收藏  举报