07

事务

数据库的原子操作

阅读量:3 · 预计 20 分钟读完

ACID隔离级别快照隔离可序列化
阅读进度5%

第七章 事务 - 数据库的原子操作

导读

事务(Transaction)是数据库系统最核心的抽象之一。它将一组数据库操作捆绑为一个不可分割的单元——要么全部成功,要么全部失败。这个看似简单的概念,实际上是数据库保证数据一致性和可靠性的基石。

当你转账 100 元时,系统需要从你的账户扣款 100 元,同时向对方账户增加 100 元。如果只完成了扣款而没完成入账,数据就不一致了。事务机制保证了这种"要么全做,要么全不做"的原子性。

本章将深入探讨事务的 ACID 特性、不同隔离级别的意义和实现、并发控制的机制,以及分布式事务的挑战。理解事务是理解数据库行为的关键。


核心概念详解

7.1 ACID 特性

ACID 是事务的四个核心特性的缩写:

7.1.1 原子性(Atomicity)

原子性保证事务中的所有操作要么全部成功,要么全部失败回滚。如果事务在执行过程中发生错误(如系统崩溃、约束违反),已经执行的操作会被撤销,数据库恢复到事务开始前的状态。

原子性的实现通常依赖于撤销日志(Undo Log):

  • 事务执行每个操作前,先将原始数据记录到 Undo Log。
  • 如果事务需要回滚,通过 Undo Log 恢复原始数据。
  • 如果系统崩溃,重启时通过 Undo Log 回滚未完成的事务。

7.1.2 一致性(Consistency)

一致性保证事务将数据库从一个合法状态转换到另一个合法状态。所谓"合法状态"是指满足所有约束(主键约束、外键约束、检查约束等)的状态。

一致性是事务的目的,而非机制。ACID 中的其他三个特性(原子性、隔离性、持久性)都是为了保证一致性。

需要注意的是,一致性有两种理解:

  • 应用层一致性:业务逻辑定义的不变量(如"账户余额不能为负")。这需要应用代码正确实现。
  • 数据库层一致性:数据库约束定义的不变量(如"主键不能重复")。这由数据库自动保证。

7.1.3 隔离性(Isolation)

隔离性保证并发执行的事务互不干扰。当多个事务同时访问和修改相同数据时,隔离性保证每个事务看到的数据状态是正确的。

隔离性是 ACID 中最复杂的特性,因为完全隔离(串行执行所有事务)会严重影响并发性能。数据库提供了多种隔离级别(Isolation Level),在并发性能和隔离强度之间做出不同的权衡。

7.1.4 持久性(Durability)

持久性保证一旦事务提交,其修改将永久保存,即使系统崩溃也不会丢失。

持久性的实现通常依赖于重做日志(Redo Log / Write-Ahead Log):

  • 事务提交时,先将所有修改记录到 Redo Log 并刷盘(fsync)。
  • 即使数据页还没有写入磁盘,系统崩溃后也可以通过 Redo Log 恢复已提交事务的修改。

在复制环境中,持久性还涉及数据是否已经复制到从节点。异步复制下,主节点崩溃可能导致已提交但未复制的数据丢失。

7.2 并发问题与隔离级别

7.2.1 并发问题

当多个事务并发执行时,可能出现以下问题:

脏读(Dirty Read):

  • 事务 A 读取了事务 B 尚未提交的修改。
  • 如果事务 B 回滚,事务 A 读取的数据就是无效的。

不可重复读(Non-Repeatable Read):

  • 事务 A 两次读取同一行数据,得到不同的结果。
  • 因为在这两次读取之间,事务 B 修改了这行数据并提交。

幻读(Phantom Read / Read Skew):

  • 事务 A 两次执行相同的范围查询,第二次查询看到了第一次查询中不存在的新行。
  • 因为在这两次查询之间,事务 B 插入了新行并提交。

写倾斜(Write Skew):

  • 事务 A 和事务 B 同时读取相同的数据,基于读取的结果做出不同的修改。
  • 两个事务的修改单独看都是正确的,但组合在一起违反了业务约束。
  • 例如:两个医生同时查看值班表,都认为自己可以休息,结果两人都提交了休息申请,导致没有人值班。

丢失更新(Lost Update):

  • 事务 A 和事务 B 同时读取同一行数据,各自修改后提交。
  • 后提交的事务覆盖了先提交的事务的修改,导致先提交的修改丢失。

7.2.2 隔离级别

SQL 标准定义了四种隔离级别,从低到高:

READ UNCOMMITTED(读未提交):

  • 允许脏读。事务可以看到其他事务尚未提交的修改。
  • 并发性能最高,但数据一致性最差。
  • 实际应用中很少使用。

READ COMMITTED(读已提交):

  • 禁止脏读。事务只能看到其他事务已提交的修改。
  • 允许不可重复读和幻读。
  • 每次 SELECT 语句都会看到最新的已提交数据(语句级快照)。
  • PostgreSQL 和 Oracle 的默认隔离级别。

REPEATABLE READ(可重复读):

  • 禁止脏读和不可重复读。事务在整个生命周期内看到的数据是一致的(事务级快照)。
  • 允许幻读(在标准定义中)和写倾斜。
  • MySQL InnoDB 的默认隔离级别。InnoDB 通过 MVCC + Gap Lock 实现了比标准 REPEATABLE READ 更强的隔离,实际上防止了大部分幻读。

SERIALIZABLE(可串行化):

  • 最高隔离级别。保证并发执行的结果与串行执行完全一致。
  • 禁止所有并发问题(脏读、不可重复读、幻读、写倾斜、丢失更新)。
  • 实现代价最高,并发性能最低。

7.3 隔离级别的实现

7.3.1 读已提交的实现

读已提交通常通过锁或MVCC实现。

基于锁的实现:

  • 读操作获取共享锁(S Lock),写操作获取排他锁(X Lock)。
  • 读操作在语句结束时释放锁(不是事务结束时),允许其他事务修改数据。
  • 因此,同一事务中的两次读取可能看到不同的数据(不可重复读)。

基于 MVCC 的实现:

  • 每个事务在开始时会获得一个快照(Snapshot),包含所有已提交事务的集合。
  • 每条 SELECT 语句使用最新的快照(语句级快照),可以看到在此语句开始前所有已提交的修改。
  • PostgreSQL 的 READ COMMITTED 就是这种实现。

7.3.2 可重复读的实现

可重复读通常通过MVCC(Multi-Version Concurrency Control)实现。

MVCC 的核心思想:

  • 数据库中每行数据维护多个版本,每个版本有一个事务 ID(创建该版本的事务)。
  • 事务开始时获得一个快照,包含所有在该事务开始前已提交的事务 ID。
  • 事务中的每次读取都使用这个快照,只能看到快照中已提交事务修改的最新版本。
  • 因此,同一事务中的多次读取看到的数据是一致的。

MVCC 的优势:

  • 读操作不阻塞写操作,写操作不阻塞读操作。
  • 读写冲突通过版本选择来解决,不需要加锁。
  • 并发性能高。

MVCC 的代价:

  • 需要存储多个版本的数据,占用更多存储空间。
  • 需要定期清理不再被任何活跃事务引用的旧版本(Vacuum / GC)。
  • 长事务会阻止旧版本的清理,可能导致存储膨胀。

7.3.3 可串行化的实现

可串行化是最难实现的隔离级别,主要有三种方法:

方法一:串行执行(Literal Serial Execution):

  • 所有事务串行执行,一次只有一个事务在运行。
  • 优势:实现简单,没有并发问题。
  • 劣势:并发性能极低,只适合吞吐量要求不高的场景。
  • 代表:SQLite(单线程模型)、Redis(单线程处理命令)。

方法二:两阶段锁(Two-Phase Locking, 2PL):

  • 事务分为两个阶段:增长阶段(只能获取锁,不能释放锁)和收缩阶段(只能释放锁,不能获取锁)。
  • 读操作获取共享锁,写操作获取排他锁。
  • 2PL 保证了可串行化,但可能导致死锁(两个事务互相等待对方的锁)。
  • 代表:MySQL InnoDB 的 SERIALIZABLE 模式(通过 lock-based MVCC 实现)。

方法三:可串行化快照隔离(Serializable Snapshot Isolation, SSI):

  • 基于 MVCC,但增加了写冲突检测。
  • 事务在提交时检查是否存在"危险结构"(Dangerous Structure)——可能导致不可串行化的读写依赖环。
  • 如果检测到危险结构,中止(Abort)当前事务。
  • 优势:读写不冲突,并发性能高。只有在真正发生冲突时才中止事务。
  • 代表:PostgreSQL 的 SERIALIZABLE 模式、CockroachDB。

7.4 快照隔离与写倾斜

7.4.1 快照隔离(Snapshot Isolation)

快照隔离是很多数据库的默认隔离级别(如 Oracle 的 READ COMMITTED、PostgreSQL 的 REPEATABLE READ)。

核心规则:

  • 事务在开始时获得一个快照。
  • 事务中的所有读取都基于这个快照。
  • 事务在提交时检查第一更新者规则(First-Updater-Wins):如果另一个事务已经修改了当前事务也要修改的数据,并且那个事务已经提交,则当前事务被中止。

快照隔离可以防止脏读、不可重复读和大部分幻读,但不能防止写倾斜。

7.4.2 写倾斜问题

写倾斜是快照隔离的一个著名缺陷。经典案例:

值班安排问题:

  • 两个医生(Alice 和 Bob)值班的约束是:至少需要一人值班。
  • Alice 开始事务,查看当前值班情况:Alice 值班,Bob 休息。
  • Bob 同时开始事务,查看当前值班情况:Alice 值班,Bob 休息。
  • Alice 决定休息,提交修改:Alice 休息,Bob 休息。
  • Bob 决定休息,提交修改:Alice 休息,Bob 休息。
  • 结果:两人都休息了,违反了"至少一人值班"的约束。

写倾斜发生的原因:两个事务各自基于快照看到的"合法状态"做出修改,但组合在一起违反了约束。

7.4.3 解决写倾斜

方法一:使用 SERIALIZABLE 隔离级别:

  • SSI 可以检测写倾斜并中止其中一个事务。

方法二:显式加锁:

  • 在读取时使用 SELECT ... FOR UPDATE 加排他锁,阻止其他事务同时修改。
  • 缺点:降低了并发性能,且容易忘记加锁。

方法三:物化约束:

  • 将业务约束转化为数据库约束。例如,创建一个 on_call 表,添加检查约束 CHECK (count(*) >= 1)。
  • 缺点:不是所有约束都能转化为数据库约束。

方法四:应用层重试:

  • 捕获写倾斜导致的异常,在应用层重试事务。
  • 缺点:需要应用代码正确处理重试逻辑。

重要知识点

知识点 1:MVCC 的垃圾回收

MVCC 需要存储多个版本的数据,旧版本需要定期清理。清理的时机是:当没有活跃事务可能引用该版本时。

PostgreSQL 的 VACUUM:

  • 事务提交后,旧版本不会立即删除,而是标记为"可能不再需要"。
  • VACUUM 进程定期检查,删除所有活跃事务都不再需要的旧版本。
  • 长事务会阻止 VACUUM 清理,可能导致表膨胀(Table Bloat)。

MySQL InnoDB 的 Purge:

  • InnoDB 使用 Undo Log 存储旧版本。
  • 当没有活跃事务引用某个旧版本时,Purge 线程会清理对应的 Undo Log。
  • 长事务同样会阻止 Purge,导致 Undo Log 膨胀。

知识点 2:索引与 MVCC 的交互

在 MVCC 数据库中,索引也需要处理多版本的问题。

PostgreSQL:

  • 索引条目指向特定的行版本(通过 TID,Tuple ID)。
  • 如果一个行有多个版本,索引中可能有多个条目指向同一行的不同版本。
  • 这就是为什么频繁的 UPDATE 会导致索引膨胀。

MySQL InnoDB:

  • 索引条目指向主键,通过主键在聚簇索引中找到对应行。
  • MVCC 的隐藏列(事务 ID、回滚指针)用于追踪行版本。
  • 索引不需要存储多个版本,通过隐藏列和 Undo Log 实现 MVCC。

知识点 3:分布式事务

当事务涉及多个数据库或微服务时,需要分布式事务来保证跨服务的原子性。

两阶段提交(2PC):

  • 准备阶段:协调者询问所有参与者是否可以提交。
  • 提交阶段:如果所有参与者都同意,协调者通知提交;否则通知回滚。
  • 问题:协调者是单点故障,参与者可能长时间阻塞。

三阶段提交(3PC):

  • 在 2PC 基础上增加预提交阶段,减少阻塞时间。
  • 但仍然有单点故障问题。

Saga 模式:

  • 将长事务拆分为一系列本地事务,每个本地事务有对应的补偿操作。
  • 如果某个步骤失败,逆序执行之前步骤的补偿操作。
  • 优势:不需要全局锁,性能好。
  • 劣势:不保证隔离性,可能出现"中间状态"。

知识点 4:事务的历史与演化

事务的概念起源于 1970 年代,Jim Gray 等人提出了事务处理的基本模型。

  • 1970s:IBM 在 System R 中实现了第一个 SQL 事务。
  • 1980s:Oracle 引入 MVCC,避免读阻塞写。
  • 1990s:MySQL 出现,默认隔离级别为 READ COMMITTED。InnoDB 引入 REPEATABLE READ。
  • 2000s:PostgreSQL 引入 SSI(可串行化快照隔离)。
  • 2010s:NewSQL 数据库(CockroachDB、TiDB)将 SSI 和分布式事务结合。
  • 2020s:事务模型继续演化,云原生数据库(Aurora、CockroachDB)将事务与分布式系统深度融合。

常见误区

误区 1:"READ COMMITTED 足够安全"

纠正:READ COMMITTED 允许不可重复读和幻读。在以下场景中可能出问题:

  • 同一事务中两次读取同一行数据用于计算,结果可能不一致。
  • 范围查询的结果可能在事务执行过程中变化。

对于需要事务内一致性的场景,应该使用 REPEATABLE READ 或更高的隔离级别。

误区 2:"REPEATABLE READ 可以防止所有并发问题"

纠正:标准的 REPEATABLE READ 不能防止写倾斜和幻读。MySQL InnoDB 的 REPEATABLE READ 通过 Gap Lock 提供了比标准更强的隔离,但也不是完全的可串行化。如果需要完全的可串行化保证,应该显式使用 SERIALIZABLE 隔离级别。

误区 3:"事务可以保证所有业务约束"

纠正:事务只能保证数据库层面的约束(主键、外键、检查约束等)。业务层面的约束(如"至少一个医生值班")需要应用代码正确实现。如果应用代码有 bug(如忘记检查约束),事务也无法阻止数据不一致。

误区 4:"高隔离级别总是更好"

纠正:更高的隔离级别意味着更低的并发性能。SERIALIZABLE 虽然最安全,但可能导致大量的事务中止和重试。实践中,大多数应用使用 READ COMMITTED 或 REPEATABLE READ 就足够了。只有在明确需要可串行化保证时(如金融交易、库存扣减),才使用 SERIALIZABLE。

误区 5:"分布式事务和单机事务一样简单"

纠正:分布式事务的复杂性远超单机事务。2PC 有单点故障和阻塞问题,Saga 不保证隔离性,SSI 在分布式环境下需要额外的协调开销。在设计微服务架构时,应该尽量避免跨服务的事务,转而使用最终一致性和补偿机制。


实践应用

实践 1:选择合适的隔离级别

根据业务需求选择隔离级别:

场景推荐隔离级别理由
简单 CRUD,无复杂并发READ COMMITTED性能好,足够安全
报表生成,需要一致性读REPEATABLE READ保证事务内读取一致
金融交易,库存扣减SERIALIZABLE需要完全的可串行化保证
高并发写入,可以容忍短暂不一致READ COMMITTED最大化并发性能

实践 2:避免长事务

长事务是数据库性能的杀手:

阻塞 VACUUM / Purge:长事务阻止旧版本的清理,导致存储膨胀。

持有锁时间长:在基于锁的隔离级别中,长事务长时间持有锁,阻塞其他事务。

快照过期:在 MVCC 中,长事务的快照可能很旧,导致读取效率低下。

增加死锁概率:事务执行时间越长,与其他事务冲突的概率越高。

最佳实践:

  • 将大事务拆分为多个小事务。
  • 避免在事务中执行耗时操作(如网络调用、文件 I/O)。
  • 设置事务超时时间。
  • 监控长事务,及时告警。

实践 3:处理并发冲突

在高并发场景中,事务冲突是不可避免的。处理策略:

乐观并发控制(Optimistic Concurrency Control):

- 假设冲突很少发生,不加锁直接执行。

- 提交时检查是否发生冲突,如果冲突则中止重试。

- 适合冲突率低的场景。

悲观并发控制(Pessimistic Concurrency Control):

- 假设冲突很可能发生,先加锁再执行。

- 适合冲突率高的场景。

应用层重试:

- 捕获数据库返回的冲突异常(如 serialization_failure)。

- 在应用层重试事务,使用指数退避避免重试风暴。

实践 4:设计幂等操作

在分布式系统中,重试是常见的容错手段。为了保证重试的安全性,操作应该是幂等的(Idempotent)——执行一次和执行多次的结果相同。

幂等的设计策略:

  • 使用唯一请求 ID:每个操作携带唯一的请求 ID,服务端根据 ID 去重。
  • 使用条件写入:如 UPDATE ... WHERE version = expected_version,只有版本匹配时才更新。
  • 使用幂等的 API 设计:如 PUT 操作天然幂等(设置值),POST 操作不幂等(增加值)。

本章小结

本章深入探讨了数据库事务的核心概念:

ACID 特性:

- 原子性:事务中的操作要么全部成功,要么全部回滚。

- 一致性:事务将数据库从合法状态转换到合法状态。

- 隔离性:并发事务互不干扰。

- 持久性:已提交的事务修改永久保存。

隔离级别:

- READ UNCOMMITTED:允许脏读,很少使用。

- READ COMMITTED:禁止脏读,允许不可重复读。

- REPEATABLE READ:禁止脏读和不可重复读,基于 MVCC。

- SERIALIZABLE:完全可串行化,防止所有并发问题。

并发控制机制:

- 锁:2PL 保证可串行化,但可能导致死锁。

- MVCC:读写不冲突,并发性能高,但需要垃圾回收。

- SSI:基于 MVCC + 写冲突检测,实现可串行化。

并发问题:

- 脏读、不可重复读、幻读、写倾斜、丢失更新。

- 不同隔离级别防止不同的并发问题。

分布式事务:

- 2PC、3PC、Saga 等方案各有优劣。

- 分布式事务的复杂性远超单机事务,应尽量避免。

事务是数据库保证数据一致性的核心机制。理解事务的 ACID 特性、隔离级别和并发控制机制,是正确使用数据库、避免数据不一致问题的基础。在实际应用中,需要根据业务需求选择合适的隔离级别,平衡一致性和性能。

值得深入思考的是,事务的设计不仅仅是技术问题,更是业务问题。一个事务的边界应该对应一个业务操作的边界——例如"下单"是一个业务操作,它涉及创建订单、扣减库存、生成支付单等多个步骤,这些步骤应该在同一个事务中完成。如果事务边界划分不当(过大或过小),都会导致业务逻辑的混乱。过大的事务会降低并发性能,增加死锁概率;过小的事务则可能破坏业务一致性。

此外,随着分布式系统的普及,越来越多的应用需要面对跨服务的事务问题。在这种情况下,应该优先考虑通过架构设计避免分布式事务——例如将强一致性的操作限制在单个服务内部,跨服务的操作采用最终一致性和补偿机制。只有在确实无法避免时,才使用 Saga 或 TCC 等分布式事务模式。记住:最好的分布式事务是不需要分布式事务。

在实际开发中,还有一些容易被忽视的事务相关细节值得注意。例如,在很多 Web 框架中,数据库连接和事务的管理是由框架自动处理的。如果不了解框架的事务管理策略,可能会无意中创建出过长的事务(如在事务中执行耗时的外部 API 调用),或者在应该使用事务的地方遗漏了事务声明。另外,不同数据库对 SQL 标准中隔离级别的实现可能存在差异——同一个隔离级别在不同数据库中的行为可能不完全相同。因此,在切换数据库或升级版本时,应该仔细测试事务的行为是否符合预期,避免因为隔离语义的差异而引入难以排查的数据一致性问题。