# 事务
作者:Ethan.Yang
博客:https://blog.ethanyang.cn (opens new window)
# 概述
在数据库应用中,我们经常希望涉及多个操作的数据能保持一致性,例如客户下单时,需要同时操作 订单表、资金表、物流表 等,如果其中任何一步失败,都希望整个操作回滚。为实现这种“要么全部成功,要么全部失败”的需求,数据库引入了 事务(Transaction) 的概念。
事务的定义:事务是数据库管理系统(DBMS)执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成。它可能包含多个 DML(insert、update、delete)语句,也可能只包含一条操作(称为隐式事务),但无论操作多少,都遵循 ACID 原则。
在 MySQL 中,主要支持事务的存储引擎是 InnoDB(以及集群版的 NDB),其他如 MyISAM 不支持事务。
# 4大特性
# 原子性(Atomicity)
事务中对于数据的操作要么全都成功, 要么全都失败, InnoDB是通过undo log来实现这一机制的, undo log 会记录事务执行前的数据旧值,当事务回滚时,可以利用 undo log 将数据恢复到修改前的状态,从而保证事务的原子性。
# 持久性(Durability)
在 InnoDB 中,事务提交成功后,修改结果就具有持久性。事务执行时,数据页的变更首先发生在内存中的 Buffer Pool 中,同时生成对应的 redo log 记录并写入 redo log buffer。在事务提交时,InnoDB 会将 redo log 刷盘,保证即使系统宕机,也能通过 redo log 将已提交事务的修改重做到数据页中,从而实现崩溃恢复。后台线程会在合适的时机将 Buffer Pool 中修改后的数据页刷到磁盘,这是一个异步过程。
# 隔离性(Isolation)
同一张表可以同时被多个事务访问和修改。为了保证事务之间的操作互不干扰,实现逻辑上的“相互隔离”,数据库引入了事务的隔离性(Isolation)。在 InnoDB 中,隔离性的实现依赖于 MVCC(多版本并发控制)等机制,后续章节将详细介绍其工作原理。
# 一致性(Consistency)
数据库的 一致性(Consistency) 是指事务执行前后,数据都满足预定义的完整性约束,始终处于合法状态。原子性(Atomicity)、隔离性(Isolation) 和 持久性(Durability) 都是为实现一致性提供的保障手段
# 触发与并发问题
# 触发时机
事务可以在两种情况下出现:
- 单条 DML(insert、update、delete)语句执行时
- 数据库会自动开启 隐式事务。
- 在执行时,事务会经过两阶段提交的第二阶段,确保
redo log与binlog一致,保证操作的持久性和可复制性。
- 手动开启事务
- 当业务需要将多条操作放在一个事务中,保证整体一致性时,需要显式开启事务并在完成后提交(如 Spring 的
@Transactional)。
- 当业务需要将多条操作放在一个事务中,保证整体一致性时,需要显式开启事务并在完成后提交(如 Spring 的
# 事务并发问题
# 脏读
假设数据库中有如下数据:
1 张三
2 李四
2
B事务将 id=1 的姓名改为“王五”但未提交,此时 A事务读取到了“王五”。随后 B事务回滚,数据恢复为“张三”。这意味着 A事务读到了一个并不存在的临时值,这就是脏读。
脏读指事务读取了其他事务未提交的数据,存在极大风险。
# 不可重复读
A事务先查询 id=1,得到“张三”。此时 B事务将该记录修改为“王五”并提交。A事务再次查询同一数据,发现变成了“王五”。
不可重复读指同一事务内多次读取同一数据,结果却不一致。
# 幻读
A事务查询“所有年龄 > 18 的用户”,结果返回 3 条记录。此时 B事务插入一条符合条件的新记录并提交。A事务再次执行同样的查询,结果变成了 4 条。
幻读 = 同一查询条件,前后读出行数不同,仿佛“幻觉”般多出或少了数据。
解决不一致性的问题, 需要数据库给出统一的隔离规范。
# 隔离级别
以 InnoDB 为例, 支持四种标准事务隔离级别, 从低到高如下:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 是否常用 | 默认 |
|---|---|---|---|---|---|
| Read Uncommitted | ✅ 有 | ✅ 有 | ✅ 有 | ❌ 极少用 | 否 |
| Read Committed | ❌ 无 | ✅ 有 | ✅ 有 | ✅ 常用 | 否 |
| Repeatable Read | ❌ 无 | ❌ 无 | ✅ 有 | ✅ 常用 | ✅ 是 |
| Serializable | ❌ 无 | ❌ 无 | ❌ 无 | ❌ 慢 | 否 |
说明:
- Read Uncommitted(读未提交)
- 可以读取其他事务未提交的数据,可能产生脏读。
- 几乎不会用于生产环境。
- Read Committed(读已提交)
- 只读取已经提交的数据,避免了脏读。
- 但可能出现不可重复读和幻读。
- Oracle 默认使用这个级别, 大部分业务系统用这个隔离级别更好, 并发写的问题通过加锁
- Repeatable Read(可重复读)
- 同一事务内多次读取相同记录结果一致,防止不可重复读。
- 但仍可能出现幻读。
- InnoDB 默认使用,且通过 间隙锁(Gap Lock) 机制避免幻读。
- Serializable(可串行化)
- 最严格的隔离级别,完全串行执行事务。
- 防止一切并发问题,但性能最差,几乎不会用于高并发系统。
# 隔离机制的实现
# LBCC
通过锁的机制实现事务隔离, 当数据被读取时, 不允许其它事务对其修改, 对该数据加锁; 这种方式相当于不支持并发写, 效率很低。
# MVCC
以 InnoDB 为例, MVCC(Multi-Version Concurrency Control), 即多版本并发控制,为了解决读写冲突、提高数据库并发性能而提出的一种机制。
MVCC 的核心思想:
对数据做快照,不加锁也能实现事务隔离。
# MVCC 的可见性规则
一个事务在读取数据时,可以看到以下版本的数据:
✅ 可见数据版本:
- 在本事务 开始之前已经提交的事务所做的修改;
- 本事务自己所做的修改(无论是否提交);
❌ 不可见数据版本:
- 在本事务开始之后才开启的事务所做的修改;
- 其他事务尚未提交的修改;
简单理解:一个事务永远只能“看到过去”,看不到“将来”。
# MVCC 是如何实现的(InnoDB)?
InnoDB 通过 隐藏字段 + Undo Log + ReadView 来实现多版本并发控制(MVCC),保证高并发下的读一致性和事务隔离。
# 🔹 行记录的隐藏字段
| 字段名 | 含义 |
|---|---|
DB_TRX_ID | 插入或更新该行的事务 ID |
DB_ROLL_PTR | 回滚指针,指向旧版本的数据 |
# 🔹 版本链
- 每次事务更新一行数据时,会:
- 在 Undo Log 中保存修改前的旧版本;
- 更新当前行记录,同时
DB_TRX_ID写入当前事务 ID; DB_ROLL_PTR指向刚生成的 Undo Log 条目。
这样,当前行记录作为链头,通过 DB_ROLL_PTR 依次串起旧版本,形成 版本链:
[最新行记录] trx_id=105
roll_ptr ---> [Undo Log v104]
roll_ptr ---> [Undo Log v103]
roll_ptr ---> NULL (最早版本)
2
3
4
- 链头(Head):当前表中的最新版本记录。
- 链尾(Tail):最早版本记录,无前驱,roll_ptr 为 NULL。
- Undo Log 会在不再需要时被 Purge 线程清理。
# MVCC 的读取流程:ReadView
什么是 ReadView?
当事务读取数据时,会生成一个 ReadView 视图,来决定每一行数据的哪个版本对当前事务可见。该ReadView会排除自己在外的事务, 即活跃数组中不包括自己
活跃事务即创建但未提交事务
| 字段名 | 含义 |
|---|---|
| m_ids | 当前系统中活跃事务的 ID 列表(未提交的) |
| min_trx_id | 当前活跃事务中最小的事务 ID(即 m_ids 中最小的)如果不存在活跃事务默认为下一次分配的事务 ID |
| max_trx_id | 下一次分配的事务 ID(即 ReadView 创建后新事务的起始 ID) |
| creator_trx_id | 创建 ReadView 的事务 ID |
可见性判断
读取某行数据时,判断该版本是否可见:
- 当前版本的
trx_id == creator_trx_id:表示是当前事务创建的 → 可见 trx_id < min_trx_id:表示版本创建时事务已提交 → 可见trx_id >= max_trx_id:该版本由之后的事务创建 → 不可见trx_id ∈ (min_trx_id, max_trx_id)且trx_id ∈ m_ids:事务仍未提交 → 不可见- 否则 → 可见
如果当前版本不可见,则沿着 DB_ROLL_PTR 指向的 undo log 查找上一个版本,继续判断,直到找到可见版本或无可见版本。
事务隔离级别与ReadView关系
| 隔离级别 | ReadView 创建时机 |
|---|---|
| Read Committed (RC) | 每次 select 都创建新的 ReadView |
| Repeatable Read (RR) | 第一次 select 时创建,整个事务期间复用 |
# 具体案例
第一个事务插入数据(事务ID=1)
begin; insert into mvcctest values(NULL,'zs'); insert into mvcctest values(NULL,'ls'); commit;1
2
3
4id name trx_id roll_ptr 1 zs 1 undefined 2 ls 1 undefined 第二个事务开始并查询(事务ID=2)
begin; select * from mvcctest; -- 生成 ReadView,ID=21
2ReadView:
min_trx_id = 3max_trx_id = 3m_ids = [](当前无活跃事务, 不包括自身)creator_trx_id = 2
结果:能看到 ID=1 和 2
第三个事务插入数据(事务ID=3)
begin; insert into mvcctest values(NULL,'ww'); commit;1
2
3id name trx_id roll_ptr 3 ww 3 undefined 第二个事务再次查询(事务ID=2)
select * from mvcctest;1结果仍为:ID=1、2,看不到 ID=3(因为事务3启动时间 > 当前 ReadView)
第四个事务删除 id=2(事务ID=4)
begin; delete from mvcctest where id=2; commit;1
2
3id name trx_id roll_ptr 2 ls 1 4 第二个事务第三次查询(事务ID=2)
select * from mvcctest;1仍能看到 ID=2,因为它的删除版本是 4 > 当前事务ID=2,不影响可见性。
第五个事务更新 id=1(事务ID=5)
begin; update mvcctest set name='zl' where id=1; commit;1
2
3id name trx_id roll_ptr 1 zl 5 undefined 1 zs 1 5 第二个事务第四次查询(事务ID=2)
select * from mvcctest;1结果:仍然看到旧版本
zs,新版本zl的事务ID=5 > 当前ID=2 → 不可见