数据库事务隔离:从并发异常到 MVCC

举报
yd_232225224 发表于 2026/10/04 17:36:50 2026/10/04
【摘要】 数据库通常同时服务大量客户端,多个事务并发读写同一份数据是常态。如果不加任何控制,事务之间会相互干扰:一个事务读到了另一个事务尚未提交的修改,两个事务的更新互相覆盖,同一个查询在一个事务中执行两次得到不同的结果。这些问题轻则导致报表数字对不上,重则造成账户余额错误、库存超卖。事务隔离就是数据库为解决这类问题而提供的机制。本文从事务的基本性质出发,介绍各类并发异常、SQL 标准定义的隔离级别、...

数据库通常同时服务大量客户端,多个事务并发读写同一份数据是常态。如果不加任何控制,事务之间会相互干扰:一个事务读到了另一个事务尚未提交的修改,两个事务的更新互相覆盖,同一个查询在一个事务中执行两次得到不同的结果。这些问题轻则导致报表数字对不上,重则造成账户余额错误、库存超卖。

事务隔离就是数据库为解决这类问题而提供的机制。本文从事务的基本性质出发,介绍各类并发异常、SQL 标准定义的隔离级别、基于锁的实现方式,以及现代数据库广泛采用的多版本并发控制(MVCC),并讨论快照隔离的局限与可串行化的实现。

一、事务与 ACID

事务是一组被视为整体的数据库操作,要么全部成功,要么全部不生效。事务通常被要求满足四项性质,简称 ACID:

  • 原子性(Atomicity):事务中的操作要么全部完成,要么全部撤销,不存在执行了一半的中间状态;
  • 一致性(Consistency):事务执行前后,数据都满足预定义的约束和业务规则;
  • 隔离性(Isolation):并发执行的事务之间互不干扰,每个事务仿佛在独占数据库;
  • 持久性(Durability):事务一旦提交,其结果就会永久保存,即使随后发生崩溃也不会丢失。

其中,原子性和持久性主要通过日志机制实现,一致性在很大程度上依赖于应用程序的正确设计,而隔离性则是并发控制的核心问题,也是本文讨论的重点。

最理想的隔离性是可串行化(Serializable):多个事务并发执行的结果,与它们按某种顺序逐个串行执行的结果完全相同。可串行化消除了所有并发带来的异常,但实现代价较高。因此,数据库通常提供多个较弱的隔离级别,允许以一定程度的异常为代价,换取更高的并发性能。

二、并发异常

要理解隔离级别,首先需要了解并发事务可能产生哪些异常。

1. 脏写

事务 A 修改了某行数据但尚未提交,事务 B 又修改了同一行。如果此后 A 回滚,应当恢复到哪个值就变得混乱。几乎所有数据库在任何隔离级别下都会防止脏写,通常通过对被修改的行加写锁实现,直到事务结束才释放。

2. 脏读

事务 A 修改了某行数据但尚未提交,事务 B 读到了这个未提交的值。如果 A 随后回滚,B 读到的就是一个从未真正存在过的数据。

例如,A 将某账户余额从 100 改为 200,B 读到 200 并据此做出决策,随后 A 因故回滚,余额实际仍为 100。

3. 不可重复读

事务 A 读取了某行数据,随后事务 B 修改了这行数据并提交,A 再次读取同一行,得到了不同的值。

例如,一个统计事务先读取了某商品的价格,在计算过程中价格被另一个事务修改,再次读取时价格已经变了,导致前后计算的依据不一致。

4. 幻读

事务 A 按照某个条件查询出一组行,随后事务 B 插入或删除了满足该条件的行并提交,A 再次按相同条件查询,得到的行数发生了变化,仿佛出现了"幻影"。

不可重复读针对的是已存在行的值被修改,幻读针对的是满足条件的行集合发生变化。防止幻读通常更困难,因为需要锁定的不是某一行,而是"所有满足某条件的行",包括尚不存在的行。

5. 丢失更新

两个事务都读取同一个值,各自基于读到的值计算新值,再分别写回。后提交的写入覆盖了先提交的写入,前一个事务的更新丢失了。

典型的例子是计数器:两个事务同时读取计数为 10,各自加 1 后写回 11,最终结果为 11 而不是 12。

6. 读偏斜

事务 A 读取了两个相关联的数据,但在两次读取之间,事务 B 同时修改了这两个数据并提交。A 读到的是一个"旧值"和一个"新值"的组合,这个组合在任何时刻都未曾真实存在过。

例如,某人在两个账户之间转账,总额始终是 1000。一个查询事务先读取了账户甲(转账前为 500),此时转账发生并提交,账户甲变为 400、账户乙变为 600,查询事务再读取账户乙得到 600,计算出的总额为 1100。

7. 写偏斜

两个事务读取了相同的数据集合,基于读取的结果分别做出判断,然后修改了不同的行。每个事务单独看都没有违反约束,但两者的结果组合在一起时违反了约束。

一个经典的例子是医院值班:规则要求任何时刻至少有一名医生值班。当前有两名医生在值班,两人几乎同时申请休息。两个事务都查询到当前值班人数为 2,判断"另一人仍在值班,自己可以离开",于是各自将自己的状态改为休息。由于两个事务修改的是不同的行,不存在写冲突,都能成功提交,结果无人值班。

写偏斜是一种隐蔽的异常,它不涉及对同一行的并发写入,常规的行锁无法阻止它。

三、SQL 标准的隔离级别

SQL 标准根据能够防止的异常,定义了四个隔离级别:

隔离级别 脏读 不可重复读 幻读
读未提交(Read Uncommitted) 可能 可能 可能
读已提交(Read Committed) 防止 可能 可能
可重复读(Repeatable Read) 防止 防止 可能
可串行化(Serializable) 防止 防止 防止

这套定义在业界广泛使用,但也存在明显的不足。

首先,它只关注了三种异常,没有涵盖丢失更新、读偏斜、写偏斜等情况。其次,它的定义基于基于锁的实现方式,而现代数据库大量采用的多版本并发控制,其行为与这套定义并不完全对应。

因此,不同数据库中同名的隔离级别,实际提供的保证可能存在差异。例如,某些数据库的"可重复读"实际上也能防止幻读;某些数据库声称的"可串行化"实际上是快照隔离,并不能防止写偏斜。在依赖特定隔离行为时,应当查阅所用数据库的具体文档,而不能仅凭级别名称做判断。

各数据库的默认隔离级别也不相同,常见的是读已提交或可重复读,而非可串行化。

四、基于锁的并发控制

实现隔离最直接的方式是加锁。

共享锁与排他锁

  • 共享锁(读锁):多个事务可以同时持有同一数据的共享锁,用于读取;
  • 排他锁(写锁):同一时刻只有一个事务可以持有,且与共享锁互斥,用于修改。

两阶段锁

两阶段锁(Two-Phase Locking,2PL)是实现可串行化的经典协议。它将事务的加锁过程分为两个阶段:

  1. 扩展阶段:事务可以获取锁,但不能释放任何锁;
  2. 收缩阶段:事务可以释放锁,但不能再获取任何新锁。

可以证明,遵循两阶段锁协议的事务调度一定是可串行化的。实践中通常采用严格两阶段锁:所有排他锁一直持有到事务提交或回滚时才释放,以避免其他事务读到未提交的数据。

为了防止幻读,仅锁定已存在的行是不够的,还需要锁定"查询条件所覆盖的范围"。常见做法是使用间隙锁或范围锁,锁定索引中的某个区间,阻止其他事务向该区间插入新行。

锁的代价

基于锁的方法虽然能够提供严格的隔离,但存在明显的性能问题:

  • 读写互相阻塞:一个长时间运行的读事务会阻塞所有试图修改相关数据的写事务,反之亦然;
  • 死锁:两个事务各自持有对方需要的锁,相互等待。数据库需要检测死锁并选择一个事务回滚;
  • 并发度低:在读多写少的场景下,读操作之间本无冲突,但读写之间的阻塞会显著降低吞吐量。

正是为了缓解读写之间的相互阻塞,多版本并发控制应运而生。

五、多版本并发控制

多版本并发控制(Multi-Version Concurrency Control,MVCC)的核心思想是:修改数据时不覆盖旧值,而是创建一个新版本。数据库同时保留同一行数据的多个版本,每个事务根据自己的可见性规则,读取适合自己的那个版本。

这带来了一个重要特性:读不阻塞写,写不阻塞读。读事务读取旧版本,写事务创建新版本,两者互不干扰。写与写之间仍然需要通过锁或冲突检测来协调。

版本的组织

不同数据库组织多版本数据的方式不同,常见的有以下几种:

  • 追加新版本到表中:每次更新都在表中插入一行新数据,旧版本保留在原处,通过元数据标记各版本的有效范围。旧版本需要后台进程定期清理;
  • 原地更新加撤销日志:表中只保存最新版本,修改时将旧值写入撤销日志,各版本通过指针串成链表。读取旧版本时,沿着链表回溯并应用撤销记录;
  • 键与版本号组合存储:在键值存储中,将版本号或时间戳作为键的一部分,同一个键的多个版本按版本号有序排列。

事务标识与可见性

每个事务开始时会获得一个唯一的、单调递增的事务标识。每个数据版本则记录着创建它的事务标识,以及删除或覆盖它的事务标识。

读取数据时,事务需要判断每个版本对自己是否可见。为此,事务会在特定时刻创建一个读视图(也称快照),其中记录:

  • 创建快照时,所有正在运行、尚未提交的事务标识列表;
  • 当时已分配的最大事务标识。

基于读视图,一个数据版本对当前事务可见,需要满足:

  1. 创建该版本的事务是当前事务自身,或者在快照创建时已经提交;
  2. 该版本尚未被删除,或者删除它的事务在快照创建时尚未提交。

简单来说,事务只能看到在快照创建之前已经提交的修改,以及自己所做的修改。之后提交的修改,以及快照创建时尚未提交的修改,都对它不可见。

读已提交与可重复读的区别

基于 MVCC,两种常见隔离级别的区别,主要在于读视图的创建时机:

  • 读已提交:事务中的每一条语句执行时都创建一个新的读视图。因此每条语句都能看到在它开始之前已提交的最新数据,但同一事务中前后两条语句可能看到不同的数据,即存在不可重复读;
  • 可重复读(或快照隔离):事务在第一次读取时创建读视图,此后整个事务都使用这一个快照。事务中的所有读取都基于同一时刻的一致状态,既不会出现不可重复读,也能避免读偏斜。

旧版本的清理

MVCC 会持续产生旧版本,如果不加清理,存储空间会不断增长,读取时需要跳过的版本也越来越多。

当一个旧版本对所有活跃事务都不再可见时,就可以被安全清除。数据库通常有后台进程负责这项工作,称为垃圾回收或清理(Vacuum、Purge 等)。

清理工作的效率受到最老的活跃事务的限制:只要还有一个早期开始的事务没有结束,它可能需要访问的所有旧版本都不能被清理。因此,长时间运行的事务是 MVCC 数据库中常见的性能隐患:它会导致旧版本大量堆积、存储膨胀、查询变慢。在生产环境中,应当避免开启事务后长时间不提交,例如在事务中等待用户输入或调用外部服务。

六、快照隔离及其局限

基于 MVCC 的可重复读级别,在学术上通常被称为快照隔离(Snapshot Isolation)。它的规则可以概括为:

  1. 每个事务从一个一致的快照中读取数据;
  2. 提交时,如果发现自己修改的某一行,在自己的快照之后已经被其他已提交的事务修改过,则提交失败,事务回滚。这一规则称为先提交者胜。

快照隔离能够防止脏读、不可重复读、读偏斜,并通过先提交者胜规则防止丢失更新。由于读操作完全不需要加锁,它的性能非常好,因此被大量数据库采用。

然而,快照隔离并不等同于可串行化,它无法防止写偏斜。

回到医生值班的例子:两个事务基于同一个快照,都看到两名医生在值班;它们分别修改的是不同医生的记录,彼此之间不存在写冲突,先提交者胜规则不会被触发。两个事务都能成功提交,约束被破坏。

类似的问题还出现在许多场景中:

  • 会议室预订:两个事务都查询某时段无预订,然后各自插入一条预订记录;
  • 用户名注册:两个事务都检查某用户名未被占用,然后各自插入该用户名(如果没有唯一约束);
  • 账户透支检查:两个事务都检查多个账户的总余额足够,然后分别从不同账户扣款。

这类问题的共同模式是:先查询,根据查询结果做判断,然后写入,而写入会改变先前查询的结果。

应对写偏斜的方法

在快照隔离下,可以通过以下方式避免写偏斜:

  • 显式加锁:在查询时使用"加锁读"语句,对查询到的行加排他锁,使并发事务在此处串行化。但如果需要锁定的是"不存在的行"(例如检查某时段无预订),这种方法无效;
  • 物化冲突:人为创建一些行来代表需要锁定的对象。例如预先为每个会议室的每个时段创建一行记录,预订时锁定对应的行。这种方法有效,但会使数据模型变得不自然;
  • 使用数据库约束:唯一约束、外键约束、排他约束等由数据库强制保证,不受隔离级别影响;
  • 使用可串行化隔离级别:从根本上解决问题。

七、可串行化的实现

实现可串行化主要有三种方式。

1. 真正的串行执行

最简单的方式是不并发:所有事务在单个线程中逐一执行。这听起来效率很低,但在特定条件下是可行的:

  • 数据全部在内存中,没有磁盘 I/O 等待;
  • 事务都很短小,执行时间在微秒级;
  • 事务以存储过程的形式一次性提交给数据库,不需要在事务执行中与客户端多次交互。

满足这些条件时,单线程可以达到很高的吞吐量,并且完全没有锁和冲突检测的开销。一些内存数据库就采用了这种设计,并通过数据分区,让每个分区由一个独立的线程处理,从而利用多核。

2. 两阶段锁

如前所述,严格两阶段锁加上范围锁可以实现可串行化。这是传统数据库长期以来的实现方式。其代价是读写相互阻塞,在争用激烈时性能下降明显,延迟也不够稳定。

3. 可串行化快照隔离

可串行化快照隔离(Serializable Snapshot Isolation,SSI)是一种较新的方法,它在快照隔离的基础上增加了冲突检测,既保留了快照隔离读写不阻塞的优点,又能保证可串行化。

SSI 的基本思路是:事务正常基于快照执行,不因读取而加阻塞锁;数据库在后台追踪事务之间的读写依赖关系:

  • 如果事务 A 读取了某些数据,而事务 B 随后修改了这些数据,则 A 读到的数据在 B 提交后已经"过时",形成一条从 A 到 B 的依赖;
  • 理论分析表明,快照隔离下的所有非可串行化执行,都必然包含一种特定的依赖模式:存在一个事务,它既有指向其他事务的此类依赖,也被其他事务以此类依赖指向;
  • 当数据库检测到这种危险结构时,就中止其中一个事务,让它重试。

SSI 是一种乐观的并发控制方法:它假设冲突不常发生,先让事务执行,在提交时再检查是否存在冲突。

  • 在争用较少的场景下,SSI 的性能接近快照隔离,远好于两阶段锁;
  • 在争用激烈的场景下,大量事务会被中止重试,性能可能下降;
  • 由于依赖追踪的粒度有限,SSI 可能会中止一些实际上并不会导致异常的事务,即存在一定的误判。

使用 SSI 的应用程序必须能够正确处理事务因序列化冲突而失败的情况,通常的做法是捕获相应的错误并重试整个事务。

八、分布式环境下的隔离

在分布式数据库中,数据分布在多个节点上,实现事务隔离面临额外的挑战。

全局快照的获取。 单机数据库可以通过单调递增的事务标识来定义快照,但在分布式环境下,各节点需要对"哪些事务在快照之前提交"达成一致。常见做法包括:使用一个中心化的时间戳分配服务,为每个事务分配全局有序的时间戳;使用混合逻辑时钟等分布式时钟方案;或借助高精度时钟同步并显式等待时钟不确定区间。

跨节点提交的原子性。 一个事务可能修改多个节点上的数据,需要保证所有节点要么都提交、要么都回滚,通常使用两阶段提交协议实现,并结合共识算法提高协调者的可用性。

冲突检测的范围。 跨节点事务的读写依赖分布在不同节点上,冲突检测需要跨节点协调,代价比单机更高。

许多分布式数据库默认提供快照隔离级别,部分系统提供可串行化级别,具体实现和性能特征差异较大。

九、实践建议

了解所用数据库的默认隔离级别及其实际语义。 不要仅凭级别名称判断它能防止哪些异常,同名级别在不同数据库中的行为可能不同。

识别"先读后写"的业务逻辑。 凡是先查询、根据结果判断、再写入的逻辑,都需要警惕写偏斜和丢失更新。对于这些关键路径,考虑使用加锁读、原子更新语句、数据库约束,或提高隔离级别。

优先使用原子操作。 对于计数器、余额增减等场景,使用"在原值基础上增减"的单条更新语句,而不是"先读出值、在应用中计算、再写回"的方式,可以直接避免丢失更新。

利用数据库约束兜底。 唯一约束、检查约束、外键约束由数据库在任何隔离级别下强制执行,是防止数据不一致的最后一道防线。

保持事务短小。 事务越短,持有锁的时间越短,与其他事务冲突的概率越低,MVCC 的旧版本也能更快被清理。避免在事务中执行网络调用、等待用户操作或进行大量计算。

为重试做好准备。 在可串行化或快照隔离级别下,事务可能因冲突而失败。应用程序应当捕获这类错误并安全地重试。需要注意的是,重试时应当重新执行整个事务的逻辑,包括重新读取数据,而不是仅仅重发写入语句。

根据业务需求权衡隔离级别。 并不是所有场景都需要可串行化。报表查询可能只需要一个一致的快照;对实时性要求高、能容忍一定异常的场景,读已提交可能已经足够。关键在于明确知道每个级别能防止什么、不能防止什么,并据此做出有意识的选择。

结语

事务隔离的本质,是在并发性能与数据正确性之间进行权衡。完全的串行化最安全但并发度最低,完全不加控制则会产生各种难以预料的异常。各个隔离级别,就是在这两个极端之间设定的不同平衡点。

多版本并发控制通过保留数据的多个版本,让读写操作不再相互阻塞,极大地提升了数据库的并发能力,已成为现代数据库的标准做法。但它所提供的快照隔离,仍然留下了写偏斜这样的漏洞。理解这些异常的成因和表现,才能在设计应用时识别出潜在的风险,并选择合适的手段加以防范。

数据库提供了工具,而正确地使用这些工具,最终仍然需要开发者对并发行为有清晰的认识。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。