30

mysql事务原理及MVCC

 4 years ago
source link: http://www.cnblogs.com/sx-wuyj/p/12567440.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

mysql事务原理及MVCC

事务是数据库最为重要的机制之一,凡是使用过数据库的人,都了解数据库的事务机制,也对ACID四个

基本特性如数家珍。但是聊起事务或者ACID的底层实现原理,往往言之不详,不明所以。在MySQL中

的事务是由存储引擎实现的,而且支持事务的存储引擎不多,我们主要讲解InnoDB存储引擎中的事

务。所以,今天我们就一起来分析和探讨InnoDB的事务机制,希望能建立起对事务底层实现原理的具

体了解。

事务的特性

MVbIVfE.jpg!web

  • 原子性:事务最小工作单元,事务开始要不全部成功,要不全部失败.
  • 一致性:事务的开始和结束后,数据库的完整性不会被破坏
  • 隔离性:不同事务之间互不影响,四种隔离级别为RU(读未提交)、RC(读已提
    交)、RR(可重复读)、SERIALIZABLE (串行化)。
  • 持久性:事务提交后,对数据的修改是永久性的,即使系统故障也不会丢失 。

隔离级别

有一张表,结构如下:

7j6BbeF.jpg!web

  • 未提交读(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

    1. 是在 insert 操作中产生的 undo log。

    2. 因为 insert 操作的记录只对事务本身可见,对于其它事务此记录是不可见的,所以 insert undo

      log 可以在事务提交后直接删除而不需要进行 purge 操作。

  • update undo log

    1. 是 update 或 delete 操作中产生的 undo log
    2. 因为会对已经存在的记录产生影响,为了提供 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.

Efm2YvY.jpg!web

ps:咳咳~~理解意思就好,捂脸.jpg

每次对记录进行改动,都会记录一条 undo日志 ,每条 undo日志 也都有一个 roll_pointer 属性

( INSERT 操作对应的 undo日志 没有该属性,因为该记录并没有更早的版本),可以将这些 undo日志

都连起来,串成一个链表,所以现在的情况就像下图一样:

3auUVfj.jpg!web

对该记录每次更新后,都会将旧值放到一条 undo日志 中,就算是该记录的一个旧版本,随着更新次数

的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,我们把这个链表称之为 版本链 ,版本

链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务id,这个信息很

重要,我们稍后就会用到。

如下图所示(初始状态):

maAjMfb.jpg!web

当事务2使用UPDATE语句修改该行数据时,会首先使用排他锁锁定改行,将该行当前的值复制到undo

log中,然后再真正地修改当前行的值,最后填写事务ID,使用回滚指针指向undo log中修改前的行。

如下图所示(第一次修改):

jIBZbqR.jpg!web

当事务3进行修改与事务2的处理过程类似,如下图所示(第二次修改):

73aI73A.jpg!web

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将数据回滚到修

改之前的样子。

以上就是本篇博客分享的内容,欢迎提出问题,讨论交流.

联系方式:[email protected]


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK