第七章 事务 - 数据库的原子操作
导读
事务(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 标准中隔离级别的实现可能存在差异——同一个隔离级别在不同数据库中的行为可能不完全相同。因此,在切换数据库或升级版本时,应该仔细测试事务的行为是否符合预期,避免因为隔离语义的差异而引入难以排查的数据一致性问题。