Featured image of post 数据库如何实现隔离性

数据库如何实现隔离性

介绍数据库通过读锁、写锁、范围锁与两阶段锁协议实现隔离性,以及不同隔离级别在一致性与吞吐量之间的取舍。

隔离性保证了每个事务各自读、写的数据互相独立,不会彼此影响。只从定义上就能嗅出隔离性肯定与并发密切相关——如果没有并发,所有事务都是串行执行的,那就不需要任何隔离,或者说这样的访问天然具备了隔离性。而在并发情况下要实现串行般的数据访问效果,就需要靠加锁来同步。现代数据库均提供了以下三种锁:

  • 写锁(Write Lock,也叫排他锁,eXclusive Lock,简写为X-Lock): 数据一旦被加上写锁,就只有持有写锁的事务才能对其进行写入操作;数据被加持着写锁期间,其他事务既不能写入,也不能再施加读锁。
  • 读锁(Read Lock,也叫共享锁,Shared Lock,简写为S-Lock): 多个事务可以对同一份数据同时加上多个读锁,数据一旦被加上读锁就不能再被加写锁,因此其他事务不能写入该数据,但仍然可以读取。对于持有读锁的事务而言,如果该数据当前只有它自己一个事务加了读锁,则允许直接将其升级为写锁,再写入数据。
  • 范围锁(Range Lock): 直接对某个范围加排他锁,范围内的数据都不能被写入。下面是一个典型的加范围锁的例子:
    SELECT * FROM books WHERE price < 100 FOR UPDATE;
    
    需要注意“范围不能被写入”与“一批数据不能被写入”的差别,不要把范围锁理解成一组排他锁的集合。加了范围锁后,不仅不能修改该范围内已有的数据,也不能在该范围内新增或删除任何数据——而后者是一组排他锁的集合无法做到的。

两阶段锁协议与可串行化

串行化访问提供了最高强度的隔离性,SQL-92 中定义的最高等级隔离级别便是可串行化(Serializable)。可串行化的直观理解完全符合普通程序员对数据竞争加锁的认知:在不考虑性能优化的条件下,对事务所有读、写涉及的数据全都加上读锁、写锁和范围锁即可做到可串行化。但实际上数据库不能在事务执行过程中随心所欲地加锁、解锁,必须严格遵循“两阶段”原则:

  • 加锁阶段(Expanding): 事务刚开始时,只能不断申请新锁,绝对不能释放任何已持有的锁。
  • 解锁阶段(Shrinking): 一旦开始释放第一个锁,就只能继续释放,绝对不能再申请任何新锁。

这样做是为了防止多个并发事务在交叉加锁时产生逻辑错乱或死锁,确保最终执行的结果真正等价于“串行排队”。

隔离级别:在隔离性与吞吐量之间取舍

数据库不考虑性能显然是不行的,并发控制(Concurrency Control)理论决定了隔离程度与并发能力是相互抵触的——隔离程度越高,并发访问时的吞吐量就越低。

正因如此,现代数据库一定会提供可串行化以外的其他隔离级别供用户选用,让用户自主调节数据库的加锁方式,在隔离性与吞吐量之间取得平衡。

可重复读:幻读问题

可串行化的下一个隔离级别是可重复读(Repeatable Read)。可重复读对事务所涉及的数据加读锁和写锁,且一直持有至事务结束,但不再加范围锁。它比可串行化弱化的地方在于幻读问题(Phantom Read)——指事务执行过程中,两个完全相同的范围查询得到了不同的结果集。下面是一个例子:

SELECT count(1) FROM books WHERE price < 100 /* 时间顺序:1,事务:T1  */
INSERT INTO books(name, price) VALUE('深入理解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)——指事务执行过程中,对同一数据的两次查询得到了不同的结果。譬如想要获取《深入理解Java虚拟机》这本书的售价,同样执行了两条 SQL 语句,在这两条语句执行之间,恰好有另一个事务将这本书的价格从 90 元修改为 110 元,如下所示:

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)——指事务执行过程中,一个事务读取到了另一个事务尚未提交的数据。譬如下面的例子:

SELECT * FROM books WHERE id = 1; /*时间顺序: 1,事务:T1 */
/*注意这里没有COMMIT*/
UPDATE books SET price = 90 WHERE id = 1; /*时间顺序:2,事务:T2 */
SELECT * FROM books WHERE id = 1; /*时间顺序:3,事务:T1 */
ROLLBACK ;/*时间顺序:4,事务:T2 */

在这个例子中,事务 T1 两次读取的数据并不一致。原因是读未提交在数据上完全不加读锁,这反而让 T1 能读到其他事务加了写锁的数据,即事务 T1 中两条查询语句得到的结果并不相同。这里要注意写锁的定义:写锁禁止其他事务施加读锁,而不是禁止事务读取数据——如果事务 T1 读取数据前并不需要加读锁,就会导致事务 T2 尚未提交的数据也马上能被事务 T1 读到。这同样是一个事务受到其他事务影响、隔离性被破坏的表现。假如隔离级别是读已提交,由于事务 T2 持有数据的写锁,事务 T1 的第二次查询就无法获得读锁,而读已提交级别要求先加读锁后读数据,因此 T1 中的查询就会被阻塞,直至事务 T2 提交或回滚后才能得到结果。

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

其实不同隔离级别以及幻读、不可重复读、脏读等问题都只是表面现象,是各种锁在不同加锁时机上组合应用所产生的结果——以锁为手段来实现隔离性,才是数据库表现出不同隔离性的根本原因。

MVCC:无锁的读优化

除了都以锁来实现之外,以上四种隔离级别还有另一个共同特点:幻读、不可重复读、脏读等问题都是由于一个事务在读数据的过程中,受到另一个写数据的事务影响而破坏了隔离性。针对这种“一个事务读 + 另一个事务写”的隔离问题,近年来有一种名为“多版本并发控制”(Multi-Version Concurrency Control, MVCC)的无锁优化方案被主流商业数据库广泛采用。

MVCC 是一种读取优化策略,它的“无锁”特指读取时不需要加锁。其基本思路是:对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版本与老版本共存,以此达到读取时可以完全不加锁的目的。这里的“版本”可以理解为数据库中每一行记录都存在两个看不见的字段——CREATE_VERSION 和 DELETE_VERSION,这两个字段记录的值都是事务 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)。最后,没有必要迷信“乐观锁比悲观锁更快”的说法——这纯粹取决于竞争的激烈程度,如果竞争激烈,乐观锁反而会更慢。

参考自《凤凰架构:构建可靠的大型分布式系统》周志明

发表了9篇文章 · 总计22.03k字
Built with Hugo
Theme Stack designed by Jimmy