主题
第五章 事务管理与并发控制
📖 ⏱️ 预计阅读时长 1 分钟 👑 会员专属
1. 事务的基本概念
1.1 事务的定义
事务 (Transaction) 是数据库操作的逻辑工作单位,由用户定义的一组操作序列组成。这组操作要么全部成功执行,要么全部不执行,是一个不可分割的整体。在 SQL 中,事务通常以 BEGIN TRANSACTION 语句开始,以 COMMIT(提交)或 ROLLBACK(回滚)语句结束。COMMIT 表示事务正常结束,其所有对数据库的修改将被永久保存;ROLLBACK 表示事务异常结束,系统会将数据库恢复到事务开始之前的状态。
事务是数据库并发控制和故障恢复的基本单位。理解事务的概念和特性是掌握数据库系统运行机制的前提。
1.2 事务的 ACID 特性
事务必须满足原子性、一致性、隔离性和持久性四个特性,简称 ACID 特性。这四个特性共同构成了数据库系统保证数据正确性的基础。
原子性 (Atomicity) 是指事务中包含的所有操作,要么全部成功执行并持久化,要么全部不执行。即使事务在执行过程中发生了故障,已经执行过的操作也必须被完全撤消,使数据库回到事务开始之前的状态。原子性由 DBMS 的事务管理子系统通过撤销日志 (Undo Log) 来保证。当事务需要回滚时(无论是因为用户主动执行 ROLLBACK 还是系统在故障恢复时发现事务未完成),系统根据 Undo Log 中记录的旧值对已经做过的修改逐一执行逆向操作。
一致性 (Consistency) 是指事务的执行必须使数据库从一个一致性状态转变到另一个一致性状态。一致性状态是指数据库中的数据满足所有已定义的完整性约束条件。例如在一个银行转账事务中,无论转账操作的中间过程如何,事务完成后两个账户的余额之和不应该发生变化。如果事务的执行结果违反了任何完整性约束,系统必须阻止该事务的提交。一致性是事务追求的最终目标,它由原子性、隔离性和持久性共同来保障。
隔离性 (Isolation) 是指在并发执行环境中,每个事务的执行不应感知到其他并发事务的存在。一个事务内部的操作及其使用的数据对并发执行的其他事务是隔离的,其他事务不应在该事务的中间状态干预其执行。隔离性通过并发控制机制(主要包括封锁和多版本并发控制)来实现。不同的隔离级别提供不同强度的隔离保证,在隔离强度和系统吞吐量之间进行权衡。
持久性 (Durability) 是指一旦事务成功提交,它对数据库所做的修改就是永久性的,即使在提交之后系统立即发生崩溃,已提交的修改也不会丢失。持久性通过重做日志 (Redo Log) 和预写式日志技术 (WAL, Write-Ahead Logging) 来保证。WAL 的核心规则是:在任何数据修改被写入磁盘的数据文件之前,对应的日志记录必须先被写入磁盘的日志文件。由于日志的写入是顺序 I/O 操作,速度远快于数据文件的随机 I/O 写入,因此 WAL 在保证安全性的同时不会成为性能瓶颈。在系统重启后,恢复管理器会扫描日志文件,对已提交但数据页尚未写入磁盘的事务执行重做 (Redo) 操作,从而恢复这些事务的修改。
ACID 四个特性看上去是四个孤立的定义,实际上它们共同回答的是同一个问题:在并发执行和故障不可避免的世界里,数据库如何仍然让结果“看起来可靠”。很多业务事故并不是程序直接报错,而是库存被悄悄超卖、余额被覆盖、统计口径前后不一致。这类问题之所以难处理,恰恰因为它们表面上不一定崩溃,实际上却破坏了业务可信度。也正因为如此,事务管理并不是数据库内核才需要关心的主题,它直接决定业务系统能否让用户和管理者信任结果。
2. 并发操作带来的数据不一致问题
当多个事务同时对数据库中的同一数据进行读或者写操作时,如果系统不对这些并发操作进行适当的控制和协调,就可能产生各种不正确的结果。这些问题可以归纳为以下四类。
丢失修改 (Lost Update) 发生在两个事务同时读取并修改同一数据项的场景中。事务 T1 和 T2 先后读取数据项 A 的值(假设为 100),然后各自基于这个值进行修改。T1 给 A 加上 50 并提交(A 变为 150),随后 T2 给 A 减去 20 并提交(T2 是基于它当初读到的 100 来计算的,所以将 A 设为 80)。最终 A 的值为 80,T1 所做的加 50 操作被完全覆盖,就好像从来没有发生过一样。
读脏数据 (Dirty Read) 是指一个事务读取了另一个尚未提交的事务所修改的数据。如果后者随后执行了回滚操作,则前者读到的数据就是一个在数据库历史中从未真正有效存在过的值。例如事务 T1 将余额从 1000 修改为 2000 但尚未提交,此时事务 T2 读取到余额为 2000 并基于此值进行业务处理。之后 T1 因某种原因回滚,余额恢复为 1000。T2 此前读取到的 2000 就是脏数据。
不可重复读 (Non-repeatable Read) 是指一个事务在其执行过程中两次读取同一数据项,却得到了不同的结果。这是因为在两次读取之间,另一个事务修改了该数据项并已经完成提交。例如事务 T1 在 10 点读取余额为 1000,事务 T2 在 10 点 01 分将余额修改为 500 并提交。T1 在 10 点 02 分再次读取余额时发现变成了 500。对于 T1 而言,同一个事务内对同一数据的两次读取产生了不同的结果。
幻读 (Phantom Read) 与不可重复读类似,但涉及的是记录数量的变化而非单条记录值的变化。一个事务按照某个查询条件查询一组记录,得到了 N 条结果。随后另一个事务插入或删除了若干满足该查询条件的记录并提交。当第一个事务再次执行相同的查询时,结果集的行数发生了变化,出现了之前不存在的新行(如同幻觉一般),或者之前存在的行消失了。
这些异常之所以必须区分,是因为它们对应的风险并不相同。丢失修改通常直接破坏更新结果,脏读会让事务基于从未真正成立的数据继续计算,不可重复读破坏同一事务内部观察的一致性,而幻读则影响范围判断和集合统计。数据库并发控制的目标,并不是把所有事务都强行串行化,而是在可接受的性能成本下,阻止这些危险冲突发生。换句话说,并发控制真正追求的是“像串行一样正确”,而不是“真的完全串行执行”。
3. 封锁 (Locking)
封锁是最传统也是最广泛使用的并发控制手段。其基本思想是:事务在访问某个数据对象之前需要先对其加锁,获得锁之后才能进行读或写操作。根据锁的类型不同,其他事务对被锁定的数据的访问将受到不同程度的限制。
3.1 锁的基本类型
共享锁 (S 锁,也称读锁) 允许事务读取数据但不允许修改。多个事务可以同时持有同一数据对象的共享锁,因为多个读操作之间不会相互干扰。
排他锁 (X 锁,也称写锁) 允许事务读取和修改数据。一旦某个事务获得了数据对象的排他锁,其他任何事务都不能再对该对象加任何类型的锁,必须等待排他锁释放。
锁的相容性规则可以用一个矩阵来表示:S 锁与 S 锁相容(允许多个事务同时读取同一数据),S 锁与 X 锁不相容(一个事务在读取数据时另一个事务不能修改该数据,反之亦然),X 锁与 X 锁不相容(不允许两个事务同时修改同一数据)。
3.2 封锁协议
通过规定加锁类型、加锁时机和释放锁的时机,可以解决不同级别的并发一致性问题。
一级封锁协议规定事务在修改数据之前必须先加 X 锁,并且持有该锁直到事务结束才释放。一级协议能够防止丢失修改,但不能防止脏读和不可重复读。
二级封锁协议在一级协议的基础上增加一条规定:事务在读取数据之前必须先加 S 锁,读取完成后即可释放(不必等到事务结束)。二级协议能够进一步防止脏读,但由于 S 锁在读取后立即释放,其他事务可能在 S 锁释放后修改该数据,因此仍然不能防止不可重复读。
三级封锁协议在一级协议的基础上增加一条规定:事务在读取数据之前必须先加 S 锁,并且持有该锁直到事务结束才释放。由于 S 锁在整个事务期间都保持着,其他事务无法在此期间获取 X 锁来修改该数据,因此三级协议能够防止不可重复读。
3.3 两段锁协议 (2PL)
两段锁协议是保证并发调度可串行化的一种实用协议。它要求每个事务的所有加锁操作和所有解锁操作分成两个连续的阶段。在第一个阶段(称为增长阶段或加锁阶段),事务只能申请获取锁而不能释放任何锁。当事务释放第一个锁的时刻起,进入第二个阶段(称为收缩阶段或解锁阶段),此后只能释放锁而不能再申请新的锁。
遵循两段锁协议的调度一定是冲突可串行化的。但需要注意,两段锁协议并不能预防死锁的发生。两个都遵循两段锁协议的事务之间仍然可能出现相互等待对方释放锁的情况。
3.4 多粒度封锁与意向锁
在实际系统中,锁的对象可以是整个数据库、单个表、数据页或单条记录。锁的粒度越大,加锁和解锁的开销越低但并发度越差;锁的粒度越小,并发度越高但系统维护锁表的开销越大。
当系统同时支持不同粒度的锁时,需要一种高效的机制来检测冲突。意向锁就是为此设计的。意向共享锁 (IS) 表示事务打算对某个下级数据对象(如表中的某些行)加 S 锁。意向排他锁 (IX) 表示事务打算对某个下级对象加 X 锁。事务在对行加锁之前必须先在包含该行的表上加相应的意向锁。这样当另一个事务想对整张表加锁时,只需检查表上的意向锁标志就能快速判断是否存在冲突,而不必逐行检查。
在工程实践中,锁从来不是越少越好,也不是越细越好。锁粒度越细,并发能力通常越强,但锁表维护成本更高;锁粒度越粗,实现和管理更简单,却更容易形成热点阻塞。因此,合理的做法通常不是追求某一种“最优锁”,而是结合访问模式持续调整。同时还要形成几条基本纪律:尽量缩短事务生命周期,避免在事务内部执行网络调用;尽量按照固定顺序访问资源,降低死锁概率;对极热点对象考虑队列化、分片化或乐观并发控制。只有把锁放回业务访问场景中理解,才能真正用好锁机制。
4. 隔离级别
SQL 标准定义了四个事务隔离级别,从低到高依次为:Read Uncommitted(读未提交,允许脏读)、Read Committed(读已提交,防止脏读)、Repeatable Read(可重复读,防止脏读和不可重复读)和 Serializable(可串行化,防止所有并发异常)。隔离级别越高,数据的一致性保证越强,但系统的并发性能开销通常也越大。
在实际系统中,不同的 DBMS 有不同的默认隔离级别。Oracle 默认使用 Read Committed。MySQL 的 InnoDB 引擎默认使用 Repeatable Read,并且通过间隙锁 (Gap Lock) 和临键锁 (Next-Key Lock) 在很大程度上避免了幻读问题。
隔离级别不应被当作单纯的技术参数来背诵,它实际上是一种业务语义承诺。选择读已提交、可重复读或可串行化,本质上是在告诉业务系统:同一事务期间,我允许你看到怎样的数据变化。管理后台普通列表查询往往可以容忍一定波动,而财务记账、库存扣减、清结算等场景通常需要更强保证。若所有事务一律使用最高隔离级别,系统吞吐量可能大幅下降;若为了性能盲目降低隔离,又可能把隐蔽错误带入核心业务。因此,隔离级别的选择必须和业务风险等级一起讨论。
4.1 多版本并发控制 (MVCC)
多版本并发控制是现代数据库系统广泛采用的一种并发控制技术。其核心思想是:当事务修改数据时,系统不是直接覆盖旧值,而是创建数据的一个新版本。旧版本通过 Undo Log 链条保存。当其他事务需要读取数据时,根据该事务的快照时间点(由其启动时的事务 ID 和系统的 Read View 规则决定),可以顺着版本链找到对该事务可见的那个历史版本。
MVCC 最大的优势在于实现了读操作和写操作之间的互不阻塞——读事务不需要加锁就能获得一致性读结果(称为快照读或一致性非锁定读),写事务也不会被读事务所阻塞。这种机制显著提高了系统在高并发场景下的吞吐量。
不过,“读不加锁”只是 MVCC 的表面结论,更重要的是理解版本可见性规则。不同事务看到的是同一行在不同时间点上的合法版本,而不是绝对实时的最新值。明白这一点后,很多现象就能解释清楚:为什么长事务会阻碍旧版本清理,为什么某些统计结果在事务内部看起来相对稳定,为什么关键一致性场景有时还需要配合锁定读使用。MVCC 的本质是一种空间换时间、版本换并发的设计取舍,它带来高吞吐的同时,也要求系统重视版本回收和长事务治理。
5. 死锁
5.1 死锁的产生条件与示例
当两个或多个事务各自持有对方所需的锁,并且都在等待对方释放锁时,就形成了循环等待,即死锁。例如事务 T1 已经锁定了数据 A 并且正在等待锁定数据 B,而事务 T2 已经锁定了数据 B 并且正在等待锁定数据 A。两者互相等待,都无法继续推进。
5.2 死锁的预防
一次封锁法要求每个事务在开始执行之前一次性地申请它在整个执行过程中所需要的全部锁。如果无法一次性获得所有锁,则事务不开始执行并且释放已获得的锁。这种方法简单但缺点明显:事务可能过早锁定某些数据项,降低了并发度;而且在事务开始之前准确预知所需的全部数据项有时是困难的。
顺序封锁法对所有数据对象规定一个统一的加锁顺序(例如按数据的内存地址或数据项编号的升序),所有事务必须按照这个顺序来申请锁。由于所有事务都按照相同的顺序申请锁,循环等待就不可能形成。
5.3 死锁的检测与解除
超时法规定如果一个事务的等待时间超过了某个预设的阈值,就判定该事务处于死锁状态并将其回滚。这种方法实现简单但阈值的选取较难把握。
等待图法是更精确的检测方法。系统在运行过程中维护一个等待图,图中的节点代表正在运行的事务,从事务 T1 到事务 T2 的有向边表示 T1 正在等待 T2 释放某个锁。系统定期检测等待图中是否存在回路。如果发现回路即存在死锁。解除死锁时通常选择代价最小的事务(例如已执行时间最短或修改数据量最少的事务)作为牺牲者,对其执行回滚操作以释放其持有的所有锁。
死锁问题之所以值得单独讨论,是因为它提醒我们:并发控制不仅要保证正确,还要保证系统能继续前进。一个事务即使逻辑完全正确,只要资源访问顺序设计不当,也可能把自己和其他事务一起拖入等待循环。因此,事务设计时最好从一开始就统一资源获取顺序,尽量把更新集中在必要对象上,并减少用户交互、网络调用等不确定操作进入事务内部。很多死锁并不是数据库“太复杂”,而是业务流程把不必要的等待带进了锁持有阶段。
6. 数据库恢复技术
数据库恢复是指在系统发生故障后将数据库从不正确的状态恢复到某个已知的正确状态(即最近的一致状态)的过程。恢复技术是保障事务原子性和持久性的核心手段。
6.1 故障的分类
事务内部故障是指某个事务在运行过程中由于程序逻辑错误、违反完整性约束或用户主动取消等原因而未能正常完成。系统故障是指由于操作系统崩溃、DBMS 代码错误、掉电或硬件故障等原因导致系统突然停止运行,内存中的数据全部丢失,但存储在磁盘上的数据通常不受影响。介质故障是指磁盘等存储设备发生物理损坏,导致存储在其上的数据部分或全部丢失。这是最严重的故障类型。
把故障分类讲清楚的意义,在于不同故障需要完全不同的应对思路。事务内部故障主要依赖回滚与撤销,系统故障主要依赖日志重做和撤销,介质故障则必须引入备份与日志联合恢复。若在脑中把所有故障都模糊地理解成“系统坏了”,就很难在设计备份、演练恢复和制定应急预案时做出正确决策。数据库恢复理论之所以重要,恰恰因为它让“出故障怎么办”从临场经验变成可预先设计的机制。
6.2 恢复的基本原理
恢复的核心依据是冗余数据,主要包括日志文件和数据备份两部分。
日志文件是 DBMS 在运行过程中自动维护的一个顺序追加的文件。每条日志记录通常包含:事务标识符、操作类型(INSERT UPDATE DELETE)、操作的数据对象标识、数据修改前的旧值和修改后的新值。日志文件按照操作发生的时间顺序记录,是恢复操作最重要的信息来源。
数据备份是定期对数据库进行的复制,分为静态转储(转储期间不允许数据库有任何运行事务)和动态转储(转储期间允许数据库继续运行,但需要配合日志使用才能保证一致性),以及全量备份和增量备份。
6.3 恢复策略
对于事务内部故障,由于该事务的 Undo Log 已经记录了修改前的旧值,系统通过逆向扫描该事务的日志记录,利用旧值逐一撤消已经执行的修改操作,将数据库恢复到该事务开始前的状态。
对于系统故障,恢复管理器需要同时执行两个操作。对于故障发生时已经提交但其修改可能尚未写入磁盘数据文件的事务,执行重做操作,利用 Redo Log 中的新值重新执行这些修改。对于故障发生时尚未提交的事务,执行撤销操作,利用 Undo Log 中的旧值撤消这些事务已经做过的修改。
对于介质故障,首先需要将最近一次的数据备份加载到新的存储设备上,然后利用日志文件中备份时间点之后的所有日志记录,对已提交的事务重新执行其修改操作,从而将数据库恢复到故障发生前的最新一致状态。
6.4 检查点技术
如果每次系统故障恢复都需要从日志文件的起始位置开始扫描,其代价在日志文件很大时将变得难以接受。检查点技术通过定期设置检查点来限制恢复时需要处理的日志范围。
设置检查点时,系统执行以下操作:将当前内存缓冲区中所有已修改的数据页(脏页)强制写入磁盘数据文件;在日志文件中写入一条检查点记录,记录当前所有活跃事务的列表及其最新的日志记录地址;将检查点记录在日志文件中的地址记录到一个固定的重启文件中。
恢复时,系统从重启文件中获取最近检查点的位置,然后只需从该检查点开始扫描日志即可。在检查点之前已经提交的事务不需要做任何处理,因为它们的修改已经在检查点设置时被写入了磁盘。
6.5 ARIES 恢复算法
ARIES 是一种被广泛采用的恢复算法。其恢复过程分为三个阶段。
分析阶段从最近的检查点开始正向扫描日志,确定两组信息:哪些事务在故障发生时尚未完成需要撤销,以及哪些数据页可能包含未写入磁盘的修改需要重做。
重做阶段从日志中记录的最早的可能需要重做的位置 (RedoLSN) 开始正向扫描日志,按照日志中记录的顺序重新执行所有已记录的修改操作。这一阶段不区分事务是否已提交,其目的是将数据库的状态精确地恢复到故障发生那一刻的样子。
撤销阶段逆向扫描日志,对分析阶段确定的需要撤销的事务的所有操作进行逆操作,将这些未完成事务所做的修改全部取消。撤销过程中产生的补偿日志记录 (CLR) 也被写入日志文件,以保证撤销操作本身在系统再次故障时不需要重复执行。
恢复机制不能只在理论图示中理解,还必须转化为运维纪律。任何恢复方案都需要回答两个现实问题:最多允许丢失多少数据,也就是恢复点目标(RPO);从故障发生到业务恢复大致需要多久,也就是恢复时间目标(RTO)。若备份从未演练、日志无法校验、恢复步骤只存在于想象中,那么纸面上的恢复能力并不能真正保护系统。数据库恢复真正可靠的标志,是故障发生后能按预案执行、按时间点恢复,并且这个过程已经被反复验证过。
进一步说,单库事务并不是业务一致性的全部。现代系统中的一次完整业务动作往往会跨越数据库、缓存、消息队列和多个服务,此时数据库事务负责的是局部资源的一致性,系统级一致性还需要依靠幂等、补偿、重试和对账机制共同完成。理解这一点有助于避免一个常见误区:认为只要数据库里写了 COMMIT,整个业务流程就天然安全了。事实上,数据库事务是基础,但不是终点。
🔒 会员专属内容
检查登录状态中...
