事务是数据库最为重要的机制之一, 凡是使用过数据库的人, 都了解数据库的事务机制, 也对 ACID 四个
基本特性如数家珍. 但是聊起事务或者 ACID 的底层实现原理, 往往言之不详, 不明所以. 在 MySQL 中
的事务是由存储引擎实现的, 而且支持事务的存储引擎不多, 我们主要讲解 InnoDB 存储引擎中的事
务. 所以, 今天我们就一起来分析和探讨 InnoDB 的事务机制, 希望能建立起对事务底层实现原理的具
体了解.
事务的特性
原子性: 事务最小工作单元, 事务开始要不全部成功, 要不全部失败.
一致性: 事务的开始和结束后, 数据库的完整性不会被破坏
隔离性: 不同事务之间互不影响, 四种隔离级别为 RU(读未提交),RC(读已提
交),RR(可重复读),SERIALIZABLE (串行化).
持久性: 事务提交后, 对数据的修改是永久性的, 即使系统故障也不会丢失 .
隔离级别
有一张表, 结构如下:
未提交读 (RU)
一个事务读取到另一个事务尚未提交的数据, 称之为脏读
发生时间编号 | session A | session B |
---|---|---|
1 | begin; | |
2 | begin; | |
3 | update t set c="关羽" where id = 1; | |
4 | select * from t where id = 1; | |
时间编号为 4 时, AB 两个 session 均未提交事务, select 语句读取到的值为关羽, 读取到了 B 尚未提交的事务, 此为脏读, 这种隔离级别是最不安全的一种.
已提交读 (RC)
一个事务读取到另一个事务已提交的数据, 导致对同一条记录读取两次以上的结果不一致, 称之为不可重复读
发生时间编号 | session A | session B |
---|---|---|
1 | begin; | |
2 | begin; | |
3 | update t set c="关羽" where id = 1; | |
4 | select * from t where id = 1; | |
5 | commit; | |
6 | select * from t where id = 1; |
时间编号为 4 时, B 尚未提交, 此时读取到的数据依然是刘备, 时间编号为 5,B 事务提交, 时间编号为 6 时再次读取到的数据变成了关羽. 这种情况是可以被理解的, 因为 B 事务已经提交了.
可重复读 (RR)
一个事务读取到另一个事务已经提交的 delete 或者 insert 数据, 导致对同一张表读取两次以上结果不一致, 称之为幻读
幻读可以通过串行化或者间隙锁来解决
发生时间编号 | session A | session B |
---|---|---|
1 | begin; | |
2 | begin; | |
3 | update t set c="关羽" where id = 1; | |
4 | select * from t where id = 1; | |
5 | commit; | |
6 | select * from t where id = 1; |
时间编号为 4 时, B 尚未提交, 此时读取到的数据依然是刘备, 时间编号为 5,B 事务提交, 时间编号为 6 时再次读取到的数据依然是刘备. 同一个事务中读取到的数据永远是一致的.
串行化
简单来说就是加锁, 这种隔离级别是最安全的, 可以解决其他隔离级别所产生的问题, 但是效率较低.
发生时间编号 | session A | session B |
---|---|---|
1 | begin; | |
2 | begin; | |
3 | update t set c="关羽" where id = 1; | |
4 | select * from t where id = 1; | |
5 | commit; | |
6 | select * from t where id = 1; |
时间编号为 4 时, B 尚未提交, 此时读取时, 将会被阻塞, 处于等待中直到 B 事务提交释放锁, 时间编号为 5,B 事务提交释放锁, 时间编号为 6 时再次读取到的数据是关羽.
丢失更新, 两个事务同时对一条数据进行修改时, 会存在丢失更新问题.
时间 | 取款事务 A | 取款事务 B |
---|---|---|
1 | 开始事务 | |
2 | 开始事务 | |
3 | 查询余额为 1000 元 | |
4 | 查询余额为 1000 元 | |
5 | 汇入 100 元, 余额变为 1100 | |
6 | 提交事务 | |
7 | 取出 100 元, 余额变为 900 元 | |
8 | 回滚事务 | |
9 | 余额恢复为 1000 元, 丢失更新 |
MySQL 的默认隔离级别为 RR
数据库的事务并发问题需要使用并发控制机制去解决, 数据库的并发控制机制有很多, 最为常见
的就是锁机制. 锁机制一般会给竞争资源加锁, 阻塞读或者写操作来解决事务之间的竞争条件,
最终保证事务的可串行化.
而 MVCC 则引入了另外一种并发控制, 它让读写操作互不阻塞, 每一个写操作都会创建一个新版
本的数据, 读操作会从有限多个版本的数据中挑选一个最合适的结果直接返回, 由此解决了事务
的竞争条件.
MVCC
mvcc 也是多版本并发控制, MySQL 中引入了这种并发机制. 我们接下来就聊聊 mvcc
版本链
回滚段 / undo log
insert undo log
是在 insert 操作中产生的 undo log.
因为 insert 操作的记录只对事务本身可见, 对于其它事务此记录是不可见的, 所以 insert undo
log 可以在事务提交后直接删除而不需要进行 purge 操作.
update undo log
是 update 或 delete 操作中产生的 undo log
因为会对已经存在的记录产生影响, 为了提供 MVCC 机制, 因此 update undo log 不能在事务提交时就进行删除, 而是将事务提交时放到入 history list 上, 等待 purge 线程进行最后的删除操作
为了保证事务并发操作时, 在写各自的 undo log 时不产生冲突, InnoDB 采用回滚段的方式来维护 undo
log 的并发写入和持久化. 回滚段实际上是一种 Undo 文件组织方式.
InnoDB 行记录有三个隐藏字段: 分别对应该行的 rowid, 事务号 db_trx_id 和回滚指针 db_roll_ptr, 其
中 db_trx_id 表示最近修改的事务的 id,db_roll_ptr 指向回滚段中的 undo log.
对于使用 InnoDB 存储引擎的表来说, 它的聚簇索引记录中都包含两个必要的隐藏列 ( row_id 并不是
必要的, 我们创建的表中有主键或者非 NULL 唯一键时都不会包含 row_id 列):
trx_id : 每次对某条聚簇索引记录进行改动时, 都会把对应的事务 id 赋值给 trx_id 隐藏列.
roll_pointer : 每次对某条聚簇索引记录进行改动时, 都会把旧的版本写入到 undo 日志 中, 然
后这个隐藏列就相当于一个指针, 可以通过它来找到该记录修改前的信息.
我们有一张表
- create table user(
- id int,
- name varchar,
- primary key (id)
- )
- insert into user values(1,'张三');
我们此时插入这条数据, 假设事务 id 为 80.
ps: 咳咳~~ 理解意思就好, 捂脸. jpg
每次对记录进行改动, 都会记录一条 undo 日志 , 每条 undo 日志 也都有一个 roll_pointer 属性
( INSERT 操作对应的 undo 日志 没有该属性, 因为该记录并没有更早的版本), 可以将这些 undo 日志
都连起来, 串成一个链表, 所以现在的情况就像下图一样:
对该记录每次更新后, 都会将旧值放到一条 undo 日志 中, 就算是该记录的一个旧版本, 随着更新次数
的增多, 所有的版本都会被 roll_pointer 属性连接成一个链表, 我们把这个链表称之为 版本链 , 版本
链的头节点就是当前记录最新的值. 另外, 每个版本中还包含生成该版本时对应的事务 id, 这个信息很
重要, 我们稍后就会用到.
如下图所示 (初始状态):
当事务 2 使用 UPDATE 语句修改该行数据时, 会首先使用排他锁锁定改行, 将该行当前的值复制到 undo
log 中, 然后再真正地修改当前行的值, 最后填写事务 ID, 使用回滚指针指向 undo log 中修改前的行.
如下图所示 (第一次修改):
当事务 3 进行修改与事务 2 的处理过程类似, 如下图所示 (第二次修改):
REPEATABLE READ 隔离级别下事务开始后使用 MVCC 机制进行读取时, 会将当时活动的事务 id 记录下
来, 记录到 Read View 中. READ COMMITTED 隔离级别下则是每次读取时都创建一个新的 Read View.
ReadView
对于使用 READ UNCOMMITTED 隔离级别的事务来说, 直接读取记录的最新版本就好了, 对于使用
SERIALIZABLE 隔离级别的事务来说, 使用加锁的方式来访问记录. 对于使用 READ COMMITTED 和
REPEATABLE READ 隔离级别的事务来说, 就需要用到我们上边所说的 版本链 了, 核心问题就是: 需要
判断一下版本链中的哪个版本是当前事务可见的. 所以设计 InnoDB 的大叔提出了一个 ReadView 的概
念, 这个 ReadView 中主要包含当前系统中还有哪些活跃的读写事务, 把它们的事务 id 放到一个列表
中, 我们把这个列表命名为为 m_ids . 这样在访问某条记录时, 只需要按照下边的步骤判断记录的某个
版本是否可见:
如果被访问版本的 trx_id 属性值小于 m_ids 列表中最小的事务 id, 表明生成该版本的事务在生成
ReadView 前已经提交, 所以该版本可以被当前事务访问.
如果被访问版本的 trx_id 属性值大于 m_ids 列表中最大的事务 id, 表明生成该版本的事务在生成
ReadView 后才生成, 所以该版本不可以被当前事务访问.
如果被访问版本的 trx_id 属性值在 m_ids 列表中最大的事务 id 和最小事务 id 之间, 那就需要判断
一下 trx_id 属性值是不是在 m_ids 列表中, 如果在, 说明创建 ReadView 时生成该版本的事务还
是活跃的, 该版本不可以被访问; 如果不在, 说明创建 ReadView 时生成该版本的事务已经被提
交, 该版本可以被访问.
如果某个版本的数据对当前事务不可见的话, 那就顺着版本链找到下一个版本的数据, 继续按照上边的
步骤判断可见性, 依此类推, 直到版本链中的最后一个版本, 如果最后一个版本也不可见的话, 那么就
意味着该条记录对该事务不可见, 查询结果就不包含该记录.
在 MySQL 中, READ COMMITTED 和 REPEATABLE READ 隔离级别的的一个非常大的区别就是它们生成
ReadView 的时机不同, 我们来看一下.
RC 隔离级别和 RR 隔离级别区别
每次读取数据前都生成一个 ReadView
比方说现在系统里有两个 id 分别为 100 , 200 的事务在执行:
- # Transaction 100
- BEGIN;
- UPDATE user SET name = '张三' WHERE id = 1;
- UPDATE user SET name = '李四' WHERE id = 1;
- # Transaction 200
- BEGIN;
- # 更新了一些别的表的记录
- ...
假设现在有一个使用 READ COMMITTED 隔离级别的事务开始执行:
- # 使用 READ COMMITTED 隔离级别的事务
- BEGIN;
- # SELECT1:Transaction 100,200 未提交
- SELECT * FROM user WHERE id = 1; # 得到的列 name 的值为'王五'
这个 SELECT1 的执行过程如下:
在执行 SELECT 语句时会先生成一个 ReadView , ReadView 的 m_ids 列表的内容就是 [100,
200] .
然后从版本链中挑选可见的记录, 最新版本的列 name 的内容是 '张三' , 该版本的 trx_id 值为 100 , 在 m_ids 列表内, 所以不符合可见性要求, 根据 roll_pointer 跳到下一个版本.
下一个版本的列 name 的内容是 '李四' , 该版本的 trx_id 值也为 100 , 也在 m_ids 列表内, 所以也不符合要求, 继续跳到下一个版本.
下一个版本的列 name 的内容是 '王五' , 该版本的 trx_id 值为 80 , 小于 m_ids 列表中最小的事务 id 100 , 所以这个版本是符合要求的, 最后返回给用户的版本就是这条列 name 为 '王五' 的记录.
之后, 我们把事务 id 为 100 的事务提交一下, 就像这样:
- # Transaction 100
- BEGIN;
- UPDATE user SET name = '关羽' WHERE id = 1;
- UPDATE user SET name = '张飞' WHERE id = 1;
- COMMIT;
然后再到事务 id 为 200 的事务中更新一下表 user 中 id 为 1 的记录:
- # Transaction 200
- BEGIN;
- # 更新了一些别的表的记录
- ...
- UPDATE user SET name = '云六' WHERE id = 1;
- UPDATE user SET name = '王麻子' WHERE id = 1;
然后再到刚才使用 READ COMMITTED 隔离级别的事务中继续查找这个 id 为 1 的记录, 如下:
- # 使用 READ COMMITTED 隔离级别的事务
- BEGIN;
- # SELECT1:Transaction 100,200 均未提交
- SELECT * FROM user WHERE id = 1; # 得到的列 name 的值为'李四'
- # SELECT2:Transaction 100 提交, Transaction 200 未提交
- SELECT * FROM user WHERE id = 1; # 得到的列 name 的值为'张三'
这个 SELECT2 的执行过程如下:
在执行 SELECT 语句时会先生成一个 ReadView , ReadView 的 m_ids 列表的内容就是 [200] (事务 id 为 100 的那个事务已经提交了, 所以生成快照时就没有它了).
然后从版本链中挑选可见的记录, 最新版本的列 name 的内容是 '王麻子' , 该版本的 trx_id 值为 200 , 在 m_ids 列表内, 所以不符合可见性要求, 根据 roll_pointer 跳到下一个版本.
下一个版本的列 name 的内容是 '云六' , 该版本的 trx_id 值为 200 , 也在 m_ids 列表内, 所以也不符合要求, 继续跳到下一个版本.
下一个版本的列 name 的内容是 '张三' , 该版本的 trx_id 值为 100 , 比 m_ids 列表中最小的事务
id 200 还要小, 所以这个版本是符合要求的, 最后返回给用户的版本就是这条列 name 为 '张三' 的记录.
以此类推, 如果之后事务 id 为 200 的记录也提交了, 再此在使用 READ COMMITTED 隔离级别的事务中查询表 user 中 id 值为 1 的记录时, 得到的结果就是 '王麻子' 了, 具体流程我们就不分析了. 总结一下就
是: 使用 READ COMMITTED 隔离级别的事务在每次查询开始时都会生成一个独立的 ReadView.
只在第一次读取数据生成一个 ReadView
对于使用 REPEATABLE READ 隔离级别的事务来说, 只会在第一次执行查询语句时生成一个
ReadView , 之后的查询就不会重复生成了. 我们还是用例子看一下是什么效果.
比方说现在系统里有两个 id 分别为 100 , 200 的事务在执行:
- # Transaction 100
- BEGIN;
- UPDATE user SET name = '张三' WHERE id = 1;
- UPDATE user SET name = '李四' WHERE id = 1;
- # Transaction 200
- BEGIN;
- # 更新了一些别的表的记录
- ...
假设现在有一个使用 REPEATABLE READ 隔离级别的事务开始执行:
- # 使用 REPEATABLE READ 隔离级别的事务
- BEGIN;
- # SELECT1:Transaction 100,200 未提交
- SELECT * FROM user WHERE id = 1; # 得到的列 name 的值为'王五'
这个 SELECT1 的执行过程如下:
在执行 SELECT 语句时会先生成一个 ReadView , ReadView 的 m_ids 列表的内容就是 [100,
200] .
然后从版本链中挑选可见的记录, 最新版本的列 name 的内容是 '张三' , 该版本的 trx_id 值为 100 , 在 m_ids 列表内, 所以不符合可见性要求, 根据 roll_pointer 跳到下一个版
本.
下一个版本的列 name 的内容是 '李四' , 该版本的 trx_id 值也为 100 , 也在 m_ids 列表内, 所以也不符合要求, 继续跳到下一个版本.
下一个版本的列 name 的内容是 '王五' , 该版本的 trx_id 值为 80 , 小于 m_ids 列表中最小的事务 id 100 , 所以这个版本是符合要求的, 最后返回给用户的版本就是这条列 name 为 '王五' 的记录.
之后, 我们把事务 id 为 100 的事务提交一下, 就像这样:
- # Transaction 100
- BEGIN;
- UPDATE user SET name = '李四' WHERE id = 1;
- UPDATE user SET name = '张三' WHERE id = 1;
- COMMIT;
然后再到事务 id 为 200 的事务中更新一下表 user 中 id 为 1 的记录:
- # Transaction 200
- BEGIN;
- # 更新了一些别的表的记录
- ...
- UPDATE user SET name = '云六' WHERE id = 1;
- UPDATE user SET name = '王麻子' WHERE id = 1;
然后再到刚才使用 REPEATABLE READ 隔离级别的事务中继续查找这个 id 为 1 的记录, 如下:
- # 使用 REPEATABLE READ 隔离级别的事务
- BEGIN;
- # SELECT1:Transaction 100,200 均未提交
- SELECT * FROM user WHERE id = 1; # 得到的列 name 的值为'李四'
- # SELECT2:Transaction 100 提交, Transaction 200 未提交
- SELECT * FROM user WHERE id = 1; # 得到的列 name 的值仍为'李四'
这个 SELECT2 的执行过程如下:
因为之前已经生成过 ReadView 了, 所以此时直接复用之前的 ReadView , 之前的 ReadView 中的
m_ids 列表就是 [100, 200] .
然后从版本链中挑选可见的记录, 最新版本的列 name 的内容是 '王麻子' , 该版本的 trx_id 值为 200 , 在 m_ids 列表内, 所以不符合可见性要求, 根据 roll_pointer 跳到下一个版本.
下一个版本的列 name 的内容是 '云六' , 该版本的 trx_id 值为 200 , 也在 m_ids 列表内, 所以也不符合要求, 继续跳到下一个版本.
下一个版本的列 name 的内容是 '张三' , 该版本的 trx_id 值为 100 , 而 m_ids 列表中是包含值为
100 的事务 id 的, 所以该版本也不符合要求, 同理下一个列 name 的内容是 '关羽' 的版本也不符合要求. 继续跳到下一个版本.
下一个版本的列 name 的内容是 '李四' , 该版本的 trx_id 值为 80 , 80 小于 m_ids 列表中最小的事务 id 100 , 所以这个版本是符合要求的, 最后返回给用户的版本就是这条列 name 为 '李四' 的记录.
也就是说两次 SELECT 查询得到的结果是重复的, 记录的列 name 值都是 '李四' , 这就是 可重复读 的含义. 如果我们之后再把事务 id 为 200 的记录提交了, 之后再到刚才使用 REPEATABLE READ 隔离级别的事务中继续查找这个 id 为 1 的记录, 得到的结果还是 '李四' , 具体执行过程大家可以自己分析一下.
InnoDB 的 MVCC 实现
我们首先来看一下 wiki 上对 MVCC 的定义:
- Multiversion concurrency control (MCC or MVCC), is a concurrency control
- method commonly used by database management systems to provide
- concurrent access to the database and in programming languages to
implement transactional memory.
由定义可知, MVCC 是用于数据库提供并发访问控制的并发控制技术. 与 MVCC 相对的, 是基于锁的并
发控制, Lock-Based Concurrency Control .MVCC 最大的好处, 相信也是耳熟能详: 读不加锁, 读
写不冲突. 在读多写少的 OLTP 应用中, 读写不冲突是非常重要的, 极大的增加了系统的并发性能, 这
也是为什么现阶段, 几乎所有的 RDBMS, 都支持了 MVCC.
多版本并发控制仅仅是一种技术概念, 并没有统一的实现标准, 其核心理念就是数据快照, 不同的事务
访问不同版本的数据快照, 从而实现不同的事务隔离级别. 虽然字面上是说具有多个版本的数据快照,
但这并不意味着数据库必须拷贝数据, 保存多份数据文件, 这样会浪费大量的存储空间. InnoDB 通过
事务的 undo 日志巧妙地实现了多版本的数据快照.
数据库的事务有时需要进行回滚操作, 这时就需要对之前的操作进行 undo. 因此, 在对数据进行修改
时, InnoDB 会产生 undo log. 当事务需要进行回滚时, InnoDB 可以利用这些 undo log 将数据回滚到修
改之前的样子.
以上就是本篇博客分享的内容, 欢迎提出问题, 讨论交流.
联系方式: sx_wuyj@163.com
来源: https://www.cnblogs.com/sx-wuyj/p/12567440.html