MySQL 8.0.34 中可重复读有假?


杰普森对 MySQL 并发控制的深入分析,结果包括:

  • MySQL 可重复读取违反了内部一致性并违反了单调原子视图
  • AWS RDS MySQL 集群经常违反可串行性
  • MySQL 的 binlog 复制显得很脆弱。我们在本地 Jepsen 测试中观察到许多复制停止的神秘场景。

该文重温了 关于 MySQL 隔离级别的工作,发现 8.0.34 中存在令人惊讶的行为。

  • MySQL 的重复读取(REPEATABLE READ)不仅表现出 G2-item、G-single 和丢失更新,还违反了内部一致性和单调原子视图(Monotonic Atomic View)。
  • 它既不符合 Adya 的可重复读取标准,也不符合含糊不清的 ANSI SQL 定义。
  • 还发现 AWS RDS MySQL 集群经常在 SERIALIZABLE 隔离级别上违反串行化原则。

杰普森致力于提高分布式数据库、队列、共识系统等的安全性:

  • 我们维护着一个用于系统测试的开源软件库,并通过博客文章和会议讲座探讨特定系统的失效模式。
  • 在每次分析中,我们都会探讨系统是否符合其文档中的要求,提出新的错误,并向操作人员提出建议。

以下是原文摘要:点击标题见原文

ANSI SQL 隔离实际上很糟糕
为了讨论 SQL 隔离级别的细微差别,我们必须首先解释一些历史。

  • 1977 年,Gray、Lorie、Putzolu 和 Traiger 发表了《共享数据库中的锁粒度和一致性程度》,其中引入了四种日益安全的事务一致性程度。
  • 1973 年,IBM 开发了 System R,这是最早的关系数据库之一,随后不久就引入了 SQL作为其查询语言。
  • 从1986 年开始,ANSI 发布了一系列规范SQL[url=https://jepsen.io/analyses/mysql-8.0.34fn1]行为[/url]的标准。

标准的第三个修订版 SQL-92 通过四个事务隔离级别定义了并发事务的语义,同样提高了安全程度。这些隔离级别与日益保守的锁定机制的行为有关。

然而,为了允许使用非锁定并发控制的数据库,ANSI 用三种不应该发生的可能现象来表述它们的级别。

  • P1(“脏读”):SQL 事务 T1 修改了一条记录。然后,SQL 事务 T2 在 T1 执行 COMMIT 之前读取该记录。如果 T1 随后执行 ROLLBACK,T2 将读取一条从未提交的记录,因此可以认为该记录从未存在过。
  • P2(“不可重复读取”):SQL 事务 T1 读取一条记录。然后,SQL 事务 T2 修改或删除该行,并执行 COMMIT。如果 T1 随后尝试重新读取该行,它可能会收到修改后的值或发现该行已被删除。
  • P3(“幻影”​​):SQL 事务 T1 读取满足某个 < 搜索条件> 的记录集 N。然后,SQL-事务 T2 执行 SQL 语句,生成一条或多条满足 SQL-事务 T1 使用的 < 搜索条件> 的记录。如果 SQL 事务 T1 以相同的 < 搜索条件> 重复初始读取,则会获得不同的记录集合。

ANSI SQL 根据这些异常定义了四种隔离级别。它首先指出,在可串行化隔离级别执行的事务必须等效于某种串行执行,即,该组事务一个接一个地执行。然后它说“对于现象 P1、P2 和 P3,隔离级别是不同的。”

1995 年,Berenson、Bernstein、Gray、3 Melton 和 O'Neils 发表了《ANSI SQL 隔离级别批判》,指出了这些定义中的严重缺陷。“这三种 ANSI 现象是模棱两可的。即使他们最广泛的解释也不排除异常行为。”例如,

  • P1 说如果 T1 终止,可能会发生不好的事情,但实际上并没有说它是否终止。
  • 有些人将该标准解释为要求 T1 中止。这样一来,在读取提交条件下,事务从其他事务读取尚未提交的状态(只要它们继续提交)就合法了。
  • T1 可以写 x = 1,T2 可以写 y = 2,而且 T1 和 T2 都能看到对方的效果。
  • 这种循环信息流似乎很糟糕,但标准是否允许这样做取决于解释。

P2 和 P3 也存在类似的模糊之处。

即使从广义上解释,防止 P1、P2 和 P3 也不能确保可串行化。该标准忽略了一个关键现象 P0("脏写"),即事务 T1 写入某些行,事务 T2 覆盖 T1 的写入,然后 T1 提交。这显然是不可取的,但在 ANSI 未提交读取、已提交读取和可重复读取下是合法的。
此外,ANSI SQL P3 只禁止影响谓词的插入,但不禁止更新或删除。

1999 年,Atul Adya 在 Berenson 等人的批评基础上开发了各种事务隔离级别(包括 ANSI SQL 中的隔离级别)的正式且独立于实现的定义。

  • 写入-写入:事务 T1 写入对象 x 的某个版本 x1,事务 T2 通过安装 x 的下一个版本 x2 来覆盖该版本。
  • 写入-读取:事务 T1 写入版本 x1,事务 T2 读取该版本。
  • 读写:事务 T1 读取版本 x1,事务 T2 通过安装下一个版本 x: x2 来覆盖该版本。

然后,Adya 定义了可移植隔离级别 PL-1、PL-2、PL-2.99 和 PL-3,它们捕捉到了 ANSI SQL 标准的意图。每个级别都排除了事务依赖关系图中逐步扩大的循环类型:

  • PL-1("未提交读取):禁止 G0("写循环"):写-写依赖循环。这类似于贝伦森的 P0("脏写")。
  • PL-2("已提交读取):禁止 G0 和 G1。G1 包括三种异常情况:G1a("中止读取")、G1b("中间读取")5 和 G1c("循环信息流"):写-写或写-读依赖关系的循环。这抓住了 P1 预防性解释的本质。
  • PL-2.99("可重复读取):禁止 G0、G1 和 G2-项:涉及无谓词的写-写、写-读或读-写边的循环。这抓住了 ANSI SQL 可重复读取的本质,它与 Serializable 的区别仅在于谓词安全。
  • PL-3("可序列化):禁止 G0、G1 和 G2:涉及写入-写入、写入-读取或读取-写入边沿(无论有无谓词)的循环。这保证了等同于串行执行。

Adya 基于依赖图的隔离级别解决了 ANSI 定义的模糊性,目前仍是描述事务历史和异常情况最广泛使用的形式主义。Jepsen 一般使用 Adya 的形式。

尽管数据库界几十年前就知道 ANSI SQL 的隔离级别定义是错误的,但该标准的语言仍然没有改变。在该标准的 2023 年修订版中,仍然存在同样模棱两可、不完整的定义。

可重复读取
ANSI SQL 的隔离级别很糟糕,但某些级别造成的问题比其他级别更多。  不同数据库供应商提供名称相同的隔离级别这一事实,只有在特定级别的语义在不同供应商之间保持一致时才有用。就三个隔离级别而言,这通常是正确的。我们评估过的大多数数据库都至少确保了未提交读取的 PL-1、已提交读取的 PL-2 和可序列化的 PL-3。

Adya 的 PL-2.99 可重复读取定义相当严格,排除了除涉及谓词边之外的所有依赖循环。ANSI 的定义虽然模棱两可,但似乎同样严格:它禁止所有列出的异常情况,但 "幻影 "除外,因为 "幻影 "依赖于谓词读取。考虑到隔离级别在锁定机制中的根源,这就不足为奇了:最初的 "可重复读取 "是在遵循严格的两阶段锁定(在事务结束前保持读写锁)但不执行谓词锁定时获得的隔离级别。

出于某种原因,数据库供应商选择了与 Adya 和 ANSI 标准不同的可重复读取定义,几乎没有供应商提供相同的可重复读取保证。事实上,在我们测试过的数据库中,只有 Microsoft SQL Server 的可重复读取似乎与 PL-2.99 和 ANSI 的定义一致。在 Postgres 中,"可重复读取 "意味着 "快照隔离"(Snapshot Isolation),这个级别不比 PL-2.99 强,也不比 PL-2.99 弱7。

考虑到实现方式的多样性,我们转而讨论手头的问题:MySQL 是怎么做的?

MySQL 隔离
MySQL 的事务隔离级别文档指出,带有 InnoDB 的 MySQL "提供 SQL:1992 标准描述的所有四种事务隔离级别":未提交读取(Read Uncommitted)、已提交读取(Read Committed)、可重复读取(Repeatable Read)和可序列化(Serializable)。文档接着解释了 MySQL 如何实现这些隔离级别。

在 MySQL 的 "未提交读取 "级别,事务的行为应 "与已提交读取类似",但允许 "脏读"(dirty read),即读取时观察到 "已被另一个事务更新但尚未提交的数据 "的异常情况。

在 MySQL 已提交读取中,每个单独的一致读取都是从已提交状态的新鲜快照中读取的。一致读取 "是读取(如 SELECT * FROM problems)的默认行为,也是本报告的重点。还有更强的读取(如 SELECT ... FOR UPDATE)和更弱的读取(如 SELECT ... SKIP LOCKED),前者明确请求锁,后者跳过一些默认锁。

  • MySQL 的默认隔离级别 "可重复读取 "通过快照机制确保安全
  • MySQL 的一致性读取文档进一步强调,读取操作应基于事务中第一次读取时的数据库快照。
  • 串行化与 可重复读类似,但如果禁用了自动提交,InnoDB 会将所有普通 SELECT 语句隐式转换为 SELECT ... FOR SHARE"。

...详细点击标题

建议
核心问题是MySQL声称实现了可重复读,但实际上提供的东西要弱得多。我们认为有两种途径可以解决这个问题。

首先是保持 MySQL 的行为不变,并明确记录 "可重复读取 "实际提供的一致性模型。其他数据库也有先例:PostgreSQL 的 "可重复读取 "实际上是 "快照隔离"(Snapshot Isolation),其行为违反了 PL-2.99 的 "可重复读取 "规定。不过,PostgreSQL 的文档最终还是提到了他们的可重复读取实现实际上是快照隔离。同样,MySQL 也可以在文档中说明,他们的 "可重复读取 "是指 "已提交读取,加上某种保证,直到事务写入某些内容,此时神秘事件就会发生"。如果能准确描述这些神秘之处,我们将非常欢迎。

第二种选择是将这些行为视为错误并加以修复。如果 MySQL 和其他供应商承诺提供 PL-2.99 可重复读取功能,Jepsen 会非常高兴。不过,即使满足 ANSI 对可重复读取不完整、不明确的定义,也会比目前的情况有所改善。

同时,需要 PL-2.99 或 ANSI 可重复读取功能的 MySQL 用户应谨慎使用 MySQL 可重复读取功能。读取可能不可重复,甚至不能反映已提交状态的快照。

常见的 ORM 模式(即事务将对象读入内存、修改对象,然后在事务中将其写回)可能会导致已提交的更新悄然丢失。需要可重复读取语义的用户应改用 MySQL 的 Serializable 隔离。或者,他们可以使用 SELECT ... FOR UPDATE 等锁定技术,有选择地加强在 READ COMMITTED 时执行的读取。