MySQL InnoDB 事务


三月的第一周,来学习 MySQL 的事务,包含事务、事务的隔离级别、redo 日志、undo 日志、MVCC 这些内容。

本周的学习来源还还还是《MySQL 是怎样运行的:从根儿上理解 MySQL》(写得太好了www)。


事务

事务(数据库事务的简称),是指一系列数据库操作组成的一个逻辑单元(也就是一个整体),这个逻辑单元符合四条特性:原子性(Atomicity)、隔离性(Isolation)、一致性(Consistency)和持久性(Durability)(英文首字母缩写为 ACID)。

下面四条 SQL 语句就属于一个事务:

1
2
3
4
BEGIN;
UPDATE account SET balance = balance - 10 WHERE id = 1;
UPDATE account SET balance = balance + 10 WHERE id = 2;
COMMIT;

提交事务的方式

  • 手动提交

    通过 BEGIN 手动开始一个事务,中间输入一些数据库操作,再通过 COMMIT 手动提交一个事务(就像上面那四条 SQL 一样),此外中途还可以通过 ROLLBACK 回滚事务(撤回事务里的全部操作)。

    还可以通过 START TRANSACTION 开启一个事务,它跟 BEGIN 的作用是相同的,但是 START TRANSACTION 语句后边能跟随几个修饰符,相当于增强版的 BEGIN

  • 自动提交

    默认情况下,MySQL 的每一条语句都是一个独立的事务,这是 MySQL 的系统变量 autocommit 设置的(默认 ON)。

  • 隐式提交

    如果通过 BEGINSTART TRANSACTION 开启一个事务,即使不输入 COMMIT 手动提交,在一些情况下也会隐式提交事务,例如 ALTER USERCREATE USERSET PASSWORDSTART SLAVEFLUSH 等等。

事务的隔离级别

两个事务有可能同时执行,比如事务 A 执行到一半,事务 B 开始执行,如果它们操作了同一条数据,就会造成一些意想不到的情况。

有四种因为事务并发执行带来的意想不到的情况(以下将事务 A 简写成 A,事务 B 简写成 B):

  • 脏写:A 修改了 B 修改过(未提交)的数据(这是非常严重的行为,在所有情况下都应该避免)
  • 脏读:A 读到了 B 修改过(未提交)的数据
  • 不可重复读:A 读到了 B 修改过(已提交)的数据,即每次都能读到已提交事务的最新值
  • 幻读:A 读到了 B 修改过(已提交)的数据,且这些数据原来不存在,是新查出来的

为了避免这些情况,事务有四种隔离级别,详见下表:

未提交读
(READ UNCOMMITTED)
已提交读
(READ COMMITTED)
可重复读
(REPEATABLE READ)
可串行化
(SERIALIZABLE)
脏写 不可能发生 不可能发生 不可能发生 不可能发生
脏读 不可能发生 不可能发生 不可能发生
不可重复读 不可能发生 不可能发生
幻读 不可能发生

InnoDB 是怎么实现这四种隔离级别的,要等到最后才能讲到。


顺便一提,不是所有 MySQL 存储引擎都支持事务,InnoDB 支持事务,而 MyISAM 就不支持。不支持的存储引擎也不会报错,BEGINROLLBACK 之类的操作也都是可以执行的,只不过没有效果。

有关事务的概念,可以参考《廖雪峰博客 - 事务》,整理得很干练。


事务持久化:redo 日志

补知识

数据库最终存储在磁盘当中,但 InnoDB 使用 Buffer Pool(缓存池)暂存磁盘中的数据。Buffer Pool 实际上就是一块内存空间,默认 128MB。

InnoDB 读取数据,从磁盘中拷贝到 Buffer Pool 里,以后就可以直接从 Buffer Pool 中查询这些数据。

InnoDB 修改数据,先在 Buffer Pool 中修改(修改完的部分被叫做“脏页”),后台线程不断地把脏页写回磁盘,这个过程是异步的。

使用 Buffer Pool 的原因是磁盘太慢了,尤其是随机读写更是慢,如果查询一条数据就去磁盘搜索,或者更新一条数据就去磁盘保存,阻塞的时间太长了。为了避免这种情况,InnoDB 根据局部性原理引入缓存的概念,把用过的数据缓存在 Buffer Pool 中。

Buffer Pool 的数据结构类似于 LinkedHashMap,由多个 LRU 双向链表构成,并通过哈希查找缓存页。

redo 日志的作用

InnoDB 的增删查改操作都是先操作 Buffer Pool,再由后台线程异步保存进磁盘,这是为了避免磁盘 IO 带来的龟速。但是数据不保存到磁盘里,如果服务器突然宕机,那么还没保存的 Buffer Pool 脏页数据就会丢失,事务的持久性就无法实现。

数据一定要保存进磁盘里,才能保证事务的持久性,那就想个办法,让磁盘保存得快一点。

redo 日志(redo log)记录了数据库做了哪些操作,比如记录了将第 0 号表空间的 100 号页面的偏移量为 1000 处的值更新为 2 这个行为。数据库执行一条操作,redo 日志就记录一条操作,如果宕机了,根据 redo 日志把之前的操作重做一遍就可以了。

【保存 redo 日志进数据库】比【保存数据进数据库】快很多,有两方面的原因:

  1. InnoDB 以页为单位保存进磁盘,如果直接保存数据,即使改一个字节也要保存一页 16 KB 的数据,但是保存 redo 日志可以少很多(后面提)。
  2. redo 日志是顺序写入磁盘的,磁盘的顺序读写比随机读写快多了。

redo 日志的保存过程

为了避免数据写入磁盘太慢,InnoDB 引入了 Buffer Pool 的概念,将修改的页先缓存下来。

同理,为了避免 redo 日志写入磁盘太慢,InnoDB 引入了 redo log buffer 的概念(简称 log buffer),将 redo 日志先缓存下来(不用担心,事务提交时会确保写入磁盘的)。

InnoDB保存数据mermaid

InnoDB 修改的数据,可能会出现在上图的 Buffer Poollog buffer磁盘 - 数据库部分磁盘 - redo 日志部分里。

简单想通两个逻辑:

  • 如果 redo 日志已经保存进磁盘了,那么缓存就可以删除了(在 log buffer 中删除)。
  • 如果真正的数据已经保存进磁盘了,那么 redo 日志就可以删除了(缓存和磁盘里都可以删除)。

对应这两种情况,InnoDB 提出两个 LSN(log sequence number),方便清理存储空间:

  • flushed_to_disk_lsn:redo 日志已经刷入到磁盘的字节数
  • checkpoint_lsn:真正的数据已经写入磁盘,对应的 redo 日志的字节数

redo 日志从 log buffer 刷入到磁盘的时机如下:

  • log buffer 内存不足(启动参数默认 16KB)
  • 事务提交
  • 后台线程一直刷入磁盘
  • 正常关闭服务器
  • 磁盘空间不够用,做 checkpoint
  • ……

为了保证事务的持久性,每次提交事务都要将 redo 日志保存到磁盘里,实际上磁盘 IO 还是很慢的。如果对持久性需求不强烈,可以修改系统变量 innodb_flush_log_at_trx_commit,事务提交时不刷入磁盘(数据库挂了,操作系统没挂,还是可以恢复的)。


(本来我是想画一下 redo 日志的数据结构,以及数据是怎么保存进 buffer 又保存进磁盘的,但是我太懒了hhh,就抛一个链接吧《MySQL 是怎样运行的:从根儿上理解 MySQL - redo日志》)


事务回滚:undo 日志

undo 日志的作用

事务回滚指的是,放弃所有修改,使数据库状态恢复到事务开启之前的时刻,就好像事务从来没发生过一样。

InnoDB 实现事务回滚的方式是,把操作反过来执行一遍:事务执行插入操作,回滚时就执行删除操作,事务执行修改操作,回滚时就把数据修改回来。也就是说,实际上 InnoDB 执行的不是撤销操作,而是反向操作,最终效果等同于撤销,好像什么都没执行过一样。

这些反向操作,InnoDB 记录下来,称作 undo 日志(undo log),在事务回滚的时候,就执行 undo 日志的内容。

undo 日志的类型

总体有三类 undo 日志,分别对应着 INSERT 操作、UPDATE 操作、DELETE 操作(查询没有 undo 日志,因为查询不用回滚):

  1. INSERT 操作

    数据库插入数据,undo 日志就记录一条删除操作,类型为 TRX_UNDO_INSERT_REC,这条日志记录了插入数据的主键。

  2. DELETE 操作

    数据库删除数据,undo 日志就记录一条插入操作,类型为 TRX_UNDO_DEL_MARK_REC,这条日志记录了删除数据的所有信息。

  3. UPDATE 操作

    UPDATE 分为两种,分别是更新主键和不更新主键:

    1. 更新主键的话,由于聚簇索引的调整比较复杂,因此实际的操作是先删除再插入,最终达到更新的效果。这样会生成两条 undo 日志,先生成 DELETE 操作对应的日志,再生成 INSERT 操作对应的日志。
    2. 不更新主键,原地更新就可以了,undo 日志记录一条更新回去的操作,类型为 TRX_UNDO_UPD_EXIST_REC,这条日志记录了更新数据的主键信息,和需要更新的字段原来的信息。

注意一下,undo 日志实际上记录了很多信息,不仅仅包含反向修改数据的信息,还包含比如地址、日志类型、undo 编号等等信息。

undo 日志的工作逻辑

要回忆一条很久远的知识,最初学习 InnoDB 数据结构时,我画了一张 Compact 行格式的图,这是聚簇索引的叶子节点:

compact行格式

数据库中的每一条数据,最终在磁盘中会被存储成这个样子。

当时我们忽略了事务 ID 和回滚指针这两个字段,现在就能用得上了。

  • 事务 ID(DB_TRX_ID)

    事务 ID 是一个全局字段,数据库在内存中维护一个全局变量,每次需要为某个事务分配一个事务 ID 时,就会把该变量当做事务 ID 分配给该事务,并把该变量自增 1。

  • 回滚指针(DB_ROLL_PTR)

    这个字段是一个指针,指向它对应的 undo 日志的地址。

    也就是说,如果要回滚这条数据,可以通过这个字段找到 undo 日志,从而实现回滚(实际上设计得更精妙)。

    下图是《MySQL 是怎样运行的:从根儿上理解 MySQL》画的示意图:

    InnoDB行隐藏字段

    实际上,undo 日志内部还有一个 roll_pointer 属性,它指向更早的 undo 日志。

    比如这两句 SQL:

    1
    2
    INSERT INTO t_user(user_id, name, age) VALUES (1, "张三", 18);
    UPDATE t_user SET name = "李四" WHERE user_id = 1;

    首先插入一条数据(生成一条 INSERT 操作的 undo 日志),然后更改这条数据(生成一条 UPDATE 操作的 undo 日志)。

    第二条语句生成的 undo 日志,内部有一个 roll_pointer 属性,它指向第一条语句生成的 undo 日志。

    再举一个例子,下图是《MySQL 是怎样运行的:从根儿上理解 MySQL》画的示意图:

    undo版本链


InnoDB 实现事务的隔离级别

MySQL 有 4 种事务的隔离级别,分别拦住了四种由于事务并发造成的异常情况,详见下表:

未提交读
(READ UNCOMMITTED)
已提交读
(READ COMMITTED)
可重复读
(REPEATABLE READ)
可串行化
(SERIALIZABLE)
脏写 不可能发生 不可能发生 不可能发生 不可能发生
脏读 不可能发生 不可能发生 不可能发生
不可重复读 不可能发生 不可能发生
幻读 不可能发生

这四种隔离级别,InnoDB 基本是通过 undo 日志的版本链实现的,具体实现如下:

  1. 未提交读(READ UNCOMMITTED)

    允许读到其他事务未提交的数据,因此随便读,不用管事务。

  2. 已提交读(READ COMMITTED)

    允许读到已提交事务的数据,因此读数据之前要判断一下事务 ID,如果是已提交的事务就可以读,如果是未提交的事务,就要根据 undo 版本链一路往前找,直到找到提交的事务。

    实际上,这部分的实现更复杂一些,需要引入 ReadView 的概念(有点像快照),具体移步《MySQL 是怎样运行的:从根儿上理解 MySQL - 事务隔离级别和MVCC》。

  3. 可重复读(REPEATABLE READ)

    可重复读和已提交读,基本是相同的,只不过生成 ReadView 的时机不一样。

    • 已提交读:每次读取数据之前,都生成一个 ReadView
    • 可重复读:第一次读取数据之前,生成一个 ReadView
  4. 可串行化

    这种隔离级别最为严苛,直接上锁。

MySQL 默认的事务隔离级别是可重复读(REPEATABLE READ)。


这周就写到这里。

InnoDB 系列暂时就到这里了,如果以后要学习 MySQL 集群和分片相关的知识,再写新的内容。