浅谈MySQL并发控制:隔离级别、锁与MVCC

前言

若是数据库中的事务都是串行执行的,这种方式能够保障事务的执行不会出现异常和错误,但带来的问题是串行执行会带来性能瓶颈;而事务并发执行,若是不加以控制则会引起诸多问题,包括死锁、更新丢失等等。这就须要咱们在性能和安全之间作出合理的权衡,使用适当的并发控制机制保障并发事务的执行。mysql

并发事务带来的问题

首先咱们先来了解一下并发事务会带来哪些问题。并发事务访问相同记录大体可概括为如下3种状况:web

  • 读-读:即并发事务相继读取同一记录;
  • 写-写:即并发事务相继对同一记录作出修改;
  • 写-读读-写:即两个并发事务对同一记录分别进行读操做和写操做。
读-读

由于读取记录并不会对记录形成任何影响,因此同个事务并发读取同一记录也就不存在任何安全问题,因此容许这种操做。算法

写-写

若是容许并发事务都读取同一记录,并相继基于旧值对这一记录作出修改,那么就会出现前一个事务所作的修改被后面事务的修改覆盖,即出现提交覆盖的问题。sql

另一种状况,并发事务相继对同一记录作出修改,其中一个事务提交以后以后另外一个事务发生回滚,这样就会出现已提交的修改由于回滚而丢失的问题,即回滚覆盖问题。数据库

这两种问题都形成丢失更新,其中回滚覆盖称为第一类丢失更新问题,提交覆盖称为第二类丢失更新问题。segmentfault

写-读读-写

这种状况较为复杂,也最容易出现问题。安全

若是一个事务读取了另外一个事务还没有提交的修改记录,那么就出现了脏读的问题;ruby

若是咱们加以控制使得一个事务只能读取其余已提交事务的修改的数据,那么这个事务在另外一事物提交修改先后读取到的数据是不同的,这就意味着发生了不可重复读数据结构

若是一个事务根据一些条件查询到一些记录,以后另外一事物向表中插入了一些记录,原先的事务以相同条件再次查询时发现获得的结果跟第一次查询获得的结果不一致,这就意味着发生了幻读并发

事务的隔离级别

对于以上提到的并发事务执行过程当中可能出现的问题,其严重性也是不同的,咱们能够按照问题的严重程度排个序:

丢失更新 > 脏读 > 不可重复读 > 幻读
复制代码

所以若是咱们能够容忍一些严重程度较轻的问题,咱们就能获取一些性能上的提高。因而便有了事务的四种隔离级别:

  • 读未提交(Read Uncommitted):容许读取未提交的记录,会发生脏读、不可重复读、幻读;
  • 读已提交(Read Committed):只容许读物已提交的记录,不会发生脏读,但会出现重复读、幻读;
  • 可重复读(Repeatable Read):不会发生脏读和不可重复读的问题,但会发生幻读问题;但MySQL在此隔离级别下利用MVCC或者间隙锁能够禁止幻读问题的发生;
  • 可串行化(Serializable):即事务串行执行,以上各类问题天然也就都不会发生。

值得注意的是以上四种隔离级别都不会出现回滚覆盖的问题,可是提交覆盖的问题对于MySQL来讲,在Read UncommittedRead Committed以及Repeatable Read这三种隔离级别下都会发生(标准的Repeatable Read隔离级别不容许出现提交覆盖的问题),须要额外加锁来避免此问题。

隔离级别的实现

SQL规范定义了以上四种隔离级别,可是并无给出如何实现四种隔离级别,所以不一样数据库的实现方式和使用方式也并不相同。而SQL隔离级别的标准是依据基于锁的实现方式来制定的,由于有必要先了解一下传统的基于锁的隔离级别是如何实现的。

传统隔离级别的实现

既然说到传统的隔离级别是基于锁实现的,咱们先来了解一下锁。

传统的锁有两种:

  • 共享锁(Shared Locks):简称S锁,事务对一条记录进行读操做时,须要先获取该记录的共享锁。
  • 排他锁(Exclusive Locks):简称X锁,事务对一条记录进行写操做时,须要先获取该记录的排他锁。

须要注意的是,加了共享锁的记录,其余事务也能够得到该记录的共享锁,可是没法获取该记录的排他锁,即S锁S锁是兼容的,S锁X锁是不兼容的;而加了排他锁的记录,其余事务既没法获取该记录的共享锁也没法获取排他锁,即X锁X锁也是不兼容的。

另外,刚刚说到事务对一条记录进行读操做时,须要先获取该记录的S锁,但有时事务在读取记录时须要阻止其余事务访问该记录,这时就须要获取该记录的X锁。以MySQL为例,有如下两种锁定读的方式:

  • 读取时对记录加S锁
SELECT ... LOCK IN SHARE MODE;
复制代码

若是事务执行了该语句,则会在读取的记录上加S锁,这样就容许其余事务也能获取到该记录的S锁;而若是其余事务须要获取该记录的X锁,那么就须要等待当前事务提交后释放掉S锁

  • 读取时对记录加X锁
SELECT ... FOR UPDATE;
复制代码

若是事务执行了该语句,则会在读取的记录上加X锁,这样其余事务想要说去该记录的S锁X锁,那么须要等待当前事务提交后释放掉X锁

对于锁的粒度而言,锁又能够分为两种:

  • 行锁:只锁住某一行记录,其余行的记录不受影响。
  • 表锁:锁住整个表,全部对于该表的操做都会受影响。
基于锁实现隔离级别

在基于锁的实现方式下,四种隔离级别的区别就在于加锁方式的区别:

  • 读未提交:读操做不加锁,读读,读写,写读并行;写操做加X锁且直到事务提交后才释放。
  • 读已提交:读操做加S锁,写操做加X锁且直到事务提交后才释放;读操做不会阻塞其余事务读或写,写操做会阻塞其余事务写和读,所以能够防止脏读问题。
  • 可重复读:读操做加S锁且直到事务提交后才释放,写操做加X锁且直到事务提交后才释放;读操做不会阻塞其余事务读但会阻塞其余事务写,写操做会阻塞其余事务读和写,所以能够防止脏读、不可重复读。
  • 串行化:读操做和写操做都加X锁且直到事务提交后才释放,粒度为表锁,也就是严格串行。

这里面有一些细节值得注意:

  • 若是锁获取以后直到事务提交后才释放,这种锁称为长锁;若是锁在操做完成以后就被释放,这种锁称为短锁。例如,在读已提交隔离级别下,读操做所加S锁为短锁,写操做所加X锁为长锁。
  • 对于可重复读和串行化隔离级别,读操做所加S锁和写操做所加X锁均为长锁,即事务获取锁以后直到事务提交后才能释放,这种把获取锁和释放锁分为两个不一样的阶段的协议称为两阶段锁协议(2-phase locking)。两阶段锁协议规定在加锁阶段,一个事务能够得到锁可是不能释放锁;而在解锁阶段事务只能够释放锁,并不能得到新的锁。两阶段锁协议可以保证事务串行化执行,解决事务并发问题,但也会致使死锁发生的几率大大提高。

MySQL隔离级别的实现

不一样数据库对于SQL标准中规定的隔离级别支持是不同的,数据库引擎实现隔离级别的方式虽然都在尽量地贴近标准的隔离级别规范,但和标准的预期仍是有些不同的地方。

MySQLInnoDB)支持的4种隔离级别,与标准的各级隔离级别容许出现的问题有些出入,好比MySQL在可重复读隔离级别下能够防止幻读的问题出现,但也会出现提交覆盖的问题。

相对于传统隔离级别基于锁的实现方式,MySQL 是经过MVCC(多版本并发控制)来实现读-写并发控制,又是经过两阶段锁来实现写-写并发控制的。MVCC是一种无锁方案,用以解决事务读-写并发的问题,可以极大提高读-写并发操做的性能。

MVCC的实现原理

为了方便描述,首先咱们建立一个表book,就三个字段,分别是主键book_id, 名称book_name, 库存stock。而后向表中插入一些数据:

INSERT INTO book VALUES(1'数据结构'100);
INSERT INTO book VALUES(2'C++指南'100);
INSERT INTO book VALUES(3'精通Java'100);
复制代码
版本链

对于使用InnoDB存储引擎的表,其聚簇索引记录中包含了两个重要的隐藏列:

  • 事务ID(DB_TRX_ID):每当事务对聚簇索引中的记录进行修改时,都会把当前事务的事务id记录到DB_TRX_ID中。
  • 回滚指针(DB_ROLL_PTR):每当事务对聚簇索引中的记录进行修改时,都会把该记录的旧版本记录到undo日志中,经过DB_ROLL_PTR这个指针能够用来获取该记录旧版本的信息。

若是在一个事务中屡次对记录进行修改,则每次修改都会生成undo日志,而且这些undo日志经过DB_ROLL_PTR指针串联成一个版本链,版本链的头结点是该记录最新的值,尾结点是事务开始时的初始值。

例如,咱们在表book中作如下修改:

BEGIN;

UPDATE book SET stock = 200 WHERE id = 1;

UPDATE book SET stock = 300 WHERE id = 1;
复制代码

那么id=1的记录此时的版本链就以下图所示:

ReadView

对于使用Read Uncommitted隔离级别的事务来讲,只须要读取版本链上最新版本的记录便可;对于使用Serializable隔离级别的事务来讲,InnoDB使用加锁的方式来访问记录。而Read CommittedRepeatable Read隔离级别来讲,都须要读取已经提交的事务所修改的记录,也就是说若是版本链中某个版本的修改没有提交,那么该版本的记录时不能被读取的。因此须要肯定在Read CommittedRepeatable Read隔离级别下,版本链中哪一个版本是能被当前事务读取的。因而ReadView的概念被提出以解决这个问题。

ReadView至关于某个时刻表记录的一个快照,在这个快照中咱们能获取到与当前记录相关的事务中,哪些事务是已提交的稳定事务,哪些是正在活跃的事务,哪些是生成快照以后才开启的事务。由此咱们就能根据可见性比较算法判断出版本链中能被读取的最新版本记录。

可见性比较算法是基于事务ID的比较算法。首先咱们须要知道的一个事实是:事务id是递增分配的。从ReadView中咱们能获取到生成快照时刻系统中活跃的事务中最小和最大的事务id(最大的事务id其实是系统中将要分配给下一个事务的id值),这样咱们就获得了一个活跃事务id的范围,咱们可称之为ACTIVE_TRX_ID_RANGE。那么小于这个范围的事务id对应的事务都是已提交的稳定事务,大于这个范围的事务都是在快照生成以后才开启的事务,而在ACTIVE_TRX_ID_RANGE范围内的事务中除了正在活跃的事务,也都是已提交的稳定事务。

有了以上信息以后,咱们顺着版本链从头结点开始查找最新的可被读取的版本记录:

一、首先判断版本记录的DB_TRX_ID字段与生成ReadView的事务对应的事务ID是否相等。若是相等,那就说明该版本的记录是在当前事务中生成的,天然也就可以被当前事务读取;不然进行第2步。

二、若是版本记录的DB_TRX_ID字段小于范围ACTIVE_TRX_ID_RANGE,代表该版本记录是已提交事务修改的记录,即对当前事务可见;不然进行下一步。

三、若是版本记录的DB_TRX_ID字段位于范围ACTIVE_TRX_ID_RANGE内,若是该事务ID对应的不是活跃事务,代表该版本记录是已提交事务修改的记录,即对当前事务可见;若是该事务ID对应的是活跃事务,那么对当前事务不可见,则读取版本链中下一个版本记录,重复以上步骤,直到找到对当前事务可见的版本。

若是某个版本记录通过以上步骤判断肯定其对当前事务可见,则查询结果返回此版本记录;不然读取下一个版本记录继续按照上述步骤进行判断,直到版本链的尾结点。若是遍历完版本链没有找到对当前事务可见的版本,则查询结果为空。

MySQL中,Read CommittedRepeatable Read隔离级别下的区别就是它们生成ReadView的时机不一样。

MVCC实现不一样隔离级别

以前说到ReadView的机制只在Read CommittedRepeatable Read隔离级别下生效,因此只有这两种隔离级别才有MVCC
Read Committed隔离级别下,每次读取数据时都会生成ReadView;而在Repeatable Read隔离级别下只会在事务首次读取数据时生成ReadView,以后的读操做都会沿用此ReadView

下面咱们经过例子来看看Read CommittedRepeatable Read隔离级别下MVCC的不一样表现。咱们继续以表book为例进行演示。

Read Committed隔离级别分析

假设在Read Committed隔离级别下,有以下事务在执行,事务id为10:

BEGIN; // 开启Transaction 10

UPDATE book SET stock = 200 WHERE id = 2;

UPDATE book SET stock = 300 WHERE id = 2;
复制代码

此时该事务还没有提交,id为2的记录版本链以下图所示:

而后咱们开启一个事务对id为2的记录进行查询:

BEGIN;

SELECT * FROM book WHERE id = 2;
复制代码

当执行SELECT语句时会生成一个ReadView,该ReadView中的ACTIVE_TRX_ID_RANGE[10, 11),当前事务IDcreator_trx_id0(由于事务中当执行写操做时才会分配一个单独的事务id,不然事务id0)。按照咱们以前所述ReadView的工做原理,咱们查询到的版本记录为

+----------+-----------+-------+
| book_id  | book_name | stock |
+----------+-----------+-------+
| 2        | C++指南    |  100  |
+----------+-----------+-------+
复制代码

而后咱们将事务id为10的事务提交:

BEGIN; // 开启Transaction 10

UPDATE book SET stock = 200 WHERE id = 2;

UPDATE book SET stock = 300 WHERE id = 2;

COMMIT;
复制代码

同时开启执行另外一事务id11的事务,但不提交:

BEGIN; // 开启Transaction 11

UPDATE book SET stock = 400 WHERE id = 2;
复制代码

此时id为2的记录版本链以下图所示:

而后咱们回到刚才的查询事务中再次查询id为2的记录:

BEGIN;

SELECT * FROM book WHERE id = 2; // 此时Transaction 10 未提交

SELECT * FROM book WHERE id = 2; // 此时Transaction 10 已提交
复制代码

当第二次执行SELECT语句时会再次生成一个ReadView,该ReadView中的ACTIVE_TRX_ID_RANGE[11, 12),当前事务IDcreator_trx_id依然为0。按照ReadView的工做原理进行分析,咱们查询到的版本记录为

+----------+-----------+-------+
| book_id  | book_name | stock |
+----------+-----------+-------+
| 2        | C++指南    | 300   |
+----------+-----------+-------+
复制代码

从上述分析能够发现,由于每次执行查询语句都会生成新的ReadView,因此在Read Committed隔离级别下的事务读取到的是查询时刻表中已提交事务修改以后的数据。

Repeatable Read隔离级别分析

咱们在Repeatable Read隔离级别下重复上面的事务操做:

BEGIN; // 开启Transaction 20

UPDATE book SET stock = 200 WHERE id = 2;

UPDATE book SET stock = 300 WHERE id = 2;
复制代码

此时该事务还没有提交,而后咱们开启一个事务对id为2的记录进行查询:

BEGIN;

SELECT * FROM book WHERE id = 2;
复制代码

当事务第一次执行SELECT语句时会生成一个ReadView,该ReadView中的ACTIVE_TRX_ID_RANGE[10, 11),当前事务IDcreator_trx_id0。根据ReadView的工做原理,咱们查询到的版本记录为

+----------+-----------+-------+
| book_id  | book_name | stock |
+----------+-----------+-------+
| 2        | C++指南    |  100  |
+----------+-----------+-------+
复制代码

而后咱们将事务id为20的事务提交:

BEGIN; // 开启Transaction 20

UPDATE book SET stock = 200 WHERE id = 2;

UPDATE book SET stock = 300 WHERE id = 2;

COMMIT;
复制代码

同时开启执行另外一事务id为21的事务,但不提交:

BEGIN; // 开启Transaction 21

UPDATE book SET stock = 400 WHERE id = 2;
复制代码

而后咱们回到刚才的查询事务中再次查询id为2的记录:

BEGIN;

SELECT * FROM book WHERE id = 2; // 此时Transaction 10 未提交

SELECT * FROM book WHERE id = 2; // 此时Transaction 10 已提交
复制代码

当第二次执行SELECT语句时不会生成新的ReadView,依然会使用第一次查询时生成ReadView。所以咱们查询到的版本记录跟第一次查询到的结果是同样的:

+----------+-----------+-------+
| book_id  | book_name | stock |
+----------+-----------+-------+
| 2        | C++指南    |  100  |
+----------+-----------+-------+
复制代码

从上述分析能够发现,由于在Repeatable Read隔离级别下的事务只会在第一次执行查询时生成ReadView,该事务中后续的查询操做都会沿用这个ReadView,所以此隔离级别下一个事务中屡次执行一样的查询,其结果都是同样的,这样就实现了可重复读。

快照读和当前读
快照读

Read CommittedRepeatable Read隔离级别下,普通的SELECT查询都是读取MVCC版本链中的一个版本,至关于读取一个快照,所以称为快照读。这种读取方式不会加锁,所以读操做时非阻塞的,所以也叫非阻塞读

在标准的Repeatable Read隔离级别下读操做会加S锁,直到事务结束,所以能够阻止其余事务的写操做;但在MySQLRepeatable Read隔离级别下读操做没有加锁,不会阻止其余事务对相同记录的写操做,所以在后续进行写操做时就有可能写入基于版本链中的旧数据计算获得的结果,这就致使了提交覆盖的问题。想要避免此问题,就须要另外加锁来实现。

当前读

以前提到MySQL有两种锁定读的方式:

SELECT ... LOCK IN SHARE MODE; // 读取时对记录加S锁,直到事务结束

SELECT ... FOR UPDATE; // 读取时对记录加X锁,直到事务结束
复制代码

这种读取方式读取的是记录的当前最新版本,称为当前读。另外对于DELETEUPDATE操做,也是须要先读取记录,获取记录的X锁,这个过程也是一个当前读。因为须要对记录进行加锁,会阻塞其余事务的写操做,所以也叫加锁读阻塞读

当前读不只会对当前记录加行记录锁,还会对查询范围空间的数据加间隙锁GAP LOCK),所以能够阻止幻读问题的出现。

总结

本文介绍了事务的多种并发问题,以及用以免不一样程度问题的隔离级别,并较为详细描述了传统隔离级别的实现方式以及MySQL隔离级别的实现方式。但数据库的并发机制较为复杂,本文也只是作了大体的描述和介绍,不少细节还须要读者本身查询相关资料进行更细致的了解。

参考资料

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

二、MySQL 是怎样运行的:从根儿上理解 MySQL

相关文章
相关标签/搜索