我们是否更害怕可序列化隔离级别而非微妙的bug?(2024)
摘要
本文认为,默认使用较弱的数据库隔离级别是一种过早优化,并推荐使用可序列化隔离级别,除非数据库管理系统已经默认使用该级别。文章引用了导致经济损失的并发bug的真实案例。
暂无内容
查看缓存全文
缓存时间: 2026/06/08 03:17
# 我们对可串行化隔离级别的恐惧是否超过了对微妙错误的恐惧?
来源:https://blog.ydb.tech/do-we-fear-the-serializable-isolation-level-more-than-we-fear-subtle-bugs-5a025401b609?gi=b33be39cb5e0
Evgeniy Ivanov (https://medium.com/@eivanov89?source=post_page---byline--5a025401b609---------------------------------------)
> 我们并不理解应用程序如何受到较低隔离级别的影响。也许 READ COMMITTED 就足够好了,或者人们根本不知道自己的数据到底有多脏……Andy Pavlo 在 SIGMOD 2017 上的发言
按回车或点击查看完整图片
你只需要 ACID
## 要点
- 数据库事务意味着 ACID (https://en.wikipedia.org/wiki/ACID) 属性,其中“I”代表隔离性 (https://en.wikipedia.org/wiki/Isolation_(database_systems)) 和并发控制。
- (可串行化) 隔离属性确保并发执行的事务结果与按某种串行顺序执行的结果相同。
- 维护可串行化隔离级别在性能上并非没有代价。
- 许多 DBMS 出于性能原因提供较弱的隔离级别,将选择权留给应用程序开发者。此外,在单体数据库中,较弱的隔离级别通常是默认值,例如 PostgreSQL (https://www.postgresql.org/docs/current/transaction-iso.html#XACT-READ-COMMITTED) 和 MySQL 中的“读已提交”。与此同时,分布式数据库中则普遍采用更强的默认值:YugabyteDB (https://docs.yugabyte.com/preview/architecture/transactions/isolation-levels/) 和 TiDB (https://docs.pingcap.com/tidb/stable/transaction-isolation-levels/) 中的“可重复读”,以及 CockroachDB (https://www.cockroachlabs.com/docs/stable/transactions) 和 YDB (https://ydb.tech/docs/en/concepts/transactions) 中的“可串行化”。
- 较弱的隔离级别可能导致微妙的并发错误。这些错误可能引入安全漏洞。
- 由于数据库相关应用程序逻辑中的并发错误,数百万美元被盗,尤其是从 BTC 交易所。我们将在后续章节中详细介绍多个案例。
在这篇文章中,我们尝试回答两个重要问题:
1. 较弱的隔离级别是否经常在实际应用中导致并发错误?
2. 可串行化隔离级别的性能惩罚是否合理,还是被高估了?
在回答这些问题后,我们得出结论:默认使用较弱的隔离级别是一种过早优化和罪恶 (https://en.wikipedia.org/wiki/Program_optimization#When_to_optimize) 的形式。考虑默认使用可串行化隔离,除非你的 DBMS 是 CockroachDB (https://github.com/cockroachdb/cockroach) 或 YDB (https://github.com/ydb-platform/ydb),它们已经默认使用可串行化隔离。
## 隔离级别的微妙之处
假设一个只有一列名为“color”的表,包含字符串“black”或“white”。一个用户想要将所有“white”颜色改为“black”,而另一个用户同时尝试将“black”改为“white”。换句话说,有两个并发事务:
```
--- 事务 1 事务 2
UPDATE t SET color = 'black' UPDATE t SET color = 'white'
WHERE color = 'white'; WHERE color = 'black';
```
这两个事务的结果会是什么?直观上,所有颜色要么变成黑色,要么变成白色。但在数据库实践中,正确的答案是“取决于隔离级别”。
通常,当我们说“事务”时,我们假设事务满足 ACID 安全属性:
- **A**tomicity(原子性):事务要么全部提交,要么全部中止。Martin Kleppmann 建议将此属性称为“可中止性”,因为这样更能准确反映含义,并避免原子提交/中止与原子可见性之间的混淆。
- **C**onsistency(一致性):历史上为了拼写更好听而加入,更多是针对应用程序而非 DBMS 特定的。
- **I**solation(隔离性):并发执行的事务相互隔离。事务执行的结果看起来像是事务被一个接一个地串行执行。
- **D**urability(持久性):已提交的数据永不丢失。
虽然“隔离性”最初本意是“可串行化”,但为了在性能和安全性之间做出权衡,引入了较弱的隔离级别 (https://en.wikipedia.org/wiki/Isolation_(database_systems)):
- 读未提交
- 读已提交
- 可重复读
自 SQL:1999 标准(包括其最新版本 SQL:2023 (ISO/IEC 9075:2023))以来,可串行化是默认的隔离级别。它也是 CockroachDB 和 YDB 中的默认隔离级别。然而,许多数据库厂商默认使用较弱的隔离级别,特别是:
- PostgreSQL 和 Oracle 中的“读已提交”。
- MySQL/InnoDB 和 YugabyteDB 中的“可重复读”(有一个微妙之处,见下文)。
此外,数据库厂商还提供了自己令人困惑的命名约定。例如,根据文档 (https://dev.mysql.com/doc/refman/8.4/en/innodb-consistent-read.html),MySQL/InnoDB 中的“可重复读”仅对只读事务提供其保证。这就是为什么 Hermitage (https://github.com/ept/hermitage/blob/master/mysql.md) 指出 MySQL/InnoDB 中的“可重复读”实际上是“读已提交”(“单调原子视图”)。而 Oracle 的“可串行化”实际上并不是可串行化,而是“可重复读(快照隔离)”(应用程序开发者可以轻松绕过这一点)。详情请查看这篇稍旧的文章 (https://www.dbi-services.com/blog/oracle-serializable-is-not-serializable/) 或更新的 2022 年修订版 (https://dev.to/yugabyte/sql-to-avoid-data-corruption-in-race-conditions-with-serializable-n5c) 以及 Hermitage 专门针对 Oracle 的页面 (https://github.com/ept/hermitage/blob/master/oracle.md)。从某种意义上说,所有这些与许多类似 Citus 的分片 Postgres 解决方案类似,当涉及多分片事务时,它们并不是 ACID (https://dev.to/yugabyte/citus-is-not-acid-but-eventually-consistent-3711) 的。
## 将 Evgeniy Ivanov 的故事发送到你的收件箱
免费加入 Medium 以获取该作者的最新消息。
记住我以便更快登录
现在,回到我们最初的彩色示例,在“可串行化”隔离级别下,结果确实是所有值都变成白色或黑色。然而,在“读已提交”(PostgreSQL 的默认隔离级别)下,某些值可能会改变颜色,而某些则不会。在“可重复读”下,值预计会互换:黑色变成白色,白色变成黑色。更多有趣的例子可以在这里 (https://wiki.postgresql.org/wiki/SSI) 找到。
艺术和人为的例子到此为止。让我们跳转到取自 Martin Kleppmann 的伟大著作《设计数据密集型应用》 (https://dataintensive.net/) 中一个更现实的应用错误案例。想象你正在编写一个管理值班医生的应用程序。每个班次有多位值班医生,如果该班次至少还剩一位医生,任何医生都可以放弃他们的班次。我们使用 PostgreSQL 16 作为数据库:
```sql
CREATE TABLE shift (id int, name text, on_call boolean);
INSERT INTO shift VALUES
(1, 'Alice', true),
(1, 'Bob', true);
SELECT * FROM shift WHERE id = 1 AND on_call;
id | name | on_call
----+-------+---------
1 | Alice | t
1 | Bob | t
(2 rows)
```
现在,Alice 和 Bob 同时想要离开班次:
```sql
--- Alice --- Bob
BEGIN; BEGIN;
SELECT count(*) FROM shift SELECT count(*) FROM shift
WHERE id = 1 AND on_call; WHERE id = 1 AND on_call;
count count
------- -------
2 2
(1 row) (1 row)
UPDATE shift UPDATE shift
SET on_call = false SET on_call = false
WHERE id = 1 AND name = 'Alice'; WHERE id = 1 AND name = 'Bob';
COMMIT; COMMIT;
```
让我们检查结果:
```sql
SELECT * FROM shift WHERE id = 1;
id | name | on_call
----+-------+---------
1 | Alice | f
1 | Bob | f
(2 rows)
```
明确地说,我们并行执行了两个*事务*,结果我们的约束被破坏了。这是因为默认情况下,PostgreSQL 使用“读已提交”隔离级别。如果使用“可串行化”,我们就不会最终导致医院空无一人。
在这篇文章中,我们不介绍任何理论,因为有很多优秀的数据库教科书可用。如果你是新手,我们强烈建议阅读前面提到的 Martin Kleppmann 的《设计数据密集型应用》 (https://dataintensive.net/) 和 Alex Petrov 的《数据库内部原理》 (https://www.databass.dev/)。此外,Franck Pachot 撰写的“现代 SQL 数据库中的隔离级别系列”文章 (https://dev.to/franckpachot/series/25468) 是实践知识的绝佳来源。Martin Kleppmann 在其 Hermitage (https://github.com/ept/hermitage) 项目中对 ACID 的“I”进行了出色的测试。
相反,我们将尝试理解不将可串行化作为默认隔离级别的利弊。
## 过早优化?
使用较弱的隔离级别并不总是意味着你在一致性和性能之间进行实际权衡。这取决于数据和事务:查询可能很简单,可能不需要串行化。在这种情况下,较弱的隔离级别可能会免费提供更好的性能。不知何故,普遍存在一种信念,即较弱的隔离级别对大多数事务来说足够好,应用程序开发者应该足够熟练,能够检测到需要更强隔离的情况,并逐个案例使用它。
为了展示第二种思想流派,我们引用 Martin Kleppmann 的话:“不幸的是,那些较弱的隔离级别相当难以理解。尽管我们的行业已经在这个领域工作了 20 多年,但能够随口解释出读已提交和可重复读之间区别的人并不多。这是一个问题,因为如果你不知道可以从数据库中获得什么保证,你就无法知道代码是否有并发错误和竞态条件。”
这两种观点的问题在于它们都是理论性的。性能可以测量,错误可以计数。因此,我们决定进行一项小型二次研究,以检查:
1. 由较弱隔离级别引起的错误是否经常发生?
2. 可串行化级别的性能影响真的那么显著吗?
2017 年,斯坦福大学的 Peter Bailis 和 Todd Warszawski 发表了一篇出色的研究论文 (http://www.bailis.org/papers/acidrain-sigmod2017.pdf),题为“ACIDRain:针对数据库支持的 Web 应用程序的并发相关攻击”。他们分析了“12 个流行的自托管电子商务应用程序,这些应用程序用四种语言编写,部署在超过 200 万个网站上”,并识别并验证了“22 个关键的 ACIDRain 攻击,这些攻击允许攻击者破坏商店库存、超支礼品卡以及窃取库存”。根据论文,“在这 22 个漏洞中,有 5 个是基于级别的,意味着默认的弱隔离级别导致了漏洞背后的异常。其余 17 个是基于范围的,意味着数据库访问没有正确封装在事务中,并且并发 API 请求可能触发漏洞,而与数据库后端提供的隔离级别无关。”
此外,论文引用了几个有趣的案例,其中并发问题(回想一下 ACID 的隔离属性是关于并发的)导致了安全漏洞:
- 这里 (https://hackingdistributed.com/2014/04/06/another-one-bites-the-dust-flexcoin/) 是关于 Flexcoin 和 Poloniex 比特币交易所的并发相关攻击的故事。结果,所有 Flexcoin 的比特币都被盗,Flexcoin 被迫关闭。由于完全相同的错误,Poloniex 也发生了同样的事情。文章作者提出了一个非常有趣且重要的观点:这种错误不是安全缺陷,因为既没有发生未经授权的访问,也没有授权方案故障——应用程序只是设计上有缺陷。
- 另一个 (https://www.nytimes.com/2016/06/18/business/dealbook/hacker-may-have-removed-more-than-50-million-from-experimental-cybercurrency-project.html?smid=tw-share) 关于并发利用导致数字货币被盗的故事。
我们很容易发现另一个 BTC 故事 (https://www.reddit.com/r/Bitcoin/comments/1wtbiu/how_i_stole_roughly_100_btc_from_an_exchange_and/):一名攻击者通过利用与事务相关的并发错误(即丢失更新)窃取了 100 BTC。此外,还有很多不那么戏剧性的故事,比如这个 (https://www.michaelmelanson.net/posts/transactions-the-limits-of-isolation/),其中错误不是安全问题,但调查起来很微妙。
在我们的二次研究中,我们发现了另一篇有趣的近期论文 (https://ipads.se.sjtu.edu.cn/_media/publications/concerto-sigmod22.pdf)。作者将由应用程序协调的数据库操作定义为 ad hoc 事务。换句话说,ad hoc 事务逻辑在应用程序端实现并发控制。他们检查了 8 个流行的开源 Web 应用程序,发现了 91 个 ad hoc 事务,其中 71 个起着关键作用。其中 53 个存在正确性问题。我们认为这支持了并发控制是复杂的,即使是经验丰富的开发者也会经常犯并发错误这一观点。
另一个与默认设置的弱隔离级别相关的重要担忧。在许多 DBMS(特别是 PostgreSQL)中,可串行化事务仅与其他可串行化事务串行化。如果你的应用程序有一堆弱隔离级别的事务,并且你添加了一个需要串行化的新事务,你将不得不审查现有事务,以找出那些必须“提升”隔离级别的事务。由于应用程序通常有大量事务,很容易忽略需要提升的那些。
现在,让我们尝试了解串行化的性能影响。令人惊讶的是,关于弱隔离级别导致错误的故事和出版物,远比关于更强隔离级别导致不切实际的低性能的案例要多。
这篇论文 (https://drkp.net/papers/ssi-vldb12.pdf) 描述了 PostgreSQL 中初始的可串行化快照隔离(SSI,即可串行化的另一种名称)实现。作者得出结论,“可串行化”的性能仅略低于“可重复读”。在实践中,存在串行化失败导致重试和性能下降的情况,但与并发错误不同,这些情况可以相对容易地解决 (https://engblog.nirvanatech.com/investigating-serialization-anomalies-in-postgresql-d9944b08edf5)。
我们找到的唯一合理的“可重复读”与“读已提交”的比较是 Percona 的这篇过时文章 (https://www.percona.com/blog/read-commited-vs-repetable-read-in-tpcc-like-load/)。他们得出结论,在 TPC-C (https://en.wikipedia.org/wiki/TPC-C) 负载(行业标准的 OLTP (https://en.wikipedia.org/wiki/Online_transaction_processing) 基准测试)下,这两种模式之间几乎没有差异。我们认为,关于这个话题没有新的出版物支持了这一结论。
## 结论
现代研究清楚地表明,由弱隔离级别引起的并发错误并不罕见。它们约占所有与数据库事务相关的错误的 20%。研究人员已经在许多
相似文章
SQL:设计上的缺陷
本文分析了 SQL 中固有的并发缺陷,如原子性失效、TOCTOU 问题和死锁,并通过资金转账示例展示了正确的锁机制和事务实践。
在 TLA+ 中扩展 MVCC 以实现可串行化 (2024)
这篇博客文章讨论了如何使用 TLA+ 形式化建模将多版本并发控制(MVCC)扩展以实现可串行化隔离,这项工作建立在先前工作的基础上,并引用了 Cahill、Röhm 和 Fekete 的研究。
为什么你的AI智能体的“记忆”是一场潜在的数据泄露。
文章警告,在多租户AI智能体中使用仅具有逻辑隔离(元数据过滤器)的共享向量数据库可能会在无声无息中引发数据泄露,并提倡为每个用户提供物理隔离以确保零数据泄漏。
并发 vs. 吞吐量:为什么更多并行性反而会让数据库变慢
PlanetScale 的一篇工程博客分析了由长事务和高并发导致的 MySQL 宕机,解释了并行性如何降低吞吐量,以及 Vitess 的事务池如何处理(并放大)了该问题。
SQLite 应该采用 (Rust 风格的) 版本
文章认为,SQLite 在外键约束和类型强制方面的默认设置存在问题,并建议采用 Rust 风格的版本,让用户可以选择更安全的默认设置。