我们都知道 MySQL 支持了行锁及事务机制,如何能在保证事务的正确前提下,而又能最大程度的提升数据库的并行程度?这里就需要用到 MVCC 机制,MVCC 全称 Multi Version Concurrency Control 即“多版本并发控制”,可以更好的去处理数据库的读、写冲突,只在写、写的时候才需要加锁,大大提升了数据库的并发度。
前序知识
ACID
一个支持事务处理的系统,必须满足 ACID 标准:
- 原子性(atomicity):一个事务被视为不可分割的最小执行单元,整个事务中的所有操作,要么全部提交成功,要么全部失败回滚。如果在执行过程中失败,回滚操作需要保证数据库“好像”从没执行过这个事务一样。
- 一致性(consistency):一致性指的是数据库需要总是保持一致的状态,即使实例崩溃了,也要能保证数据的一致性,包括内部数据存储的准确性,数据结构不被破坏。InnoDB 通过 double write buffer 和 crash recovery 实现了这一点。
- 隔离性(isolation):隔离性是指多个事务不可以对相同数据同时做修改,事务修改的数据要么就是其他事务修改之前的数据,要么就是修改之后的数据。
- 持久性(durability): 事务一旦提交,其所做的修改就肯定能保存到数据库中。提交之后,即使数据库系统崩溃、迭机,提交的数据也不会丢失。InnoDB 给出了许多选项,你可以为了追求性能而弱化持久性,也可以为了完全的持久性而弱化性能。
隔离级别
SQL 中定义了四种隔离级别,每一种都规定了一个事务中所做的修改,哪些是事务内及事务间可见的,哪些是不可见的。越低的隔离级别通常可以支持更高的并发度,系统的开销也更低。
- 读未提交(READ UNCOMMITED):事务中的修改,即使未提交,对其他事务也是可见的。事务可以读取未提交的数据,也被称为脏读(Dirty Read)。这个级别会导致很多问题,例如转账时候,一个事务读到了账户余额的变化并进行使用,但转账的事务回滚了,就造成数据的错误。在实际应用中一般很少使用。
- 读已提交(READ COMMITED):大多数数据库系统中的默认隔离级别都是读已提交,但 MySQL 中不是。读已提交就可以满足“隔离性”的语义,一个事务只能看到已提交的事务所做的修改。这个级别也被称为“不可重复读”(NONREPEATABLE READ),因为可以读到已提交的数据,所以对同一记录的两次读取,可能会得到不一样的结果。
- 可重复读(REPEATABLE READ):这是 MySQL 的默认隔离级别,这个级别下保证了同一个事务中多次读取同一条记录的结果是一样的。但理论上,可重复读依然无法解决幻读(Phantom Read),指的是当某个事务在读取某个范围内的记录时,另一个事务又在该范围内插入了新记录,当前事务再次读取该范围的记录时,会产生幻行(Phantom Row)。
- 串行化(SERIALIZABLE):串行化是最高的隔离级别。通过强制事务的串行化,避免了前面说的幻读问题。串行化会在读取的每一行数据上都加锁,所以可能导致大量的超时和锁争用问题。串行化的执行效率非常低,所以实际应用中很少用到这个隔离级别。
当前读/快照读
- 当前读:当前读, 读取的是最新版本, 并且对读取的记录加锁, 阻塞其他事务同时改动相同记录,避免出现安全问题。当前读的语句有 select lock in share mode;select for update;update;delete;insert。
- 快照度:快照读,相比于当前读,读取的不是最新版本数据而是读取的快照数据,对不同隔离级别开启快照的时机也不同。“读已提交”隔离级别下每次 Select 都生成一个快照,“可重复读”隔离级别下在事务的第一次 Select 才会生成快照。不加锁的 Select 属于当前读。
InnoDB MVCC 实现逻辑
InnoDB MVCC 实现机制主要是依赖数据库记录中的 3 个隐式字段及 Undo Log 和 Read View 来实现,下面将分别介绍下,并看下他们是如何配合作用的。
隐式字段
- DB_ROW_ID:6 byte,隐含的自增ID(隐藏主键),如果表没有主键,InnoDB 会使用 DB_ROW_ID 产生一个聚簇索引
- DB_TRX_ID:6 byte,记录创建/最后一次修改该记录的事务 ID
- DB_ROLL_PTR:7 byte,回滚指针,指向这条记录的上一个版本
Undo Log
Undo Log 主要用于保存数据历史版本记录,当不同事务对同一条记录进行修改时候,Undo Log 会形成线性表,链首是最新记录,链尾是最早记录。分为如下两种:
- Insert Undo Log:事务在 Insert 新记录时产生的 Undo Log。只在事务回滚时需要,在事务提交后可以被立即删除
- Update Undo Log:事务在进行 Update 或 Delete 时产生的 Undo Log。不仅在事务回滚时需要,在快照读时也需要,所以不能随便删除,只有事务回滚和快照读不涉及该日志时,对应的日志才会被 purge 线程统一清除
insert into account (account_num, money) values (9527, 100);
例如,当我们开启事务 1 ,对 account 表执行如上 Insert 语句,将在表上生成如下数据,这时还没生成 Undo Log。
插入数据当事务 2 将 money 字段值改为 200 的时候,首先数据库将对该行加排他锁,然后将此行数据拷贝到 Undo Log 中。拷贝完毕后,修改此行的 money 字段值为 200,并修改 DB_ROLL_PTR 指向刚拷贝到 Undo Log 中的数据行,假设数据地址是 0x1234。这样就形成如下的数结构。
update account set money = 200 where account_num = 9527;
更新
然后又有事务 3 将 money 字段值修改成 300,这时候将执行跟事务 2 同样的步骤,再加一层 Undo Log。
update account set money = 300 where account_num = 9527;
再次更新
通过这种方式,就形成了一个数据链表,在需要回滚的时候可以快速找到应该回滚的数据,也可以配合下面的 Read View 机制实现对数据的并发读/写。
Read View
Read View 是事务进行快照读操作的时候生成的读视图,在事务执行快照读的那一刻,会生成数据库当前的一个快照,记录并维护系统当前活跃事务的 ID。
Read View 生成时候会同时记录如下 4 个控制变量,这些变量在 Read View 生成之后就不会再变化,与数据库记录中的 DB_TRX_ID 字段配合,实现了一个可见性原则算法。
- id:创建该 Read View 的事务 ID
- m_ids:一个数值列表,用来维护 Read View 生成时刻系统正活跃的事务 ID
- m_up_limit_id:当前系统活跃的事务也就是 m_ids 列表中最小的事务 ID
- m_low_limit_id:系统尚未分配的下一个事务 ID,也就是 m_ids 列表中最大的事务 ID + 1
可见性原则
以下为可见性原则,从上到下依次执行。如果数据行上最新的记录不符合可见性原则,则根据 DB_ROLL_PTR 依次向下寻找 Undo Log,直至找到符合可见性原则的记录。
- 如果 DB_TRX_ID(记录上最新的 DB_TRX_ID) < m_up_limit_id,则当前事务能看到 DB_TRX_ID 所在的记录
- 如果 DB_TRX_ID >= m_low_limit_id,则代表 DB_TRX_ID 所在的记录在 Read View 生成后才出现,那对当前事务肯定不可见
- 最后判断 DB_TRX_ID 是否在活跃事务之中。如果在,则代表 Read View 生成时刻,这个事务还在活跃未 Commit,修改的数据,当前事务不可见(不包含自身修改);如果不在,则说明事务在 Read View 生成之前已经 Commit,修改的结果,当前事务可见
总体来说,会根据数据行及 Undo Log 中的 DB_TRX_ID 依次进行三种判断,决定数据的可见性。接下来,让我们结合之前的 Undo Log 过程一起分析下 Read View 在执行过程中的实际应用。下面分析过程是基于“可重复读”隔离级别。
① DB_TRX_ID 小于 m_up_limit_id
假设事务 1 进行了数据插入,并提交,这时数据库中数据结构如下所示:
之后同时开启事务 2 、事务 3, 这时事务 2 执行了对 money 字段的读取操作。于是事务 2 产生了 Read View,并且其控制变量情况如下:
id | m_ids | m_up_limit_id | m_low_limit_id |
---|---|---|---|
2 | [2,3] | 2 | 4 |
这时由于数据行的 DB_TRX_ID < m_up_limit_id,所以当前读可以读取到事务 1 的内容,也就是读取到 money = 100。
② DB_TRX_ID 大于等于 m_low_limit_id
这时又开启了一个新的事务 4 ,修改 money = 400,并提交了事务。这时数据库中的数据结构如下所示:
然后事务 2 执行对 money 字段的读取操作。现在数据行的 DB_TRX_ID >= m_low_limit_id,那么可以知道记录上的事务肯定是当前 Read View 产生之后才开启的,那么其修改对事务 2 不可见。然后根据 DB_ROLL_PTR 指向读取下一条 Undo Log 记录,由于下一条 Undo Log 的 DB_TRX_ID = 1,符合可见性原则,那么就进行数据读取,现在读取到的 money 字段值依然是 100。
③ DB_TRX_ID 在 m_ids 中
然后事务 3 执行语句,修改 money = 300,并且暂时不提交事务。这时数据库中的数据结构如下所示:
事务 2 又执行对 money 字段的读取操作。现在数据行的 m_up_limit_id <= DB_TRX_ID < m_low_limit_id,并且在 m_ids 列表中,所以可以知道 Read View 生成时刻,事务 3 还没有 Commit,那么修改结果对事务 2 不可见。然后根据 DB_ROLL_PTR 指向读取下一个 Undo Log 的记录,这里又读取到事务 4 产生的 Undo Log,依然不符合可见性原则,继续向下读取。所以现在读取到的 money 字段值依然是 100。
之后事务 3 执行了提交操作,如果事务 2 再次执行对 money 字段的读取操作,结果会有变化吗?答案是否定的。因为 Read View 在生成后就不会变化,同时数据库中的数据结构也未发生变化,所以读取结果自然也不会发生变化。
自身修改
事务 2 执行语句,修改 money = 200,这时数据库中数据结构如下所示:
之后,事务 2 又执行对 money 字段的读取操作,由于数据行的 DB_TRX_ID = 当前事务ID,所以知道当前的数据记录是由自己修改,自然也可以读取到了。
整个过程的时间线如下:
时间线对于“读已提交”隔离级别下,在每次快照读的时候,都会生成一个新的 Read View,感谢兴趣的同学可以根据上面的分析过程看下,是否真的“读已提交”。
网友评论