一、事务的基本概念
1.1 什么是事务
事务(Transaction)是构成单一逻辑工作单元的操作的集合。一个事务对数据库的操作主要包括两类:
- 读操作(Read):对应 SQL 中的
SELECT语句 - 写操作(Write):对应 SQL 中的
INSERT、UPDATE、DELETE语句
在后续章节讨论理论问题时,将所有操作统一简化为”读”和”写”两种基本操作。
事务是数据库系统实现中的核心概念。关系数据库能够长期用于金融和资产管理,关键就在于事务机制能够维持数据的正确性。
1.2 为什么需要事务 —— 银行转账的例子
以一个银行转账为例:从账户 A 转 50 元到账户 B:
- 读账户 A 的余额
- 将 A 的余额减 50,写回
- 读账户 B 的余额
- 将 B 的余额加 50,写回
核心问题:程序在执行过程中的任何时刻都有可能突然中断——系统宕机、内存溢出、断电等。如果在第 2 步执行完后系统崩溃,A 已经扣了 50 元但 B 还没来得及加 50 元——这笔钱就凭空消失了。
因此,这些操作一定是同时成功或同时失败。不能执行到一半就结束,部分成功部分失败是不可以的。
这六条语句构成一个原子操作——要么全部成功,要么全部失败。这就是”单一逻辑工作单元”的含义。
1.3 事务的边界
在程序中,事务通过以下指令界定:
- BEGIN TRANSACTION:标记事务的开始。在有的系统中,程序一旦开始访问数据库就自动开始一个新事务
- COMMIT:事务执行成功,提交。此时对数据库的所有修改被写入硬盘(持久化),数据库进入一个新的正确状态
- ROLLBACK:事务执行不成功,回退。将对数据库已做的所有修改全部撤销,数据库恢复到事务开始前的正确状态
COMMIT 是一个事务成功的标志性事件——对数据库的所有修改全部写到磁盘上,才算这个事务成功结束。如果数据还在内存里,一旦断电就全没了。
1.4 事务的 ACID 特性
事务具有四个基本性质,简称 ACID:
| 特性 | 英文 | 含义 |
|---|---|---|
| 原子性 | Atomicity | 事务是不可分割的工作单元,要么全部成功,要么全部失败 |
| 一致性 | Consistency | 事务的执行使数据库从一个正确状态变为另一个正确状态 |
| 隔离性 | Isolation | 多个事务并发执行时,其结果应与每个事务单独(串行)执行的结果相同 |
| 持久性 | Durability | 事务一旦提交,其对数据库的所有更新都是持久的,已写入硬盘 |
ACID 这四个性质是数据库最基本的要求。
隔离性的正确性评估方法:如果多个事务并发执行的结果与它们串行执行的结果相同,就是正确的;否则就是不正确的。
关于分布式:ACID 在集中式环境(单机)下是同时成立的,但在分布式环境下,由于网络和副本等因素,ACID 的四条性质无法全部同时保证,需要做各种取舍。
1.5 事务的状态变迁
事务在其生命周期中经历以下状态:
1
2
活跃 (Active) → 部分提交 (Partially Committed) → 已提交 (Committed)
↘ 失败 (Failed) → 中止 (Aborted)
- 活跃状态:事务正在执行
- 部分提交状态:事务中所有操作已完成,准备提交
- 已提交状态:事务成功完成,修改已固化到磁盘
- 失败状态:发现事务无法正常完成(如余额不足、运算溢出等)
- 中止状态:事务回滚,数据库恢复到事务开始前的状态
事务终止有两种情况:
- 程序自主终止:程序内部由于某些条件(如账户余额不足)主动执行 ROLLBACK
- 系统代为终止:程序执行到中间时突然中断,系统自动帮事务执行 ROLLBACK
1.6 事务操作与数据存储的关系
完成写操作时,数据可能还在内存中,尚未写入硬盘。但执行 COMMIT 操作时,系统一定会保证将写操作的结果写到硬盘上,COMMIT 才会成功返回。
数据访问的完整链路 —— 事务请求 → 事务工作区(内存)→ 磁盘缓冲区 → 硬盘,后面讲恢复时会详细展开。
二、分布式事务与两阶段提交(2PC)
2.1 分布式事务的挑战
当数据库运行在多个节点上(分布式环境)时,一个事务可能分布在多个节点上执行。每个节点独立知道自己执行成功与否,但互相不知道对方的状态。在这种情况下要保证 ACID 就变得非常困难。
2.2 两阶段提交的基本思想
两阶段提交(Two-Phase Commit, 2PC)是早期解决分布式事务提交问题的经典方法。
在多节点之上有一个协调者(Coordinator),负责管理所有参与者节点的提交过程(类似于 Hadoop 中的 NameNode)。
阶段一:投票/询问阶段
- 协调者向所有参与者节点发送询问:”事务们是否可以提交?”
- 各参与者根据自身执行情况回复:
- 操作顺利执行完 → 回复”同意提交”
- 操作出错(如余额不足等)→ 回复”终止”
阶段二:提交/回滚阶段
协调者收集所有反馈后:
- 所有参与者都同意提交 → 向所有节点发送 COMMIT 命令
- 有一个参与者回复终止 → 向所有节点发送 ROLLBACK 命令
各参与者收到命令后,执行对应的 commit 或 rollback 操作,完成后向协调者反馈确认。
2.3 两阶段提交的问题
(1)同步阻塞(Synchronous Blocking)
参与者在等待协调者投票和最终决策的过程中,事务并未结束,仍然占有系统资源——内存未释放、锁未释放等。这造成资源浪费,影响系统吞吐量。
更严重的是锁资源占用:程序虽然执行完了但还没提交,此时事务持有的锁尚未释放,其他事务无法访问对应数据。
(2)单点故障(Single Point of Failure)
协调者至关重要,一旦协调者发生故障,整个提交流程就卡住了。虽有备份机制,但备份与主协调者之间存在信息差——主协调者宕机后内存中的信息会丢失,备份不一定有完整的最新状态。
(3)数据不一致
在第二阶段,协调者发出 COMMIT 请求后,可能由于局部网络异常,只有部分参与者收到 COMMIT 命令并提交,另一部分没有收到。此时数据就处于不一致状态。
两阶段提交是长期使用的一种分布式数据库提交方法,但它仍存在一些难以彻底解决的问题。
(4)极端情况:协调者与唯一收到信息的参与者同时宕机
协调者在发出 COMMIT 之后宕机,且唯一接收到该信息的参与者也同时宕机——此时整个事务的状态变得不可知,因为所有判断信息都收集在协调者处,一旦宕机就丢失了。
三、CAP 定理
3.1 背景
2000 年发表的一篇著名论文提出了 CAP 定理(CAP Theorem),阐述了分布式系统设计中三个基本需求的不可兼得性。
3.2 CAP 的三个维度
| 维度 | 英文 | 含义 |
|---|---|---|
| 一致性 | Consistency | 同一时刻,所有数据副本的值完全相同 |
| 可用性 | Availability | 即使部分节点发生故障,系统仍然能够响应客户端的读写请求 |
| 分区容忍性 | Partition Tolerance | 当网络发生分区(节点之间无法通信)时,系统仍能对外提供服务 |
3.3 为什么需要数据副本
在分布式环境下,几十上百台机器同时工作,很难保证所有机器都正常工作(类似 RAID 磁盘阵列的思路)。为了避免一台机器故障导致整个系统出问题,一定会存放多个数据副本——如同一数据在同网段放两个副本,再在另一个网段放一个副本(如 Hadoop 中的三副本策略)。
3.4 CAP 不可兼得
ACID 在集中式环境下是同时成立的,但在分布式环境下,CAP 的三条是无法同时成立的。
直观理解:如果网络发生分区——两边不能通信,而数据有多个副本分散在两个分区中。如果还要求系统继续提供服务(可用性 + 分区容忍性),那就无法保证两个分区里数据副本的值保持一致(一致性)。
3.5 三种权衡策略
| 策略 | 保留 | 放弃 | 说明 |
|---|---|---|---|
| CA | 一致性 + 可用性 | 分区容忍性 | 一旦发生分区,只能在各分区内部保证 CA |
| CP | 一致性 + 分区容忍性 | 可用性 | 一旦发现分区,为了保证数据一致性,可能终止服务 |
| AP | 可用性 + 分区容忍性 | 一致性 | 分区后两个分区继续工作,但数据可能不一致 |
AP 策略下的实际应对:很多 NoSQL 数据库采用 AP 策略。在这种情况下,同一时刻对同一个数据,两个不同的事务可能读到不一致的值——这在银行系统中是不能容忍的。所以实际应用系统会有很多层检测。
银行系统每天会对所有交易做层层审查——一旦发现错误,在业务层面进行恢复。同时在故障发生时记录时间点,对受影响数据进行针对性分析。
四、BASE 理论
4.1 动机
既然 CAP 三条无法同时成立,BASE 理论就是对 AP(牺牲强一致性)策略的实践延伸,探讨如何在分布式系统中尽可能实现较高的数据一致性。
ACID 是集中式数据库的基石,正是由于 ACID 的存在,使得对重要资产的管理才成为可能,数据库才获得用户信任。BASE 是在分布式情况下,系统尽可能实现比较高的一致性。
4.2 BASE 的三条
| 原则 | 英文 | 含义 |
|---|---|---|
| 基本可用 | Basically Available | 分布式系统出现故障时,允许损失部分可用性,但保证核心可用 |
| 软状态 | Soft State | 允许系统的数据存在中间状态,不同副本之间的同步可以有一定延时 |
| 最终一致性 | Eventual Consistency | 系统的各个副本经过一段时间后,最终会达到一致状态 |
基本可用的典型例子:电商大促时,访问量激增,系统可能将部分用户引导到降级页面,限制连续操作频率(如要求等十分钟再做下一个操作)。这本质上是”降低服务”以保护核心功能。
软状态:允许副本同步有一定延时(如允许一秒钟的延迟)。这样一旦出问题,只需处理最后一秒的数据即可。软状态更多是由网络通信延迟造成的。
最终一致性:强调系统运行过程中可能存在不一致,但经过一段过程后最终会达成一致。与软状态的区别在于,最终一致性更多是由系统故障造成的,而软状态更多是由网络延迟造成的。
4.3 一致性等级
在分布式环境下无法完整实现强一致性,因此产生了各种弱化和变形:
| 等级 | 说明 |
|---|---|
| 强一致性 | A 写入一个值后,所有后续操作(A、B、C 等)读到的都是最新值 |
| 弱一致性 | A 写入后,系统不能保证后续操作立刻读到最新值,存在时间窗口(如保证一分钟后读到的是对的,但当时不一定) |
| 最终一致性 | 保证经过一段时间后,所有副本最终一致 |
| 因果一致性 | 如果 A 和 B 之间有业务相关性(如在同一软件的不同服务中),A 更新后 B 读到的一定是新的;没有相关性的不保证 |
| 会话一致性 | 在同一个客户端会话内,保证读到的值是一致的;不同会话之间不保证 |
| 单调读一致性 | 如果已读到某个值,后续操作不会读到比它更早的值 |
这些一致性等级实际上都是针对实际应用场景和实际事务的结构关系,做一定程度的弱化,让系统能够在分布式环境下尽可能得到比较正确的值。
五、Paxos 算法简介
5.1 背景
近年来,国产数据库(如华为 GaussDB)在分布式事务管理中大量使用 Paxos 算法作为事务提交的模型。
5.2 基本思想
Paxos 将所有角色分为三类:
| 角色 | 英文 | 职责 |
|---|---|---|
| 提议者 | Proposer | 产生新的值(提案),发起数据修改 |
| 接受者 | Acceptor | 系统的多个副本,代表数据存储节点 |
| 学习者 | Learner | 读取数据的客户端,不参与修改决策过程 |
核心原则:当提议者提出修改后,需要多数副本(大于一半)都接受了该修改,这个修改才算成功。Learner 在读取时会检查该修改是否已被大多数副本接受——如果已达成多数共识,就可以安全读取新值。
六、数据库恢复机制
6.1 为什么需要恢复
数据库系统无法保证不出错:
- 机器在跑数据库时突然断电或死机
- 911 事件——被撞的两栋楼里有很多银行的数据库
911 事件之后,数据库备份与恢复的重要性被进一步凸显。数据库系统无法保证永不出错,但可以通过恢复机制保证机器损毁时数据不丢失,并将影响限制为业务短暂中断。
恢复的理想前提是存在永久稳定的存储器,数据写入后永不出错。现实中无法真正实现这种介质,因此数据库通过冗余机制模拟稳定存储。
6.2 数据访问的完整流程
回顾数据处理的整个链路:
1
事务请求 → 事务工作区(内存)→ 磁盘缓冲区 → 硬盘
- 事务提出读请求
- DBMS 首先检查所需数据是否在事务工作区(内存)中
- 如果在内存中,直接处理
- 如果不在内存中,到磁盘缓冲区中读
- 如果磁盘缓冲区也没有,从硬盘读入缓冲区,再调入内存
- 在内存中完成处理后,再将修改写回硬盘
从查询性能角度,最好数据在内存中,直接就能读。如果还要从硬盘调,性能就比较慢。现在芯片技术发展(如存算一体化),未来 CPU 和存储芯片会叠在一起,缓存量会更大。
写操作的两步过程:
- 在内存中执行修改
- 由一个独立进程(IO Agent)负责将内存中的修改异步写入硬盘
- 这两步之间不是完全连续的,是异步过程
IO Agent 相当于一个第三方,它不断检查内存里哪些数据被修改了但还没写到硬盘,一旦发现就将其写入硬盘。因为是异步的,所以内存和硬盘之间存在不一致的时间窗口。
6.3 恢复的基本原则 —— 冗余
恢复的核心方法是冗余(Redundancy),具体实现依赖两大类手段:
- 转储(Dump)/ 备份(Backup):定期(如每天)将整个数据库做一个拷贝,存放到磁带、光盘等介质上
- 日志(Log):记录对数据库的每次修改
6.4 故障分类
| 故障类型 | 说明 | 是否涉及磁盘 |
|---|---|---|
| 事务故障 | 运算溢出、存款余额透支等逻辑错误 | 否 |
| 系统故障 | 主板故障、内存故障等硬件问题,但磁盘完好 | 否 |
| 介质故障 | 磁盘本身发生损坏 | 是 |
对于前两种故障(磁盘未损坏),只需依靠日志进行数据恢复;对于介质故障(数据库文件被破坏),需要先用备份恢复,再结合日志恢复。
七、日志(Log)
7.1 日志的作用
日志记录了所有对数据库的修改操作,包括:
- 事务的开始和结束信息
- 每次数据的插入、删除和修改操作
- 每条元组修改前(Before Image)和修改后(After Image)的状态
日志记录了对数据库的每次修改。当系统崩溃后重启时,根据日志可以判断哪些操作已经完成、哪些尚未完成,然后并执行对应的 Undo 与 Redo。
7.2 为什么先写日志而不是先写数据库文件(WAL)
数据库在硬盘上有两类数据:
- 日志(Log):记录所有修改操作
- 数据库文件(Database File):表的实际数据
关键设计原则 —— 先写日志(WAL, Write-Ahead Logging):对数据库的所有修改,先写日志,再写数据库文件。
原因:
- 写日志是连续写(在一个文件末尾追加),速度非常快,与内存写的速度相差不大
- 写数据库文件是随机读写,效率非常慢
- 先写日志能尽快将修改固化到硬盘(不易失)
- 等系统有空时,再从日志中将修改同步到数据库文件
往数据库文件里写是随机读写,对硬盘的随机读写效率非常慢。因此先写日志,连续写效率高,等系统空闲时再逐步把日志中的修改同步到数据库文件。
7.3 COMMIT 的实质
当执行 COMMIT 时,实际上是将对数据库的修改先写到日志中(而未必写到了数据库文件)。只要已写到日志(日志在硬盘上),就代表修改已经被永久存储了。至于什么时候将修改同步到数据库文件,由数据库管理系统自己根据忙闲程度决定。
因此在事务提交时,对数据库的修改不一定已经写到数据库文件里了。但日志在硬盘上,信息就已经保存下来了。
7.4 补偿日志
在执行 UNDO(回滚)操作时,也会把撤销操作记录到日志中。这样即使在做恢复的过程中系统又崩溃了,重启后也能知道哪些撤销动作已经做过、哪些还没做,从而继续完成恢复。
八、检查点(Checkpoint)
8.1 检查点的作用
数据库处理过程在内存中进行,修改不一定立即写入硬盘。检查点定期执行以下操作:将所有需要写入硬盘的数据全部写到硬盘。可以理解为内存和硬盘之间的一次强制同步。
检查点在某种程度上是内存和硬盘之间的一个同步过程。一旦到达检查点,就一定保证事务对数据库的所有修改全部落到硬盘上。
8.2 检查点与事务恢复的关系
假设有五个事务(T1-T5)和一个检查点($t_c$),系统在故障点($t_f$)发生崩溃:
| 事务 | 与检查点的关系 | 故障时的状态 | 恢复策略 |
|---|---|---|---|
| T1 | 检查点之前就执行结束 | 已完成 | 无需任何操作(在检查点时所有修改已固化) |
| T2 | 检查点前开始,检查点后结束(已 commit) | 已提交 | Redo(重做):将对数据库文件的修改重新做一遍 |
| T3 | 检查点前开始,故障时未结束 | 未提交 | Undo(撤销):撤销已做的修改 |
| T4 | 检查点后开始,故障前结束 | 已提交 | Redo(重做) |
| T5 | 检查点后开始,故障时未结束 | 未提交 | Undo(撤销) |
为什么 T2 和 T4 已 commit 还需要 Redo?
- COMMIT 只保证日志已写硬盘,但不保证数据库文件已更新
- 如果崩溃时修改还没从日志同步到数据库文件,就需要重做一遍来使数据库文件与日志一致
为什么 T1 不需要任何操作?
- 因为在检查点时刻,系统强制将内存中所有修改同步到了硬盘,包括写日志和写数据库文件两件事都做了
九、ARIES 恢复算法
9.1 概述
ARIES(Algorithm for Recovery and Isolation Exploiting Semantics)是目前大多数数据库系统采用的标准恢复算法。各数据库实现上略有优化,但基本过程一致。
9.2 ARIES 的三个步骤
- 分析(Analysis):扫描日志,确定崩溃时各个事务的状态——哪些已提交、哪些还在活跃中
- Redo(重做):将已提交但尚未写入数据库文件的操作重新执行一遍,使数据库达到崩溃前的状态
- Undo(撤销):将未提交的事务对数据库做的所有操作全部撤销
9.3 ARIES 的三个原则
| 原则 | 内容 | 说明 |
|---|---|---|
| 先写日志 | 写数据库之前先写日志 | 保证所有修改都有日志可循 |
| Redo 时重做 | 重做已提交但未写入数据库文件的操作 | 恢复数据库到崩溃前状态 |
| Undo 时记日志 | 执行 Undo 时也将操作记录到日志 | 确保恢复过程本身是可恢复的 |
9.4 日志的结构
一条日志记录通常包含:
- 事务 ID(Transaction ID):标识属于哪个事务
- 日志类型(Log Type):操作类型(insert/update/delete/commit/abort 等)
- 页 ID(Page ID):修改了哪个数据页
- Before Image:修改前的状态
- After Image:修改后的状态
- Transaction Table:将同一事务在各处的操作串联起来(脏页表 Dirty Page Table 记录哪些页被修改但尚未写盘)
9.5 恢复实例
一个典型的日志序列:
1
2
3
检查点 → T1 update P1 → T2 update P5 → T1 update P5 →
T1 abort(撤销 T1 对 P5 的修改)→ T3 update P1 → T2 update P5 →
系统崩溃!
重启后分析日志:
- T2 和 T3 都没有提交,但它们对数据库做了修改 → 需要 Undo T2 和 T3 的操作
- T2 对 P5 的修改要恢复,T3 对 P1 的修改要恢复
- 如果恢复过程中再次崩溃,由于 Undo 操作也已经记录在日志中,重启后可以知道哪些操作已经处理过、哪些还没处理,继续执行
9.6 介质故障恢复(数据库文件损坏)
当磁盘(数据库文件)损坏时:
- 首先从最近的备份中恢复数据库
- 然后根据备份之后到故障发生时刻之间的日志,在恢复的数据库上一步步重做修改
- 最终恢复到故障前的状态
恢复机制中最严重的风险是日志丢失。数据库每晚会有备份,但日志没有备份。一旦磁盘上日志那块坏掉,是最大的灾难。
9.7 日志的保护
热备(Hot Standby):同时把数据库修改的日志在另外一台机器上也做备份。即使原机器的日志丢失,还可以从备用机器恢复。
此外,在应用系统层面也会做业务日志。例如 ATM 机内部有一大卷纸,把所有操作都记录在纸上——即使数据库出问题,也可以通过业务日志重建。
用户在 ATM 机取钱时,除了钱箱点钞的声音,还会听到咯噔咯噔的声音——那就是在纸上打印操作记录。
十、备份策略
10.1 备份的基本类型
| 类型 | 说明 |
|---|---|
| 冷备(Cold Backup) | 定期将数据库完整复制到磁盘、光盘、磁带等介质上 |
| 热备(Hot Backup) | 另有一台备用计算机,主库的所有操作通过日志实时同步到备库 |
热备的好处:主数据库一旦发生损毁,备份机马上可以起来接替工作,效率更高。只需处理同步瞬间的修改即可,其他数据两边都有。
10.2 增量备份与差异备份
| 策略 | 说明 | 例子 |
|---|---|---|
| 增量备份(Incremental) | 每天只备份发生变化的数据页,没有变化的页面不备份 | 周一全量,周二只备周二变化的部分,周三只备周三变化的部分 |
| 差异备份(Differential) | 记录最终值与初始值的差异,不记录中间每次变化 | 两天共取了 20 块钱,只记录”取了 20 块钱”,不记录每天取 10 块 |
10.3 两地三中心
国家对重要信息系统(如证券交易系统)有两地三中心的要求:
- 在同一个城市有两套服务器(如上海陆家嘴 + 外高桥),且距离足够远
- 在另一个城市(如北京)还有第三套
- 这样不管遇到什么样的灾害(地震、火灾等),系统仍然可以正常运行
10.4 热备的演进
在新型分布式数据库架构中(如 GaussDB),备份数据库本身也可以成为提供服务的一部分——备份被天然地集成到分布式副本机制中,不再是单纯的”备用”,而是参与服务的副本,效率更高。
10.5 SQL 对事务的支持
1
2
3
4
5
BEGIN TRANSACTION; -- 开始事务
-- 一系列 SQL 操作(SELECT, INSERT, UPDATE, DELETE)
COMMIT; -- 提交事务
-- 或
ROLLBACK; -- 回滚事务
十一、闪回技术(Flashback)
11.1 闪回的概念
随着内存越来越大,数据库会把大量日志放在内存中。华为 GaussDB 实现了闪回(Flashback)功能。
闪回就是利用内存中的日志,系统可以“回到过去”查看数据的历史状态。例如需要查看这个账户昨天晚上的余额是多少——通过反向回滚从昨晚到现在中间对该数据的所有修改操作,就可以得到昨晚的状态。
11.2 闪回 vs 多版本
多版本(Multi-Version)机制也能实现类似效果,但需要存储大量历史数据,真正做起来成本较高。而闪回直接利用日志回滚计算——因为日志本身比数据库小很多,效率更高。
华为 GaussDB 在这方面有它的一些特点。在某台机器发生 crash 时,该机器的日志也保存在其他机器的内存中,恢复时可以直接使用其他机器内存中的日志。这是对日志结合系统架构的一种灵活应用。
11.3 日志作用的扩展
随着 LSM-Tree(Log-Structured Merge-Tree)等存储架构的出现,日志在数据库中发挥的作用越来越大——不仅仅是用于故障恢复,还可以支持时间旅行查询、闪回等高级功能。
十二、并发控制 —— 问题与动机
12.1 并发控制的需求
数据库中的事务是并发执行的——多个事务的操作穿插在一起,随机到来。如果对这些操作不加任何控制,就会出现严重的数据正确性问题。
12.2 并发控制的正确性标准
并发执行的事务,其结果一定要和这些事务串行执行的结果相同。这是并发控制正确性的评估标准,必须遵守。
12.3 并发执行导致的问题
(1)丢失更新(Lost Update)
T1 读 A=100,T2 也读 A=100。T1 将 A 减 30 得 70 写回,T2 将 A 乘 2 得 200 写回——T1 对 A 的修改被 T2 的写操作覆盖掉了,T1 的更新丢失了。
(2)读脏数据(Dirty Read)
T1 读 A=100,减 30 得 70 写回(尚未提交)。此时 T2 读到 A=70。然后 T1 执行 ROLLBACK,A 恢复为 100——T2 读到的 70 是一个”脏数据”,因为 T1 最终撤销了该修改。
更严重的情况:T1 读 A 减 30 写回 70,T2 读 A=70 乘 2 写回 140,T1 ROLLBACK 恢复 A 为 100——T2 基于脏数据的修改也丢失了。
(3)错误求和(Incorrect Summary)
T1 正在计算 A+B+C 的和:先读 A=40、B=50(和=90),此时 T2 将 A 从 40 改为 30、C 从 30 改为 20 并提交。T1 再读 C=20,最终得到 40+50+20=110,但正确的串行结果应该是 40+50+30=120 或 30+50+20=100,不可能是 110。
(4)不可重复读(Non-repeatable Read)
T1 读 A=100,T2 将 A 乘 2 得 200 并提交,T1 再次读 A=200——在同一个事务内,两次读 A 得到了不同的值。
12.4 并发控制的目标
并发控制是一对矛盾体:
- 提高并行性:让更多事务同时执行,提高系统吞吐量和查询响应速度
- 保证正确性:并发执行的结果必须与串行执行结果相同(可串行化)
并发控制并不是禁止并发,而是在允许多个事务同时执行的同时保证结果正确。
十三、封锁(Locking)
13.1 封锁的基本思想
在访问一个数据项之前,先获得它的锁。根据锁的类型和数据上已有的其他锁的情况,决定是否能进行操作。
13.2 锁的类型
(1)排他锁(X 锁 / Exclusive Lock / Write Lock)
用于写操作(修改数据)。一旦加了排他锁,其他事务不能再对该数据加任何类型的锁。不能被两个事务同时修改。
(2)共享锁(S 锁 / Shared Lock / Read Lock)
用于读操作。加了共享锁后,其他事务仍然可以在该数据上加共享锁(同时读),但不能加排他锁。
两个事务同时对同一张表执行 SELECT 通常不会产生冲突。但一旦某个事务正在修改数据,其他事务就不能读取该数据——否则会读到未提交的中间状态。
(3)无锁(NL / No Lock)
用于不需要精确值的场景(如统计查询”中国现在有多少人”——多一个少一个对判断没有影响)。加了无锁后,不管数据处于什么状态都可以执行。
13.3 锁的相容矩阵
| 已持有 → 新请求 ↓ | 无锁 | S 锁 | X 锁 |
|---|---|---|---|
| S 锁(读锁) | ✓ | ✓ | ✗ |
| X 锁(写锁) | ✓ | ✗ | ✗ |
- 加了 X 锁后,其他事务什么锁都不能加
- 加了 S 锁后,其他事务还可以加 S 锁,但不能加 X 锁
13.4 用锁解决并发问题
解决丢失更新:T1 读 A 时先加 X 锁,T2 就无法加 X 锁(必须等待)。T1 修改完 COMMIT 后释放锁,T2 才能获得锁继续执行——保证了修改不丢失。
但一开始读操作就加写锁太严格了,除非后续一定会执行写操作。更好的做法是先用 S 锁读,确定要写时再升级为 X 锁——但这又可能引发死锁。
13.5 锁升级与死锁的产生
T1 加 S 锁读 A → T2 加 S 锁读 A → T1 想把 S 锁升级为 X 锁来修改 A,但 T2 的 S 锁还在 → T1 等待 T2 释放 S 锁 → T2 也想升级为 X 锁修改 A → T2 等待 T1 释放 S 锁 → 死锁。
数据库系统会定期巡查是否有死锁发生。一旦发现死锁,最简单的方法是终止其中一个事务(如 T2),T1 就能继续执行。T2 的用户无非就是重新提交一次。
13.6 锁的粒度
锁可以加在不同级别上:
| 粒度 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| 表级锁 | 对整个表加锁 | 锁信息少,管理开销小 | 并发性差,一张表被锁住后其他事务无法访问 |
| 元组级锁 | 对一条元组加锁 | 即使加 X 锁,其他元组仍可访问 | 若表有 1 万条元组,可能需要管理 1 万个锁信息 |
| 属性级锁 | 对某个属性值加锁 | 并发性最好 | 管理开销最大(如有 10 个属性,就是 10 万个锁信息) |
锁的粒度越大,并发性越差,但管理开销越小;粒度越小,并发性越好,但管理开销越大。这是一个典型的 trade-off。
13.7 多粒度锁(意向锁)
当需要对表中部分元组加锁时,不应该直接对整个表加 S 锁或 X 锁,而是使用意向锁:
| 锁类型 | 英文 | 含义 |
|---|---|---|
| IS 锁 | Intent Shared | 准备对表中部分元组加 S 锁 |
| IX 锁 | Intent Exclusive | 准备对表中部分元组加 X 锁 |
工作方式:
SELECT ... WHERE ...:先在表上加 IS 锁,再在符合条件的元组上加 S 锁UPDATE ... WHERE ...:先在表上加 IX 锁,再在符合条件的元组上加 X 锁- IS 锁和 IX 锁可以同时并存(说明两个事务分别要读和修改不同元组)
- 如果两个操作涉及同一元组发生冲突,再在元组层面等待
这样既能保证正确性,又能最大程度提高并发性。
13.8 锁带来的问题
(1)活锁(Livelock)
T1 申请 X 锁未成功,在等待。此时 T2 来申请并刚好拿到了释放的锁,T3 也拿到了……T1 一直等不到。解决方法是采用先来先服务(FCFS):知道 T1 在等,就先给 T1。
(2)饿死(Starvation)
数据 A 上不断有新的 S 锁被加上(S 锁之间可以共存),导致等待 X 锁的事务永远拿不到锁。解决方法是当发现有事务在等待 X 锁时,暂时不再接受新的 S 锁申请。
(3)死锁(Deadlock)
两个事务各自持有部分资源,又都在等待对方释放资源,形成循环等待。
死锁检测:数据库维护一张等待图(Wait-for Graph):
- 节点是事务
- 如果 T1 在等待 T2 释放锁,就画一条 T1 → T2 的有向边
- 如果图中出现环(循环等待),则发生死锁
死锁解决:
- 选择环中一个事务终止(如终止 T4),打破循环
- 也可以给事务赋予优先级,通过优先级来决定锁先给谁
发生死锁问题不大,数据库会定期巡查。代价通常是被终止的事务需要重新提交。
十四、谓词锁与幽灵问题(Phantom Problem)
14.1 幽灵问题
考虑如下场景:表中有船员记录,rating 有 1 和 2。
- T1:查找 rating=1 和 rating=2 中最老的船员
- T2:删除 rating=2 中最老的船员,并插入一个 rating=1、年龄 93 的新船员
T1 先锁住 rating=1 的元组(最老的是 71 岁),然后去查 rating=2 的元组。但 T2 删除了 rating=2 中最老的(89 岁)并插入了一个 rating=1 的(93 岁),导致 T1 查 rating=2 时得到的是次老的(63 岁)——T1 本该得到 71 和 89,实际却得到了 71 和 63。
因为 T1 锁住的只是已存在的 rating=1 的元组,并没有锁住 rating=2 的元组,所以 T2 可以执行删除。这是一个”幽灵”问题——查询时有些数据还不存在(或还未被插入),但被其他事务插入了,影响了事务的查询结果。
14.2 解决方法
方法一:锁住所有记录
T1 一开始就锁住表中所有记录,T2 就无法做任何增删改——简单粗暴,但并发性极差。
方法二:通过索引锁页
利用索引锁住 rating=1 和 rating=2 对应的 data entry 所在的页,阻止 T2 对相关页的修改。
方法三:谓词锁(Predicate Lock)
给查询的谓词条件(而非具体元组)加锁。例如 T1 给”rating=1 AND rating=2”这个谓词加锁,任何涉及 rating=1 或 rating=2 的新操作都会被阻止——如果新操作的条件与已有谓词锁有交集就不能执行。
谓词锁是数据库里面常用的一种锁结构,因为它锁的是逻辑条件而非物理元组。
十五、B+树上的并发控制
15.1 为什么 B+树需要并发控制
B+树支持插入和删除操作,每个节点都可能被修改。当多个事务同时访问 B+树时,需要考虑以下问题:
- 节点修改时不能读:正在修改的节点处于中间状态,此时不允许其他事务读取
- 插入的连锁效应:在叶子节点做插入操作,可能导致父节点的修改,甚至祖父节点的修改(分裂传播)
- 查询也需要加锁:在树上的查询操作也需要加共享锁,防止读到不完整的中间状态
15.2 B+树并发控制的特点
- 做插入操作时,可能需要一次性锁住多个节点(从叶子到祖先)
- 需要设计专门的锁协议(如 Crab Locking:蟹行协议——像螃蟹一样横着走,逐步获取和释放节点上的锁)
- 树的并发控制比普通数据的并发控制更复杂,因为节点的修改有层级传播效应
十六、时标法(Timestamp-Based Concurrency Control)
16.1 基本思想
给每个事务分配一个时间戳(Timestamp),就像每个人都有生日一样。早开始的事务”老”,晚开始的事务”年轻”。这个时间戳确定了事务之间的串行化顺序。
可以将时间戳理解为事务的年龄:时间戳区分了事务的新旧,串行化的顺序就固定了。
16.2 时标法的规则
对每个数据项维护两个时间戳:
- W-timestamp:最后一个写该数据的事务的时间戳
- R-timestamp:最后一个读该数据的事务的时间戳
核心原则:事务只能读前面(更老)事务写的数据,不能读后面(更年轻)事务写的数据。
当一个事务 TI 要读数据 Q 时:
- 如果 TI 的时间戳 < Q 的 W-timestamp(即 Q 被一个比 TI 更年轻的事务写过了),则拒绝读操作,重启 TI(给它新的、更晚的时间戳)
当一个事务 TI 要写数据 Q 时:
- 如果 TI 的时间戳 < Q 的 R-timestamp(即已经有一个比 TI 更年轻的事务读过了),则拒绝写操作,重启 TI
- 如果 TI 的时间戳 < Q 的 W-timestamp(即已经有一个比 TI 更年轻的事务写过了),则拒绝写操作,重启 TI
16.3 时标法 vs 封锁法
| 特性 | 封锁法(Locking) | 时标法(Timestamp) |
|---|---|---|
| 核心机制 | 等待(事务要等锁释放) | 不等待,直接执行 |
| 冲突处理 | 阻塞等待 | 发现违反顺序就终止并重启事务 |
| 优势 | 不会浪费已做的工作 | 无等待,吞吐可能更高 |
| 劣势 | 可能死锁、等待时间长 | 事务可能不断被重启,浪费计算 |
数据库并发控制的两类基本方法是封锁法(有等待)和时标法(有重启)。不同场景适合不同选择。时标的缺点是不断重启会导致事务大量重复执行,在冲突多的场景下效率反而可能更差。
16.4 时标法的例子
T2 先开始(时间戳小,更老),T1 后开始(时间戳大,更年轻):
- T2 和 T1 先后读没问题
- T1 写也没问题(符合串行顺序)
- T2 再写就出问题了——因为 T1(更年轻)已经在 T2 之后写了,T2 的写会破坏串行顺序,所以 T2 被终止重启
核心总结
ACID 是集中式数据库可信运行的基础:原子性保证事务整体成功或失败,一致性保证状态转换正确,隔离性保证并发结果可串行化,持久性保证提交后的结果不会丢失。
在分布式环境下,系统需要在 CAP 和 BASE 框架下权衡一致性、可用性与分区容忍性。两阶段提交提供了经典的分布式事务提交思路,但存在同步阻塞、单点故障和不确定状态等问题;Paxos 等共识算法则通过多数派机制提高分布式一致性的可靠性。
数据库恢复依赖冗余、日志、WAL、检查点和 ARIES 等机制:日志记录修改历史,WAL 保证先有日志再写数据页,检查点缩短恢复范围,ARIES 通过 Analysis、Redo、Undo 将数据库恢复到一致状态。
并发控制是隔离性的实现基础。封锁法通过 S 锁、X 锁、多粒度锁和谓词锁控制冲突,但可能产生等待、饥饿和死锁;时标法通过时间戳规定串行化顺序,避免等待,但在冲突较多时可能造成频繁重启。