上篇主要介绍了一下事务ACID四大特性,其中特别介绍了隔离性中的隔离级别,本文就如何实现读-提交,可重复读,串行化这3种隔离级别展开学习。
事务隔离的实现
在上篇文章中,在讨论隔离级别的时候,画了个时序图来理解,我们把图再放到这儿,来深入讨论一下事务隔离的实现
如图所示,两个事务的执行过程为:
- T1时刻事务A和B同时启动
- T2时刻事务A和B都查询到v = 1
- T3时刻事务B将v改成2
- T4时刻事务B还未提交,事务A就去获取v的值
- T5时刻事务B提交事务
- T6时刻事务A再次获取v的值
- T7时刻事务A提交
- T8时刻事务A再次获取A的值
在不同的隔离级别下,事务A获取到V的值为:
- 读-未提交:事务A在T4,T6,T8时刻获取到v的值为2。
- 读-提交:事务A在T4时刻查询v的值为1,T6,T8时刻为2。
- 可重复读:事务A在T4,T6时刻查询v的值为1,T8时刻为2。
- 串行化:事务A在T4,T6时刻查询v的值为1,T8时刻为2。
一般来说,在实现上,可重复读和读提交都会创建一个视图来实现,这种视图,还有一种别名,叫做一致性读视图(consistent read view)。其中读提交,这个视图是在每个sql语句执行的时候创建的,而可重复读,这个视图是在事务启动的时候创建的。
读-未提交,则没有视图的概念,查询的时候直接返回记录上的最新值。
串行化,主要实现方式就是通过两阶段加锁。
MVCC
什么是MVCC
首先来介绍一下可重复读和读提交的实现,我们以MySQL为例,MySQL就是通过MVCC的方式来实现隔离级别的。
MVCC,全称是Multi-Version Concurrency Control,即多版本并发控制。MVCC是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。主要用于实现读-提交和可重复读两种隔离级别。
一致性读视图
之前我们说过,可重复读和读提交都会创建一个一致性读视图(consistent read view)来实现,那么这个视图是什么,有什么用。
在 MySQL 里,有两个“视图”的概念:
- 一个是 view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view … ,而它的查询方法与表一样。
- 另一个是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。
begin/start transaction,一致性视图是在执行第一个快照读语句时创建的;
start transaction with consistent snapshot,一致性视图是在执行 start transaction with consistent snapshot 时创建的。
consistent read view就是在某一时刻给事务系统trx_sys打snapshot(快照),这个snapshot是基于整个数据库的。inndb里每个事务都有一个唯一的id,叫做trx_id,严格递增,而每行数据都有多个版本,每次更新都会产生一个版本,也都会有对应的一个trx_id,记为row trx_id。
如图所示,当前最新的版本是V4,v的值是8对应的row trx_id为41,u1,u2,u3就是undo log,另外v1,v2,v3这三个版本并不是真实物理存在的,是通过当前最新版本和undo log计算得来的,比如,需要V2的时候,就是通过V4依次执行u3、u2算出来。
事务开启后,所有读操作根据其trx_id与snapshot中的trx_sys的状态作比较,trx_id用于决定它可以看见哪些对象,看不见哪些对象。这种可见性的规则如下:
- 在每次事务开始时,数据库列出当时所有其他(尚未提交或尚未中止)的事务清单,即使之后提交了,这些事务已执行的任何写入也都会被忽略。
- 被中止事务所执行的任何写入都将被忽略。
- 由具有较晚trx_id(即,在当前事务开始之后开始的)的事务所做的任何写入都被忽略,而不管这些事务是否已经提交。
- 所有其他写入,对应用都是可见的。
因此,一个事务只要启动了,就以它启动的时刻为准,如果一个数据版本在它启动之前生成的,那么对它来说就是可见的,如果是在它之后生成的,那么对它来说就是不可见的,就需要找个上一个版本,如果上一个版本也是不可见的,那就继续根据undo log向上寻找,直到找到可见的版本。另外,在这个事务内的更新也是可见的。
事实上,inndb在每个事务启动的瞬间,都会创建一个数组(rw_trx_ids),用来保存当前所有活跃(启动了但是还没有提交)的事务ID
数组里面事务 ID 的最小值记为低水位(up_limit_id: low water mark),当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位(low_limit_id: high water mark)。
rw_trx_ids => [low water mark,high water mark),就组成一个当前事务的一致性视图(read-view),这样,对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:
- trx_id < low water mark:一定是可见的。
- trx_id >= high water mark:一定是不可见的。
- 另外一个判断,有点特殊,得先判断当前的trx_id在不在数组内。如果在,则代表我Read View生成时刻,你这个事务还在活跃,还没有Commit,你修改的数据,我当前事务也是看不见的;如果不在,则说明,你这个事务在Read View生成之前就已经Commit了,你修改的结果,我当前事务是能看见的。
当前读和快照读
如果想要了解MVCC的全流程,除了一致性读视图之外,还有两个概念需要了解,那就是当前读和快照读
- 当前读
select lock in share mode(共享锁), select for update ; update, insert ,delete(排他锁)这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。
更新数据都是先读后写的,而这个读,就是当前读(current read)。
- 快照读
不加锁的select操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本
实战
我们以一个案例来实战一下,分析事务A和事务B最后查出来v的值是多少。
| 事务A | 事务B | 事务C |
|---|---|---|
start transaction with consistent snapshot | ||
start transaction with consistent snapshot | ||
update t set v = v + 1 where id = 1 | ||
update t set v = v + 1 where id = 1; select v from t where id = 1 | ||
select v from t where id = 1 | ||
commit | ||
commit |
这里,我们做以下假设:
- 都是在MySQL上运行的,v的初始值为1,这个数据版本的trx_id为90。
- 如无特殊说明,MySQL的隔离级别就是可重复读,并且是自动提交事务。
- 事务A开始之前只有一个活跃事务,trx_id为99,事务A,事务B,事务C的trx_id分别为100,101,102。并且没有其他活跃事务
另外,还需要说明的是,begin/start transaction并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用start transaction with consistent snapshot这个命令。
事务A,事务B,事务C启动的瞬间,创建的事务数组分别为
- 事务A:[99,100]
- 事务B:[99,100,101]
- 事务C:[99,100,101,102]
事务C是第一个执行更新操作的,把V改成了2,这个数据最新版本的trx_id = 102,90这个版本已经成为历史版本。
事务B是第二个执行更新操作的,另外,事务C已经提交。事务B首先先执行了更新操作再读取数据,更新的时候是根据当前读来更新,当前读出来的结果是2,所以执行update完,v的结果从2变成了3,这个数据最新版本的trx_id = 101,此时102就变为了历史版本。当事务B开始执行查询的操作,和更新操作是在同一个事务内,并且更新操作先于查询操作,所以事务B的更新操作,对事务B的查询操作来说是可见的,所以查询到的值为3。
事务A开始查询数据了,此时事务B还没有提交事务,但它的更新操作,已经使v = 3这个数据版本变成了当前版本,但这个版本对事务A来说肯定是不可见的,否则就会引起脏读。
事务A的rw_trx_ids为[99, 100],找到v = 3这个版本的trx_id = 101,比高水位大,所以不可见。
继续寻找,找到了v = 2这个版本的trx_id = 102,比高水位大,所以不可见。
继续寻找,找到了v = 1这个版本的trx_id = 90,比低水位低,可见。
所以对于事务A的查询操作,最后查询到v的值为1。
上面的分析都是基于可重复读隔离级别下,如果事务的隔离级别换成读提交会有什么样的情况呢?这里需要说明一下,“start transaction with consistent snapshot; ”的意思是从这个语句开始,创建一个持续整个事务的一致性快照。所以,在读提交隔离级别下,这个用法就没意义了,等效于普通的 start transaction。另外,在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。
事务C会先生成一个视图,然后更新v,生成一个新的数据版本,trx_id = 100,90这个版本已经成为历史版本。
事务B生成视图之前,事务C已经提交,所以事务C的更新操作对事务B来说是可见的,生成一个新的版本trx_id = 101,又因为事务B的查询操作在事务B的更新操作之后,所以事务B查到值为3。
事务A在生成事务之前,事务B还没有提交,但是事务C已经提交,所以事务C的更新操作对事务A来说是可见的,但是事务B的更新操作则是不可见的,所以事务A查到值为2。
小结
一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:
- 版本未提交,不可见;
- 版本已提交,但是是在视图创建后提交的,不可见;
- 版本已提交,而且是在视图创建前提交的,可见。
所以可不可见,主要还是要看事务提交时机和视图创建的时机。
两阶段锁定
两阶段锁定,是数据库事务处理时的并发控制方法,以保证可串行化。
两阶段锁定类似,但是锁的要求更强得多。只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要 独占访问(exclusive access) 权限:
- 如果事务 A 读取了一个对象,并且事务 B 想要写入该对象,那么 B 必须等到 A 提交或中止才能继续(这确保 B 不能在 A 底下意外地改变对象)。
- 如果事务 A 写入了一个对象,并且事务 B 想要读取该对象,则 B 必须等到 A 提交或中止才能继续。
在 2PL 中,写入不仅会阻塞其他写入,也会阻塞读,反之亦然。快照隔离使得 读不阻塞写,写也不阻塞读,这是 2PL 和快照隔离之间的关键区别。另一方面,因为 2PL 提供了可串行化的性质,它可以防止早先讨论的所有竞争条件,包括丢失更新和写入偏差。
实现两阶段锁
2PL 用于 MySQL(InnoDB)和 SQL Server 中的可串行化隔离级别,以及 DB2 中的可重复读隔离级别【23,36】。
读与写的阻塞是通过为数据库中每个对象添加锁来实现的。锁可以处于 共享模式(shared mode) 或 独占模式(exclusive mode) 。锁使用如下:
- 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
- 若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。
- 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得独占锁相同。
- 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是 “两阶段” 这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。
由于使用了这么多的锁,因此很可能会发生:事务 A 等待事务 B 释放它的锁,反之亦然。这种情况叫做 死锁(Deadlock) 。数据库会自动检测事务之间的死锁,并中止其中一个,以便另一个继续执行。被中止的事务需要由应用程序重试。
两阶段锁定的性能
两阶段锁定的巨大缺点,以及 70 年代以来没有被所有人使用的原因,是其性能问题。两阶段锁定下的事务吞吐量与查询响应时间要比弱隔离级别下要差得多。
这一部分是由于获取和释放所有这些锁的开销,但更重要的是由于并发性的降低。按照设计,如果两个并发事务试图做任何可能导致竞争条件的事情,那么必须等待另一个完成。
传统的关系数据库不限制事务的持续时间,因为它们是为等待人类输入的交互式应用而设计的。因此,当一个事务需要等待另一个事务时,等待的时长并没有限制。即使你保证所有的事务都很短,如果有多个事务想要访问同一个对象,那么可能会形成一个队列,所以事务可能需要等待几个其他事务才能完成。
因此,运行 2PL 的数据库可能具有相当不稳定的延迟,如果在工作负载中存在争用,那么可能高百分位点处的响应会非常的慢。可能只需要一个缓慢的事务,或者一个访问大量数据并获取许多锁的事务,就能把系统的其他部分拖慢,甚至迫使系统停机。当需要稳健的操作时,这种不稳定性是有问题的。
基于锁实现的读已提交隔离级别可能发生死锁,但在基于 2PL 实现的可串行化隔离级别中,它们会出现的频繁的多(取决于事务的访问模式)。这可能是一个额外的性能问题:当事务由于死锁而被中止并被重试时,它需要从头重做它的工作。如果死锁很频繁,这可能意味着巨大的浪费。
死锁和死锁检测
当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。这里我用数据库中的行锁举个例子。
这时候,事务 A 在等待事务 B 释放 id=2 的行锁,而事务 B 在等待事务 A 释放 id=1 的行锁。 事务 A 和事务 B 在互相等待对方的资源释放,就是进入了死锁状态。当出现死锁以后,
有两种策略:
- 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。
- 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。
谓词锁
具有可串行化隔离级别的数据库是如何避免幻读的?
在会议室预订的例子中,这意味着如果一个事务在某个时间窗口内搜索了一个房间的现有预订,则另一个事务不能同时插入或更新同一时间窗口与同一房间的另一个预订 (可以同时插入其他房间的预订,或在不影响另一个预定的条件下预定同一房间的其他时间段)。
如何实现这一点?从概念上讲,我们需要一个 谓词锁(predicate lock) 。它类似于前面描述的共享 / 排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象,如:
SELECT * FROM bookings
WHERE room_id = 123 AND
end_time > '2018-01-01 12:00' AND
start_time < '2018-01-01 13:00';
谓词锁限制访问,如下所示:
- 如果事务 A 想要读取匹配某些条件的对象,就像在这个
SELECT查询中那样,它必须获取查询条件上的 共享谓词锁(shared-mode predicate lock) 。如果另一个事务 B 持有任何满足这一查询条件对象的排它锁,那么 A 必须等到 B 释放它的锁之后才允许进行查询。 - 如果事务 A 想要插入,更新或删除任何对象,则必须首先检查旧值或新值是否与任何现有的谓词锁匹配。如果事务 B 持有匹配的谓词锁,那么 A 必须等到 B 已经提交或中止后才能继续。
这里的关键思想是,谓词锁甚至适用于数据库中尚不存在,但将来可能会添加的对象(幻象)。如果两阶段锁定包含谓词锁,则数据库将阻止所有形式的写入偏差和其他竞争条件,因此其隔离实现了可串行化。
索引范围锁
不幸的是谓词锁性能不佳:如果活跃事务持有很多锁,检查匹配的锁会非常耗时。 因此,大多数使用 2PL 的数据库实际上实现了索引范围锁(index-range locking,也称为 next-key locking),这是一个简化的近似版谓词锁。
通过使谓词匹配到一个更大的集合来简化谓词锁是安全的。例如,如果你有在中午和下午 1 点之间预订 123 号房间的谓词锁,则锁定 123 号房间的所有时间段,或者锁定 12:00~13:00 时间段的所有房间(不只是 123 号房间)是一个安全的近似,因为任何满足原始谓词的写入也一定会满足这种更松散的近似。
在房间预订数据库中,你可能会在 room_id 列上有一个索引,并且 / 或者在 start_time 和 end_time 上有索引(否则前面的查询在大型数据库上的速度会非常慢):
- 假设你的索引位于
room_id上,并且数据库使用此索引查找 123 号房间的现有预订。现在数据库可以简单地将共享锁附加到这个索引项上,指示事务已搜索 123 号房间用于预订。 - 或者,如果数据库使用基于时间的索引来查找现有预订,那么它可以将共享锁附加到该索引中的一系列值,指示事务已经将 12:00~13:00 时间段标记为用于预定。
无论哪种方式,搜索条件的近似值都附加到其中一个索引上。现在,如果另一个事务想要插入、更新或删除同一个房间和 / 或重叠时间段的预订,则它将不得不更新索引的相同部分。在这样做的过程中,它会遇到共享锁,它将被迫等到锁被释放。
这种方法能够有效防止幻读和写入偏差。索引范围锁并不像谓词锁那样精确(它们可能会锁定更大范围的对象,而不是维持可串行化所必需的范围),但是由于它们的开销较低,所以是一个很好的折衷。
如果没有可以挂载范围锁的索引,数据库可以退化到使用整个表上的共享锁。这对性能不利,因为它会阻止所有其他事务写入表格,但这是一个安全的回退位置。