弱隔离级别 & 事务并发问题

介绍弱隔离级别

为什么要有弱隔离级别

如果两个事务操作的是不同的数据, 即不存在数据依赖关系, 则它们可以安全地并行执行。但是当出现某个事务修改数据而另一个事务同时要读取该数据, 或者两个事务同时修改相同数据时, 就会出现并发问题。

在应用程序的开发中,我们通常会利用锁进行并发控制,确保临界区的资源不会出现多个线程同时进行读写的情况,这其实就对应了事务的最高隔离级别:可串行化。可串行化隔离意味着数据库保证事务的最终执行结果与串行 (即一次一个, 没有任何并发) 执行结果相同。


那么为什么应用程序中可以提供可串行化的隔离级别,而数据库却不能呢?其实根本原因就是应用程序对临界区大多是内存操作,而数据库要保证持久性(Durability),需要把临界区的数据持久化到磁盘,可是磁盘操作比内存操作要慢好几个数量级,一次随机访问内存、 固态硬盘 和 机械硬盘,对应的操作时间分别为几十纳秒、几十微秒和几十毫秒,这会导致持有锁的时间变长,对临界区资源的竞争将会变得异常激烈,数据库的性能则会大大降低。

所以,数据库的研究者就对事务定义了隔离级别这个概念,也就是在高性能与正确性之间做一个权衡,相当于明确地告诉使用者,我们提供了正确性差一点但是性能好一点的模式,以及正确性好一点但是性能差一点的模式,使用者可以根据自己的业务场景来选择一个合适的隔离级别。

弱隔离级别带来的风险

弱隔离级别就是非串行化隔离级别。

较弱的隔离级别, 它可以防止某些并发问题,但并非全部的并发问题。

使用这些弱隔离级别,事务并发执行时,可能会出现异常情况,带来一些难以捉摸的隐患,因此,我们需要了解弱隔离级别存在的并发问题以及如何防范存在的并发问题。 然后, 我们就可以使用所掌握的工具和方法来构建正确、 可靠的应用。

各种隔离级别

SQL-92 标准定义了 4 种事务的隔离级别:读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)和可串行化(Serializable),在后面的发展过程中,又增加了快照隔离级别(Snapshot Isolation)。

不同的弱隔离级别解决了不同的并发问题(正确性问题),同时也存在一些并发问题。


下面是各种隔离级别及对应的并发问题:

  • ✔️代表该隔离级别已解决该并发问题;
  • ❌代表该隔离级别未解决该并发问题。
脏写 脏读 不可重复读 更新丢失 幻读 写倾斜
读未提交 ✔️
读已提交 ✔️ ✔️
可重复读 ✔️ ✔️ ✔️ ✔️
快照 ✔️ ✔️ ✔️ ✔️ ✔️
可串行化 ✔️ ✔️ ✔️ ✔️ ✔️ ✔️

SQL 标准对隔离级别的定义还是存在一些缺陷,某些定义模棱两可,不够精确,且不能做到与实现无关,所以上面的表格只是对常见的隔离级别并发问题的定义,你可以把它当成一个通用的标准参考。

当你使用某一个数据库时,需要读一下它的文档,确定好它的每一种隔离级别具体的并发问题。

  • MySQL 的默认隔离级别为:可重复读。
  • Oracle、PostgreSQL 的默认隔离级别为:读已提交

事务并发执行时,存在的并发问题

如果两个事务操作的是不同的数据, 即不存在数据依赖关系, 则它们可以安全地并行执行。但是当出现某个事务修改数据而另一个事务同时要读取该数据, 或者两个事务同时修改相同数据时, 就会出现并发问题。

并发问题总结:

  • 脏写:一个事务覆盖了其他事务尚未提交的写入。
  • 脏读:一个事务读到了其他事务尚未提交的写入。
  • 不可重复读:一个事务内,多次读取同一个记录的结果不一样。
  • 更新丢失:两个事务同时执行“读-修改-写回”操作序列,事务 A 覆盖了 事务 B 的写入,但又没有包含 事务 B 修改后的值,最终导致了部分更新数据发生了丢失。
  • 幻读:一个事务内,多次读取满足指定条件的数据,读出来的结果不一样。
  • 写倾斜:事务首先查询数据,根据返回的结果而作出某些决定,然后修改数据库。当事务提交时,支持决定的前提条件已不再成立。

脏写

一个事务覆盖了其他事务尚未提交的写入。

脏读

一个事务读到了其他事务尚未提交的写入。


举例说明脏读

事务 B 修改了 x,在事务 B 提交之前,事务 A 读到了 x 修改后的数据。这时事务 B 回滚了,相当于事务 A 读到了一个无效的数据(未实际提交到数据库中的数据),事务 A 的读就是脏读。

时间顺序 Session A Session B
1 begin; begin;
2 update t1 set c1 = 'B' where id = 1
3 select * from t1 where id = 1
4 commit;
5 rollback;

不可重复读

一个事务内,多次读取同一个记录的结果不一样。(一个事务能够读到另一个事务对同一个记录的修改)


举例说明不可重复读

事务 A 读取了 x,然后事务 B 修改了 x 并提交。这时事务 A 再次读取 x,发现两次读取同一个记录的结果不一样,这就是不可重复读。

时间顺序 Session A Session B
1 begin; 该事务设置自动提交
2 select * from t1 where id = 1(此时读到 A)
3 update t1 set c1 = 'B' where id = 1
4 select * from t1 where id = 1(此时读到 B)
update t1 set c1 = 'C' where id = 1
5 select * from t1 where id = 1(此时读到 C)

更新丢失

两个事务同时执行“读-修改-写回”操作序列,事务 A 覆盖了 事务 B 的写入,但又没有包含 事务 B 的修改,最终导致了部分更新数据发生了丢失。


举例说明更新丢失

事务 A 先读取某记录,然后事务 B 再读取某记录,事务 B 修改并写回,紧接着 事务 A 修改并写入。事务 A 覆盖了 事务 B 的写入,但又没有包含 事务 B 的修改,最终导致事务 B 的更新丢失了。

时间顺序 Session A Session B
1 begin; begin;
2 select * from t1 where id = 1;
3 select * from t1 where id = 1
4 update t1 set col1 = 2 where id = 1;
5 update t1 set col1 = 3 where id = 1;

幻读

一个事务内,多次读取满足指定条件的数据,读出来的结果不一样(一个事务能够读到另一个事务创建的满足条件的记录)


举例说明幻读

事务 A 读取一组满足条件 1 的数据,之后事务 B 创建了满足条件 1 的数据,使其满足条件 1 并提交,如果事务 A 用相同的 条件 1 再次读取,得到一组不同于第一次读取的数据。这就叫幻读。

时间顺序 Session A Session B
1 begin; 该事务设置自动提交
2 select * from t1 where id > 0
3 insert into t1 values(B)
4 select * from t1 where id > 0(能读到 B)

不可重复读和幻读都是一个事务内,多次执行相同的查询,结果不一样。那两者有什么区别呢?

  • 幻读 主要说的是,读到了另一个事务的 insert 或者 update 的满足条件的记录
  • 不可重复读 主要说的是,读到了另一个事务对同一个记录的 update

写倾斜

写倾斜就是:事务首先查询数据,根据返回的结果而作出某些决定,然后修改数据库。当事务提交时,支持决定的前提条件已不再成立。

如何防止并发问题

现在我们已经知道了每一个隔离级别可能会出现的并发问题,如果当前数据库使用了某一个隔离级别,我们也知道这个隔离级别存在的并发问题,是否有办法来避免并发问题呢?以及对于避免并发问题是如何实现的?

有些并发问题只能通过提升隔离级别来避免,接下来,我们就针对每一种并发问题一一讨论。

防止脏写

允许脏写这种并发问题出现的数据库基本上是不可用的。因此所有的隔离级别都不允许出现脏写这种并发问题。

防止“脏写”就意味着,写数据库时, 只会覆盖已成功提交的数据。

防止脏写通常的方式是推迟第二个写请求,直到前面的事务完成提交(或者中止)。


数据库通常采用行级锁来防止脏写:如果两个事务同时尝试写入同一个对象时 ,以加锁的方式来确保第二个写入等待前面事务完成(包括中止或提交)。

这种锁定是由处于读已提交模式 ( 或更强的隔离级别) 的数据库自动完成的。

防止脏读

防止 “脏读”就意味着,读数据库时, 只能看到已成功提交的数据。

如果业务中不能接受脏读,那么隔离级别要在“读已提交”隔离级别或者以上。

当有以下需求时,需要防止脏读:

  • 如果事务需要进行多个操作更新多个对象,我们需要保证另一个事务或者应用层要么看到所有操作执行前的状态,要么看到所有操作完成后的状态,而不能看到部分操作完成的中间状态。如果我们要提供这样的保证,那么就必须防止脏读。脏读意味着另一个事务可能会看到部分更新, 而非全部,观察到部分更新的数据可能会造成用户的困惑。
  • 如果事务发生中止,则所有写入操作都需要回滚,那么就必须防止脏读,避免用户观察到一些稍后被回滚的数据, 而这些数据实际并未实际提交到数据库中。

防止脏读的解决方案:

  • 两段锁协议;
  • 存储数据的旧版本和新版本。

一种选择是使用和防止脏写相同的锁,所有试图读取该对象的事务必须先申请锁,事务完成后释放锁,从而确保不会发生读取到一个脏的、 未提交的值。

然而, 加锁的方式在实际中并不可行, 因为运行时间较长的写事务会导致许多只读的事务等待太长时间, 这会严重影响只读事务的响应时间。应用程序任何局部的性能问题会扩散,进而影响整个应用,产生连锁反应。

因此, 大多数数据库采用了下面的方式来防止脏读:对于每个待更新的对象, 数据库都会维护对象的两个版本(其旧值 和 当前持锁事务将要设置的新值)。在事务提交之前, 其他事务的读操作都读取旧值;仅当写事务提交之后, 才会切换到读取新值。而 MySQL 使用了多版本并发控制来防止脏读,多版本比两个版本更加通用。

防止不可重复读

防止“不可重复读”就意味着,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。

不能忍受不可重复读的场景:

  • 备份场景:备份任务要复制整个数据库,这可能需要花费几小时才能完成。在备份过程中,数据可以继续写入数据库。因此,备份里可能包含部分旧版本数据和部分新版本数据。 如果从这样的备份进行恢复,那么就导致了永久性的不一致。

如果业务中不能接受不可重复读,那么隔离级别要在“可重复读”隔离级别或者以上。

在 MySQL 种,可重复读隔离级别即快照级别隔离。快照级别隔离的总体想法是:每个事务总是在某个时间点的一致性快照中读取数据。

为了实现快照级别隔离, MySQL 数据库采用了一种被称为多版本并发控制(MultiVersion Concurrency Control,MVCC)的机制。

防止更新丢失

更新丢失可能发生在这样一个操作场景中:应用程序从数据库读取某些值,根据应用逻辑做出修改,然后写回新值 (read-midify-write 过程)。当有两个事务在同样的数据对象上执行类似操作时,后一个写操作并不包含前一个写操作的修改,最终导致前一个写操作的修改丢失。

更新丢失属于写事务并发冲突。

防止更新丢失,目前有多种可行的解决方案。

  • 原子更新操作:许多数据库提供了原子更新操作,以避免在应用层代码完成“读-修改-写回”操作序列,如果数据库支持原子更新操作的话,通常这就是防止更新丢失最好的解决方案。

    • 原子操作通常采用对读取对象加独占锁的方式来实现,这样在更新被提交之前其他事务不可以读取它。
    • 原子操作的另一种实现方式是:强制所有的原子操作都在单线程上执行。这也是 Redis 防止更新丢失的解决方案
  • 显式的加锁:既然原子操作采用对读取对象加独占锁的方式来实现,那么我们也可以显式的锁定待更新的对象,使“读-修改-写回”操作序列串行执行。例如使用 MySQL 的 select ...... for update;

原子更新操作和 显式的加锁 都是通过强制“读-修改-写回”操作序列串行执行来防止丢失更新。

  • 自动检测更新丢失:先让“读-修改-写回”操作序列并发执行,但如果事务管理器检测到了更新丢失风险,则会中止当前事务,并强制回退到安全的“读-修改-写回”方式。
  • 比较并设置:先让“读-修改-写回”操作序列并发执行,如果读取的内容已经发生了变化且值与“旧内容”不匹配,则更新失败,需要应用层再次检查并在必要时进行重试。例如 update t1 set col1 = '新内容' where id = 1 and col1 = '旧内容';

自动检测更新丢失

PostgreSQL 的可重复读, Oracle 的可串行化以及 SQL Server 的快照级别隔离等,都可以自动检测何时发生了更新丢失,然后会中止违规的那个事务。

但是, MySQL 中 InnoDB 存储引擎的可重复读却并不支持自动检测更新丢失。

防止幻读 & 写倾斜

防止幻读:

  • 使用 可串行化隔离级别
  • 在 MySQL 的 可重复读隔离级别下,使用 select ...... for update;

使用可串行化隔离级别可以防止幻读。

可串行化隔离通常被认为是最强的隔离级别。使用可串行化隔离级别可以防止所有可能的竞争条件。

可串行化隔离保证即使事务可能会并行执行,但最终的执行结果与每次执行一个事务(即串行执行)的结果相同。

可串行化隔离级别的实现有以下几种方式:

  • 实际串行执行:
  • 两段锁 + 索引区间锁:将两段锁与索引区间锁结合使用,实现可串行化隔离
  • 可串行化快照隔离:(这个暂时还没有了解)

MySQL 的可串行化隔离级别使用了第 2 种方法(两段锁 + 索引区间锁)


写倾斜就是:事务首先查询数据,根据返回的结果而作出某些决定,然后修改数据库。当事务提交时,支持决定的前提条件已不再成立。写倾斜可能发生在这样一个操作场景中:

  1. 第一步 select:应用程序从数据库读取一组满足条件 1 的数据
  2. 第二步 决定:根据查询的结果,应用层代码来决定下一步的操作(有可能继续,或者报告错误井中止)
  3. 第三步 写入:如果应用程序决定继续执行,它将发起数据库写入(insert,update 或 delete)并提交事务。

而第 3 步的这个写操作会改变第 2 步做出决定的前提条件,如果两个事务并发执行这样的“读取-决定-写入”操作序列,那么后一个写入改变了前一个写入执行的前提条件,导致出现意料之外的结果。


防止写倾斜

对于写倾斜问题,有几种可能的解决方案:

  • 只使用 可串行化隔离级别 即可避免写倾斜(使用索引区间锁,避免其他事务写入满足条件的行)
  • 更改“读取-决定-写入”操作序列的执行顺序 为 “写入-读取-决定”:先写入,然后 select 查询并加独占锁(select ...... for update),最后根据查询的结果来决定是否提交或者放弃。
  • 实体化冲突,也称物化冲突:有的业务场景 select 查询的是不满足给定搜索条件的行(例如 select * from t1 where id != 1)如果第 1 步的查询根本没有返回任何行,则 select ...... for update 也就无从加锁,只能考虑实体化冲突。

本质上这三种可能的解决方案都是对事务所依赖的行显式的加锁。

对于实体化冲突(物化冲突)的说明

如果问题的关键是查询结果中没有对象(空)可以加锁,或许可以人为引人一些可加锁的对象。这种方法称为实体化冲突(或物化冲突),它把幻读问题转变为针对数据库中一组具体行的锁冲突问题。

然而,弄清楚如何实现实体化往往也具有挑战性,实现过程也容易出错,这种把一个并发控制机制降级为数据模型的思路总是不够优雅。出于这些原因,除非万不得己,没有其他可选方案,不推荐采用实体化冲突。

参考资料

24|事务(三):隔离性,正确与性能之间权衡的艺术-极客时间 (geekbang.org)

《数据密集型应用系统设计》

posted @ 2022-09-11 08:03  真正的飞鱼  阅读(437)  评论(0编辑  收藏  举报