事务与并发控制:当多人同时操作数据库
一句话总结:事务通过 ACID 特性保证操作的完整性;并发控制通过锁机制和多版本并发控制(MVCC)协调多个事务同时执行,防止丢失修改、读脏数据、不可重复读和幻读四大问题。
一、从一个生活场景说起
想象你和同事同时操作公司的财务数据库:
- 你:从 A 账户转 1000 元到 B 账户
- 同事:同时查询 A 账户的余额用于报表
如果没有事务和并发控制,会发生什么?
- 你的转账做了一半(A 已扣款,B 还没加款),系统崩溃了------钱去哪了?
- 同事读到了你"扣款后、加款前"的中间状态------报表数据是错的!
- 两人同时修改同一条记录------一个人的修改被另一个人覆盖!
这些问题,正是事务与并发控制要解决的核心。
二、事务:要么全做,要么全不做
2.1 什么是事务?
事务(Transaction)是用户定义的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。
2.2 事务的 ACID 特性
| 特性 | 英文 | 含义 | 类比 |
|---|---|---|---|
| 原子性 | Atomicity | 事务中的操作要么全部成功,要么全部回滚 | 转账:扣款和加款必须同时完成 |
| 一致性 | Consistency | 事务执行前后,数据库从一个一致状态变到另一个一致状态 | 转账前后,总金额不变 |
| 隔离性 | Isolation | 多个事务并发执行时,互不干扰 | 你转账,同事查账,互不干扰 |
| 持久性 | Durability | 事务一旦提交,结果永久保存 | 转账成功后,即使断电,钱也不会变回来 |
2.3 SQL 中的事务控制
sql
-- 开始事务
BEGIN TRANSACTION; -- 或 START TRANSACTION;
-- 执行操作
UPDATE 账户 SET 余额 = 余额 - 1000 WHERE 账户号 = 'A';
UPDATE 账户 SET 余额 = 余额 + 1000 WHERE 账户号 = 'B';
-- 提交事务(永久保存)
COMMIT;
-- 或者:回滚事务(撤销所有操作)
-- ROLLBACK;
2.4 事务的状态
BEGIN
│
▼
┌───────────────┐
│ 活动状态 │ ← 正在执行操作
└───────┬───────┘
│
┌───────┴───────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ 部分提交 │ │ 失败状态 │
│ (COMMIT)│ │ (ROLLBACK)
└────┬────┘ └────┬────┘
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ 提交状态 │ │ 中止状态 │
│ (持久化)│ │ (回滚完成)
└─────────┘ └─────────┘
三、并发操作带来的四大问题
当多个事务同时操作数据库时,如果不加控制,会出现以下问题:
3.1 丢失修改(Lost Update)
两个事务同时读取同一数据,然后都修改,后提交的事务覆盖了先提交的事务。
| 时间 | 事务 A | 事务 B |
|---|---|---|
| T1 | 读取余额 = 1000 | |
| T2 | 读取余额 = 1000 | |
| T3 | 余额 = 1000 + 100 = 1100 | |
| T4 | 余额 = 1000 + 200 = 1200 | |
| T5 | 提交 | |
| T6 | 提交(覆盖了 A 的修改) |
结果:A 的 +100 操作丢失了,最终余额是 1200 而非 1300。
3.2 读脏数据(Dirty Read)
一个事务读取了另一个事务尚未提交的数据,而那个事务后来回滚了。
| 时间 | 事务 A | 事务 B |
|---|---|---|
| T1 | 修改余额为 1200(未提交) | |
| T2 | 读取余额 = 1200(脏数据) | |
| T3 | 回滚(余额恢复为 1000) | |
| T4 | 基于 1200 做决策(错误!) |
3.3 不可重复读(Non-repeatable Read)
同一事务内,两次读取同一数据,结果不一样(因为另一个事务在两次读取之间修改并提交了)。
| 时间 | 事务 A | 事务 B |
|---|---|---|
| T1 | 读取余额 = 1000 | |
| T2 | 修改余额 = 800,提交 | |
| T3 | 再次读取余额 = 800(变了!) |
不可重复读强调"同一事务内两次读取结果不一致"。
3.4 幻读(Phantom Read)
同一事务内,两次查询条件相同的数据集合,但第二次出现了新的行(另一个事务插入并提交)。
| 时间 | 事务 A | 事务 B |
|---|---|---|
| T1 | SELECT COUNT(*) FROM 学生 WHERE 系='计算机' → 100 | |
| T2 | INSERT 一名计算机系学生,提交 | |
| T3 | 再次执行相同查询 → 101(幻行出现!) |
幻读和不可重复读的区别:不可重复读是某行的值变了,幻读是行数变了。
四、事务隔离级别:在并发与性能之间权衡
SQL 标准定义了四种隔离级别,级别越高,并发问题越少,但性能越低。
| 隔离级别 | 丢失修改 | 脏读 | 不可重复读 | 幻读 | 实现复杂度 |
|---|---|---|---|---|---|
| READ UNCOMMITTED | ❌ | ✅ | ✅ | ✅ | 低 |
| READ COMMITTED | ❌ | ❌ | ✅ | ✅ | 中 |
| REPEATABLE READ | ❌ | ❌ | ❌ | ✅ | 高 |
| SERIALIZABLE | ❌ | ❌ | ❌ | ❌ | 最高 |
❌ = 不会发生,✅ = 可能发生
4.1 设置隔离级别
sql
-- MySQL
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- PostgreSQL
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- SQL Server
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
4.2 各数据库默认隔离级别
| 数据库 | 默认隔离级别 | 说明 |
|---|---|---|
| MySQL (InnoDB) | REPEATABLE READ | 默认解决不可重复读,但可能有幻读 |
| PostgreSQL | READ COMMITTED | 默认解决脏读 |
| Oracle | READ COMMITTED | |
| SQL Server | READ COMMITTED |
五、并发控制:锁机制
5.1 锁的基本类型
| 锁类型 | 英文 | 含义 | 兼容 |
|---|---|---|---|
| 共享锁 | S Lock / Shared Lock | 读锁,可以多人同时加 | S 与 S 兼容,S 与 X 不兼容 |
| 排他锁 | X Lock / Exclusive Lock | 写锁,只能一人持有 | X 与任何锁都不兼容 |
5.2 锁的粒度
| 粒度 | 范围 | 并发度 | 开销 |
|---|---|---|---|
| 行锁 | 一行 | 高 | 大 |
| 页锁 | 一页(通常 8KB) | 中 | 中 |
| 表锁 | 整张表 | 低 | 小 |
| 数据库锁 | 整个库 | 最低 | 最小 |
InnoDB 默认使用行锁 ,并发度高;MyISAM 使用表锁,并发度低。
5.3 封锁协议
一级封锁协议
- 事务修改数据前加 X 锁,直到事务结束才释放
- 解决:丢失修改
二级封锁协议
- 一级协议 + 读取数据前加 S 锁,读完后立即释放
- 解决:丢失修改 + 读脏数据
三级封锁协议
-
一级协议 + 读取数据前加 S 锁,直到事务结束才释放
-
解决:丢失修改 + 读脏数据 + 不可重复读
┌────────────────────────────────────────┐
│ 隔离级别 vs 封锁协议对应关系 │
├────────────────────────────────────────┤
│ READ UNCOMMITTED → 无锁或最低级别 │
│ READ COMMITTED → 二级封锁协议 │
│ REPEATABLE READ → 三级封锁协议 │
│ SERIALIZABLE → 三级封锁协议 + 锁全表│
└────────────────────────────────────────┘
5.4 活锁与死锁
活锁(Livelock)
事务 A 申请锁,事务 B 也申请同一锁,但 B 优先级更高,A 一直等待,永远得不到执行。
解决:采用先来先服务(FIFO)策略,避免优先级导致的饥饿。
死锁(Deadlock)
两个事务互相等待对方持有的锁,形成循环等待。
| 时间 | 事务 A | 事务 B |
|---|---|---|
| T1 | 加 X 锁 on 记录 1 | |
| T2 | 加 X 锁 on 记录 2 | |
| T3 | 请求 X 锁 on 记录 2(等待 B) | |
| T4 | 请求 X 锁 on 记录 1(等待 A) | |
| T5 | 死锁! | 死锁! |
死锁的解决策略:
- 预防:一次加锁法(事务开始时一次性加所有需要的锁)、顺序加锁法(按固定顺序加锁)
- 检测与解除:设置超时时间,超时后自动回滚其中一个事务(牺牲者)
- 等待图:DBMS 维护等待图,发现环就选择一个事务回滚
六、MVCC:多版本并发控制
6.1 什么是 MVCC?
MVCC(Multi-Version Concurrency Control)是不用锁也能实现并发控制的技术。核心思想:
每个数据项都有多个版本,读操作读取的是历史版本 ,写操作创建新版本,读和写互不阻塞。
6.2 MVCC 如何工作(以 InnoDB 为例)
InnoDB 的每行数据都有两个隐藏列:
DB_TRX_ID:创建该版本的事务 IDDB_ROLL_PTR:回滚指针,指向该版本的上一个版本(形成版本链)
读操作:根据事务的 Read View(可见性判断规则),找到对该事务可见的最新版本。
写操作:不直接修改原数据,而是创建新版本的行,旧版本保留在 Undo 日志中。
6.3 MVCC 的优势
- 读不阻塞写,写不阻塞读:读操作读取历史版本,不会等待写锁
- 高并发性能:避免了大量锁等待
- 实现快照读:REPEATABLE READ 隔离级别下,事务内两次读取结果一致
七、动手练习
练习 1:事务基础
在 MySQL 中执行以下操作,观察事务的行为:
sql
START TRANSACTION;
SELECT 余额 FROM 账户 WHERE 账户号 = 'A'; -- 记录结果
UPDATE 账户 SET 余额 = 余额 - 100 WHERE 账户号 = 'A';
SELECT 余额 FROM 账户 WHERE 账户号 = 'A'; -- 查看变化
ROLLBACK; -- 撤销
SELECT 余额 FROM 账户 WHERE 账户号 = 'A'; -- 验证是否恢复
练习 2:隔离级别实验
开启两个 MySQL 客户端窗口,分别模拟事务 A 和事务 B,测试不同隔离级别下的行为:
- 在 READ UNCOMMITTED 下,A 修改未提交,B 能否读到?
- 在 READ COMMITTED 下,A 修改未提交,B 能否读到?A 提交后呢?
- 在 REPEATABLE READ 下,A 修改并提交,B 再次读,结果是否变化?
练习 3:死锁模拟
在两个会话中执行:
sql
-- 会话 A
BEGIN;
UPDATE 账户 SET 余额 = 余额 - 100 WHERE 账户号 = 'A';
-- 暂停
UPDATE 账户 SET 余额 = 余额 - 100 WHERE 账户号 = 'B'; -- 等待 B 释放锁
COMMIT;
-- 会话 B
BEGIN;
UPDATE 账户 SET 余额 = 余额 - 100 WHERE 账户号 = 'B';
-- 暂停
UPDATE 账户 SET 余额 = 余额 - 100 WHERE 账户号 = 'A'; -- 等待 A 释放锁
COMMIT;
观察哪个事务被回滚,以及错误信息。
八、常见误区与避坑指南
| 误区 | 正确理解 |
|---|---|
| "事务越多越好" | 长事务占用锁时间长,降低并发性能,尽量缩小事务范围 |
| "SERIALIZABLE 最安全, always 用它" | 隔离级别越高性能越差,大多数场景 READ COMMITTED 或 REPEATABLE READ 足够 |
| "InnoDB 的默认隔离级别不会幻读" | REPEATABLE READ 下 InnoDB 通过 MVCC + 间隙锁解决了大部分幻读,但某些场景仍可能 |
| "锁越粗越好" | 粗粒度锁并发度低,细粒度锁并发度高但管理复杂 |
| "ROLLBACK 很慢" | ROLLBACK 利用 Undo 日志恢复,通常很快,不要因担心性能而不用 |
九、下篇预告
下一篇,我们将学习数据库恢复技术------当数据库遭遇故障(断电、崩溃、误操作)时,如何通过日志、检查点和备份把数据救回来。