说来和 MySQL 倒是有缘, 毕业的第一份工作就被分配到了 RDS 团队, 主要负责把 MySQL 弄到云上做成数据库服务. 虽说整天和 MySQL 打交道, 但说实话那段时间并没有很深入的理解 MySQL 内核, 做的事情基本都是围绕着 MySQL 做管控系统, 比较上层. 好在周边都是 MySQL 内核神级人物, 在他们的熏陶下多多少少对 MySQL 的一些基本知识有一些零碎的记录和模糊的认识, 这些基础对于今天整理理解 MySQL 跨行事务模型非常重要. 更重要的, 有很多不解的地方也可以向大神请教.
MySQL 事务模型在网上也有很多的介绍, 在写这篇文章之前本人也翻看了很多资料作为参考, 以期让自己理解的更加深入全面. 看了大多数介绍文章之后发现部分文章并不完整, 比如有的只介绍了几种隔离级别下 MySQL 的表现, 并没有从技术角度进行解读. 有的文章说的倒很全面, 但缺乏些许条理, 读起来并不容易理解. 这也是笔者希望能够带给大家一点不一样的东西, 从技术角度进行解读, 并且利于理解.
MySQL 事务原子性保证
事务原子性要求事务中的一系列操作要么全部完成, 要么不做任何操作, 不能只做一半. 原子性对于原子操作很容易实现, 就像 HBase 中行级事务的原子性实现就比较简单. 但对于多条语句组成的事务来说, 如果事务执行过程中发生异常, 需要保证原子性就只能回滚, 回滚到事务开始前的状态, 就像这个事务根本没有发生过一样. 如何实现呢?
MySQL 实现回滚操作完全依赖于 undo log, 多说一句, undo log 在 MySQL 除了用来实现原子性保证之外, 还用来实现 MVCC, 下文也会涉及到. 使用 undo 实现原子性在操作任何数据之前, 首先会将修改前的数据记录到 undo log 中, 再进行实际修改. 如果出现异常需要回滚, 系统可以利用 undo 中的备份将数据恢复到事务开始之前的状态. 下图是 MySQL 中表示事务的基本数据结构, 其中与 undo 相关的字段为 insert_undo 和 update_undo, 分别指向本次事务所产生的 undo log.
事务回滚根据 update_undo(或者 insert_undo)找到对应的 undo log, 做逆向操作即可. 对于已经标记删除的数据清理删除标记, 对于更新数据直接回滚更新; 插入操作稍微复杂一些, 不仅需要删除数据, 还需要删除相关的聚集索引以及二级索引记录.
undo log 是 MySQL 内核中非常重要的一块内容, 涉及知识比较多而且复杂, 比如:
1. undo log 必须在数据修改之前持久化, undo log 持久化需不需要记录 redo 以防止宕机异常? 如果需要就又涉及宕机恢复...
2. 通过 undo log 如何实现 MVCC?
3. 那些 undo log 可以在什么场景下回收清理? 如何清理?
MySQL 事务一致性保证: 强一致性事务保证
MySQL 事务隔离级别
Read Uncommitted(RU 技术解读: 使用 X 锁实现写写并发)
Read Uncommitted 只实现了写写并发控制, 并没有有效的读写并发控制, 导致当前事务可能读到其他事务中还未提交的修改数据, 这些数据准确性并不靠谱(有可能被回滚掉), 因此在此基础上作出的一切假设就都不靠谱的. 在现实场景中很少有业务会选择该隔离级别.
写写并发实现机制和 HBase 并无两样, 都是使用两阶段锁协议对相应记录加行锁实现. 不过 MySQL 中行锁机制比较复杂, 根据行记录是否是主键索引, 唯一索引, 非唯一索引或者无索引等分为多种加锁情况.
1. 如果 id 列是主键索引, MySQL 只会为聚簇索引记录加锁.
2. 如果 id 列是唯一二级索引, MySQL 会为二级索引叶子节点以及聚簇索引记录加锁.
3. 如果 id 列是非唯一索引, MySQL 会为所有满足条件 (id = 15) 的二级索引叶子节点以及对应的聚簇索引记录加锁.
4. 如果 id 列是无索引的, SQL 会走聚簇索引全表扫描, 并将扫描结果加载到 SQL Server 层进行过滤, 因此 InnoDB 会为扫描过的所有记录先加上锁, 如果 SQL Server 层过滤不符合条件, InnoDB 会释放该锁. 因此 InnoDB 会为扫描到的所有记录都加锁, 很恐怖吧!
接下来无论是 RC,RR, 抑或是 Serialization, 写写并发控制都使用上述机制, 所以不再赘述. 接下来会重点分析 RC 和 RR 隔离级别中的读写并发控制机制.
在详细介绍 RC 和 RR 之前, 有必要在此先行介绍 MySQL 中 MVCC 机制, 因为 RC 和 RR 都使用 MVCC 机制实现事务之间的读写并发. 只不过两者在实现细节上有一些区别, 具体区别接下来再聊.
MVCC in MySQL
MySQL 中 MVCC 机制相比 HBase 来说要复杂的多, 涉及的数据结构也比较复杂. 为了解释的比较清晰, 以一个栗子为模版进行解释. 比如当前有一行记录如下图所示:
前面四列是该行记录的实际列值, 需要重点关注的是 DB_TRX_ID 和 DB_ROLL_PTR 两个隐藏列(对用户不可见). 其中 DB_TRX_ID 表示修改该行事务的事务 ID, 而 DB_ROLL_PTR 表示指向该行回滚段的指针, 该行记录上所有版本数据, 在 undo 中都通过链表形式组织, 该值实际指向 undo 中该行的历史记录链表.
现在假设有一个事务 trx2 修改了该行数据, 该行记录就会变为下图形式, DB_TRX_ID 为最近修改该行事务的事务 ID(trx2),DB_ROLL_PTR 指向 undo 历史纪录链表:
了解了 MySQL 行记录之后, 再来看看事务的基本结构, 下图是 MySQL 的事务数据结构, 上文我们提到过. 事务在开启之后会创建一个数据结构存储事务相关信息, 锁信息, undo log 以及非常重要的 read_view 信息.
read_view 保存了当前事务开启时整个 MySQL 中所有活跃事务列表, 如下图所示, 在当前事务开启的时候, 系统中活跃的事务有 trx4,trx6,trx7 以及 trx10. 另外, up_trx_id 表示当前事务启动时, 当前事务链表中最小的事务 ID;low_trx_id 表示当前事务启动时, 当前事务链表中最大的事务 ID.
read_view 是实现 MVCC 的一个关键点, 它用来判断记录的哪个版本对当前事务可见. 如果当前事务要读取某行记录, 该行记录的版本号 (事务 ID) 为 trxid, 那么:
1. 如果 trxid <up_trx_id, 说明该行记录所在的事务已经在当前事务创建之前就提交了, 所以该行记录对当前事务可见.
2. 如果 trxid> low_trx_id, 说明该行事务所在的事务是在当前事务创建之后才开启, 所以该行记录对当前事务不可见.
3. 如果 up_trx_id <trxid < low_trx_id, 那么表明该行记录所在事务在本次新事务创建的时候处于活动状态. 从 up_trx_id 到 low_trx_id 进行遍历, 如果 trxid 等于他们之中的某个事务 id 的话, 那么不可见, 否则可见.
以下面行记录为例, 该行记录存在多个版本(trx2,trx5,trx7 以及 trx12), 其中 trx12 是最新版本. 看看该行记录中哪个版本对当前事务可见.
1. 该行记录的最新版本为 trx12, 与当前事务 read_view 进行对比发现, trx12 大于当前活跃事务列表中的最大事务 trx10, 表示 trx12 是在当前事务创建之后才开启的, 因此不可见.
2. 再查看该行记录的第二个最新版本为 trx7, 与当前事务 read_view 对比发现, trx7 介于当前活跃事务列表最小事务 ID 和最大事务 ID 之间, 表明该行记录所在事务在当前事务创建的时候处于活动状态, 在活跃列表中遍历发现 trx7 确实存在, 说明该事务还没有提交, 所以对当前事务不可见.
3. 继续查看该记录的第三个最新版本 trx5, 也介于当前活跃事务列表最小事务 ID 和最大事务 ID 之间, 表明该行记录所在事务在当前事务创建的时候处于活动状态, 但遍历发现该版本并不在活跃事务列表中, 说明 trx5 对应事务已经提交(注: 事务提交时间与事务编号没有任何关联, 有可能事务编号大的事务先提交, 事务编号小的事务后提交), 因此 trx5 版本行记录对当前事务可见, 直接返回.
Read Committed(技术解读: 写写并发使用 X 锁, 读写并发使用 MVCC 避免脏读)
上文介绍了 MySQL 中 MVCC 技术实现机制, 但要明白 RC 隔离级别下事务可见性, 还需要 get 一个核心点: RC 隔离级别下的事务在每次执行 select 时都会生成一个最新的 read_view 代替原有的 read_view.
如上图所示, 左侧为 1 号事务, 在不同时间点对 id=1 的记录分别查询了三次. 右侧为 2 号事务, 对 id=1 的记录进行了更新. 更新前该记录只有一个版本, 更新好变成了两个版本.
1 号事务在 RC 隔离级别下每次执行 select 请求都会生成一个最新的 read_view, 前两次查询生成的全局事务活跃列表中包含 trx2, 因此根据 MVCC 规定查到的记录为老版本; 最后一次查询的时间点位于 2 号事务提交之后, 因此生成的全局活跃事务列表中不包含 trx2, 此时在根据 MVCC 规定查到的记录就是最新版本记录.
Repeatable Read(技术解读: 写写并发使用 X 锁, 读写并发使用 MVCC 避免不可重复读; 当前读使用 Gap 锁避免幻读)
和 RC 模式不同, RR 模式下事务不会再每次执行 select 的时候生成最新的 read_view, 而是在事务第一次 select 时就生成 read_view, 后续不会再变更, 直至当前事务结束. 这样可以有效避免不可重复读, 使得当前事务在整个事务过程中读到的数据都保持一致. 示意图如下所示:
这个就很容易理解, 三次查询所使用的全局活跃事务列表都一样, 且都是第一次生成的 read_view, 那之后查到的记录必然和第一次查到的记录一致.
RR 隔离级别能够避免幻读吗?
如果对幻读还不了解的话, 可以参考该系列的第一篇文章. 如下图所示, 1 号事务对针对 id>1 的过滤条件执行了三次查询2 号事务执行了一次插入, 插入的记录刚好符合 id>1 这个条件. 可以看出来, 三次查询得到的数据是一致的, 这个是由 RR 隔离级别的 MVCC 机制保证的. 这么看来, 是避免了幻读, 但是在最后 1 号事务在 id=2 处插入一条记录, MySQL 会返回 Duplicate entry 的错误, 可见避免了幻读是一种假象.
严格意义避免幻读(技术解读: 当前读使用 Gap 锁避免幻读)
之前提到的所有 RR 级别的 select 语句我们称为快照读, 快照读能够保证不可重复读, 但并不能避免幻读. 于是 MySQL 又提出 "当前读" 的概念, 常见的当前读语句有:
- select for update
- select lock in share mode
- update / delete
并且规定, RR 级别下当前读语句会给记录加上一种特殊的锁 - Gap 锁, Gap 锁并不锁定某个具体的记录, 而是锁定记录与记录之间的间隔, 保证这个间隔中不会插入新的其他记录. 下图是一个示意图:
上图中 1 号事务首先执行了一个当前读的 select 语句, 这个语句会在 id> 0 的所有间隔加上 Gap 锁, 接下来 2 号事务在 id = 3 处执行插入时系统就会返回 Lock wait timeout execcded 的异常. 当然, 其他事务可以在 id <= 0 的条件下插入成功, 这没问题.
Serializable (技术解读: S 锁(读)+X 锁(写))
Serialization 隔离级别是最严格的隔离级别, 所有读请求都会加上读锁, 不分快照读和当前读, 所有写会加上写锁. 当然, 这种隔离级别的性能因为锁开销而相对最差.
MySQL 事务持久性保证
MySQL 事务持久化策略和 HBase 基本相同, 但是涉及的组件相对比较多, 主要有 doublewrite,redo log 以及 binlog:
1. MySQL 数据持久化(DoubleWrite)
实际上 MySQL 的真实数据写入分为两次写入, 一次写入到一个称为 DoubleWrite 的地方, 写成功之后再真实写入数据所在磁盘. 为什么要写两次? 这是因为 MySQL 数据页大小与磁盘一次原子操作大小不一致, 有可能会出现部分写入的情况, 比如默认 InnoDB 数据页大小为 16K, 而磁盘一次原子写入大小为 512 字节(扇区大小), 这样一个数据页写入需要多次 IO, 这样一旦中间发生异常就会出现数据丢失. 另外需要注意的是 DoubleWrite 性能并不会影响太大, 因为写入 DoubleWrite 是顺序写入, 对性能影响来说不是很大.
2. redolog 持久化策略(innodb_flush_log_at_trx_commit)
redolog 是 InnoDB 的 WAL, 数据先写入 redolog 并落盘, 再写入更新到 bufferpool.redolog 的持久化策略和 HBase 中 hlog 的持久化策略一致, 默认为 1, 表示每次事务提交之后 log 就会持久化到磁盘; 该值为 0 表示每隔 1 秒钟左右由异步线程持久化到磁盘, 这种情况下 MySQL 发生宕机有可能会丢失部分数据. 该值为 2 表示每次事务提交之后 log 会 flush 到操作系统缓冲区, 再由操作系统异步 flush 到磁盘, 这种情况下 MySQL 发生宕机不会丢失数据, 但机器宕机有可能会丢失部分数据.
3. binlog 持久化策略(sync_binlog)
binlog 作为 Server 层的日志系统, 主要以 events 的形式顺序纪录了数据库的各种操作, 同时可以纪录每次操作所花费的时间. 在 MySQL 官方文档上, 主要介绍了 Binlog 的两个最基本核心作用: 备份和复制, 因此 binlog 的持久化会一定程度影响数据备份和复制的完整性. 和 redo 持久化策略相同, 可取值有 0,1,N. 默认为 0, 表示写入操作系统缓冲区, 异步 flush 到磁盘. 该值为 1 表示同步写入磁盘. 为 N 则表示每写 N 次操作系统缓冲就执行一次刷新操作.
总结一下, 本文是数据库事务系列文章的第三篇, 核心介绍了 MySQL 的单机跨行事务模型, 其中对隔离性所涉及到的锁技术, MVCC 机制进行了比较详细的说明. 对事务原子性, 持久性等相关特性也进行简单的分析和说明. 接着笔者将会带大家一起聊聊分布式事务模型, 看看和单机事务模型到底有何区别.
来源: http://www.linuxidc.com/Linux/2019-11/161419.htm