摘抄自:极客时间《周志明的软件架构课》

1. 数据库中的三种锁

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

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

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

SELECT * FROM books WHERE price < 100 FOR UPDATE;

2. 本地事务的四种隔离级别

以下隔离级别从高到低

可串行化

顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

串行化访问提供了强度最高的隔离性。可串行化比较符合普通程序员对数据竞争加锁的理解,如果不考虑性能优化的话,对事务所有读、写的数据全都加上读锁、写锁和范围锁即可(这种可串行化的实现方案称为 Two-Phase Lock)。

但数据库显然不可能不考虑性能,并发控制理论(Concurrency Control)决定了隔离程度与并发能力是相互抵触的,隔离程度越高,并发访问时的吞吐量就越低。现代数据库一定会提供除可串行化以外的其他隔离级别供用户使用,让用户调节隔离级别的选项,这样做的根本目的是让用户可以调节数据库的加锁方式,取得隔离性与吞吐量之间的平衡。

可重复读

一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。

可串行化的下一个隔离级别是可重复读(Repeatable Read)。可重复读的意思就是对事务所涉及到的数据加读锁和写锁,并且一直持续到事务结束,但不再加范围锁。

可重复读比可串行化弱化的地方在于幻读问题(Phantom Reads),它是指在事务执行的过程中,两个完全相同的范围查询得到了不同的结果集。比如现在准备统计一下书店中售价小于 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 */

可重复读级别对事务涉及到的数据加读锁和写锁,但不再加范围锁。这里事务T1中涉及的数据是原来数据库中已经存在的数据,但新插入的条目显然不在这个范围内。因此在事务T1再次执行读的时候就会与第一次读的结果不同。原因就是,可重复读没有范围锁来禁止在该范围内插入新的数据。

这就是一个事务遭到其他事务影响,隔离性被破坏的表现。

这里的介绍实际上是以 ARIES 理论作为讨论目标的,而具体的数据库并不一定要完全遵照着这个理论去实现。因此在同样的隔离级别下可能会出现与这里不同的行为。

读已提交

一个事务提交后,它做的变更才会被其他事务看到

可重复读的下一个隔离级别是读已提交(Read Committed)。读已提交对事务涉及到的数据加的写锁,会一直持续到事务结束,但加的读锁在查询操作完成后就马上会释放。

读已提交比可重复读弱化的地方在于不可重复读问题(Non-Repeatable Reads),它是指在事务执行过程中,对同一行数据的两次查询得到了不同的结果。

比如说,现在我要获取书店中《深入理解 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 */

在事务T1执行完第一个查询语句后,读锁被释放,这时事务T2执行了update语句,更新了书的价格,然后事务T1再次去查询,这样两次查询就得到了不同的值。读已提交的隔离级别缺乏贯穿整个事务周期的读锁,无法禁止读取过的数据发生变化。

不过,假如隔离级别是可重复读的话,由于数据已被事务 T1 施加了读锁,并且读取后不会马上释放,所以事务 T2 无法获取到写锁,更新就会被阻塞,直至事务 T1 被提交或回滚后才能提交。

读未提交

一个事务还没提交时,它做的变更就能被别的事务看到。

读已提交的下一个级别是读未提交(Read Uncommitted)。读未提交对事务涉及到的数据只加写锁,这会一直持续到事务结束,但完全不加读锁。

读未提交比读已提交弱化的地方在于脏读问题(Dirty Reads),它是指在事务执行的过程中,一个事务读取到了另一个事务未提交的数据。

比如说,我觉得《深入理解 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 被提交或者回滚后才能得到结果。

总结

  1. 可串行化: 所有读写数据都加上读锁、写锁,范围锁。
  2. 可重复读: 对事务涉及到的数据加读锁和写锁,但不加范围锁。(幻读问题)
  3. 读已提交: 对事务涉及到的数据加的写锁持续到事务结束,但读锁在查询完成后马上释放。(不可重复读问题)
  4. 读未提交: 对事务涉及到的数据加写锁,完全不加读锁。(脏读问题)

References

《周志明的软件架构课》

MySQL45讲