谈谈数据库的隔离级别以及innodb在这方面的特点

隔离(isolation)是数据库系统的根本原则和设计目标之一。隔离的目的就是让并发执行的事务,感知不到有其他并发事务正在执行,或者说不受到正在并发执行的其他事务的影响。这样,事务的正确性就不依赖与当时系统的负载了,因而是可靠的和稳定的。如果一个事务T1的执行正确的前提是T2当时也在执行,那么T1和T2就不是隔离的而是有干扰或者依赖的,这是任何数据库系统都无法容忍的错误。隔离性是通过数据库并发控制来实现的,典型的并发控制方法目前主要有3种:基于事务锁的并发控制,也就是两阶段锁协议(2PL);多版本并发控制(MVCC);乐观的并发控制。本文不解释这三种机制,需要的话可以参考“数据库系统实现”("Database System Implementation")。

隔离级别的意义和实现方法

隔离是有代价的,对于基于事务锁的系统而言,隔离的代价就是并行度(parallelism)会降低。例如,并发执行的事务T1, T2, 如果T1获取了行R1的读锁,而T2需要修改R1,那么T2只能阻塞到T1提交释放行锁后才可能获取到R1的写锁并继续执行。这显然比T1和T2完全并行执行的并行度要低。当然,本例中不做锁等待是错误的。并行度之所以重要,是因为数据库系统的吞吐量就依赖于它,因此我们总是在实现一个数据库系统时想达到尽可能高的并行度。为了让用户可以根据自身需求在隔离性和并行度之间做一定的取舍,就有了隔离级别,在不同的隔离级别下,并行度会有不同程度的损失,事务的一致性也会有不同程度的损失。隔离级别越低,并行度越高,隔离级别越高,并行度越低。这样用户就可以选择合适的数据一致性从而达到尽可能高的并行度,最大程度发挥系统的性能;同时满足其业务对事务的隔离性和一致性的要求。

目前典型的隔离级别有4种,read uncommitted(RU), read committed(RC), repeatable read(RR), serializable。常用的是后面3种。这4种隔离级别最初都是基于事务锁做并发控制的数据库系统来定义和命名的。

因为RU隔离级别下, read 操作不获取读锁,因此事务只会因为写操作(获取写锁)而被其他事务的锁阻塞。这样并行度得到了最大程度的保证,但是可能读到脏数据,也就是还没有提交的事务做的数据改动。那些事务随后可能会回滚,那么读取到的数据就成了不存在的数据。业务逻辑基于不存在的事实,通常是错误的,所以这样低的数据一致性基本是没有人可以接受的。

为了避免读取到脏数据,至少需要RC隔离级别。在RC级别下,对数据库对象(即行或者页,依据不同DBMS实现不同,有的做行级事务锁,有的做页级事务锁)的读取操作是要获取事务读锁的,如果当时有其他事务在该对象上面获取了行写锁或者页写锁,则该事务会阻塞。不过获取到的读锁是在读取操作结束后立刻释放的,而不是在事务提交时刻才释放,因此,等待对该对象(行或者页)上写锁的事务就可以较早获得锁继续执行。这样,数据库系统整体得到了较高的并行度。但是带来的问题是:首先在同一个事务中做读取同一条数据多次,得到的结果可能不同,这条数据可能发生过变化,因为可能有另外一个事务在本事务的两次读取之间修改了这行然后提交了。这就是所谓的"不可重复读" 问题。另一个问题是,RC不是遵循两阶段锁(two phase locking, 2PL) 协议的,因而更容易导致死锁。2PL要求开始放锁阶段之后,后就不可以再获取锁了,只能继续放锁,目的就是尽可能避免死锁。

为了避免这些问题,用户可以使用RR隔离级别,在此级别下,事务读锁也是在事务提交时刻才释放的,因而在同一个事务中读取同一条数据多次,得到的结果一定不受其他并行执行的事务干扰,只有本事务的写操作可能导致两次读取结果不同。这样,事务就获得了更高的一致性。由于事务读锁也要等到事务结束才释放,显然数据库系统整体的并发度进一步降低了。

在RR隔离级别下,如果做范围查找,比如 select * from t where a between 20 and 40; 在同一个事务中多次执行该语句,可能出现多出了一些行的现象,也就是所谓的幻读(phantom read)问题。为了解决这个问题,基于事务锁做并发控制的系统会在更大粒度(比如页或者表级别)获取非意向读锁从而阻止插入,或者锁住一个索引值范围的行。显然这些都会进一步损失数据库系统的并发度,好处就是用户的事务的一致性达到了最高级别 ---可串行化。意思是说,这些事务仿佛是排队一个个执行的,任何一个事务执行完毕才执行下一个事务。

通过上面的分析我们知道,数据库系统提供的这4种隔离级别,让用户可以在数据库系统并发度和事务一致性方面做一定的权衡取舍,从而达到最好的性能和符合要求的结果。

MVCC中各种隔离级别的实现方式以及对并行度的影响

事务的这4种隔离级别的命名以及它们导致的事务一致性方面的问题及其区别,都是基于事务锁做并发控制这种机制才成立的。在MVCC 机制下,读取操作并不上锁,因此读操作绝不会阻塞写操作,无论使用RC/RR/SERIALIZABLE 隔离级别中的哪一种,读操作都不会丝毫影响数据库系统的并发性能。此时用户到底需要哪一种隔离级别,就完全依赖用户应用功能需求了。另外,基于MVCC的原理,使用同一个快照的select语句的读取操作也不会因为没有获取事务读锁而读取到变化的数据集。所以基于MVCC的数据库系统本质上是不需要RC/RR等隔离级别的。事实上,在innodb和postgresql等基于MVCC做读取的存储引擎中,RC/RR都是模拟出来的 ---- 模拟的对象就是基于事务锁做读取的那些数据库系统的行为。具体做法就是,在RC隔离级别下,每个select语句(不含有上锁子句)开始执行时会做一个快照,这样同一个事务T 的两次相同的select语句执行获得的快照(获取的快照记为S1, S2)是不同的,如果在S1和S2之间,某个事务T' 修改了结果集的数据然后提交了,那么S2会看到这个修改。这样就实现出了RC的隔离级别。而为了实现RR,就是在事务T 首次开始执行SELECT语句时,获取一个快照,然后T一直使用这个快照直到结束。这样,事务T查询到的数据一定是稳定的,不会出现phantom read,也不会出现不可重复读问题。也就是说,其实MVCC 完全没有基于事务锁做读取操作并发控制的那些数据一致性问题,只是为了模拟出与之相同的行为才实现出了RC/RR隔离级别。

不过MVCC下这种模拟出来的RC/RR隔离级别的‘时效性’略显滞后(尽管仍然是正确的):在完全基于事务锁做并发控制的数据库系统中,事务T1的一条select语句(记为Select1) 执行过程中,假设它会扫描到一个行的集合S1,而S1正好也是另一个正在提交的事务T2更新过的行集合,那么即使T2是在Select1开始执行之后才启动的,并且T2在Select1执行期间执行完毕并提交,那么T1的select语句也能读取到(可能需要做事务锁等待)T2更新后的行集合S1。而在MVCC下,只有T2在Select1开始之前就完成了提交,Select1才能读取到T2更新后的行集合S1。类似地对RR级别也是这样:一个事务T只能读取到它开始第一条select语句之前已经提交的事务的改动,在此之后提交的事务的改动,T是无法读取到的,即使T的读取语句执行一个小时,期间有无数个事务完成了提交也是如此。

PostgreSQL是完全基于MVCC做并发控制的,这导致并发的多个事务写同一行的话,只有一个事务会成功提交,其余的都会回滚(RC下会重试),这显然是不利于高并发度的。innodb则对写操作使用事务锁(行锁)做并发控制,对读取做MVCC,兼顾到了MVCC在读取方面的效率优势(读完全不阻塞写,也不降低并发度),避免了MVCC在更新行冲突时导致冲突事务回滚的问题。另外,innodb的RR级别是一个比较鸡肋的隔离级别。首先,当SELECT不带加锁子句时(绝大多数情况是这样),如上所述MVCC无论使用RR还是RC,系统的并发性能是相同的(因为没有读锁)。而且innodb的RR级别下,SELECT(不带加锁子句)不会有phantom现象,因为MVCC机制本身就完全解决了phantom问题。然而,RR级别的gap lock降低了写操作的并发性,却没有带来任何多少好处。Innodb的RR级别真正有用的时候是这种情况:SELECT需要带上加锁子句。在此情况之下,RR级别可以避免phantom问题,因为insert/update/delete和带锁的select会做gap locking,防止select... for update/lock in share mode 语句产生幻象问题。不过根据经典的数据库理论,RR级别下是可以有phantom问题的,serializable级别下才需要避免phantom问题。

InnoDB的隔离级别的特点

那么,什么时候使用innodb select需要带上加锁子句呢? 通常是这种情况:在同一个事务中,你先select出目标数据,然后对这些数据做update/delete。在使用MVCC时,select出来的行集合与后续的update/delete按照相同的where条件更新/删除的行集合可能不同。例如,事务T1启动后执行了一个select TABLE1 得到行集合S1,然后事务T2修改了表TABLE1,做了插入、更新或者删除,然后T1的UPDATE/DELETE语句按照与前面的SELECT语句相同的where条件做更新、删除,那么影响到的行就可能包含事务T2写的行。留给读者构造这样一个具体的例子。

所以,对于innodb的隔离级别,我的建议是除非你需要解决上述问题,否则你就是用RC级别是最好的。另外,在innodb中使用RC比RR还有一个好处,就是RC下undo日志可以更及时地被purge,因为每个select需要新建快照,所以如果一个事务不幸需要执行很长时间,或者因为应用代码的bug而忘记结束事务,那么在RC级别下该事务的select语句不会导致undo日志无法被及时purge导致系统表空间暴涨,并且该超长事务后续的select语句执行也不会因为需要大量undo操作而变的很慢。

另外,innodb 的select for update/lock in share mode

这两个加锁子句还有一些非常tricky的问题:

1. 在read committed级别下没有在语句结束时放锁,而是与RR级别相同---

在事务提交时刻放锁。这与经典的基于事务锁做读取并发控制的技术是不同的(经典算法是RR级别下在SELECT语句结束时就放读锁)。当然,这也是可以理解的,因为之所以要用加锁子句,就是上例描述的,要让后续的update/delete能够操作相同的行集。

2. 在autocommit=true时候,无论什么隔离级别,单独执行的select... for update/lock in share mode仍然会获取行级读锁。关于这一点,mysql的官方文档的说法是错误的----官方文档说此时不会上行级读锁。关于这一点读者可以自行验证,我是验证过的。本来从功能角度考虑,由于此时语句结束事务就结束了,就会放锁,那么获取行锁似乎没有意义,因为没有后续的同一个事务中的update/delete语句。但是,在某些用例下这样的行为还是有用的,比如,需要读取到另一个事务正在更新的数据的最新值,此时就应该阻塞等待更新结束。

3. SERIALIZABLE模式下,显式事务的select不需要上锁子句也会上行级读锁。

  • 发表于:
  • 原文链接https://kuaibao.qq.com/s/20180706G1IEQ200?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券