数据库事务隔离级别与并发问题解析

前言

本文解析了数据库事务的隔离级别与并发问题,包括排他锁、共享锁、范围锁等,介绍了脏读、不可重复读、更新丢失、幻读、写偏斜等现象,以及未提交读、已提交读、可重复读、串行化这四种隔离级别及其特点和实现方式。

排他锁(写锁,X 锁)

事务在获得数据的排他锁之后,就可以对数据进行写入操作,此时其他事务不能读取也不能写入数据。如果一条数据被加上了共享锁或排他锁,就不能再获得这条数据的排他锁。如果需要对这条数据进行写入,就必须排队等待,直到数据上所有的共享锁或排他锁都被释放后,再重新获取排他锁。

共享锁(读锁,S 锁)

当数据被加上共享锁时,就不能再被加上排他锁,因此其他事务将无法对这些数据进行写入,但仍然可以继续读取这些数据。这有点像是不允许你写入,但为了性能,我继续与你共享数据让你读取。

特别的是,同一条数据可以被多个事务『同时』加上共享锁。当一条数据上有一个或多个共享锁时,就没有任何事务可以获得排他锁,也就没有人可以对数据进行写入,必须等到所有共享锁都被释放后再获取排他锁。

但如果数据上只有一个共享锁,并且这个共享锁的所有者是事务本身时,事务可以直接将这个共享锁『升级』为排他锁,直接对数据进行写入。

范围锁

对一个范围内的数据进行锁定,主要用于避免幻读现象。例如,如果执行以下 SQL:

SELECT * FROM student WHERE height >= 170 FOR UPDATE;

除了被读取到的数据会被加上排他锁外,在身高从 170 到无限大的范围内也会被加上排他锁,任何身高在这个范围锁范围内的数据都不能读取也不能写入。

对于 InnoDB 来说,如果隔离级别设置为可序列化(Serializable),其他事务不能读取也不能写入身高在范围锁内的数据。但如果隔离级别设置为可重复读(Repeatable Read),由于 InnoDB 在可重复读隔离级别下采用快照隔离级别(Snapshot Isolation),读取操作都是读取快照内的数据,因此读取范围锁内的数据是可以成功的(读取快照内的数据),但仍然不能写入任何身高在范围锁范围内的数据。

脏读

在下图中,事务 B 读取到事务 A 刚刚更新但尚未提交的数据(数量=6)。之后事务 A 可能会再次更新数据,或者像图中一样进行回滚所做的更改,事务 B 因此获得脏数据,这就是脏读。已提交读(Read Committed)或更高的隔离级别可以避免这种现象。

MySQL脏读

不可重复读

如果将数据库隔离级别设置为已提交读(Read Committed),就会像下图中事务 B 第一次读取时只能获得已经被提交的数据(数量=10),看不到事务 A 刚刚更新但尚未提交的数据(数量=6)。但是当事务 B 第二次读取时,获得的就是事务 A 提交之后的数据。在同一个事务中,重复读取时会获得不一致的数据就叫做不可重复读。可重复读(Repeatable Read)或更高的隔离级别可以避免这种现象。

不可重复读

更新丢失

在下图中,两个事务同时进行卖出商品 A 的操作,事务 B 理论上应该考虑事务 A 的更改,但实际上却直接覆盖了事务 A 的更新,导致事务 A 的更新丢失,这就是丢失更新。串行化隔离级别(Serializable Isolation)可以避免这种现象,可重复读隔离级别(Repeatable Read Isolation)根据每个数据库实现的不同而有不同的行为,有的无法避免丢失更新(例如:MySQL InnoDB),有的则可以(例如:PostgreSQL)。

MySQL更新丢失

幻读

在下图中,事务 A 第一次读取所有数量小于 5 的数据时,总共获得 A、B 和 C 三笔,这时事务 B 中途新增了一笔 D,当事务 A 第二次读取时,变成总共获得四笔数据,比原先多读到数据 D,出现幻读现象。串行化隔离级别(Serializable Isolation)可以避免这种现象。可重复读隔离级别(Repeatable Read Isolation)根据每个数据库实现的不同而有不同的行为。例如,PostgreSQL 可以完全避免幻读,但 MySQL InnoDB 只能避免幻读读操作,UPDATE 和 DELETE 等 DML 的写入操作则无法避免幻读现象,详细可以查看:『对于 MySQL 可重复读隔离级别常见的三个误解』。

MySQL幻读

写偏斜(Write Skew)

在下图中,两个事务分别同时卖出 2 个商品 A,为了确保有库存,在销售前会先读取当前库存数量,确认数量大于 2,扣除销售的数量后不会小于 0,才更新库存。从下图可以看到,两个事务都满足大于 2 这个『前提』,因此继续进行更新库存的操作,但最终导致库存数量等于 -1,违反了库存不能小于 0 的前提,这就是写偏斜。

写偏斜

导致写偏斜现象的场景通常会有以下场景:

  1. 读取数据,确认符合『前提』
  2. 更新写入数据
  3. 写入的数据会影响到其他事务对相同『前提』的判断结果,如上图两个 Transaction 更新库存的数量,让库存大于 2 的这个『前提』改变。

这些『前提』的逻辑判断有各种可能性,因此数据库无法自动检测。唯一的解决方案是确保事务之间的数据更新具有可序列化的特性,因此写偏斜现象只有在串行化隔离级别(Serializable Isolation)(下一段说明)下才能避免。

隔离级别

未提交读: 最低的隔离级别,允许读取尚未提交的数据,可能会发生脏读和其他所有现象。
已提交读: 只允许读取已经被提交的数据,可以避免脏读,但其他四个现象仍有可能发生。
可重复读: 只要能够避免脏读和不可重复读现象,就可以被称为可重复读隔离级别。根据实现方法的不同,有的可重复读隔离级别还可以避免丢失更新或幻读现象。

可重复读有几种实现方法,其中一种是对读取过的数据都加上共享锁,直到事务结束,期间不允许其他事务进行写入更新。但这种实现方法因为没有做范围锁,通常都无法避免幻读现象。

另一种比较常见的实现方法是快照隔离级别(Snapshot Isolation),在每个事务第一次读取数据时,对数据库做一个概念上像是快照的记录。事务之后就都只能看到这个快照的内容,无法读取到其他事务所做的更改,避免不可重复读。包括 MySQL InnoDB 和 PostgreSQL 都是采用快照隔离级别作为可重复读隔离级别。

串行化:串行化隔离级别可以保证多个事务同时对数据库进行读写所得到的结果,与一次只让一个事务按顺序(serially)进行读写所得到的结果完全一致。可序列化通常被认为是最严格的隔离级别,可以避免上述全部五种现象,但由于必须牺牲一些并发性,性能较差。

串行化隔离级别有三种实现方式:

  1. 串行顺序(Serial Order):真正地按顺序(serially)进行数据读写,避免所有同时执行更新可能造成的冲突。缺点是牺牲了所有并发性,性能差,适合读写较快速的内存数据库采用,例如 Redis。

  2. 两阶段锁(Two-phase Lock):使用共享锁和排他锁的搭配,让每条数据在同一时间最多只有一个事务对其进行读取和更新。另外还要配合范围锁来避免幻读现象。大部分数据库都采用这种实现。

  3. 可序列化快照隔离(Serializable Snapshot Isolation, SSI):与其他实现不同的是,SSI 采用乐观并发控制,乐观地认为发生冲突现象的概率其实很小,因此,与其对数据做共享/排他锁让事务互相阻塞,不如让所有事务都正常执行,在最后的提交阶段再进行检查,看是否有违反隔离规则的冲突发生。因此 SSI 对性能的影响很小,但由于它在 2008 年才被提出,目前大部分数据库实现都还没有采用。PostgreSQL 从 9.1 版本之后才开始采用。

关于我
loading