3.1.2 实现隔离性

本节我们来探讨数据库是如何实现隔离性的。隔离性保证了每个事务各自读、写的数据互相独立,不会彼此影响。只从定义上就能嗅出隔离性肯定与并发密切相关,因为如果没有并发,所有事务全都是串行的,那就不需要任何隔离,或者说这样的访问具备了天然的隔离性。但现实情况是不可能没有并发,那么,要如何在并发下实现串行的数据访问呢?几乎所有程序员都会回答:加锁同步呀!正确,现代数据库均提供了以下三种锁。

·写锁(Write Lock,也叫作排他锁,eXclusive Lock,简写为X-Lock):如果数据有加写锁,就只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。

·读锁(Read Lock,也叫作共享锁,Shared Lock,简写为S-Lock):多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。对于持有读锁的事务,如果该数据只有它自己一个事务加了读锁,则允许直接将其升级为写锁,然后写入数据。

·范围锁(Range Lock):对于某个范围直接加排他锁,在这个范围内的数据不能被写入。如下语句是典型的加范围锁的例子:


SELECT * FROM books WHERE price < 100 FOR UPDATE;

请注意“范围不能被写入”与“一批数据不能被写入”的差别,即不要把范围锁理解成一组排他锁的集合。加了范围锁后,不仅不能修改该范围内已有的数据,也不能在该范围内新增或删除任何数据,后者是一组排他锁的集合无法做到的。

串行化访问提供了最高强度的隔离性,ANSI/ISO SQL-92[1]中定义的最高等级的隔离级别便是可串行化(Serializable)。可串行化完全符合普通程序员对数据竞争加锁的理解,如果不考虑性能优化的话,对事务所有读、写的数据全都加上读锁、写锁和范围锁即可做到可串行化,“即可”是简化理解,实际还是很复杂的,要分成加锁(Expanding)和解锁(Shrinking)两阶段去处理读锁、写锁与数据间的关系,称为两阶段锁(Two-Phase Lock,2PL)。但数据库不考虑性能肯定是不行的,并发控制(Concurrency Control)理论[2]决定了隔离程度与并发能力是相互抵触的,隔离程度越高,并发访问时的吞吐量就越低。现代数据库一定会提供除可串行化以外的其他隔离级别供用户使用,让用户自主调节隔离级别,根本目的是让用户可以调节数据库的加锁方式,取得隔离性与吞吐量之间的平衡。

可串行化的下一个隔离级别是可重复读(Repeatable Read),可重复读对事务所涉及的数据加读锁和写锁,且一直持有至事务结束,但不再加范围锁。可重复读比可串行化弱化的地方在于幻读问题(Phantom Read),它是指在事务执行过程中,两个完全相同的范围查询得到了不同的结果集。譬如现在要准备统计一下Fenix’s Bookstore中售价小于100元的书的本数,可以执行以下第一条SQL语句:


SELECT count(1) FROM books WHERE price < 100 /* 时间顺序:1,事务: T1 */
INSERT INTO books(name,price) VALUES ('深入理解Java虚拟机',90)/* 时间顺序:2,事务: T2 */
SELECT count(1) FROM books WHERE price < 100 /* 时间顺序:3,事务: T1 */

根据前面对范围锁、读锁和写锁的定义可知,假如这条SQL语句在同一个事务中重复执行了两次,且这两次执行之间恰好有另外一个事务在数据库插入了一本小于100元的书,这是会被允许的,那这两次相同的SQL查询就会得到不一样的结果,原因是可重复读没有范围锁来禁止在该范围内插入新的数据,这是一个事务受到其他事务影响,隔离性被破坏的表现。

注意,这里的介绍是以ARIES理论为讨论目标,具体的数据库并不一定要完全遵照理论去实现。一个例子是MySQL/InnoDB的默认隔离级别为可重复读,但它在只读事务中可以完全避免幻读问题,譬如上面例子中事务T1只有查询语句,是一个只读事务,所以上述问题在MySQL中并不会出现。但在读写事务中,MySQL仍然会出现幻读问题,譬如例子中事务T1如果在其他事务插入新书后,不是重新查询一次数量,而是将所有小于100元的书改名,那就依然会受到新插入书的影响。

可重复读的下一个隔离级别是读已提交(Read Committed),读已提交对事务涉及的数据加的写锁会一直持续到事务结束,但加的读锁在查询操作完成后会马上释放。读已提交比可重复读弱化的地方在于不可重复读问题(Non-Repeatable Read),它是指在事务执行过程中,对同一行数据的两次查询得到了不同的结果。譬如笔者要获取Fenix’s Bookstore中《深入理解Java虚拟机》这本书的售价,同样执行了两条SQL语句,在此两条语句执行之间,恰好有另外一个事务修改了这本书的价格,将书的价格从90元调整到了110元,如下SQL所示:


SELECT * FROM books WHERE id = 1; /* 时间顺序:1,事务: T1 */
UPDATE books SET price = 110 WHERE id = 1; COMMIT; /* 时间顺序:2,事务: T2 */
SELECT * FROM books WHERE id = 1; COMMIT; /* 时间顺序:3,事务: T1 */

如果隔离级别是读已提交,这两次重复执行的查询结果就会不一样,原因是读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化,此时事务T2中的更新语句可以马上提交成功,这也是一个事务受到其他事务影响,隔离性被破坏的表现。假如隔离级别是可重复读,由于数据已被事务T1施加了读锁且读取后不会马上释放,所以事务T2无法获取到写锁,更新就会被阻塞,直至事务T1被提交或回滚后才能提交。

读已提交的下一个级别是读未提交(Read Uncommitted),它只会对事务涉及的数据加写锁,且一直持续到事务结束,但完全不加读锁。读未提交比读已提交弱化的地方在于脏读问题(Dirty Read),它是指在事务执行过程中,一个事务读取到了另一个事务未提交的数据。譬如笔者觉得《深入理解Java虚拟机》从90元涨价到110元是损害消费者利益的行为,又执行了一条更新语句把价格改回了90元,在提交事务之前,同事说这并不是随便涨价,而是印刷成本上升导致的,按90元卖要亏本,于是笔者随即回滚了事务,如下SQL所示:


SELECT * FROM books WHERE id = 1; /* 时间顺序:1,事务: T1 */
/* 注意没有COMMIT */
UPDATE books SET price = 90 WHERE id = 1; /* 时间顺序:2,事务: T2 */
/* 这条SELECT模拟购书的操作的逻辑 */
SELECT * FROM books WHERE id = 1; /* 时间顺序:3,事务: T1 */
ROLLBACK;/* 时间顺序:4,事务: T2 */

不过,在之前修改价格后,事务T1已经按90元的价格卖出了几本。原因是读未提交在数据上完全不加读锁,这反而令它能读到其他事务加了写锁的数据,即上述事务T1中两条查询语句得到的结果并不相同。如果你不能理解这句话中的“反而”二字,请再读一次写锁的定义:写锁禁止其他事务施加读锁,而不是禁止事务读取数据,如果事务T1读取数据前并不需要加读锁的话,就会导致事务T2未提交的数据也马上能被事务T1所读到。这同样是一个事务受到其他事务影响,隔离性被破坏的表现。假如隔离级别是读已提交的话,由于事务T2持有数据的写锁,所以事务T1的第二次查询就无法获得读锁,而读已提交级别是要求先加读锁后读数据的,因此T1中的查询就会被阻塞,直至事务T2被提交或者回滚后才能得到结果。

理论上还存在更低的隔离级别,就是“完全不隔离”,即读、写锁都不加。读未提交会有脏读问题,但不会有脏写问题(Dirty Write),即一个事务没提交之前的修改可以被另外一个事务的修改覆盖掉。脏写已经不单纯是隔离性上的问题了,它将导致事务的原子性都无法实现,所以一般谈论隔离级别时不会将完全不隔离纳入讨论范围内,而是将读未提交视为最低级的隔离级别。

以上四种隔离级别属于数据库理论的基础知识,多数大学的计算机课程应该都会讲到,可惜的是不少教材、资料将它们当作数据库的某种固有属性或设定来讲解,导致很多同学只能对这些现象死记硬背。其实不同隔离级别以及幻读、不可重复读、脏读等问题都只是表面现象,是各种锁在不同加锁时间上组合应用所产生的结果,以锁为手段来实现隔离性才是数据库表现出不同隔离级别的根本原因。

除了都以锁来实现外,以上四种隔离级别还有另外一个共同特点,就是幻读、不可重复读、脏读等问题都是由于一个事务在读数据的过程中,受另外一个写数据的事务影响而破坏了隔离性。针对这种“一个事务读+另一个事务写”的隔离问题,近年来有一种名为“多版本并发控制[3]”(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。MVCC是一种读取优化策略,它的“无锁”特指读取时不需要加锁。MVCC的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版本与老版本共存,以此达到读取时可以完全不加锁的目的。在这句话中,“版本”是个关键词,你不妨将版本理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION和DELETE_VERSION,这两个字段记录的值都是事务ID,事务ID是一个全局严格递增的数值,然后根据以下规则写入数据。

·插入数据时:CREATE_VERSION记录插入数据的事务ID,DELETE_VERSION为空。

·删除数据时:DELETE_VERSION记录删除数据的事务ID,CREATE_VERSION为空。

·修改数据时:将修改数据视为“删除旧数据,插入新数据”的组合,即先将原有数据复制一份,原有数据的DELETE_VERSION记录修改数据的事务ID,CREATE_VERSION为空。复制后的新数据的CREATE_VERSION记录修改数据的事务ID,DELETE_VERSION为空。

此时,如有另外一个事务要读取这些发生了变化的数据,将根据隔离级别来决定到底应该读取哪个版本的数据。

·隔离级别是可重复读:总是读取CREATE_VERSION小于或等于当前事务ID的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务ID最大)的。

·隔离级别是读已提交:总是取最新的版本即可,即最近被提交的那个版本的数据记录。

另外两个隔离级别都没有必要用到MVCC,因为读未提交直接修改原始数据即可,其他事务查看数据的时候立刻可以看到,根本无须版本字段。可串行化本来的语义就是要阻塞其他事务的读取操作,而MVCC是做读取时的无锁优化的,自然不会放到一起用。

MVCC是只针对“读+写”场景的优化,如果是两个事务同时修改数据,即“写+写”的情况,那就没有多少优化的空间了,此时加锁几乎是唯一可行的解决方案,稍微有点讨论余地的是加锁策略是选“乐观加锁”(Optimistic Locking)还是选“悲观加锁”(Pessimistic Locking)。前面笔者介绍的加锁都属于悲观加锁策略,即认为如果不先加锁再访问数据,就肯定会出现问题。相对地,乐观加锁策略认为事务之间数据存在竞争是偶然情况,没有竞争才是普遍情况,这样就不应该在一开始就加锁,而是应当在出现竞争时再找补救措施。这种思路也被称为“乐观并发控制”(Optimistic Concurrency Control,OCC),囿于篇幅与主题,这里就不再展开了,不过笔者提醒一句,没有必要迷信什么乐观锁要比悲观锁更快的说法,这纯粹看竞争的激烈程度,如果竞争激烈的话,乐观锁反而更慢。

[1] SQL-92标准:https://en.wikipedia.org/wiki/SQL-92。

[2] 并发控制理论:https://en.wikipedia.org/wiki/Concurrency_control。

[3] MVCC:https://en.wikipedia.org/wiki/Multiversion_concurrency_control。