在写上一篇 MySQL 锁机制的时候就一直在想关于 InnoDb 事务的问题, 一直拖到了现在才写这篇博客. 一方面是时间问题, 另一方面是事务系统实在是太复杂了, 查阅了很多资料梳理了很久, 有很多零碎生涩的概念. 文中有些地方只是粗略的带过, 讲得不清楚或者是错误的希望大家包容并指出
事务的四个条件
事务满足的 4 个条件(ACID): 原子性(Atomicity), 一致性(Consistency), 隔离性(Isolation), 持久性(Durability)
原子性: 一个事务 (transaction) 中的所有操作, 要么全部完成, 要么全部不完成. 事务在执行过程中发生错误, 会被回滚 (Rollback) 到事务开始前的状态.
一致性: 指的是在任何时刻, 包括数据库正常提供服务的时候, 数据库从异常中恢复过来的时候, 数据都是一致的, 保证不会读到中间状态的数据.
隔离性: 允许多个并发事务同时对其数据进行读写和修改的能力, 隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致. 事务隔离分为不同级别, 包括读未提交 (Read uncommitted), 读提交(read committed), 可重复读(repeatable read) 和串行化(Serializable).
持久性: 指的是事务 commit 的数据在任何情况下都不能丢.
实现: InnoDB 通过 undolog 保证 rollback 的时候能找到之前的数据保证了原子性; 通过 crash recovery 和 double write buffer 的机制保证数据的一致性. 通过 redolog 保证持久性. 隔离性则由锁和 mvcc 保证.
重要结构体的概念
undo segments: 回滚段(数据页的修改链), 链表最前面的是最老的一次修改, 最后面的最新的一次修改, 从链表尾部逆向操作可以恢复到数据最老的版本. 与之相关的还有 undo tablespace, undo segment, undo slot, undo log 这几个概念. undo log 是最小的粒度, 所在的数据页称为 undo page, 然后若干个 undo page 构成一个 undo slot. 一个事务最多可以有两个 undo slot, 一个是 insert undo slot, 用来存储 insert undo log, 里面主要记录了主键的信息, 方便在回滚的时候快速找到这一行. 另外一个是 update undo slot, 用来存储这个事务 delete/update 产生的 undo log, 里面详细记录了被修改之前每一列的信息, 便于在读请求需要的时候构造. 1024 个 undo slot 构成了一个 undo segment. 然后若干个 undo segemnt 构成了 undo tablespace.
history list:insert undo 可以在事务提交 / 回滚后直接删除, 没有事务会要求查询新插入数据的历史版本, 但是 update undo 则不可以, 因为其他读请求可能需要使用 update undo 构建之前的历史版本. 因此, 在事务提交的时候, 会把 update undo 加入到一个全局链表 (history list) 中, 链表按照事务提交的顺序排序, 保证最先提交的事务的 update undo 在前面, 这样 Purge 线程就可以从最老的事务开始做清理.
trx_t: 每个连接持有一个, 在创建连接后执行第一个事务开始被初始化, 后续这个连接的所有事务一直复用里面的数据结构, 直到这个连接断开. 事务启动后会把这个结构体加入到全局事务链表中(mysql_trx_list), 如果是读写事务, 还会加入到全局读写事务链表中(rw_trx_list). 在事务提交的时候加入到全局提交事务链表中(trx_serial_list).
state 字段记录了事务四种状态:
- TRX_STATE_NOT_STARTED
- , TRX_STATE_ACTIVE, TRX_STATE_PREPARED,
- TRX_STATE_COMMITTED_IN_MEMORY
- .
id 字段是在事务刚创建的时候分配的(只读事务永远为 0, 读写事务通过一个全局 id 产生器产生), 目的就是为了区分不同的事务(只读事务通过指针地址来区分).
而 no 字段是在事务提交前, 通过同一个全局 id 生产器产生的, 主要是为了确定事务提交的顺序, 保证加入到 history list 中的 update undo 有序, 方便 purge 线程清理.
read_view(视图)用来表示当前事务的可见范围(视图).
insert undo slot 和 update undo slot.
read_only 表示是否是只读事务.
trx_sys_t: 用来维护系统的事务信息, 全局唯一, 在数据库启动的时候初始化.
max_trx_id, 表示系统当前还未分配的最小事务 id, 如果有一个新的事务, 直接把这个值作为新事务的 id, 然后这个字段递增.
descriptors, 这个是一个数组, 里面存放着当前所有活跃的读写事务 id, 当需要开启一个 readview 的时候, 就从这个字段里面拷贝一份, 用来判断记录的对事务的可见性.
rw_trx_list, 这个主要是用来存放当前系统的所有读写事务, 按照事务 id 排序.
mysql_trx_list, 这里面存放所有用户创建的事务, 系统的事务和奔溃恢复后的事务不会在这个链表上, 但是这个链表上可能会有还没开始的用户事务.
trx_serial_list, 按照事务 no 排序的已经提交的事务.
rseg_array, 这个指向系统所有可以用的回滚段(undo segments), 当某个事务需要回滚段的时候, 就从这里分配.
rseg_history_len, 所有提交事务的 update undo 的长度, 也就是上文提到的历史链表的长度.
view_list, 这个是系统当前所有的 readview, 所有开启的 readview 的事务都会把自己的 readview 放在这个上面, 按照事务 no 排序.
read_view_t:InnoDB 为了判断某条记录是否对当前事务可见, 需要对此记录进行可见性判断, 这个结构体就是用来辅助判断的
low_limit_no, 这个主要是给 purge 线程用, readview 创建的时候, 会把当前最小的提交事务 id 赋值给 low_limit_no, 这样 Purge 线程就可以把所有已经提交的事务的 undo 日志给删除.
low_limit_id, 创建 readview 时的 max_trx_id, 即一定大于 descriptors 中的最大值. 所有大于等于此值的记录都不应该被此 readview 看到.
up_limit_id, 是 descriptors 中最小的值, 所有小于此值的记录都可以被 readview 看到
descriptors, 里面存了 readview 创建时候当前所有活跃的读写事务 id, 除了事务自己做的变更外, 此 readview 应该看不到 descriptors 中事务所做的变更.
view_list, 每个 readview 都会被加入到 trx_sys 中的全局 readview 链表中.
trx_rseg_t:undo segment 内存中的结构体. 每个 undo segment 都对应一个.
update_undo_list 表示已经被分配出去的正在使用的 update undo 链表,
insert_undo_list 表示已经被分配出去的正在使用的 insert undo 链表.
update_undo_cached 和 insert_undo_cached 表示为了快速使用而缓存起来的 undo 链表.
事务开启
InnoDB 提供了多种方式来开启一个事务, 所有显式开启事务的行为都会隐式的将上一条事务提交掉.
BEGIN,BEGIN WORK,START TRANSACTION: 执行 BEGIN 命令并不会真的去引擎层 (InnoDB) 开启一个事务, 仅仅是为当前线程设定标记, 表示为显式开启的事务.
START TRANSACTION READ ONLY: 为当前线程的 thd->tx_read_only 设置为 true. 当 Server 层接受到任何数据更改的 SQL 时, 都会直接拒绝请求, 返回错误不会进入引擎层.
START TRANSACTION READ WRITE: 允许 super 用户在 read_only 参数为 true 的情况下启动读写事务.
START TRANSACTION WITH CONSISTENT SNAPSHOT: 这种启动方式会进入引擎层层, 并开启一个 readview. 只有在 RR 隔离级别下, 这种操作才有效, 否则会报错.
除了 with consistent snapshot 的方式会进入 InnoDB 层, 其他所有的方式都只是在 Server 层做个标记, 没有进入 InnoDB 做标记, 在 InnoDB 看来所有的事务在启动时候都是只读状态, 只有接受到修改数据的 SQL 后才把只读事务提升为读写事务. 读写事务需要分配事务 id, 分配回滚段, 加入到全局读写事务链表(rw_trx_list), 把事务 id 加入到活跃读写事务数组中(descriptors).
实例分析 1(RR 级别)
在 book 表中有一条记录 id:1,name:book1; 按顺序执行下列语句
事务 1:BEGIN;(1)
- SELECT * FROM book_book WHERE id = 1;(5)
- COMMIT;(6)
事务 2:BEGIN;(2)
UPDATE book_book SET name = 'book2' WHERE id = 1;(3)
COMMIT;(4)
分析: 结果 (5) 查出来的 name 为 book2, 明明事务 1 比事务 2 先, 为什么事务 1 读到了 2 中提交的内容? 这实际上涉及到了 readview 一致性读的问题, 在 RR 级别下事务 1 开始时并不会去给事务系统 trx_sys 打快照生成 readview, 而是在第一条 SQL 语句执行时生成的 readview. 这时事务 2 已经提交, 事务 2id 在 readview 中 up-low 之间且 descriptors 中不存在事务 2id, 所以事务 1 能读到事务 2 修改的数据. 如果事务 1 是用 with consistent snapshot 方式开启事务那么便不能读到事务 2 修改的数据, 因为此时 readview 中的 low_limit_id 等于事务 2id. 此外在 RC 级别下每次执行 SQL 都会生成 readview.
这里讲到了一致性读 (又称快照读) 即普通 select, 对于加锁的 select 和 delete,update,insert 成为当前读.
Undo log
undo log 作用: 提供回滚和 MVCC. 在数据修改的时候, 记录了相对应的 undo, 如果事务失败或回滚了, 可以借助该 undo 进行回滚. undo log 是逻辑日志, 记录更改前的镜像. 当需要当前读的时候, 它可以从 undo log 中分析出该行记录以前的数据提供版本信息. 另外 undo log 也会产生 redo log, 因为 undo log 也要实现持久性保护.
事务提交
使用全局事务 id 产生器生成事务 no, 然后把事务 trx_t 加入到 trx_serial_list.
标记 undo, 如果这个事务只使用了一个 undopage 且使用量小于四分之三个 page, 则把这个 page 标记为(TRX_UNDO_CACHED). 如果不满足且是 insert undo 则标记为 TRX_UNDO_TO_FREE, 否则 undo 为 update undo 则标记为 TRX_UNDO_TO_PURGE. 标记为 TRX_UNDO_CACHED 的 undo 会被回收, 方便下次重新利用.
把 update undo 放入所在 undo segment 的 history list, 并递增 rseg_history_len(全局). 同时更新 page 上的 TRX_UNDO_TRX_NO, 如果删除了数据, 则重置 delete_mark.
把 undate undo 从 update_undo_list 中删除, 如果被标记为 TRX_UNDO_CACHED, 则加入到 update_undo_cached 队列中.
mtr_commit(日志 undo/redo 写入公共缓冲区), 至此, 在文件层次事务提交. 这个时候即使 crash, 重启后依然能保证事务是被提交的. 接下来要做的是内存数据状态的更新(trx_commit_in_memory).
只读事务只需要把 readview 从全局 readview 链表中移除, 然后重置 trx_t 结构体里面的信息即可. 读写事务首先需要是设置事务状态为 TRX_STATE_COMMITTED_IN_MEMORY, 其次, 释放所有行锁, 接着, trx_t 从 rw_trx_list 中移除, readview 从全局 readview 链表中移除, 另外如果有 insert undo 则在这里移除(update undo 在事务提交前就被移除, 主要是为了保证添加到 history list 的顺序), 如果有 update undo, 则唤醒 Purge 线程进行垃圾清理, 最后重置 trx_t 里的信息, 便于下一个事务使用.
事务回滚
如果是只读事务, 则直接返回.
判断当前是回滚整个事务还是部分事务, 如果是部分事务, 则记录下需要保留多少个 undolog, 多余的都回滚掉.
从 update undo 和 insert undo 中找出最后一条 undo, 从这条 undo 开始回滚.
如果是 update undo 则将标记为删除的记录清理标记, 更新过的数据回滚到最老的版本. 如果是 insert undo 则直接删除聚集索引和二级索引.
如果所有 undo 都已经被回滚或者回滚到了指定的 undo 则停止, 并把 undolog 删除(由于不需要使用 undo 构建历史版本).
实例分析 2
在 book 表中有一条记录 id:1,name:book1; 按顺序执行下列语句
事务一: BEGIN;(1)
- SELECT * FROM book_book WHERE id = 1;(3)
- SELECT * FROM book_book WHERE id = 1;(6)
- UPDATE book_book SET `name` = 'book3' WHERE id = 1 AND `name` = 'book2';(7)
- SELECT * FROM book_book WHERE id = 1;(8)
- COMMIT;(9)
事务二: BEGIN;(2)
UPDATE book_book SET `name` = 'book2' WHERE id = 1;(4)
COMMIT;(5)
分析: 3,6 查出来 name 为'book1',7 更新成功, 8 查询 name 为'book3'. 事务一中 3,6 查询都是 name 为'book1', 为什么 7 能更新成功呢? 这是因为在 3 时生成了 read view 对事务二是不可见的, 6 还是快照读依旧对事务二中的修改不可见, 7 是当前读会去通过 history list 中的 undolog 构建历史版本, 从而看到事务二修改的 name 为 book2,8 还是快照读对当前事务可见所以查询结果 name 为 book3;
事务提交流程简要分析
- (1)BEGIN;
- (2)UPDATE book_book SET `name` = 'book2' WHERE id = 1; undolog,redolog
- (3)INSERT INTO `book_book` (`id`, `name`) VALUES ('2', 'JAVA 面向对象编程'); undolog,redolog
- (COMMIT)
(4)undo log buffer 中 undolog 写入磁盘
(5)redo log buffer 中 redolog 写入磁盘
(6)dbbuffer 中数据写入磁盘
关于 redolog(物理日志记录数据页的变化, 保证事务的持久性用来在 crash 后恢复事务)和 binlog(逻辑日志记录数据或者 sql)等日志有兴趣的同学可以深入了解
来源: https://juejin.im/post/5c175babf265da616a4790c9