mysql事务与多版本并发控制

Posted 耳冉鹅

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了mysql事务与多版本并发控制相关的知识,希望对你有一定的参考价值。

1.1 mysql逻辑架构

MySQL服务器逻辑架构图:

  1. 最上层的服务并不是MySQL所独有的,大多数基于网络的客户端/服务器的工具或者 服务都有类似的架构。比如连接处理、授权认证、安全等等。
  2. 第二层架构是MySQL比较有意思的部分。大多数MySQL的核心服务功能都在这一层, 包括査询解析、分析、优化、缓存以及所有的内置函数(例如,日期、时间、数学和加密函数),所有跨存储引擎的功能都在这一层实现:存储过程、触发器、视图等。
  3. 第三层包含了存储引擎。存储引擎负责MySQL中数据的存储和提取。和GNU/Linux T 的各种文件系统一样,每个存储引擎都有它的优势和劣势。服务器通过API与存储引擎 进行通信。这些接口屏蔽了不同存储引擎之间的差异,使得这些差异对上层的査询过程 ’ 透明。存储引擎API包含几十个底层函数,用于执行诸如“开始一个事务”或者“根据 主键提取一行记录”等操作。但存储引擎不会去解析SQL注不同存储引擎之间也不会 相互通信,而只是简单地响应上层服务器的请求。

1.1.1 连接管理与安全性

​ 每个客户端连接都会在服务器进程中拥有一个线程,这个连接的査询只会在这个单独的线程中执行,该线程只能轮流在某个CPU核心或者CPU中运行。

​ 服务器会负责缓存线程,因此不需要为每一个新建的连接创建或者销毁线程。

1.1.2优化与执行

NULL

1.2 并发控制

​ 以Unix系统的email box为例,典型的mbox文件格式是非常简单的。一个mbox邮箱 中的所有邮件都串行在一起,彼此首尾相连。这种格式对于读取和分析邮件信息非常友好,同时投递邮件也很容易,只要在文件末尾附加新的邮件内容即可。

​ 但如果两个进程在同一时刻对同一个邮箱投递邮件,会发生什么情况?显然,邮箱的数据会被破坏,两封邮件的内容会交叉地附加在邮箱文件的末尾。设计良好的邮箱投递系统会通过锁(lock)来防止数据损坏。如果客户试图投递邮件,而邮箱已经被其他客户锁住, 那就必须等待,直到锁释放才能进行投递。

​ 这种锁的方案在实际应用环境中虽然工作良好,但并不支持并发处理。因为在任意一个时刻,只有一个进程可以修改邮箱的数据,这在大容量的邮箱系统中是个问题。

1.2.1 读写锁

​ 如果把上述的邮箱当成数据库中的一张表,把邮件当成表中的一行记录,就很容易看出, 同样的问题依然存在。从很多方面来说,邮箱就是一张简单的数据库表。修改数据库表中的记录,和删除或者修改邮箱中的邮件信息,十分类似。

​ 解决这类经典问题的方法就是并发控制,其实非常简单。在处理并发读或者写时,可以通过实现一个由两种类型的锁组成的锁系统来解决问题。这两种类型的锁通常被称为

  • 共享锁(shared lock)和排他锁(exclusive lock)

  • 也叫读锁(read lock)和写锁(write lock)。

​ 这里先不讨论锁的具体实现,描述一下锁的概念如下:读锁是共享的,或者说是相互不阻塞的。多个客户在同一时刻可以同时读取同一个资源,而互不干扰。写锁则是排他的, 也就是说一个写锁会阻塞其他的写锁和读锁,这是出于安全策略的考虑,只有这样,才能确保在给定的时间里,只有一个用户能执行写入,并防止其他用户读取正在写入的同一资源。

1.2.2 锁粒度

​ 一种提高共享资源并发性的方式就是让锁定对象更有选择性。尽量只锁定需要修改的部分数据,而不是所有的资源。更理想的方式是,只对会修改的数据片进行精确的锁定。

class MyCacheLock {
    private volatile Map<String, Object> map = new HashMap<>();
    // 读写锁 更加细粒度的控制
    private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();

    // 存,写入的时候,只希望同时只有一个线程写
    public void put(String key, Object value) {
        readWriteLock.writeLock().lock();

        try {
            System.out.println(Thread.currentThread().getName() + "写入" + key);
            map.put(key, value);
            System.out.println(Thread.currentThread().getName() + "写入完毕");
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            readWriteLock.writeLock().unlock();
        }
    }

    // 取,读
    public void get(String key) {
        readWriteLock.readLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + "读取" + key);
            Object o = map.get(key);
            System.out.println(Thread.currentThread().getName() + "读取完毕");
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            readWriteLock.readLock().unlock();
        }
    }
}

​ 所谓的锁策略,就是在锁的开销和数据的安全性之间寻求平衡,这种平衡当然也会影响到性能。大多数商业数据库系统没有提供更多的选择,一般都是在表上施加行级锁(row- level lock),并以各种复杂的方式来实现,以便在锁比较多的情况下尽可能地提供更好的性能。

  • 表锁(table lock)

​ 表锁是MySQL中最基本的锁策略,并且是开销最小的策略。表锁非常类似于前文描述 的邮箱加锁机制:它会锁定整张表。一个用户在对表进行写操作(插入、删除、更新等) 前,需要先获得写锁,这会阻塞其他用户对该表的所有读写操作。只有没有写锁时,其他读取的用户才能获得读锁,读锁之间是不相互阻塞的。

  • 行级锁(row lock)

​ 行级锁可以最大程度地支持并发处理(同时也带来了最大的锁开销)。众所周知,在 InnoDB和XtraDB,以及其他一些存储引擎中实现了行级锁。行级锁只在存储引擎层实现,而MySQL服务器层(如有必要,请回顾前文的逻辑架构图)没有实现。服务器层完全不了解存储引擎中的锁实现。

1.3 事务

银行应用是解释事务必要性的一个经典例子。假设一个银行的数据库有两张表:支票 (checking)表和储蓄(savings)表。现在要从用户Jane的支票账户转移200美元到她 的储蓄账户,那么需要至少三个步骤:

  1. 检査支票账户的余额髙于200美元。
  2. 从支票账户余额中减去200美元。
  3. 在储蓄账户余额中增加200美元。
	START TRANSACTION;
	SELECT balance FROM checking WHERE customer_id = 10233276;
	UPDATE checking SET balance = balance - 200.00 WHERE customer_id = 10233276;
	UPDATE savings SET balance = balance + 200.00 WHERE customer_id = 10233276;
	COMMIT;
  • 原子性(atomicity)

一个事务必须被视为一个不可分割的最小工作单元,整个事务中的所有操作要么全部提交成功,要么全部失败回滚,对于一个事务来说,不可能只执行其中的一部分 操作,这就是事务的原子性。

  • 一致性(consistency)

​ 数据库总是从一个一致性的状态转换到另外一个一致性的状态。在前面的例子中, 一致性确保了,即使在执行第三、四条语句之间时系统崩潰,支票账户中也不会损失200美元,因为事务最终没有提交,所以事务中所做的修改也不会保存到数据库中。

  • 隔离性(isolation)

​ 通常来说,一个事务所做的修改在最终提交以前,对其他事务是不可见的。在前面 的例子中,当执行完第三条语句、第四条语句还未开始时,此时有另外一个账户汇 总程序开始运行,则其看到的支票账户的余额并没有被减去200美元。后面我们讨 论隔离级别(Isolation level)的时候,会发现为什么我们要说“通常来说”是不可见的。

  • 持久性(durability)

​ 一旦事务提交,则其所做的修改就会永久保存到数据库中。此时即使系统崩溃,修 改的数据也不会丢失。持久性是个有点模糊的概念,因为实际上持久性也分很多不同的级别。有些持久性策略能够提供非常强的安全保障,而有些则未必。

1.3.1 隔离级别

​ 隔离性其实比想象的要复杂。

​ 在SQL标准中定义了四种隔离级别,每一种级别都规定了一个事务中所做的修改,哪些在事务内和事务间是可见的,哪些是不可见的。

​ 较低级别的隔离通常可以执行更高的并发,系统的开销也更低。

  • READ UNCOMMITTED(未提交读)

    • 事务中的修改,即使没有提交,对其他事务也都是可见 的。事务可以读取未提交的数据,这也被称为脏读(Dirty Read)。

    • 基本不适用。

  • READ COMMITTED(提交读)

    • 大多数数据库系统的默认隔离级别是read committed(MySQL不是)。
    • 满足隔离性的简单定义:一个事务开始时,只能“看见”已经提交的事务所做的修改。
    • 又叫做 不可重复读(nonrepeatable read),因为两次执行同样的查询,可能得到不一样的结果。
  • REPEATABLE READ(可重复读)

    • 解决了脏读的问题。
    • 该级别保证了在同一事务中的多次读取同样记录的结果是一致的。
    • 无法解决另外一个问题:幻读(Phantom Read)
      • 幻读指的是,当某个事务在读取某个范围内的记录时,另外一个事务又在该范围内插入了新的纪录,当之前的事务再次读取该范围的记录时,会产生幻行(Phantom Row)
      • InnoDB和XtraDB存储引擎通过多版本并发控制(MVCC,Multiversion Concurrency Control——解决了幻读的问题。
    • 可重复读是MySQL的默认事务隔离级别
  • SERIALIZABLE(可串行化)

    • 是最高的隔离级别
    • 通过强制事务串行执行,避免了前面说的幻读问题
    • 简单来说,SERILAZABLE会在读取的每一行数据上都加锁,所以可能导致大量的超时和锁争用问题

上图

1.3.2 死锁

​ 死锁是指两个或者多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。

​ 当多个事务试图以不同的顺序锁定资源时,就可能会产生死锁。

​ 多个事务同时锁定同一个资源时,也会产生死锁。

事务1
	START TRANSACTION;
	UPDATE StockPrice SET close = 45.50 WHERE stock_id = 4 and date = '2002-05-01'; 		
	UPDATE StockPrice SET close = 19.80 WHERE stock_id = 3 and date = '2002-05-02';
	COMMIT;	
事务2
	START TRANSACTION;
	UPDATE StockPrice SET high = 20.12 WHERE stock_id = 3 and date = '2002-05-02'; 
	UPDATE 	StockPrice SET high = 47.20 WHERE stock_id = 4 and date = '2002-05-01';
	COMMIT;

​ 两个事务都执行了第一条UPDATE语句,更新了一行数据,同时也锁定了该行数据,接着每个事务都尝试去执行第二条UPDATE语句,却发现该行已经被对方锁定,然后两个事务都等待对方释放锁,同时又持有对方需要的锁,则陷入死循环。除非有外部因素介入才可能解除死锁。

​ InnoDB目前处理死锁的方法是,将持有最少行级排他锁的事务进行回滚(这是相对比较简单的死锁回滚算法)。

1.3.3 事务日志

​ 事务日志可以帮助提高事务的效率。

​ 使用事务日志,存储引擎在修改表的数据时只需要修改其内存拷贝,再把该修改行为记录到持久在硬盘上的事务日志中,而不用每次都将修改的数据本身持久到磁盘。

​ 事务日志采用的是追加的方式,因此写日志的操作时磁盘上一小块区域内的顺序I/O,而不像随机I/O需要在磁盘的多个地方移动磁头,所以采用事务日志的方式相对来说要快得多。

​ 事务日志持久以后,内存中被修改的数据在后台可以慢慢地刷回到磁盘。目前大多数存储引擎都是这样实现的,我们通常称之为预写式日志(Write-Ahead Logging),修改数据需要写两次磁盘。

​ 如果数据的修改已经记录到事务日志并持久化,但数据本身还没有写会磁盘,此时系统崩溃,存储引擎在重启时能够自动恢复这部分修改的数据。具体的恢复方法则视存储引擎而定。

1.3.4 MySQL中的事务

​ MySQL提供了两种事务型的存储引擎:InnoDB和NDB Cluster。

隐式和显式锁定

​ InnoDB采用的是两阶段锁定协议(two-phase locking protocol)。在事务执行过程中,随时都可以执行锁定,锁只有在执行COMMIT或者ROLLBACK的时候才会释放,并且所有的锁是在同一时刻被释放。前面描述的锁定都是隐式锁定,InnoDB会根据隔离级别在需要的时候自动加锁。

另外,IimoDB也支持通过特定的语句进行显式锁定,这些语句不属于SQL规范

SELECT ... LOCK IN SHARE MODE

SELECT ... FOR UPDATE

​ 经常可以发现,应用已经将表从MylSAM转换到InnoDB,但还是显式地使用LOCK TABLES语句。这不但没有必要,还会严重影响性能,实际上InnoDB的行级锁工作得更好

​ LOCK TABLES和事务之间相互影响的话,情况会变得非常复杂,在某些MySQL版本中甚至会产生无法预料的结果。因此,本书建议,除了事务中禁用了 AUTOCOMMIT, 可以使用LOCK TABLES之外,其他任何时候都不要显式地执行LOCK TABLES,不管使用的是什么存储引擎。

1.4 多版本并发控制

​ MySQL的大多数事务型存储引擎实现的都不是简单的行级锁。

​ 基于提升并发性能的考虑,它们一般都同时实现了多版本并发控制(MVCC)

​ 可以认为MVCC是行级锁的一个变种,但是它在很多情况下避免了加锁操作,因此开销更低。虽然实现机制有所不同,但大都实现了非阻塞的读操作,写操作也只锁定必要的行。

​ MVCC的实现主要依赖于数据库在每个表中添加的三个隐藏字段以及事务在查询时创建的快照(read view)和数据库的数据版本链(Undo log)。这里先介绍这三个部分的作用,然后再介绍它们是如何联合作战进行非阻塞的实现RC和RR隔离级别。

MVCC的重要特性:

  • MVCC只支持RC(读取已提交)和RR(可重复读)隔离级别。
  • MVCC能解决脏读、不可重复读问题,不能解决丢失更新问题和幻读问题。
  • MVCC是用来解决读写操作之间的阻塞问题。使得在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能。

1.4.1 MVCC的实现依赖

1 三个隐藏字段

InnoDB会为每个使用InnoDB存储引擎的表添加三个隐藏字段,用于实现数据多版本以及聚集索引,他们的作用如下:

  • DB_TRX_ID(6字节): 它是最近一次更新或者插入或者删除该行数据的事务ID
    • 若是删除,则该行有一个删除位更新为已删除。但并不是真正的进行物理删除,当InnoDB丢弃为删除而编写的更新撤消日志记录时,它才会物理删除相应的行及其索引记录。
    • 此删除操作称为清除,速度非常快
  • DB_ROLL_PTR(7字节)回滚指针,指向当前记录行的undo log信息(指向该数据的前一个版本数据)
  • DB_ROW_ID(6字节): 随着新行插入而单调递增的行ID。
    • InnoDB使用聚集索引,数据存储是以聚集索引字段的大小顺序进行存储的,当表没有主键或唯一非空索引时,innodb就会使用这个行ID自动产生聚簇索引。
    • 如果表有主键或唯一非空索引,聚簇索引就不会包含这个行ID了。
    • 这个DB_ROW_ID跟MVCC关系不大。
2 Read View
  • read view 是读视图,其实就相当于一种快照,里面记录了系统中当前活跃事务的ID以及相关信息
  • 主要用途:用于做可见性判断,判断当前事务是否有资格访问该行数据
  • 关键变量
    • trx_ids: 存储了活跃事务列表,也就是Read View开始创建时其他未提交的活跃事务的ID列表。例如事务A在创建read view(快照)时,数据库中事务B和事务C还没提交或者回滚结束事务,此时trx_ids就会将事务B和事务C的事务ID记录下来。
    • low_limit_id: 目前出现过的最大的事务ID+1,即下一个将被分配的事务ID。
    • up_limit_id: 活跃事务列表trx_ids中最小的事务ID,如果trx_ids为空,则up_limit_id 为 low_limit_id,虽然该字段名为up_limit,但在trx_ids中的活跃事务号是降序的,所以最后一个为最小活跃事务ID。
    • creator_trx_id: 当前创建read view的事务的ID。
3 Undo log
  • Undo log中存储的是老版本数据
  • 当一个事物需要读取记录行时,若当前记录行不可见,则可以通过回滚指针顺着undo log链找到满足其可见性条件的记录行版本

在InnoDB里,undo log分为如下两类:

  • insert undo log :
  • 事务对insert新记录时产生的undo log, 只在事务回滚时需要, 并且在事务提交后就可以立即丢弃。
  • update undo log :
  • 事务对记录进行delete和update操作时产生的undo log,不仅在事务回滚时需要,快照读也需要,只有当数据库所使用的快照中不涉及该日志记录,对应的回滚日志才会被purge线程删除 。
    • Purge线程:上文提到了InnoDB删除一个行记录时,并不是立刻物理删除,而是将该行数据的DB_TRX_ID字段更新为做删除操作的事务ID,并将删除位deleted_bit设置为true(已删除),将其放入update undo log中。为了节省磁盘空间,InnoDB有专门的purge线程来清理deleted_bit为true的记录。purge线程自己也维护了一个read view,如果某个记录的deleted_bit为true,并且DB_TRX_ID相对于purge线程的read view可见,那么这条记录一定是可以被安全清除的。

1.4.2 MVCC更新操作的实现原理

快照读:使用普通的select 语句进行查询时会生成快照,进行快照读,快照读不会上锁,根据可见性判断,来决定是读取该行记录的最新版本还是旧版本。(只有使用普通的select语句进行查询才会用到快照读,才享受到了MVCC机制的读写非阻塞的优越性)

MVCC机制下实现更新还是会用到排它锁,但由于我们读的时候可以通过快照读,读多个版本避免使用共享锁,因此可以使得读事务不会因为写事务阻塞。

MVCC的优越性在于事务需要读行记录的时候不会因为有事务在更新该行记录而阻塞,事务在写行记录时也不会因为有事务在读数据而阻塞。

更新原理: 假设我现在需要修改行记录A,他们的修改过程如下:

  1. MVCC更新行记录A时会先用排他锁锁住该行记录A;
  2. 然后将该行记录复制到update undo log中,生成旧版本行记录B;
  3. 使行记录A的回滚指针指向这条旧版本B,再在行记录A中修改 用户需要修改的字段,并将DB_TRX_ID字段更新为更新这条记录的事务ID;
  4. 最后提交事务。(用户需要修改的字段指的是业务字段,比如我们要修改name等)

通过回滚指针,形成了一条当前行记录指向历代旧版本行记录的链表,通过这条链表,我们就可以查询该行记录的多个旧版本

1.4.3 MVCC查询操作的实现原理

当前读:使用select … lock in share mode,select … for update,insert,update,delete 语句等语句进行查询或者更新时,会使用相应的锁进行锁定,查询到的肯定数据库中该行记录的最新版本。

InnoDB中,事务在第一次进行普通的select查询时,会创建一个read view(快照),用于可见性判断,事务只能查询到行记录对于事务来说可见的数据版本。

可见性判断是通过行记录的DB_TRX_ID(最近一次插入/更新/删除该行记录的事务ID)以及read view中的变量比较来判断。

查询过程如下:

  1. 如果 DB_TRX_ID< up_limit_id,则表明这个行记录最近一次更新在当前事务创建快照之前就已经提交了,该记录行的值对当前事务是可见的,当前事务可以访问该行记录,跳到步骤6。
  2. 如果DB_TRX_ID>=low_limit_id,则表明这个行记录最近一次更新是快照创建之后才创建的事务完成的,该记录行的值对当前事务是不可见的,当前事务不可以访问该行记录。因此当前事务只能访问比该行记录更旧的数据版本。通过该记录行的 DB_ROLL_PTR 指针,找到更旧一版的行记录,取出更旧一版的行记录的事务号DB_TRX_ID,然后跳到步骤(1)重新判断当前事务是否有资格访问该行记录。
  3. 如果up_limit_id<=DB_TRX_ID< low_limit_id,则表明对这个行记录最近一次更新的事务可能是活跃列表中的事务也可能是已经成功提交的事务(事务ID号大的事务可能会比ID号小的事务先进行提交),比如说初始时有5个事务在并发执行,事务ID分别是1001~1005,1004事务完成提交,1001事务进行普通select的时候创建的快照中活跃事务列表就是1002、1003、1005。因此up_limit_id就是1002, low_limit_id就是1006。对于这种情况,我们需要在活跃事务列表中进行遍历(因为活跃事务列表中的事务ID是有序的,因此用二分查找),确定DB_TRX_ID是否在活跃事务列表中。
  4. 若不在,说明对这个行记录最近一次更新的事务是在创建快照之前提交的事务,此行记录对当前事务是可见的,也就是说当前事务有资格访问此行记录,跳到步骤6。
  5. 若在,说明对这个行记录最近一次更新的事务是当前活跃事务,在快照创建过程中或者之后完成的数据更新,此行记录对当前事务是不可见的(若可见则会造成脏读、不可重复读等问题)。因此当前事务只能访问该行记录的更旧的版本数据。通过该记录行的 DB_ROLL_PTR 指针,找到更旧一版的行记录,取出更旧一版的行记录的事务号DB_TRX_ID,然后跳到步骤1重新判断当前事务是否有资格访问该行记录。
  6. 可以访问,将该行记录的值返回。

1.4.4 MVVC实现RC和RR

MVCC对两个隔离级别实现的差异在其产生的read view次数不同

**RC:**读已提交隔离级别

  • 避免了脏读
  • 存在不可重复读、幻读问题
  • MVCC对该级别的实现就是每次进行普通的select查询,都会产生一个新的快照(不同时间,当前活跃的事务不同,行记录最近一次更新的事务ID也可能不同)。就相当于二级锁协议,进行读操作需要加读锁,读完就释放锁。
  • 虽然并发性更好、避免脏读,但是存在不可重复读

**RR:**可重复读隔离级别

  • 避免了脏读、不可重复读
  • 存在幻读问题
  • MVCC对该级别的实现就是在当前事务中只有第一次进行普通的select查询才会产生快照,此后这个事务一直使用这一个快照进行快照查,相当于三级锁协议,进行读操作需要加读锁,事务结束才释放。
  • 禁止幻读可以通过Next-Key Locks算法的间隙锁和记录锁实现

免了脏读

  • 存在不可重复读、幻读问题
  • MVCC对该级别的实现就是每次进行普通的select查询,都会产生一个新的快照(不同时间,当前活跃的事务不同,行记录最近一次更新的事务ID也可能不同)。就相当于二级锁协议,进行读操作需要加读锁,读完就释放锁。
  • 虽然并发性更好、避免脏读,但是存在不可重复读

**RR:**可重复读隔离级别

  • 避免了脏读、不可重复读
  • 存在幻读问题
  • MVCC对该级别的实现就是在当前事务中只有第一次进行普通的select查询才会产生快照,此后这个事务一直使用这一个快照进行快照查,相当于三级锁协议,进行读操作需要加读锁,事务结束才释放。
  • 禁止幻读可以通过Next-Key Locks算法的间隙锁和记录锁实现

以上是关于mysql事务与多版本并发控制的主要内容,如果未能解决你的问题,请参考以下文章

oracle 并发与多版本

MySQL-InnoDB-MVCC多版本并发控制

《数据库系统概念》19-并发控制

mysql 多版本并发控制

MySQL 进阶 InnoDB引擎 -- 逻辑存储结构架构(内存结构磁盘结构后台线程)事务原理(事务基础redo logundo logMVCC多版本并发控制:版本链 ReadView)

《高性能MySQL》读书笔记之 MySQL锁事务多版本并发控制的基础知识