分布式数据库中的隔离级别陷阱
在分布式系统中,组件之间的耦合度越低,系统的扩展性就越好。这条规则同样适用于分布式数据库,而隔离级别在其中扮演了重要角色。这篇文章试图阐释这些隔离级别的含义及其中的权衡取舍,同时提供建议,帮助你选择最适合需求的隔离级别。
ANSI 隔离级别标准
隔离性存在一套 ANSI 的标准。针对这些标准也有批判性的分析,其中指出了标准中存在的模糊性。对于热衷于研究数据库和事务的人来说,这些分析非常有趣;但对于使用数据库来说,这样的深入理解并非必要。
本篇文章将覆盖使用隔离级别所需的最低知识。为了达到目的,我们将通过两个具有代表性用例来研究不同隔离级别的效果。
用例 A:银行
客户从银行账户中提现:
Begin Transaction 读取用户的账户余额 在活动表中创建一条记录(为了避免与数据库事务混淆,我们不称它为事务) 更新用户的余额(将提现金额从账户余额中减去) Commit
在事务完成之前,我们不希望用户的余额发生任何变化。
用例 B:零售
一个国际客户从零售商店购买商品,使用的货币与商品标价的货币不同:
Begin Transaction 读取 exchange_rate 表,获取最新的汇率 在订单表中创建一条记录 Commit
我们假设有一个独立的进程会不断更新汇率,但我们不关心在当前事务完成之前汇率是否发生变化。
Serializable 隔离级别
Serializable 是唯一符合理论上 ACID 属性定义的隔离级别。它的核心原则是,两个并发事务不得相互干扰它们的变化,并且无论事务是串行执行还是并发执行,它们都必须产生相同的结果。
然而,Serializable 一般被认为是不切实际的,即使是在非分布式数据库中也如此。目前所有流行的数据库(如 Postgres 和 MySQL)都建议避免使用这个隔离级别。
为什么 Serializable 难以使用?
让我们看这两个用例:
在银行的用例中,Serializable 是完美的。读取用户余额后,数据库保证用户余额不会发生变化。因此,我们可以安全地进行业务逻辑,例如确保余额充足,并基于读取的值更新余额。
在零售的用例中,Serializable 同样可以正确工作。然而,用于更新汇率的进程将在创建订单的事务成功之前无法执行操作。
乍一看,这种事件的明确顺序可能显得是一个好特性。但是,如果创建订单的事务复杂且耗时,比如它需要调用仓库检查库存或对用户进行信用检查,那么在此期间它将持有该行的锁,阻止汇率更新进程对其进行更新。这种可能的非预期依赖性会影响系统的扩展性。
死锁问题
使用 Serializable 的设置很容易出现死锁。例如,如果两个事务读取了用户的余额,它们都会对该行设置共享读锁。当这些事务尝试修改该行时,它们需要将读锁升级为写锁,这会导致死锁,因为每个事务都被另一个事务持有的读锁所阻塞。其他隔离级别可以轻松避免这个问题。
总而言之,对于一个高争用的工作负载,使用 Serializable 会导致扩展失败。如果工作负载并不是争用密集型,那么其实根本不需要这么高的隔离级别;较低的隔离级别可以同样有效。
为了避免这种不必要且昂贵的安全性,应用程序必须进行重构。例如,获取汇率的代码可能需要在事务开始之前调用,或者读取需要在独立的连接上完成。
其他隔离级别尽管不如 Serializable 那样理论纯粹,却允许在指定情况下执行 Serializable 读取,从而使它们在编写可扩展系统时更加灵活与实用。
基于无锁的实现
一些实现提供了无需锁的数据一致性来支持 Serializable。但这些系统会表现出类似的问题;冲突的事务可能只是以不同的方式失败。问题的根源在于隔离级别本身,任何实现都无法摆脱这些限制。
RepeatableRead 隔离级别
RepeatableRead 隔离级别具有一定的歧义性。它区分了点查询和搜索操作,为它们分别定义了不同的行为。这并非是明确且统一的定义,导致了许多不同的实现。本文不详细讨论该隔离级别,但对于我们的用例而言,RepeatableRead 提供了与 Serializable 相同的保证,因而继承了相同的问题。
SnapshotRead 隔离级别
SnapshotRead 隔离级别虽然不是 ANSI 标准的一部分,但正越来越受欢迎。它也被称为 MVCC(多版本并发控制)。该隔离级别的优点是无争用:它在事务开始时创建一个快照,所有读取操作基于这个快照而无需锁定。而写操作遵循严格的 Serializable 规则。
主要用途
SnapshotRead 非常适合于只读工作负载,因为你可以看到数据库的一个一致快照。这会避免在加载相互依赖的数据时产生意外。例如,你可以读取多个表的指定时刻的快照,并在稍后检查自快照以来发生的变化。这种功能对那些将更改流式传输到分析数据库的“数据变更捕获”(Change Data Capture)工具来说非常方便。
对写事务的作用
对于写事务来说,SnapshotRead 并不显得特别有用。写事务主要关心的是是否允许值在最后一次读取之后发生变化。如果允许发生变化,那么读取的值立即就可能过时,与快照无关。如果不想允许变化,则需要锁定行以防止修改。
用例适配
对于零售用例,SnapshotRead 可以自然地工作而不会引发争用:读取的汇率基于事务开始时的快照。在此事务进行期间,另一个事务仍然可以更新汇率。
对于银行用例,数据库允许对数据施加锁,例如 MySQL 提供了 “select… lock in share mode”(共享读锁)。这种模式将读取升级为 Serializable 事务。当然,这样也继承了 Serializable 的死锁风险。
另一方面,较低隔离级别为你提供了两全其美的选择。例如,还有一种选项是使用“select… for update”(写锁)。此锁阻止其他事务对该行获取任何锁定。尽管听起来锁定很糟糕,但这种悲观锁定允许两个竞速事务成功完成而无需遭遇死锁。第二个事务将等待第一个事务完成后再读取和锁定对新值进行操作。
MySQL 默认支持用不同机制实现的 SnapshotRead。令人困惑的是,它错误地将其命名为 RepeatableRead。
ReadCommitted 隔离级别
ReadCommitted 的定义不如 SnapshotRead 隔离级别那么模糊,因为它持续返回数据库的最新视图。这也是所有隔离级别中最少冲突的隔离级别。在这一隔离级别中,每次读取某行时可能会获得不同的值。
ReadCommitted 设置还允许通过添加读锁或写锁来升级读取,从而实现按需 Serializable 读取。正如前面提到的,结合应用事务的修改意图,这种机制提供了两全其美的选择。
Postgres 的默认隔离级别是 ReadCommitted。
ReadUncommitted 隔离级别
ReadUncommitted 一般被认为不安全,不建议应用于分布式或非分布式的环境。从这种隔离级别中读取的数据可能后来被回滚(或根本不存在)。
分布式事务
这一主题与隔离级别相互独立,但由于其涉及到松耦合设计,它值得在这里讨论。
在分布式系统中,如果两个行分别位于不同分片或数据库中,并且你希望原子性地修改它们,你会面临两阶段提交(2PC)的开销。两阶段提交需要完成以下额外的工作:
- 创建并保存分布式事务的元数据到持久存储。
- 向所有单个事务发出准备(Prepare)命令。
- 将提交的决策保存到元数据。
- 向准备好的事务发出提交命令。
这种准备过程要求将元数据保存下来,以便在提交或回滚之前,如果节点发生崩溃,可以将事务重新上线。
分布式事务还会与隔离级别发生交互。例如,假设 2PC 事务中的第一个提交已成功,第二个提交则被延迟。如果应用程序读取了第一个提交效果,数据库必须阻止应用程序读取第二个提交的行直到完成。而相对的,如果应用程序在第二个提交之前读取了其中的行,则它不能看到第一个提交的效果。
为了支持分布式事务的隔离性保证,数据库需要做额外的工作。如果应用程序能容忍这些部分提交,那么其实我们是在做应用程序不关心的额外工作。不妨考虑一种新的隔离级别,比如 ReadPartialCommits(读部分提交)。注意,这与 ReadUncommitted 不同,后者可能读取到最终会被回滚的无效数据。
此外,过多使用 2PC 会对系统的整体可用性和延迟产生负面影响。因为你的系统的有效可用性将被性能最差的分片所决定。
总结
为了实现系统的扩展性,应用程序应尽量避免依赖数据库的高级隔离功能。转而使用最少量的保证。如果程序能够在 ReadCommitted 隔离级别下正常运行,那么最好不要使用 SnapshotRead。而 Serializable 和 RepeatableRead 几乎总是一个糟糕的选择。
如果可能的话,也应尽量避免多语句事务。不过随着应用程序的不断演化,这种需求可能会变得不可避免。此时建议主要依赖事务的原子性保证,并尽量使用数据库系统支持的最低隔离级别。
如果是使用分片数据库,则应避免分布式事务。这可以通过将相关的行保存在同一个分片中来实现。
虽然这些建议可能与不提前优化程序的通用建议有冲突,但此类情况有所不同。这是从一开始就必须做的事情,因为将一个非并发程序转换为并发程序是非常困难的。
关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台
除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接
本文链接:http://www.choupangxia.com/2025/05/19/pitfalls-of-isolation-levels-in-distributed-databases/