数据库并发控制详解
本文详细介绍数据库并发问题与并发控制的基本概念,包括并发问题、事务管理、封锁协议和死锁处理等核心技术。
目录
- [第一章 并发控制概述](#第一章 并发控制概述)
- [第二章 并发操作带来的问题](#第二章 并发操作带来的问题)
- [第三章 事务的基本概念](#第三章 事务的基本概念)
- [第四章 封锁协议概述](#第四章 封锁协议概述)
- [第五章 死锁问题概述](#第五章 死锁问题概述)
- [第六章 并发控制技术总览](#第六章 并发控制技术总览)
- [第七章 面试与考试重点](#第七章 面试与考试重点)
- 附录
第一章 并发控制概述
1.1 什么是数据库并发控制
数据库并发控制(Concurrency Control) 是指当多个用户或程序同时访问和操作数据库时,通过一定的机制来协调这些操作,确保数据的一致性和正确性。
┌─────────────────────────────────────────────────────────────────┐
│ 并发控制的本质 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 核心思想:让多个操作"看起来"像是串行执行的 │
│ │
│ 生活类比: │
│ • 多人同时在银行ATM取款 │
│ • 每个人都要确保账户余额不会出错 │
│ • 不能同时取款导致余额变成负数 │
│ │
│ 技术实现: │
│ • 通过锁、时间戳等机制 │
│ • 控制事务的执行顺序 │
│ • 保证数据的正确性 │
│ │
└─────────────────────────────────────────────────────────────────┘
并发 vs 并行:
并发(Concurrency):多个任务交替执行
┌───────────────────────────────────┐
│ 时间轴 │
├───────────────────────────────────┤
│ T1: ──█── ──█── ──█── │
│ T2: ──█── ──█── ──█── │
│ T3: ──█── ──█── ──█── │
└───────────────────────────────────┘
单核CPU,快速切换,看起来同时执行
并行(Parallelism):多个任务真正同时执行
┌───────────────────────────────────┐
│ 时间轴 │
├───────────────────────────────────┤
│ T1: ─────────────█ │
│ T2: ─────────────█ │
│ T3: ─────────────█ │
└───────────────────────────────────┘
多核CPU,真正的同时运行
数据库中的并发场景:
| 场景 | 示例 | 并发操作数 |
|---|---|---|
| 低并发 | 小型企业ERP | 10-100 |
| 中并发 | 电商网站 | 1000-10000 |
| 高并发 | 抢购秒杀 | 10万+ |
| 超高并发 | 双十一 | 百万+ |
1.2 为什么需要并发控制
问题的根源:资源共享
在单用户环境下,数据库操作是串行的,不会产生冲突。但在多用户环境下,如果不加控制,会导致数据混乱。
典型问题示例:银行转账
场景:账户A余额1000元,两个人同时取款500元
没有并发控制的情况:
时刻 用户1 用户2 账户A余额
T1 读取余额(1000) - 1000
T2 检查余额够 - 1000
T3 - 读取余额(1000) 1000
T4 - 检查余额够 1000
T5 扣款500 - 500
T6 - 扣款500 0
T7 写回余额500 - 500
T8 - 写回余额500 500
问题:应该剩余0元,实际剩余500元!(丢失了一次修改)
有并发控制的情况:
时刻 用户1 用户2 账户A余额
T1 读取余额(1000) - 1000
T2 加锁 - 1000
T3 检查余额够 - 1000
T4 - 尝试读取(等待...) 1000
T5 扣款500 - 500
T6 写回余额500 - 500
T7 释放锁 - 500
T8 - 获得锁,读取(500) 500
T9 - 检查余额够 500
T10 - 扣款500 0
T11 - 写回余额0 0
T12 - 释放锁 0
结果:正确!剩余0元
为什么必须进行并发控制:
┌─────────────────────────────────────────────────────────────────┐
│ 并发控制的必要性 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 保证数据正确性 │
│ • 避免数据丢失 │
│ • 避免数据不一致 │
│ │
│ 2. 满足业务需求 │
│ • 多用户同时使用系统 │
│ • 提高系统吞吐量 │
│ │
│ 3. 符合ACID要求 │
│ • 隔离性(Isolation)必须有并发控制支撑 │
│ • 一致性(Consistency)需要并发控制保障 │
│ │
│ 4. 提升用户体验 │
│ • 减少等待时间 │
│ • 提高响应速度 │
│ │
└─────────────────────────────────────────────────────────────────┘
1.3 并发控制的目标
并发控制要在正确性 和性能之间找到平衡。
┌─────────────────────────────────────────────────────────────────┐
│ 并发控制的三大目标 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 保证数据一致性(Consistency) │
│ • 并发操作后,数据库仍处于一致状态 │
│ • 不违反完整性约束 │
│ • 示例:转账前后总金额不变 │
│ │
│ 2. 保证事务隔离性(Isolation) │
│ • 一个事务的执行不受其他事务干扰 │
│ • 多个事务并发执行的结果等同于某种串行执行结果 │
│ • 示例:A转账时,B看不到中间状态 │
│ │
│ 3. 提高系统并发度(Concurrency) │
│ • 尽可能多的事务同时执行 │
│ • 减少等待时间 │
│ • 提高资源利用率 │
│ │
└─────────────────────────────────────────────────────────────────┘
并发控制的权衡:
严格控制(强一致性) vs 宽松控制(高性能)
↓ ↓
• 数据绝对正确 • 吞吐量大
• 并发度低 • 可能有短暂不一致
• 性能较差 • 适合读多写少场景
适用场景: 适用场景:
• 银行系统 • 社交网络点赞数
• 库存管理 • 新闻网站浏览量
• 订单系统 • 日志统计
平衡点
↓
根据业务需求选择合适的隔离级别
可串行化(Serializability):
并发控制的理论基础是可串行化。
┌─────────────────────────────────────────────────────────────────┐
│ 可串行化调度 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 定义: │
│ 多个事务并发执行的结果,与这些事务按某种顺序串行执行的结果相同 │
│ │
│ 示例: │
│ 事务T1: A=A+100 │
│ 事务T2: A=A*2 │
│ 初始A=50 │
│ │
│ 串行调度1:T1→T2 │
│ A=50+100=150 → A=150*2=300 ✓ │
│ │
│ 串行调度2:T2→T1 │
│ A=50*2=100 → A=100+100=200 ✓ │
│ │
│ 可串行化的并发调度: │
│ 结果必须是300或200之一 │
│ │
│ 不可串行化的调度: │
│ 结果是其他值(如250),则不可接受 ✗ │
│ │
└─────────────────────────────────────────────────────────────────┘
1.4 并发控制的核心挑战
┌─────────────────────────────────────────────────────────────────┐
│ 并发控制面临的挑战 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 性能与正确性的矛盾 │
│ • 锁越严格,正确性越高,但性能越差 │
│ • 锁越宽松,性能越好,但可能不一致 │
│ │
│ 2. 死锁问题 │
│ • 多个事务互相等待对方释放锁 │
│ • 导致系统hang住 │
│ • 需要死锁检测和解除机制 │
│ │
│ 3. 活锁和饥饿 │
│ • 事务一直得不到执行机会 │
│ • 优先级机制可能导致低优先级事务饿死 │
│ │
│ 4. 开销问题 │
│ • 锁管理开销(内存、CPU) │
│ • 死锁检测开销 │
│ • 事务回滚开销 │
│ │
│ 5. 分布式环境 │
│ • 多机器间的协调更复杂 │
│ • 网络延迟 │
│ • 时钟同步问题 │
│ │
└─────────────────────────────────────────────────────────────────┘
并发控制技术演进:
时间线:
1970s
↓
基于锁的并发控制(Locking)
• 最经典的方法
• 两段锁协议(2PL)
1980s
↓
基于时间戳的并发控制(Timestamp)
• 无锁机制
• 通过时间戳判断冲突
1980s
↓
乐观并发控制(Optimistic CC)
• 假设冲突少
• 提交时才检查冲突
1990s-至今
↓
多版本并发控制(MVCC)
• 为每个事务提供数据快照
• 读不阻塞写,写不阻塞读
• 现代数据库主流方案
• 代表:PostgreSQL, MySQL InnoDB, Oracle
并发控制的核心问题:
| 问题 | 描述 | 解决方法 |
|---|---|---|
| 丢失修改 | 一个事务的修改被另一个覆盖 | 加写锁 |
| 脏读 | 读到未提交的数据 | 读已提交隔离级别 |
| 不可重复读 | 同一事务内多次读取结果不同 | 可重复读隔离级别 |
| 幻读 | 范围查询时,其他事务插入新数据 | 串行化隔离级别或间隙锁 |
这些问题将在第二章详细讲解。
第二章 并发操作带来的问题
在没有适当并发控制的情况下,多个事务同时操作数据库会产生以下四类经典问题。
2.1 丢失修改(Lost Update)
2.1.1 问题描述
丢失修改是指两个或多个事务读取同一数据并进行修改,后提交的事务覆盖了先提交的事务的修改,导致先提交的修改丢失。
┌─────────────────────────────────────────────────────────────────┐
│ 丢失修改示意图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ T1: 读取(X=100) → 修改(X=X-50) → 写回(X=50) │
│ T2: 读取(X=100) → 修改(X=X+30) → 写回(X=130) │
│ │
│ 时间线: │
│ ────────────────────────────────────────────────────────> │
│ T1: ───R(X=100)────────W(X=50)────── │
│ T2: ──────────R(X=100)─────────W(X=130) │
│ │
│ 结果:X=130(T1的修改-50丢失了!) │
│ 正确结果应该是:X=100-50+30=80 │
│ │
└─────────────────────────────────────────────────────────────────┘
严重性:⭐⭐⭐⭐⭐(最严重)
这是最严重的并发问题,会导致数据彻底错误。
2.1.2 示例场景
场景1:库存扣减
sql
-- 初始库存:product_stock = 100
-- 事务T1:订单A购买50件
BEGIN;
SELECT stock FROM product WHERE id=1; -- 读取到100
-- 计算:100-50=50
UPDATE product SET stock=50 WHERE id=1;
COMMIT;
-- 事务T2:订单B购买30件(同时执行)
BEGIN;
SELECT stock FROM product WHERE id=1; -- 也读取到100
-- 计算:100-30=70
UPDATE product SET stock=70 WHERE id=1; -- 覆盖了T1的修改
COMMIT;
-- 结果:库存为70,但实际卖出了80件!库存错误!
场景2:账户余额
场景:账户余额1000元,存款100元和取款200元同时操作
时间 T1(存款) T2(取款) 余额
────────────────────────────────────────────────────
T1 读取余额(1000) - 1000
T2 - 读取余额(1000) 1000
T3 余额+100=1100 - 1000
T4 - 余额-200=800 1000
T5 写回(1100) - 1100
T6 - 写回(800) 800
结果:余额800元
正确结果:1000+100-200=900元
丢失了存款操作!
场景3:文章点赞数
初始点赞数:1000
用户A点赞: 用户B点赞:
1. 读取点赞数=1000 1. 读取点赞数=1000
2. 计算:1000+1=1001 2. 计算:1000+1=1001
3. 写回1001 3. 写回1001
结果:点赞数=1001(两个赞只记录了一个)
2.1.3 产生原因
┌─────────────────────────────────────────────────────────────────┐
│ 丢失修改的根本原因 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 读-改-写模式(Read-Modify-Write) │
│ • 先读取数据 │
│ • 在内存中修改 │
│ • 最后写回数据库 │
│ • 中间没有保护机制 │
│ │
│ 2. 无锁保护 │
│ • 读取时没有加锁 │
│ • 其他事务可以同时读取 │
│ • 基于过时数据进行修改 │
│ │
│ 3. 覆盖写入 │
│ • 后提交的事务覆盖先提交的 │
│ • 没有检查数据是否已被修改 │
│ │
└─────────────────────────────────────────────────────────────────┘
解决方案:
sql
-- 方案1:使用排他锁
BEGIN;
SELECT stock FROM product WHERE id=1 FOR UPDATE; -- 加排他锁
UPDATE product SET stock = stock - 50 WHERE id=1;
COMMIT;
-- 方案2:使用乐观锁(版本号)
BEGIN;
SELECT stock, version FROM product WHERE id=1; -- stock=100, version=1
UPDATE product
SET stock = 50, version = 2
WHERE id=1 AND version=1; -- 只有版本号匹配才更新
COMMIT;
-- 方案3:原子操作
UPDATE product SET stock = stock - 50 WHERE id=1; -- 直接在SQL中计算
2.2 不可重复读(Non-Repeatable Read)
2.2.1 问题描述
不可重复读是指在同一事务中,多次读取同一数据,但得到的结果不同。原因是在两次读取之间,另一个事务修改了该数据并提交。
┌─────────────────────────────────────────────────────────────────┐
│ 不可重复读示意图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ T1: 第一次读(X=100) → ... → 第二次读(X=200) ?! │
│ T2: 修改X=200并提交 │
│ │
│ 时间线: │
│ ────────────────────────────────────────────────────────> │
│ T1: ───R(X=100)────────────────R(X=200)──── │
│ T2: ──────────W(X=200),COMMIT────── │
│ │
│ 问题:T1在同一事务中两次读取X,结果不一样! │
│ │
└─────────────────────────────────────────────────────────────────┘
严重性:⭐⭐⭐
导致事务内部数据不一致,影响业务逻辑判断。
2.2.2 示例场景
场景1:银行转账验证
sql
-- 事务T1:检查并转账
BEGIN;
-- 第一次查询账户余额
SELECT balance FROM account WHERE id=1; -- 余额1000元
-- 判断:余额足够,可以转账500元
-- ... 此时T2介入 ...
-- 第二次查询账户余额(再次确认)
SELECT balance FROM account WHERE id=1; -- 余额变成200元!
COMMIT;
-- 事务T2:其他地方扣款(同时执行)
BEGIN;
UPDATE account SET balance=200 WHERE id=1; -- 扣款800元
COMMIT;
-- 问题:T1第一次看到1000元,决定转账500元
-- 但第二次看到200元,逻辑混乱!
场景2:统计分析
sql
-- 事务T1:计算平均工资
BEGIN;
-- 第一次读取
SELECT AVG(salary) FROM employee; -- 结果:5000元
-- ... 业务处理 ...
-- 第二次读取(验证)
SELECT AVG(salary) FROM employee; -- 结果:8000元(?!)
COMMIT;
-- 事务T2:给员工加薪(同时执行)
BEGIN;
UPDATE employee SET salary = salary * 1.5;
COMMIT;
-- 问题:同一报表中两个数据不一致
场景3:订单状态检查
时间 T1(查询订单状态) T2(修改订单状态)
─────────────────────────────────────────────────────────
T1 第一次查询:status=待支付 -
T2 验证状态... -
T3 - 修改为"已支付"并提交
T4 第二次查询:status=已支付 -
T5 ??状态变化了 -
问题:T1事务内部,同一订单状态前后不一致
2.2.3 产生原因
┌─────────────────────────────────────────────────────────────────┐
│ 不可重复读的原因 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 读取时未加锁 │
│ • SELECT语句默认不加锁 │
│ • 读完后立即释放共享锁 │
│ │
│ 2. 其他事务可以修改 │
│ • 读取期间,其他事务可以UPDATE │
│ • 其他事务提交后,数据已改变 │
│ │
│ 3. 隔离级别不够 │
│ • READ COMMITTED级别只保证读到已提交数据 │
│ • 但不保证多次读取结果相同 │
│ │
└─────────────────────────────────────────────────────────────────┘
解决方案:
sql
-- 方案1:使用REPEATABLE READ隔离级别
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM account WHERE id=1; -- 100元
-- 其他事务修改也看不到
SELECT balance FROM account WHERE id=1; -- 仍然是100元
COMMIT;
-- 方案2:使用共享锁
BEGIN;
SELECT balance FROM account WHERE id=1 LOCK IN SHARE MODE;
-- 持有共享锁,其他事务不能修改
SELECT balance FROM account WHERE id=1; -- 结果一致
COMMIT;
-- 方案3:使用排他锁
BEGIN;
SELECT balance FROM account WHERE id=1 FOR UPDATE;
-- 持有排他锁,完全锁定数据
SELECT balance FROM account WHERE id=1;
COMMIT;
2.3 脏读(Dirty Read)
2.3.1 问题描述
脏读 是指一个事务读取了另一个未提交事务修改的数据。如果该事务回滚,则读取的数据就是无效的"脏数据"。
┌─────────────────────────────────────────────────────────────────┐
│ 脏读示意图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ T1: 修改X=200(未提交) │
│ T2: 读取X=200(脏数据)→ 基于脏数据做决策 │
│ T1: ROLLBACK(回滚!) │
│ │
│ 时间线: │
│ ────────────────────────────────────────────────────────> │
│ T1: ───W(X=200)────────────ROLLBACK── │
│ T2: ──────────R(X=200)────────────(使用了无效数据) │
│ │
│ 问题:T2读到的X=200根本不存在!T1已回滚! │
│ │
└─────────────────────────────────────────────────────────────────┘
严重性:⭐⭐⭐⭐
读取到从未真正存在过的数据,导致严重的业务错误。
2.3.2 示例场景
场景1:转账操作
sql
-- 账户A余额:1000元
-- 账户B余额:500元
-- 事务T1:从A转账500元到B(但会失败回滚)
BEGIN;
UPDATE account SET balance=500 WHERE id='A'; -- A减少500
UPDATE account SET balance=1000 WHERE id='B'; -- B增加500
-- 此时A=500, B=1000
-- ... 发生错误,准备回滚 ...
-- 事务T2:查询B的余额(同时执行)
BEGIN;
SELECT balance FROM account WHERE id='B'; -- 读到1000(脏数据!)
-- 认为B有1000元,允许B消费900元
UPDATE account SET balance=100 WHERE id='B';
COMMIT;
-- 事务T1:回滚
ROLLBACK; -- A恢复到1000, B恢复到500
-- 结果:B账户余额变成负数!
-- T2以为B有1000元,扣了900元,剩100元
-- 但T1回滚后,B实际只有500元,扣900元后应该是-400元!
场景2:库存判断
时间 T1(补货,但会回滚) T2(检查库存)
────────────────────────────────────────────────────
T1 库存=10 -
T2 UPDATE stock=110 -
T3 (补货100件,未提交) -
T4 - 读取stock=110
T5 - 判断:库存充足
T6 - 允许下单100件
T7 ROLLBACK -
T8 库存恢复到10 -
问题:实际只有10件库存,却下单了100件!
场景3:审批流程
sql
-- 事务T1:审批文档(但审批人反悔)
BEGIN;
UPDATE document SET status='已审批' WHERE id=1;
-- 未提交
-- 事务T2:查看文档状态
SELECT status FROM document WHERE id=1; -- 读到"已审批"(脏数据)
-- 基于"已审批"状态,执行后续流程...
-- 事务T1:反悔,撤销审批
ROLLBACK;
-- 结果:文档实际未审批,但后续流程已启动!
2.3.3 产生原因
┌─────────────────────────────────────────────────────────────────┐
│ 脏读的原因 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 读取未提交数据 │
│ • 事务隔离级别设置为READ UNCOMMITTED │
│ • 读取时不检查数据是否已提交 │
│ │
│ 2. 无锁保护 │
│ • 写操作未加排他锁 │
│ • 或读操作无视排他锁 │
│ │
│ 3. 事务回滚 │
│ • 写事务可能回滚 │
│ • 但读事务已经使用了数据 │
│ │
└─────────────────────────────────────────────────────────────────┘
解决方案:
sql
-- 方案1:使用READ COMMITTED或更高隔离级别
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM account WHERE id='B'; -- 只能读到已提交的数据
COMMIT;
-- 方案2:等待其他事务提交
-- 大多数数据库默认会等待写锁释放
BEGIN;
SELECT balance FROM account WHERE id='B';
-- 如果B正在被其他事务修改,会等待其提交或回滚
COMMIT;
2.4 幻读(Phantom Read)
2.4.1 问题描述
幻读 是指在同一事务中,两次执行相同的范围查询 ,得到的记录数量不同。原因是其他事务插入或删除了符合条件的新记录。
┌─────────────────────────────────────────────────────────────────┐
│ 幻读示意图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ T1: 第一次范围查询(3条记录)→ 第二次范围查询(5条记录)?! │
│ T2: INSERT新记录(2条) │
│ │
│ 时间线: │
│ ────────────────────────────────────────────────────────> │
│ T1: ──查询(3条)──────────────查询(5条)──── │
│ T2: ──────────INSERT 2条, COMMIT─── │
│ │
│ 问题:T1两次查询,结果集的记录数不同!像产生了"幻影" │
│ │
└─────────────────────────────────────────────────────────────────┘
严重性:⭐⭐
主要影响统计类查询,在某些场景下可以接受。
2.4.2 示例场景
场景1:工资统计
sql
-- 部门员工工资表
-- 初始数据:3名员工,工资分别为5000, 6000, 7000
-- 事务T1:统计部门工资
BEGIN;
-- 第一次统计
SELECT COUNT(*) FROM employee WHERE dept='销售部'; -- 结果:3人
SELECT SUM(salary) FROM employee WHERE dept='销售部'; -- 总额:18000
-- ... 业务处理 ...
-- 第二次统计(验证)
SELECT COUNT(*) FROM employee WHERE dept='销售部'; -- 结果:5人!
SELECT SUM(salary) FROM employee WHERE dept='销售部'; -- 总额:28000!
COMMIT;
-- 事务T2:新员工入职(同时执行)
BEGIN;
INSERT INTO employee VALUES (4, '张三', '销售部', 5000);
INSERT INTO employee VALUES (5, '李四', '销售部', 5000);
COMMIT;
-- 问题:报表中统计数据前后矛盾
场景2:库存盘点
时间 T1(盘点) T2(入库)
────────────────────────────────────────────────────────
T1 第一次查询:10种商品 -
T2 逐个记录... -
T3 - INSERT新商品(2种)并提交
T4 第二次查询:12种商品 -
T5 总数对不上! -
问题:盘点过程中"凭空出现"了新商品
场景3:订单总额计算
sql
-- 事务T1:计算某天的订单总额
BEGIN;
-- 第一次查询订单数量
SELECT COUNT(*) FROM orders
WHERE order_date='2024-01-01'; -- 100个订单
-- 计算总金额
SELECT SUM(amount) FROM orders
WHERE order_date='2024-01-01'; -- 10000元
-- ... 生成报表 ...
-- 再次验证
SELECT COUNT(*) FROM orders
WHERE order_date='2024-01-01'; -- 102个订单!
COMMIT;
-- 事务T2:补录订单(同时执行)
BEGIN;
INSERT INTO orders VALUES (101, '2024-01-01', 100);
INSERT INTO orders VALUES (102, '2024-01-01', 50);
COMMIT;
-- 问题:报表显示"100个订单,总额10000元"
-- 但实际已经有102个订单
2.4.3 产生原因
┌─────────────────────────────────────────────────────────────────┐
│ 幻读的原因 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 行锁无法锁定"不存在的行" │
│ • 普通锁只能锁定已存在的记录 │
│ • 无法阻止其他事务INSERT新记录 │
│ │
│ 2. 范围查询的特性 │
│ • WHERE条件是范围(如salary>5000) │
│ • 新插入的记录可能满足范围条件 │
│ │
│ 3. 隔离级别不够 │
│ • REPEATABLE READ级别不能完全防止幻读 │
│ • 需要SERIALIZABLE级别 │
│ │
└─────────────────────────────────────────────────────────────────┘
解决方案:
sql
-- 方案1:使用SERIALIZABLE隔离级别
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT COUNT(*) FROM employee WHERE dept='销售部';
-- 其他事务无法INSERT符合条件的新记录
SELECT COUNT(*) FROM employee WHERE dept='销售部'; -- 结果一致
COMMIT;
-- 方案2:使用间隙锁(Gap Lock)- MySQL InnoDB
BEGIN;
SELECT * FROM employee WHERE dept='销售部' FOR UPDATE;
-- 锁定现有记录 + 锁定"间隙",阻止INSERT
SELECT * FROM employee WHERE dept='销售部';
COMMIT;
-- 方案3:锁表(不推荐,性能差)
BEGIN;
LOCK TABLE employee WRITE;
SELECT COUNT(*) FROM employee WHERE dept='销售部';
UNLOCK TABLES;
COMMIT;
2.5 并发问题对比总结
┌─────────────────────────────────────────────────────────────────┐
│ 四大并发问题对比 │
├──────────┬────────────┬─────────────┬────────────┬──────────────┤
│ 问题类型 │ 影响的操作 │ 数据变化 │ 严重性 │ 典型场景 │
├──────────┼────────────┼─────────────┼────────────┼──────────────┤
│ 丢失修改 │ 写-写冲突 │ 修改被覆盖 │ ⭐⭐⭐⭐⭐ │ 库存扣减 │
│ │ │ │ │ 余额更新 │
├──────────┼────────────┼─────────────┼────────────┼──────────────┤
│ 脏读 │ 写-读冲突 │ 读到未提交 │ ⭐⭐⭐⭐ │ 转账审核 │
│ │ │ 数据 │ │ 状态检查 │
├──────────┼────────────┼─────────────┼────────────┼──────────────┤
│不可重复读│ 读-写冲突 │ 记录被修改 │ ⭐⭐⭐ │ 余额验证 │
│ │ │ │ │ 统计分析 │
├──────────┼────────────┼─────────────┼────────────┼──────────────┤
│ 幻读 │ 读-写冲突 │ 记录数量 │ ⭐⭐ │ 报表统计 │
│ │ │ 变化 │ │ 批量处理 │
└──────────┴────────────┴─────────────┴────────────┴──────────────┘
关键区别:
| 对比维度 | 不可重复读 | 幻读 |
|---|---|---|
| 变化内容 | 已有记录的值改变 | 记录总数改变 |
| 触发操作 | UPDATE/DELETE | INSERT |
| 影响范围 | 单条记录 | 查询结果集 |
| 锁定方式 | 行锁可解决 | 需要间隙锁/表锁 |
问题关系图:
并发问题严重性递减:
丢失修改(最严重)
↓
脏读
↓
不可重复读
↓
幻读(最轻微)
解决难度递增:
丢失修改 → 简单行锁即可
脏读 → 读已提交即可
不可重复读 → 需要可重复读
幻读 → 需要串行化或间隙锁
各隔离级别能防止的问题:
| 隔离级别 | 丢失修改 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|---|
| READ UNCOMMITTED | ✗ | ✗ | ✗ | ✗ |
| READ COMMITTED | ✓ | ✓ | ✗ | ✗ |
| REPEATABLE READ | ✓ | ✓ | ✓ | ✗(MySQL可防) |
| SERIALIZABLE | ✓ | ✓ | ✓ | ✓ |
注:MySQL InnoDB在REPEATABLE READ级别通过间隙锁可以防止幻读。
第三章 事务的基本概念
事务是并发控制的核心单元,是解决并发问题的基础。
3.1 什么是事务
事务(Transaction) 是数据库执行的最小工作单元,包含一组操作,这些操作要么全部成功,要么全部失败。
┌─────────────────────────────────────────────────────────────────┐
│ 事务的本质 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 定义:一个不可分割的工作单元 │
│ │
│ 生活类比:去银行办理转账业务 │
│ 1. 从账户A扣款 │
│ 2. 向账户B加款 │
│ • 两个操作必须都成功,或者都不执行 │
│ • 不能只扣了A的钱,B却没收到 │
│ │
│ 技术特点: │
│ • All or Nothing(全部或都不) │
│ • 是数据库恢复和并发控制的基本单位 │
│ │
└─────────────────────────────────────────────────────────────────┘
事务示例:
sql
-- 转账事务
BEGIN TRANSACTION; -- 开始事务
-- 操作1:扣款
UPDATE account SET balance = balance - 1000
WHERE account_id = 'A';
-- 操作2:加款
UPDATE account SET balance = balance + 1000
WHERE account_id = 'B';
COMMIT; -- 提交事务(成功)
-- 或
ROLLBACK; -- 回滚事务(失败)
事务的边界:
┌──────────────────────────────────────────────────────┐
│ 事务的生命周期 │
├──────────────────────────────────────────────────────┤
│ │
│ BEGIN TRANSACTION │
│ ↓ │
│ ┌──────────────────────────────┐ │
│ │ Transaction 活动状态 │ │
│ │ • 执行SQL语句 │ │
│ │ • 读写数据 │ │
│ │ • 修改数据 │ │
│ └──────────────────────────────┘ │
│ ↓ ↓ │
│ COMMIT ROLLBACK │
│ (提交) (回滚) │
│ ↓ ↓ │
│ 持久化到磁盘 恢复到初始状态 │
│ │
└──────────────────────────────────────────────────────┘
3.2 事务的ACID特性
ACID是事务必须满足的四个特性,是数据库正确性的基石。
┌─────────────────────────────────────────────────────────────────┐
│ ACID特性总览 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ A - Atomicity (原子性) → 全有或全无 │
│ C - Consistency (一致性) → 符合约束 │
│ I - Isolation (隔离性) → 互不干扰 │
│ D - Durability (持久性) → 永久保存 │
│ │
└─────────────────────────────────────────────────────────────────┘
3.2.1 原子性(Atomicity)
定义: 事务中的所有操作,要么全部完成,要么全部不完成,不会停留在中间某个状态。
┌─────────────────────────────────────────────────────────────────┐
│ 原子性示意 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 转账事务包含2个操作: │
│ ┌──────┐ ┌──────┐ │
│ │ 扣A │ + │ 加B │ │
│ └──────┘ └──────┘ │
│ │
│ 正确结果: │
│ ✓ 两个操作都成功 → COMMIT │
│ ✓ 任一操作失败 → ROLLBACK(全部撤销) │
│ │
│ 不允许的情况: │
│ ✗ 只执行了扣A,没有加B │
│ ✗ 只执行了加B,没有扣A │
│ │
└─────────────────────────────────────────────────────────────────┘
实现机制:
sql
-- 示例:原子性保障
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id=1;
-- 如果这里发生错误(如余额不足)
UPDATE account SET balance = balance + 100 WHERE id=2;
-- 整个事务会回滚,第一个UPDATE也会撤销
COMMIT;
-- 数据库通过日志实现原子性
-- • Undo Log:记录修改前的值,用于回滚
-- • 失败时,根据Undo Log恢复数据
通俗理解:
原子性就像购物下单,要么商品+运费一起支付成功,要么都不支付。不能只扣了商品费,运费没扣成功。
3.2.2 一致性(Consistency)
定义: 事务执行前后,数据库从一个一致性状态转换到另一个一致性状态,不违反任何完整性约束。
┌─────────────────────────────────────────────────────────────────┐
│ 一致性示意 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 约束:账户总金额保持不变 │
│ │
│ 事务前:A=1000, B=500, 总额=1500 │
│ 事务:A转账200给B │
│ 事务后:A=800, B=700, 总额=1500 ✓ │
│ │
│ 其他一致性约束: │
│ • 余额不能为负数 │
│ • 外键约束(订单必须关联有效用户) │
│ • 唯一性约束(用户名不重复) │
│ • 业务规则(未成年人不能购买酒类) │
│ │
└─────────────────────────────────────────────────────────────────┘
一致性示例:
sql
-- 一致性约束:库存不能为负
CREATE TABLE product (
id INT PRIMARY KEY,
stock INT CHECK (stock >= 0) -- 约束
);
-- 事务违反一致性会被拒绝
BEGIN;
UPDATE product SET stock = stock - 100 WHERE id=1;
-- 如果更新后stock变成负数,事务会失败
COMMIT;
-- 数据库会自动检查约束
-- ERROR: new row violates check constraint "product_stock_check"
通俗理解:
一致性就像天平,转账前后两边重量要相等。A账户减少的钱 = B账户增加的钱。
3.2.3 隔离性(Isolation)
定义: 多个事务并发执行时,一个事务的执行不应受到其他事务的干扰。
┌─────────────────────────────────────────────────────────────────┐
│ 隔离性示意 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 理想状态:完全隔离(串行化) │
│ ┌─────────┐ │
│ │ 事务T1 │ → 完成 │
│ └─────────┘ │
│ ┌─────────┐ │
│ │ 事务T2 │ → 完成 │
│ └─────────┘ │
│ │
│ 实际情况:并发执行但互不干扰 │
│ ┌─────────┐ │
│ │ 事务T1 │ ─┬─ 看起来像是独立执行 │
│ └─────────┘ │ │
│ ┌─────────┐ │ │
│ │ 事务T2 │ ─┴─ 虽然交叉执行,但感知不到对方 │
│ └─────────┘ │
│ │
│ 这就是并发控制的核心! │
│ │
└─────────────────────────────────────────────────────────────────┘
隔离性级别:
不同级别的隔离性,性能和安全性不同(详见3.5节)。
通俗理解:
隔离性就像ATM机取款,虽然很多人在不同ATM同时操作同一账户,但每个人感觉就像只有自己在操作。
3.2.4 持久性(Durability)
定义: 一旦事务提交,其对数据库的修改就是永久性的,即使系统故障也不会丢失。
┌─────────────────────────────────────────────────────────────────┐
│ 持久性示意 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ COMMIT │
│ ↓ │
│ 数据写入磁盘 │
│ ↓ │
│ 即使发生: │
│ • 断电 │
│ • 系统崩溃 │
│ • 磁盘故障(有备份) │
│ ↓ │
│ 数据依然存在! │
│ │
│ 实现机制: │
│ • Redo Log(重做日志) │
│ • 先写日志,再写数据(WAL: Write-Ahead Logging) │
│ • 崩溃恢复时,根据Redo Log重做已提交事务 │
│ │
└─────────────────────────────────────────────────────────────────┘
持久性示例:
sql
BEGIN;
INSERT INTO orders VALUES (1001, 'product_A', 100);
COMMIT; -- 提交后,数据永久保存
-- 即使此时:
-- 1. 数据库崩溃
-- 2. 服务器重启
-- 3. 断电
-- 订单1001的数据依然存在
-- 数据库通过日志保证持久性
-- • 提交时先写Redo Log
-- • 日志持久化后,事务才算成功
-- • 崩溃恢复时,重放Redo Log
通俗理解:
持久性就像银行存款凭证,一旦存款成功,即使银行停电、电脑坏了,你的钱也不会丢失(有记录可查)。
ACID之间的关系:
┌─────────────────────────────────────────────────────────────────┐
│ ACID关系图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 原子性(A) │
│ ↓ │
│ 保证操作完整性 │
│ │
│ 一致性(C) ← 最终目标 │
│ ↑ │
│ 由AID共同保证 │
│ │
│ 隔离性(I) │
│ ↓ │
│ 解决并发问题 │
│ │
│ 持久性(D) │
│ ↓ │
│ 防止数据丢失 │
│ │
└─────────────────────────────────────────────────────────────────┘
3.3 事务的状态
事务在执行过程中会经历不同的状态。
┌─────────────────────────────────────────────────────────────────┐
│ 事务状态转换图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ BEGIN │
│ ↓ │
│ ┌─────────┐ │
│ │ 活动状态 │ ← 正在执行SQL语句 │
│ │(Active) │ │
│ └─────────┘ │
│ ↓ │
│ 最后一条SQL执行完 │
│ ↓ │
│ ┌──────────┐ │
│ │ 部分提交 │ ← 已执行完,但未写入磁盘 │
│ │(Partially│ │
│ │Committed)│ │
│ └──────────┘ │
│ ↓ ↓ │
│ 写入成功 写入失败 │
│ ↓ ↓ │
│ ┌─────────┐ ┌─────────┐ │
│ │ 提交状态 │ │ 失败状态 │ │
│ │(Committ-│ │(Failed) │ │
│ │ed) │ └─────────┘ │
│ └─────────┘ ↓ │
│ 执行回滚 │
│ ↓ │
│ ┌─────────┐ │
│ │ 中止状态 │ │
│ │(Aborted)│ │
│ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
状态说明:
| 状态 | 说明 | 可以进行的操作 |
|---|---|---|
| Active | 初始状态,事务正在执行 | 继续执行SQL |
| Partially Committed | 最后一条语句执行完毕 | 等待写入磁盘 |
| Committed | 事务成功完成 | 无(已结束) |
| Failed | 无法正常执行 | 准备回滚 |
| Aborted | 事务回滚,恢复到初始状态 | 可重启事务或放弃 |
3.4 事务的操作
基本操作命令:
sql
-- 1. 开始事务
BEGIN;
-- 或
START TRANSACTION;
-- 或 (隐式开始)
-- 大多数数据库在执行第一条SQL时自动开始事务
-- 2. 提交事务
COMMIT;
-- 使所有修改永久生效
-- 3. 回滚事务
ROLLBACK;
-- 撤销所有修改
-- 4. 保存点(Savepoint)
SAVEPOINT sp1; -- 设置保存点
-- ... 一些操作 ...
ROLLBACK TO sp1; -- 回滚到保存点(部分回滚)
RELEASE SAVEPOINT sp1; -- 删除保存点
事务操作示例:
sql
-- 示例1:完整的转账事务
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id=1;
UPDATE account SET balance = balance + 100 WHERE id=2;
COMMIT;
-- 示例2:带错误处理的事务
BEGIN;
UPDATE inventory SET stock = stock - 10 WHERE product_id=100;
-- 检查库存是否足够
SELECT stock FROM inventory WHERE product_id=100;
-- 如果stock < 0,则回滚
IF (stock < 0) THEN
ROLLBACK;
ELSE
COMMIT;
END IF;
-- 示例3:使用保存点
BEGIN;
INSERT INTO orders VALUES (1, 'A', 100); -- 订单主记录
SAVEPOINT sp1;
INSERT INTO order_items VALUES (1, 1, 10); -- 订单明细1
INSERT INTO order_items VALUES (1, 2, 20); -- 订单明细2
-- 如果明细有错误,可以只回滚明细
ROLLBACK TO sp1;
-- 重新插入正确的明细
INSERT INTO order_items VALUES (1, 1, 15);
COMMIT;
3.5 事务隔离级别
为什么需要隔离级别?
性能 ←──────────────────────────→ 安全性
低隔离 中隔离 高隔离
↓ ↓ ↓
更快但 平衡 更安全但
可能不一致 可能更慢
SQL标准定义了4个隔离级别,从低到高依次是:
3.5.1 读未提交(Read Uncommitted)
特点: 最低级别,允许读取未提交的数据。
┌─────────────────────────────────────────────────────────────────┐
│ READ UNCOMMITTED │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 特性: │
│ • 事务可以读取其他事务未提交的数据 │
│ • 几乎没有隔离性 │
│ • 性能最好,但最不安全 │
│ │
│ 可能出现的问题: │
│ ✗ 脏读 - 会出现 │
│ ✗ 不可重复读 - 会出现 │
│ ✗ 幻读 - 会出现 │
│ ✗ 丢失修改 - 会出现 │
│ │
│ 使用场景: │
│ • 几乎不使用(太不安全) │
│ • 仅用于对一致性要求极低的统计 │
│ │
└─────────────────────────────────────────────────────────────────┘
sql
-- 设置隔离级别
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
BEGIN;
SELECT * FROM account WHERE id=1;
-- 可能读到其他事务未提交的数据(脏读)
COMMIT;
3.5.2 读已提交(Read Committed)
特点: 只能读取已提交的数据,避免脏读。
┌─────────────────────────────────────────────────────────────────┐
│ READ COMMITTED │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 特性: │
│ • 只能读取已提交的数据 │
│ • 避免了脏读 │
│ • Oracle和SQL Server的默认级别 │
│ │
│ 可能出现的问题: │
│ ✓ 脏读 - 不会出现 │
│ ✗ 不可重复读 - 会出现 │
│ ✗ 幻读 - 会出现 │
│ │
│ 使用场景: │
│ • 大多数OLTP系统的默认选择 │
│ • 适合读多写少的应用 │
│ • 余额查询、订单查询等 │
│ │
└─────────────────────────────────────────────────────────────────┘
sql
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM account WHERE id=1; -- 读取:1000元
-- 此时其他事务修改并提交:balance=500
SELECT balance FROM account WHERE id=1; -- 读取:500元(不可重复读)
COMMIT;
3.5.3 可重复读(Repeatable Read)
特点: 同一事务中多次读取结果一致。
┌─────────────────────────────────────────────────────────────────┐
│ REPEATABLE READ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 特性: │
│ • 同一事务内多次读取同一数据,结果相同 │
│ • 通过锁定读取的行实现 │
│ • MySQL InnoDB的默认级别 │
│ │
│ 可能出现的问题: │
│ ✓ 脏读 - 不会出现 │
│ ✓ 不可重复读 - 不会出现 │
│ ✗ 幻读 - 可能出现(MySQL InnoDB通过间隙锁避免) │
│ │
│ 使用场景: │
│ • 需要一致性读的报表 │
│ • 批量处理 │
│ • 统计分析 │
│ │
└─────────────────────────────────────────────────────────────────┘
sql
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM account WHERE id=1; -- 读取:1000元
-- 其他事务修改并提交:balance=500
SELECT balance FROM account WHERE id=1; -- 仍然读取:1000元(可重复读)
COMMIT;
3.5.4 串行化(Serializable)
特点: 最高隔离级别,完全隔离,如同串行执行。
┌─────────────────────────────────────────────────────────────────┐
│ SERIALIZABLE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 特性: │
│ • 最严格的隔离级别 │
│ • 事务串行执行(看起来像) │
│ • 锁定整个范围,防止插入 │
│ │
│ 可能出现的问题: │
│ ✓ 脏读 - 不会出现 │
│ ✓ 不可重复读 - 不会出现 │
│ ✓ 幻读 - 不会出现 │
│ │
│ 代价: │
│ • 性能最差 │
│ • 并发度最低 │
│ • 容易产生锁等待和死锁 │
│ │
│ 使用场景: │
│ • 金融交易 │
│ • 关键业务逻辑 │
│ • 对一致性要求极高的场景 │
│ │
└─────────────────────────────────────────────────────────────────┘
sql
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT COUNT(*) FROM orders WHERE date='2024-01-01'; -- 100条
-- 其他事务无法插入新订单
SELECT COUNT(*) FROM orders WHERE date='2024-01-01'; -- 仍然100条
COMMIT;
3.5.5 隔离级别对比
详细对比表:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 | 性能 | 使用场景 |
|---|---|---|---|---|---|---|
| READ UNCOMMITTED | ✗ | ✗ | ✗ | 无锁 | ⭐⭐⭐⭐⭐ | 几乎不用 |
| READ COMMITTED | ✓ | ✗ | ✗ | 读加锁,读完释放 | ⭐⭐⭐⭐ | 默认选择 |
| REPEATABLE READ | ✓ | ✓ | △ | 事务期间持有锁 | ⭐⭐⭐ | 报表统计 |
| SERIALIZABLE | ✓ | ✓ | ✓ | 范围锁 | ⭐⭐ | 金融系统 |
注:△ 表示MySQL InnoDB可以通过间隙锁防止
性能vs安全性权衡:
隔离级别 并发性能 数据安全 推荐场景
────────────────────────────────────────────
READ ⭐⭐⭐⭐⭐ ⭐ 测试环境
UNCOMMITTED
READ ⭐⭐⭐⭐ ⭐⭐⭐ 大多数Web应用
COMMITTED (Oracle默认)
REPEATABLE ⭐⭐⭐ ⭐⭐⭐⭐ 报表、银行查询
READ (MySQL默认)
SERIALIZABLE ⭐⭐ ⭐⭐⭐⭐⭐ 关键金融业务
如何选择隔离级别:
┌─────────────────────────────────────────────────────────────────┐
│ 隔离级别选择指南 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 普通Web应用 │
│ → READ COMMITTED │
│ • 性能好,基本够用 │
│ • Oracle/PostgreSQL默认 │
│ │
│ 2. 报表统计 │
│ → REPEATABLE READ │
│ • 保证数据一致性 │
│ • MySQL默认 │
│ │
│ 3. 金融交易 │
│ → SERIALIZABLE │
│ • 绝对安全 │
│ • 牺牲性能换正确性 │
│ │
│ 4. 高并发、对一致性要求不严格 │
│ → READ UNCOMMITTED │
│ • 实时数据展示(如在线人数) │
│ • 非关键业务 │
│ │
└─────────────────────────────────────────────────────────────────┘
主流数据库默认隔离级别:
| 数据库 | 默认隔离级别 |
|---|---|
| MySQL InnoDB | REPEATABLE READ |
| Oracle | READ COMMITTED |
| SQL Server | READ COMMITTED |
| PostgreSQL | READ COMMITTED |
第四章 封锁协议概述
封锁是实现并发控制最基本和最重要的技术之一。
4.1 什么是封锁
封锁(Locking) 是事务在访问数据前,先对数据加锁,阻止其他事务同时访问,从而避免并发冲突。
┌─────────────────────────────────────────────────────────────────┐
│ 封锁的本质 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 通俗理解:图书馆借书 │
│ │
│ 1. 你要看一本书 │
│ 2. 先从书架上拿下来(加锁) │
│ 3. 看书期间,别人无法同时看这本书 │
│ 4. 看完后放回书架(解锁) │
│ 5. 其他人才能借阅 │
│ │
│ 数据库也是同样的道理! │
│ │
└─────────────────────────────────────────────────────────────────┘
基本封锁流程:
┌──────────────────────────────────────────┐
│ 封锁操作流程 │
├──────────────────────────────────────────┤
│ │
│ 事务开始 │
│ ↓ │
│ 加锁(Lock) │
│ ↓ │
│ 访问数据(读/写) │
│ ↓ │
│ 解锁(Unlock) │
│ ↓ │
│ 其他事务可以访问 │
│ │
└──────────────────────────────────────────┘
4.2 锁的类型
数据库中主要有两种基本锁类型:
4.2.1 排他锁(X锁)
排他锁(Exclusive Lock, X-Lock) 也叫写锁,当事务对数据加排他锁时,其他事务既不能读也不能写。
┌─────────────────────────────────────────────────────────────────┐
│ 排他锁(X锁) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 特点: │
│ • 独占访问 │
│ • 其他事务不能读,也不能写 │
│ • 保护写操作 │
│ │
│ 使用场景: │
│ • UPDATE语句 │
│ • DELETE语句 │
│ • INSERT语句 │
│ • SELECT ... FOR UPDATE │
│ │
└─────────────────────────────────────────────────────────────────┘
示例:
sql
-- 事务T1:加排他锁
BEGIN;
SELECT * FROM account WHERE id=1 FOR UPDATE; -- 加X锁
UPDATE account SET balance=500 WHERE id=1;
COMMIT; -- 释放锁
-- 事务T2:等待T1释放锁
BEGIN;
SELECT * FROM account WHERE id=1; -- 等待...
4.2.2 共享锁(S锁)
共享锁(Shared Lock, S-Lock) 也叫读锁,当事务对数据加共享锁时,其他事务可以读但不能写。
┌─────────────────────────────────────────────────────────────────┐
│ 共享锁(S锁) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 特点: │
│ • 多个事务可以同时持有共享锁 │
│ • 其他事务可以读,但不能写 │
│ • 保护读操作 │
│ │
│ 使用场景: │
│ • SELECT语句(某些隔离级别) │
│ • SELECT ... LOCK IN SHARE MODE │
│ │
└─────────────────────────────────────────────────────────────────┘
示例:
sql
-- 事务T1和T2:同时加共享锁(可以)
BEGIN; -- T1
SELECT * FROM account WHERE id=1 LOCK IN SHARE MODE; -- 加S锁
BEGIN; -- T2
SELECT * FROM account WHERE id=1 LOCK IN SHARE MODE; -- 也可以加S锁
-- 事务T3:尝试加排他锁(阻塞)
BEGIN;
UPDATE account SET balance=500 WHERE id=1; -- 等待T1和T2释放S锁
4.2.3 锁的兼容矩阵
锁的兼容性表:
┌─────────────────────────────────────────┐
│ 锁的兼容矩阵 │
├──────────┬─────────────┬───────────────┤
│ │ 请求S锁 │ 请求X锁 │
├──────────┼─────────────┼───────────────┤
│ 已有S锁 │ ✓ 兼容 │ ✗ 不兼容 │
├──────────┼─────────────┼───────────────┤
│ 已有X锁 │ ✗ 不兼容 │ ✗ 不兼容 │
└──────────┴─────────────┴───────────────┘
✓ = 可以授予锁(兼容)
✗ = 必须等待(不兼容)
简记口诀:
读读共享(S + S = ✓)
读写互斥(S + X = ✗)
写写互斥(X + X = ✗)
写读互斥(X + S = ✗)
4.3 封锁协议
封锁协议规定了何时加锁、何时解锁,不同协议解决不同的并发问题。
4.3.1 一级封锁协议
┌─────────────────────────────────────────────────────────────────┐
│ 一级封锁协议 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 规则: │
│ • 事务修改数据前,必须加X锁 │
│ • 事务结束后释放X锁 │
│ │
│ 能解决的问题: │
│ ✓ 丢失修改 │
│ │
│ 不能解决的问题: │
│ ✗ 脏读 │
│ ✗ 不可重复读 │
│ ✗ 幻读 │
│ │
└─────────────────────────────────────────────────────────────────┘
示例:
sql
-- 一级封锁协议
BEGIN;
LOCK-X(A); -- 修改前加X锁
UPDATE account SET balance=500 WHERE id=A;
-- 读操作不需要加锁
SELECT * FROM account WHERE id=B; -- 可能读到脏数据
COMMIT; -- 释放所有锁
4.3.2 二级封锁协议
┌─────────────────────────────────────────────────────────────────┐
│ 二级封锁协议 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 规则: │
│ • 一级封锁协议的基础上 │
│ • 读数据前加S锁 │
│ • 读完后立即释放S锁(不必等到事务结束) │
│ │
│ 能解决的问题: │
│ ✓ 丢失修改 │
│ ✓ 脏读 │
│ │
│ 不能解决的问题: │
│ ✗ 不可重复读 │
│ ✗ 幻读 │
│ │
└─────────────────────────────────────────────────────────────────┘
示例:
sql
-- 二级封锁协议
BEGIN;
LOCK-S(A); -- 读前加S锁
SELECT * FROM account WHERE id=A; -- balance=1000
UNLOCK-S(A); -- 读完立即释放
-- ...
LOCK-S(A); -- 再次读取
SELECT * FROM account WHERE id=A; -- 可能变成balance=500(不可重复读)
UNLOCK-S(A);
COMMIT;
4.3.3 三级封锁协议
┌─────────────────────────────────────────────────────────────────┐
│ 三级封锁协议 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 规则: │
│ • 一级封锁协议的基础上 │
│ • 读数据前加S锁 │
│ • 事务结束后才释放S锁(持有到COMMIT) │
│ │
│ 能解决的问题: │
│ ✓ 丢失修改 │
│ ✓ 脏读 │
│ ✓ 不可重复读 │
│ │
│ 不能解决的问题: │
│ ✗ 幻读(需要更高级的锁机制) │
│ │
└─────────────────────────────────────────────────────────────────┘
示例:
sql
-- 三级封锁协议
BEGIN;
LOCK-S(A); -- 读前加S锁
SELECT * FROM account WHERE id=A; -- balance=1000
-- 保持S锁不释放
-- ...
SELECT * FROM account WHERE id=A; -- 仍然是balance=1000(可重复读)
COMMIT; -- 事务结束才释放S锁
4.3.4 封锁协议对比
| 协议 | 写操作 | 读操作 | 解决的问题 | 对应隔离级别 |
|---|---|---|---|---|
| 一级 | X锁到事务末 | 不加锁 | 丢失修改 | - |
| 二级 | X锁到事务末 | S锁读完即释放 | 丢失修改、脏读 | READ COMMITTED |
| 三级 | X锁到事务末 | S锁到事务末 | 丢失修改、脏读、不可重复读 | REPEATABLE READ |
4.4 两段锁协议(2PL)
4.4.1 两段锁协议原理
两段锁协议(Two-Phase Locking, 2PL) 是保证事务可串行化的重要协议。
┌─────────────────────────────────────────────────────────────────┐
│ 两段锁协议(2PL) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 两个阶段: │
│ │
│ 1. 扩展阶段(Growing Phase) │
│ • 事务可以获得锁 │
│ • 但不能释放锁 │
│ │
│ 2. 收缩阶段(Shrinking Phase) │
│ • 事务可以释放锁 │
│ • 但不能再获得新锁 │
│ │
│ 关键点:一旦开始释放锁,就不能再加新锁! │
│ │
└─────────────────────────────────────────────────────────────────┘
时间线示意:
事务执行时间线
────────────────────────────────────────────────────>
扩展阶段 收缩阶段
├─────────────┼───────────────┤
加锁A 释放A
加锁B 释放B
加锁C 释放C
获取锁 → ← 释放锁
为什么2PL能保证可串行化?
因为:
• 扩展阶段不释放锁 → 确保持有所有需要的资源
• 收缩阶段不加锁 → 避免与其他事务交叉
• 结果:事务之间有明确的顺序关系
4.4.2 两段锁协议的分类
1. 基本两段锁(Basic 2PL)
• 遵循扩展-收缩规则
• 但可能导致级联回滚
2. 严格两段锁(Strict 2PL)
• 所有X锁必须保持到事务COMMIT/ROLLBACK
• 避免级联回滚
• 大多数数据库采用这种方式
3. 强两段锁(Rigorous 2PL)
• 所有锁(S锁和X锁)都保持到事务结束
• 最严格,性能最差
4.5 锁的粒度
锁的粒度是指锁定数据的范围大小。
4.5.1 行级锁
┌─────────────────────────────────────────────────────────────────┐
│ 行级锁(Row Lock) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 特点: │
│ • 锁定单个数据行 │
│ • 粒度最小 │
│ • 并发度最高 │
│ • 锁开销大(每行都需要锁) │
│ │
│ 使用场景: │
│ • OLTP系统 │
│ • 高并发环境 │
│ • 冲突少的场景 │
│ │
└─────────────────────────────────────────────────────────────────┘
sql
-- 行级锁示例
UPDATE account SET balance=500 WHERE id=1; -- 只锁定id=1的行
4.5.2 表级锁
┌─────────────────────────────────────────────────────────────────┐
│ 表级锁(Table Lock) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 特点: │
│ • 锁定整张表 │
│ • 粒度最大 │
│ • 并发度最低 │
│ • 锁开销小(一个锁) │
│ │
│ 使用场景: │
│ • 批量操作(如导入数据) │
│ • DDL操作(ALTER TABLE) │
│ • 全表扫描 │
│ │
└─────────────────────────────────────────────────────────────────┘
sql
-- 表级锁示例
LOCK TABLE account WRITE; -- 锁定整张表
UPDATE account SET balance=balance*1.1; -- 全表更新
UNLOCK TABLES;
4.5.3 页级锁
┌─────────────────────────────────────────────────────────────────┐
│ 页级锁(Page Lock) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 特点: │
│ • 锁定数据页(通常8KB) │
│ • 粒度介于行锁和表锁之间 │
│ • 折中方案 │
│ │
│ 使用场景: │
│ • BerkeleyDB │
│ • SQL Server部分场景 │
│ │
└─────────────────────────────────────────────────────────────────┘
粒度对比:
| 粒度 | 并发性 | 锁开销 | 使用场景 |
|---|---|---|---|
| 行级锁 | ⭐⭐⭐⭐⭐ | 大 | OLTP、高并发 |
| 页级锁 | ⭐⭐⭐ | 中 | 折中方案 |
| 表级锁 | ⭐ | 小 | 批量操作、DDL |
4.5.4 意向锁
意向锁(Intention Lock) 是为了提高加表级锁的效率而引入的。
┌─────────────────────────────────────────────────────────────────┐
│ 意向锁的作用 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 问题: │
│ • 想给表加X锁 │
│ • 需要检查表中所有行是否已被锁定 │
│ • 如果表很大,检查非常耗时 │
│ │
│ 解决: │
│ • 加行锁前,先在表上加意向锁 │
│ • 加表锁时,只需检查表的意向锁 │
│ • 大大提高效率! │
│ │
└─────────────────────────────────────────────────────────────────┘
意向锁类型:
IS锁(Intention Shared) - 意向共享锁
IX锁(Intention Exclusive) - 意向排他锁
示例:
sql
-- 加行级X锁的完整流程
1. 在表上加IX锁
2. 在行上加X锁
3. 执行UPDATE
4. 释放锁
-- 加表级X锁时
1. 检查表的意向锁
2. 如果有IX锁,说明有行被锁定,等待
3. 否则加表级X锁
第五章 死锁问题概述
死锁是并发控制中最棘手的问题之一。
5.1 什么是死锁
死锁(Deadlock) 是指两个或多个事务互相等待对方释放锁,导致所有事务都无法继续执行的状态。
┌─────────────────────────────────────────────────────────────────┐
│ 死锁示意图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 经典死锁场景: │
│ │
│ T1: 持有A的锁 → 等待B的锁 │
│ ↑ ↓ │
│ | | │
│ └──────┬───────┘ │
│ × 互相等待(死锁!) │
│ ┌──────┴───────┐ │
│ | | │
│ ↓ ↑ │
│ T2: 持有B的锁 → 等待A的锁 │
│ │
│ 结果:两个事务都卡住,永远无法继续! │
│ │
└─────────────────────────────────────────────────────────────────┘
通俗理解:
死锁就像两辆车在窄路相遇,互不相让,谁也过不去。
死锁示例:
sql
-- 事务T1
BEGIN;
UPDATE account SET balance=500 WHERE id=1; -- 锁定账户1
-- 等待一会儿...
UPDATE account SET balance=600 WHERE id=2; -- 尝试锁定账户2(等待T2)
COMMIT;
-- 事务T2(同时执行)
BEGIN;
UPDATE account SET balance=700 WHERE id=2; -- 锁定账户2
-- 等待一会儿...
UPDATE account SET balance=800 WHERE id=1; -- 尝试锁定账户1(等待T1)
COMMIT;
-- 结果:T1等T2释放账户2,T2等T1释放账户1,死锁!
5.2 死锁产生的条件
死锁产生需要同时满足以下四个条件(Coffman条件):
┌─────────────────────────────────────────────────────────────────┐
│ 死锁的四个必要条件 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 互斥(Mutual Exclusion) │
│ • 资源不能被共享 │
│ • 一次只能由一个事务使用 │
│ │
│ 2. 持有并等待(Hold and Wait) │
│ • 事务已持有至少一个资源 │
│ • 又在等待获取其他资源 │
│ │
│ 3. 不可剥夺(No Preemption) │
│ • 资源不能被强制抢占 │
│ • 只能由持有者主动释放 │
│ │
│ 4. 循环等待(Circular Wait) │
│ • 存在事务等待环路 │
│ • T1→T2→T3→...→T1 │
│ │
│ 破坏任一条件,即可预防死锁! │
│ │
└─────────────────────────────────────────────────────────────────┘
5.3 死锁的预防
预防死锁就是破坏死锁产生的四个条件之一。
5.3.1 一次性封锁法
原理: 事务开始时一次性申请所有需要的锁,破坏"持有并等待"条件。
┌─────────────────────────────────────────────────────────────────┐
│ 一次性封锁法 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 做法: │
│ 1. 事务开始前,申请所有需要的锁 │
│ 2. 全部获得才开始执行 │
│ 3. 执行完毕后一次性释放 │
│ │
│ 优点: │
│ ✓ 完全避免死锁 │
│ │
│ 缺点: │
│ ✗ 降低并发度(资源利用率低) │
│ ✗ 难以预知所有需要的资源 │
│ ✗ 可能导致饥饿 │
│ │
└─────────────────────────────────────────────────────────────────┘
sql
-- 一次性封锁示例
BEGIN;
-- 一次性申请所有锁
SELECT * FROM account WHERE id IN (1,2,3) FOR UPDATE;
-- 执行所有操作
UPDATE account SET balance=balance-100 WHERE id=1;
UPDATE account SET balance=balance+50 WHERE id=2;
UPDATE account SET balance=balance+50 WHERE id=3;
COMMIT;
5.3.2 顺序封锁法
原理: 所有事务按照相同的顺序申请锁,破坏"循环等待"条件。
┌─────────────────────────────────────────────────────────────────┐
│ 顺序封锁法 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 做法: │
│ 1. 给所有数据对象编号(如主键) │
│ 2. 所有事务按照编号顺序申请锁 │
│ 3. 保证不会出现环路等待 │
│ │
│ 示例: │
│ • 总是先锁id小的,再锁id大的 │
│ • T1: 锁1 → 锁2 │
│ • T2: 锁1 → 锁2(都先锁1,不会死锁) │
│ │
│ 优点: │
│ ✓ 有效预防死锁 │
│ ✓ 并发度较高 │
│ │
│ 缺点: │
│ ✗ 需要应用层遵守规则 │
│ ✗ 难以在复杂系统中维护 │
│ │
└─────────────────────────────────────────────────────────────────┘
sql
-- 顺序封锁示例
-- 错误做法(可能死锁):
BEGIN;
UPDATE account SET balance=balance-100 WHERE id=2; -- 先锁2
UPDATE account SET balance=balance+100 WHERE id=1; -- 再锁1
COMMIT;
-- 正确做法(按ID顺序):
BEGIN;
UPDATE account SET balance=balance+100 WHERE id=1; -- 先锁1(小的)
UPDATE account SET balance=balance-100 WHERE id=2; -- 再锁2(大的)
COMMIT;
5.4 死锁的检测
如果无法预防死锁,就需要检测死锁并解除。
5.4.1 等待图法
原理: 构建事务等待图,检测是否有环路。
┌─────────────────────────────────────────────────────────────────┐
│ 等待图(Wait-For Graph) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 图的构建: │
│ • 节点:事务 │
│ • 边: T1→T2 表示T1等待T2释放锁 │
│ │
│ 死锁检测: │
│ • 如果图中有环,则存在死锁 │
│ • 定期运行检测算法 │
│ │
│ 示例: │
│ │
│ T1 ──→ T2 │
│ ↑ ↓ │
│ └───────┘ │
│ 有环!存在死锁 │
│ │
└─────────────────────────────────────────────────────────────────┘
检测算法:
1. 构建等待图
2. 使用DFS/BFS检测环
3. 如果发现环,确认死锁
4. 选择牺牲者(victim)回滚
5.4.2 超时机制
原理: 如果事务等待超过一定时间,认为发生死锁。
┌─────────────────────────────────────────────────────────────────┐
│ 超时检测法 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 机制: │
│ • 设置锁等待超时时间(如30秒) │
│ • 超时后自动回滚事务 │
│ • 简单但不精确 │
│ │
│ 优点: │
│ ✓ 实现简单 │
│ ✓ 开销小 │
│ │
│ 缺点: │
│ ✗ 可能误判(不是死锁只是慢) │
│ ✗ 超时时间难以设置 │
│ │
└─────────────────────────────────────────────────────────────────┘
sql
-- MySQL设置锁等待超时
SET innodb_lock_wait_timeout = 50; -- 50秒
BEGIN;
UPDATE account SET balance=500 WHERE id=1;
-- 如果等待超过50秒,自动回滚
5.5 死锁的解除
检测到死锁后,需要选择一个事务回滚。
┌─────────────────────────────────────────────────────────────────┐
│ 死锁解除策略 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 选择牺牲者(Victim Selection) │
│ │
│ 选择标准: │
│ • 执行时间最短的(浪费小) │
│ • 修改数据最少的(回滚成本低) │
│ • 优先级最低的 │
│ • 已回滚次数最少的(避免饥饿) │
│ │
│ 2. 回滚级别 │
│ │
│ • 完全回滚:撤销事务的所有操作 │
│ • 部分回滚:只回滚到打破死锁为止 │
│ │
│ 3. 重启事务 │
│ │
│ • 自动重试被回滚的事务 │
│ • 设置最大重试次数 │
│ │
└─────────────────────────────────────────────────────────────────┘
数据库的死锁处理:
MySQL:
• 自动检测死锁
• 选择回滚代价小的事务
• 返回错误:ERROR 1213: Deadlock found
PostgreSQL:
• 超时机制
• 返回错误:ERROR: deadlock detected
Oracle:
• 自动检测死锁
• 回滚其中一个事务
• 返回:ORA-00060: deadlock detected
5.6 活锁与饥饿
除了死锁,还有两个相关问题:
活锁(Livelock):
┌─────────────────────────────────────────────────────────────────┐
│ 活锁 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 定义: │
│ • 事务不断重试,但始终无法完成 │
│ • 系统看起来在运行,实际没有进展 │
│ │
│ 类比: │
│ • 两个人在走廊相遇 │
│ • 都想让对方先过 │
│ • 结果同时左右移动,谁也过不去 │
│ │
│ 解决: │
│ • 引入随机延迟 │
│ • 设置优先级 │
│ │
└─────────────────────────────────────────────────────────────────┘
饥饿(Starvation):
┌─────────────────────────────────────────────────────────────────┐
│ 饥饿 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 定义: │
│ • 某个事务一直得不到所需资源 │
│ • 永远无法执行 │
│ │
│ 原因: │
│ • 优先级调度不公平 │
│ • 高优先级事务不断插队 │
│ • 低优先级事务一直等待 │
│ │
│ 解决: │
│ • 先来先服务(FCFS) │
│ • 老化(Aging):等待时间越长,优先级越高 │
│ • 公平调度算法 │
│ │
└─────────────────────────────────────────────────────────────────┘
三者对比:
| 问题 | 特征 | 表现 | 解决难度 |
|---|---|---|---|
| 死锁 | 互相等待,完全卡住 | 事务挂起 | 容易检测和解除 |
| 活锁 | 不断重试,无进展 | CPU忙但无效 | 较难检测 |
| 饥饿 | 永远得不到资源 | 事务一直等待 | 需要公平调度 |
第六章 并发控制技术总览
数据库采用多种并发控制技术,各有优缺点。
6.1 基于封锁的并发控制
基于封锁的并发控制是最传统和最广泛使用的技术。
┌─────────────────────────────────────────────────────────────────┐
│ 基于封锁的并发控制 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 核心思想: │
│ • 访问数据前先加锁 │
│ • 其他事务必须等待 │
│ • 访问完毕后释放锁 │
│ │
│ 优点: │
│ ✓ 实现简单 │
│ ✓ 效果可靠 │
│ ✓ 广泛应用 │
│ │
│ 缺点: │
│ ✗ 可能死锁 │
│ ✗ 锁开销大 │
│ ✗ 并发度受限 │
│ │
│ 使用场景: │
│ • 所有主流数据库 │
│ • 写多读少的场景 │
│ │
└─────────────────────────────────────────────────────────────────┘
6.2 基于时间戳的并发控制
6.2.1 时间戳原理
**时间戳并发控制(Timestamp-based CC)**使用时间戳来确定事务顺序。
┌─────────────────────────────────────────────────────────────────┐
│ 时间戳并发控制 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 核心思想: │
│ • 每个事务有唯一的时间戳TS(T) │
│ • 每个数据项记录: │
│ - 最后读的时间戳 Read-TS(X) │
│ - 最后写的时间戳 Write-TS(X) │
│ • 根据时间戳顺序决定是否允许操作 │
│ │
│ 读规则: │
│ • 如果TS(T) < Write-TS(X) → 拒绝(读过时数据) │
│ • 否则允许读,并更新Read-TS(X) │
│ │
│ 写规则: │
│ • 如果TS(T) < Read-TS(X) → 拒绝(覆盖新数据) │
│ • 如果TS(T) < Write-TS(X) → 拒绝(覆盖新数据) │
│ • 否则允许写,并更新Write-TS(X) │
│ │
└─────────────────────────────────────────────────────────────────┘
优缺点:
优点:
✓ 无死锁(不用锁)
✓ 读事务不会被阻塞
缺点:
✗ 冲突时需要回滚
✗ 时间戳管理开销
✗ 级联回滚问题
6.2.2 时间戳排序协议
基本时间戳排序(Basic TO):
按照事务时间戳顺序执行,保证可串行化
6.3 乐观并发控制
6.3.1 乐观控制原理
**乐观并发控制(Optimistic CC)**假设冲突很少,提交时才检查。
┌─────────────────────────────────────────────────────────────────┐
│ 乐观并发控制 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 哲学思想: │
│ • 假设:冲突很少发生 │
│ • 策略:先执行,提交时检查冲突 │
│ • 如果有冲突,再回滚 │
│ │
│ 类比: │
│ • 悲观锁:出门前先锁门(加锁) │
│ • 乐观锁:回家时检查门锁(提交时检查) │
│ │
└─────────────────────────────────────────────────────────────────┘
6.3.2 三阶段协议
┌─────────────────────────────────────────────────────────────────┐
│ 乐观并发控制三阶段 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 读阶段(Read Phase) │
│ • 事务读取数据到私有工作区 │
│ • 所有修改也在私有区进行 │
│ • 不影响其他事务 │
│ │
│ 2. 验证阶段(Validation Phase) │
│ • 检查是否与其他事务冲突 │
│ • 读集、写集检查 │
│ • 有冲突则回滚 │
│ │
│ 3. 写阶段(Write Phase) │
│ • 验证通过后 │
│ • 将私有工作区的修改写入数据库 │
│ • 成为永久性修改 │
│ │
└─────────────────────────────────────────────────────────────────┘
优缺点:
优点:
✓ 读阶段不加锁,性能好
✓ 无死锁
✓ 适合读多写少的场景
缺点:
✗ 验证和回滚开销
✗ 写冲突多时性能差
✗ 饥饿问题
应用场景:
• 缓存更新(如version字段)
• 文档编辑系统
• 源代码版本控制
6.4 多版本并发控制(MVCC)
6.4.1 MVCC原理
**多版本并发控制(Multi-Version Concurrency Control)**是现代数据库的主流技术。
┌─────────────────────────────────────────────────────────────────┐
│ MVCC核心思想 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 基本原理: │
│ • 为每个数据保存多个版本 │
│ • 每个版本有时间戳或事务ID │
│ • 读事务看到一致的快照 │
│ • 写事务创建新版本 │
│ │
│ 关键优势: │
│ • 读不阻塞写 │
│ • 写不阻塞读 │
│ • 极大提高并发性 │
│ │
│ 示例: │
│ │
│ 数据X的版本链: │
│ X₁(TS=100) → X₂(TS=200) → X₃(TS=300) │
│ │
│ 事务T(TS=250): │
│ • 读X → 看到X₂版本(TS=200) │
│ • 看不到X₃(TS=300 > 250) │
│ │
└─────────────────────────────────────────────────────────────────┘
MVCC的实现:
┌─────────────────────────────────────────────────────────────────┐
│ MVCC实现机制 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 版本存储 │
│ • 在记录中保存多个版本 │
│ • 或在Undo Log中保存历史版本 │
│ │
│ 2. 版本选择 │
│ • 根据事务的可见性规则 │
│ • 选择合适的版本读取 │
│ │
│ 3. 垃圾回收 │
│ • 定期清理不再需要的旧版本 │
│ • 释放存储空间 │
│ │
└─────────────────────────────────────────────────────────────────┘
6.4.2 MVCC的优势
┌─────────────────────────────────────────────────────────────────┐
│ MVCC优势 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 高并发性 │
│ ✓ 读写不互斥 │
│ ✓ 多个读事务并发执行 │
│ ✓ 极大提升性能 │
│ │
│ 2. 一致性快照 │
│ ✓ 事务看到一致的数据视图 │
│ ✓ 自然支持可重复读 │
│ ✓ 无需长时间持有锁 │
│ │
│ 3. 无读锁 │
│ ✓ 读操作几乎不加锁 │
│ ✓ 减少锁竞争 │
│ ✓ 降低死锁概率 │
│ │
│ 4. 支持时间旅行 │
│ ✓ 可以查询历史数据 │
│ ✓ 闪回查询功能 │
│ │
└─────────────────────────────────────────────────────────────────┘
代价:
✗ 存储开销(保存多版本)
✗ 垃圾回收开销
✗ 写操作需要创建新版本
6.4.3 MVCC在主流数据库中的应用
PostgreSQL:
• 每行数据有xmin和xmax字段
• xmin:创建该版本的事务ID
• xmax:删除该版本的事务ID
• 根据事务ID判断可见性
MySQL InnoDB:
• Undo Log保存历史版本
• 每行有DBTRX_ID(事务ID)和DBROLL_PTR(回滚指针)
• Read View判断版本可见性
• 结合Next-Key Lock防止幻读
Oracle:
• Undo表空间保存旧版本
• SCN(System Change Number)标识版本
• 支持闪回查询(Flashback Query)
6.5 并发控制技术对比
| 技术 | 原理 | 优点 | 缺点 | 代表数据库 |
|---|---|---|---|---|
| 基于锁 | 访问前加锁 | 简单可靠 | 可能死锁,性能差 | 所有数据库 |
| 时间戳 | 时间戳排序 | 无死锁 | 回滚多 | 部分学术系统 |
| 乐观控制 | 提交时检查 | 读快 | 冲突多时性能差 | 应用层实现 |
| MVCC | 多版本 | 读写不冲突 | 存储开销大 | PostgreSQL,MySQL,Oracle |
选择建议:
┌─────────────────────────────────────────────────────────────────┐
│ 技术选择指南 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 读多写少: │
│ → MVCC或乐观控制 │
│ │
│ 写多读少: │
│ → 基于锁 │
│ │
│ 极高并发: │
│ → MVCC │
│ │
│ 简单系统: │
│ → 基于锁 │
│ │
└─────────────────────────────────────────────────────────────────┘
第七章 面试与考试重点
7.1 理论知识要点
必须掌握的核心概念:
1. 四大并发问题
• 丢失修改 ⭐⭐⭐⭐⭐
• 脏读 ⭐⭐⭐⭐
• 不可重复读 ⭐⭐⭐
• 幻读 ⭐⭐⭐
2. 事务ACID特性
• 原子性 → Undo Log
• 一致性 → 约束检查
• 隔离性 → 并发控制
• 持久性 → Redo Log
3. 隔离级别
• 读未提交 → 几乎不用
• 读已提交 → Oracle默认
• 可重复读 → MySQL默认
• 串行化 → 最严格
4. 封锁协议
• 一级封锁协议 → 防丢失修改
• 二级封锁协议 → 防脏读
• 三级封锁协议 → 防不可重复读
• 两段锁协议 → 保证可串行化
5. 死锁
• 四个必要条件
• 预防方法
• 检测方法
• 解除方法
7.2 经典面试题
问题1:解释四大并发问题及解决方法
答题要点:
1. 每个问题给出定义
2. 举一个生活化的例子
3. 说明严重性
4. 给出解决方案(隔离级别或锁)
示例:
脏读:事务读取了另一个未提交事务的数据。
例如:银行转账未提交,但ATM显示了余额增加,如果转账回滚就出问题。
解决:使用READ COMMITTED或更高隔离级别。
问题2:讲解事务隔离级别
答题框架:
1. 四个级别从低到高
2. 每个级别能解决什么问题
3. 性能vs安全性权衡
4. 主流数据库默认级别
关键点:
• READ UNCOMMITTED → 允许所有问题
• READ COMMITTED → 防脏读
• REPEATABLE READ → 防脏读+不可重复读
• SERIALIZABLE → 防所有问题
问题3:什么是两段锁协议?为什么能保证可串行化?
答题要点:
1. 定义两阶段:扩展阶段和收缩阶段
2. 扩展阶段只能加锁不能解锁
3. 收缩阶段只能解锁不能加锁
4. 保证可串行化原因:
• 事务持有所有需要的锁
• 不会与其他事务交叉
• 形成严格的执行顺序
问题4:死锁的四个必要条件及预防方法
答题框架:
1. 四个条件:
• 互斥
• 持有并等待
• 不可剥夺
• 循环等待
2. 预防方法(破坏其中一个):
• 一次性封锁 → 破坏持有并等待
• 顺序封锁 → 破坏循环等待
• 抢占 → 破坏不可剥夺
3. 检测与解除:
• 等待图法
• 超时机制
• 选择牺牲者回滚
问题5:解释MVCC及其优势
答题要点:
1. 定义:多版本并发控制
2. 原理:为数据维护多个版本
3. 优势:
• 读不阻塞写
• 写不阻塞读
• 极大提高并发性
4. 应用:PostgreSQL、MySQL InnoDB、Oracle
5. 代价:存储开销、垃圾回收
7.3 实践应用题
题目1:设计转账事务
sql
-- 问:如何设计一个安全的转账事务?
-- 答案:
BEGIN TRANSACTION;
-- 1. 加排他锁防止并发修改
SELECT balance FROM account
WHERE id IN (1, 2) FOR UPDATE;
-- 2. 检查余额
IF (SELECT balance FROM account WHERE id=1) < 100 THEN
ROLLBACK;
RETURN 'insufficient balance';
END IF;
-- 3. 执行转账
UPDATE account SET balance = balance - 100 WHERE id=1;
UPDATE account SET balance = balance + 100 WHERE id=2;
COMMIT;
-- 关键点:
-- • FOR UPDATE加锁
-- • 检查余额
-- • 按ID顺序锁定(防死锁)
-- • 使用事务保证原子性
题目2:避免死锁的订单系统
sql
-- 问:订单系统如何避免死锁?
-- 错误做法:
BEGIN;
UPDATE inventory SET stock=stock-10 WHERE product_id=100;
UPDATE orders SET status='paid' WHERE order_id=1;
COMMIT;
-- 正确做法:
BEGIN;
-- 1. 按照固定顺序锁定资源
-- 2. 先锁ID小的表,再锁ID大的表
-- 3. 同一张表内先锁ID小的行
-- 方案1:顺序封锁
SELECT * FROM inventory
WHERE product_id IN (100,200)
ORDER BY product_id -- 按ID排序!
FOR UPDATE;
-- 方案2:一次性封锁
SELECT * FROM inventory WHERE product_id IN (100,200) FOR UPDATE;
SELECT * FROM orders WHERE order_id IN (1,2) FOR UPDATE;
-- 然后执行更新...
COMMIT;
题目3:选择合适的隔离级别
场景1:银行转账
→ 使用SERIALIZABLE
理由:金融数据,必须绝对准确
场景2:电商库存查询
→ 使用READ COMMITTED
理由:允许不可重复读,性能优先
场景3:报表统计
→ 使用REPEATABLE READ
理由:需要一致性快照,防止数据前后矛盾
场景4:在线人数统计
→ 使用READ UNCOMMITTED
理由:实时性重要,容忍一定误差
7.4 易混淆概念辨析
1. 不可重复读 vs 幻读
| 对比维度 | 不可重复读 | 幻读 |
|---|---|---|
| 变化内容 | 已有记录的值 | 记录总数 |
| 触发操作 | UPDATE/DELETE | INSERT |
| 影响范围 | 单条记录 | 查询结果集 |
| 解决方法 | 行锁 | 间隙锁/表锁 |
2. 封锁协议 vs 隔离级别
封锁协议:
• 理论概念
• 描述加锁解锁规则
• 一级、二级、三级、两段锁
隔离级别:
• 实际配置
• 用户可设置
• READ UNCOMMITTED、READ COMMITTED等
关系:
封锁协议是隔离级别的实现机制
3. 悲观锁 vs 乐观锁
悲观锁:
• 假设冲突会发生
• 先加锁再操作
• 数据库的锁机制
• 适合写多的场景
乐观锁:
• 假设冲突很少
• 先操作后检查
• 版本号机制
• 适合读多写少
4. 共享锁 vs 排他锁
共享锁(S锁):
• 多个事务可同时持有
• 读读兼容
• 读写互斥
排他锁(X锁):
• 只能一个事务持有
• 与任何锁都互斥
• 保护写操作
记忆:
读读共享(S+S=✓)
其他都互斥
核心口诀:
并发问题四兄弟:丢修脏读不重幻
隔离级别分四档:未提已提可重串
ACID四特性:原一隔持
封锁三兄弟:一二三级各不同
死锁四条件:互持不循环
MVCC是王道:读写不冲突
附录
附录A:并发控制相关SQL
设置隔离级别:
sql
-- MySQL
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- PostgreSQL
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- SQL Server
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
加锁语法:
sql
-- 共享锁
SELECT * FROM account WHERE id=1 LOCK IN SHARE MODE; -- MySQL
SELECT * FROM account WHERE id=1 FOR SHARE; -- PostgreSQL
-- 排他锁
SELECT * FROM account WHERE id=1 FOR UPDATE; -- 通用
-- 表级锁
LOCK TABLE account IN SHARE MODE; -- Oracle/PostgreSQL
LOCK TABLES account WRITE; -- MySQL
死锁检测:
sql
-- MySQL查看死锁日志
SHOW ENGINE INNODB STATUS;
-- PostgreSQL查看锁等待
SELECT * FROM pg_locks WHERE NOT granted;
-- 查看当前事务
SELECT * FROM information_schema.innodb_trx;
附录B:主流数据库并发控制机制
MySQL InnoDB:
并发控制:
• MVCC + 锁
• 默认隔离级别:REPEATABLE READ
• 支持行锁、表锁、间隙锁
• Next-Key Lock防止幻读
锁类型:
• 共享锁(S)
• 排他锁(X)
• 意向锁(IS、IX)
• 记录锁(Record Lock)
• 间隙锁(Gap Lock)
• Next-Key Lock(Record + Gap)
MVCC实现:
• Undo Log保存历史版本
• Read View判断可见性
• 每行有隐藏字段:DBTRX_ID、DBROLL_PTR
PostgreSQL:
并发控制:
• MVCC
• 默认隔离级别:READ COMMITTED
• 支持行锁、表锁、Advisory Lock
MVCC实现:
• 每行有xmin、xmax字段
• 事务ID判断可见性
• VACUUM回收旧版本
特色:
• SSI(Serializable Snapshot Isolation)
• 真正的无死锁串行化
Oracle:
并发控制:
• MVCC
• 默认隔离级别:READ COMMITTED
• 多粒度锁
MVCC实现:
• Undo表空间
• SCN(System Change Number)
• 支持闪回查询
特色:
• 读不加锁
• 极高的并发性能
SQL Server:
并发控制:
• 锁 + 可选MVCC
• 默认隔离级别:READ COMMITTED
• 支持行版本控制
锁类型:
• 共享锁、排他锁、更新锁
• 意向锁、架构锁、批量更新锁
特色:
• 可配置快照隔离
• 自动死锁检测
附录C:推荐学习资源
经典书籍:
1. 《数据库系统概念》(Database System Concepts)
• Abraham Silberschatz等著
• 权威教材,理论全面
2. 《数据库系统实现》(Database System Implementation)
• Hector Garcia-Molina等著
• 深入实现细节
3. 《高性能MySQL》
• Baron Schwartz等著
• InnoDB锁和MVCC讲解详细
4. 《PostgreSQL技术内幕》
• MVCC实现原理
在线资源:
• MySQL官方文档 - InnoDB锁章节
• PostgreSQL官方文档 - 并发控制
• 数据库内核月报(阿里云)
• CMU 15-445数据库系统课程
附录D:术语表
| 中文术语 | 英文术语 | 缩写 | 说明 |
|---|---|---|---|
| 并发控制 | Concurrency Control | CC | 协调并发事务访问 |
| 原子性 | Atomicity | A | ACID之A |
| 一致性 | Consistency | C | ACID之C |
| 隔离性 | Isolation | I | ACID之I |
| 持久性 | Durability | D | ACID之D |
| 排他锁 | Exclusive Lock | X锁 | 写锁 |
| 共享锁 | Shared Lock | S锁 | 读锁 |
| 两段锁 | Two-Phase Locking | 2PL | 保证可串行化 |
| 死锁 | Deadlock | - | 互相等待 |
| 活锁 | Livelock | - | 不断重试无进展 |
| 饥饿 | Starvation | - | 永远得不到资源 |
| 多版本并发控制 | Multi-Version CC | MVCC | 现代主流技术 |
| 乐观并发控制 | Optimistic CC | OCC | 提交时检查 |
| 时间戳排序 | Timestamp Ordering | TO | 无锁技术 |
| 可串行化 | Serializability | - | 最高隔离级别 |
| 脏读 | Dirty Read | - | 读未提交数据 |
| 不可重复读 | Non-Repeatable Read | - | 多次读结果不同 |
| 幻读 | Phantom Read | - | 插入导致记录数变化 |
| 丢失修改 | Lost Update | - | 修改被覆盖 |
注: 本文是并发控制的总览文章,具体技术细节(封锁协议、事务管理、死锁处理)将在后续独立文章中详细讲解。