Skip to main content

SQL 异象

Guan Chao

Guan Chao

全栈工程师

SQL 异象是指:

  • 脏读(Dirty Read)
  • 不可重复读(Non-Repeatable Read)
  • 幻象读(Phantom Read)
  • 脏写(Dirty Write)
  • 读偏移(Read Skew)
  • 写偏移(Write Skew)
  • 丢失更新(Lost Update)

正如这些名称所示,这些现象表示的都是一些数据完整性异常,而导致这些问题的原因就是开发者为了压榨事务并发的性能而放宽了 SERIALIZABLE 隔离等级,使用了其他的隔离等级。

note

在选择事务隔离级别和事务并发性性能之间总是存在权衡。

脏写#

脏写同样也是丢失更新。在脏写的情况下,一个事务会覆写另一个并发的事务,也就是这两个事务在同一时间都可以允许影响同样的行。

Figure E-1. Dirty write Figure E-1. Dirty write

  • 第一步:John 尝试付 Mary 的账单。首先,他的事务触发了一个 SELECT 语句查询账单总额。Mary 同时也准备付这个账单。因此她触发了一个同样的查询,得到的结果和 John 一样($345)。
  • 第二步:John 的事务付完了整个账单欠款。因此,待付金额更新为了 $0。
  • 第三步:Mary 的事务并没有意识到这个更新,并且她尝试付了一半的欠款并且成功了(她的事务提交了)。她触发了 UPDATE 语句并将待付金额更新为了 $173。
  • 第四步:不幸的是,John 的事务没法提交成功了,必须回滚。因此,待付金额恢复到了 $345。这就意味着 Mary 损失了 $172。
info

在这样的环境下做商业决策是非常危险的。好在,默认情况下,所有的数据库系统都是阻止脏写的(即使处于 Read Uncommitted 隔离级别)。

脏读#

脏读通常与 Read Uncommitted 隔离级别有关。在脏读发生的时候,事务读取了另一个事务未提交的修改并在最后发生了回滚。

Figure E-2. Dirty read Figure E-2. Dirty read

  • 第一步:John 准备给 Mary 的工作付钱。他的事务触发了 UPDATE 操作,并将薪水金额设置为 $5000。
  • 第二步:后来,Mary 使用她的电脑查询了薪资,并注意到了 John 已经向她转了钱了。至此,Mary 的事务完成提交。
  • 第三步:Mary 准备去 ATM 提取这部分钱。
  • 第四步:然而,此时 John 的事务还伴有其他更多的查询。
  • 第五步:John 的事务失败了,执行回滚操作。因此 Mary 的薪水回滚到了 $0。
  • 第六步:最后,Mary 到了 ATM 并准备提取她的薪水。不幸的是,ATM 显示薪资为 $0。
note

基于未提交的数据做商业决策是非常恼人的,并且也会影响到数据的完整性。一个最简单的解决方案就是使用更高的隔离级别。经验之言,你要总是检查数据库的默认隔离级别。最好的就是,默认的隔离级别不要是 Read Uncommitted,你要意识到这个问题并且要总是检查它。

不可重复读#

不可重复读通常与 Read Committed 隔离级别相关。一个事务读取了一些记录,同时一个并发的事务向同样的记录中写入了数据(字段或列)并提交。后来,第一个事务又读取了同样的记录,但是却得到了不一样的值。

Figure E-3. Non-repeatable reads Figure E-3. Non-repeatable reads

  • 第一步:John 的事务触发了一个 SELECT 查询并获取到金额 $65000,与此同时,Mary 的事务做了同样的事情。
  • 第二步:John 的事务将金额从 $65000 更新到了 $85000。
  • 第三步:Mary 的事务又读取了金额,这个值仍是 $65000(因此,脏读是被阻止了)。
  • 第四步:John 的事务提交了。
  • 第五步:Mary 的事务又再次读取了金额。这个时候她得到的是 $85000。这次金额的变动就是由于 John 的事务导致的。这就是 不可重复读
note

当事务正在基于第一次读取的值做商业决定时,不可重复读的问题就显现出来了。解决方案就是将隔离级别设置为 Repeatable Read 或者 Serializable(两者都会阻止发生这样的异常)。或者,你可以保持 Read Committed 隔离级别,但是显式通过 SELECT FOR SHARE 的方式获取一个共享锁。此外,数据库使用 MVCC(多版本并发控制),通过检查行版本来查看记录是否被其他并发事务所修改从而阻止不可重复读。如果发生了修改,那么当前的事务就会被取消。

info

Hibernate 保证的是会话级的可重复读(项 21)。这意味着获取到的实体(通过直接获取或者实体查询)缓存在持久化上下文中。后续相同实体的获取(通过直接获取或者实体查询)是直接来自持久化上下文中的。然而,这在跨多个(HTTP)请求中是不起作用的。在这种情况下,解决方案就需要依赖广义持久化上下文,或者,推荐的方式就是脱离的实体(在 Web 应用中,脱离的实体可以缓存在一个 HTTP 会话中)。你同样需要一个应用级的并发控制策略,比如用于防止 丢失更新项 131)的积极并发锁。

幻象读#

幻象读通常与 Repeatable Read 隔离级别相关。一个事务读取了一部分记录(基于某个条件)。与此同时,一个并发的事务在这个条件范围内插入了一个新的记录并提交。然后,第一个事务使用相同的条件继续查询,那么就会看到一个新的记录。

Figure E-4. Phantom read Figure E-4. Phantom read

  • 第一步:John 获取了价格在 $50,000 到 $60,000 区间的汽车,他得到两条记录。
  • 第二步:Mary 插入了一个价格为 $52,000 的新记录。Mary 并提交了事务。
  • 第三步:John 又再次查询了价格在 $50,000 到 $60,000 区间的汽车,这一次多了一条 Mary 刚插入的记录。这就被称为 幻象读
tip

这个现象可以通过 SERIALIZABLE 隔离级别或者 MVCC 一致性快照预防。

读偏移#

读偏移现象的产生至少需要两个表(比如:carengine)。一个事务从第一张表中读取数据(比如:从 car 表中读取记录)。然后,一个并发的事务同步更新了两张表(比如:更新了第一个事务中获取的汽车以及与之相关的引擎)。两个表都被更新后,第一个事务继续从第二张表中读取数据(比如:读取先前获取到的汽车的引擎信息)。第一个事务中获取的是旧版本的汽车记录(没有意识到更新的发生)以及新版本的引擎信息。

Figure E-5. Read skew Figure E-5. Read skew

  • 第一步:John 从 car 表中查询出了一个名为 Dacia 的汽车。
  • 第二步:Mary 同步更新了 carengine 表。注意:Mary 将 Dacia 汽车的引擎从 TCe-D4FT 修改为了 V8。
  • 第三步:John 查询了 Dacia 汽车的引擎,得到的结果是 V8。这就是 读偏移
tip

可以通过在每一次读的时候获取共享锁或者 Repeatable Read 隔离级别的 MVVC 实现(或 Serializable)来防止读偏移的发生。

写偏移#

写偏移现象的产生至少需要两个表(比如:carengine)。两张表应该同步更新,但是写偏移允许两个并发事务打破这样的约束。

Figure E-6. Write skew Figure E-6. Write skew

  • 第一步:John 查询了 Dacia 汽车以及它的引擎 V8。
  • 第二步:Mary 执行了和 John 一样的查询并得到相同的结果(他们都意识到 Dacia 和 V8 并不是兼容的配置,不是汽车就是引擎的名称发生了错误)。
  • 第三步:John 决定将汽车的名称更新为 BMW。
  • 第四步:Mary 决定将引擎的类型更新为 TCe-4DFT。这就是 写偏移
tip

可以通过在每一次读的时候获取共享锁或者 Repeatable Read 隔离级别的 MVVC 实现(或 Serializable)来防止读偏移的发生。

丢失更新#

丢失更新是一个常见的异象,并且严重影响到了数据的完整性。一个事务读取了一条记录,并根据这个信息做商业决定(比如:决定可能会导致对这个记录的修改),然而并没有意识到与此同时,另一个并发记录修改了这条记录并提交了事务。当第一个事务提交的时候,他完全没有意识到发生了丢失更新。这就会导致数据完整性问题(比如:库存报告了负数数量)。

Figure E-7. Lost update Figure E-7. Lost update

  • 第一步:John 和 Mary 获取了笔记本的数量(库存中有五条)。
  • 第二步:Mary 准备买 4 个笔记本。因此数量减少到了 1。
  • 第三步:John 的事务没有意识到 Mary 的更新。
  • 第四步:John 决定买 3 个笔记本。这时,数量变成了 -2(但是,数量应该是一个非负整数)。
tip

这个现象会影响 Read Committed 隔离级别,通过设置 Repeatable Read 或者 Serializable 隔离级别来避免。对于没有 MVCC 的 Repeatable Read 的隔离级别,数据库使用共享锁来拒绝其他尝试修改已经获取到的记录的修改事务。

info

如果有 MVVC,一个并发事务(事务 B)可以执行对上一个事务(事务 A)获取到的记录的修改。当前一个事务(事务 A)准备提交它的变更时,数据库会比较当前记录的记录版本(这个值已经被并发事务(事务 B)修改了)与之前推送给事务 A 的记录版本。如果不匹配(意味着事务 A 的数据是旧的),那么事务 A 就会被回滚。如果记录被修改,Hibernate 会通过应用级别的积极并发锁机制自动将记录版本附加到对应的 SQL 上。

对于跨多个(HTTP)请求的长会话,除了应用级别的积极锁机制,你还需要通过广义持久化上下文来保存旧的实体快照,或者,推荐的方式就是通过脱离的实体(在 Web 应用中,他们可以被存储在 HTTP 会话中)。

总结#

不可重复读#

我们先说一下可重复读是什么意思,就是在同一个事务中,以及相同的读取条件下,得到的结果是一样的。那么不可重复读为什么与 Read Committed 隔离级别有关呢?这是因为这个隔离策略允许你读取提交了的数据,换句话说,在你的事务期间,如果有别的事务更改了记录并提交,那么你就会读取到更改了的数据,而这样就无法实现可重复读这个要求。