事务的基本特性(ACID)
事务具有四个基本特性,通常称为ACID特性:
-
原子性(Atomicity)
- 事务是一个不可分割的工作单位,事务中的操作要么全部成功,要么全部失败回滚。
- 例如:银行转账,A转给B100元,A账户扣减100元和B账户增加100元必须同时成功或同时失败。
-
一致性(Consistency)
- 事务执行前后,数据库从一个一致状态变到另一个一致状态。
- 例如:转账前后,A和B的账户总额保持不变。
-
隔离性(Isolation)
- 多个事务并发执行时,一个事务的执行不应影响其他事务的执行。
- 数据库通过锁机制、多版本并发控制(MVCC)等技术实现隔离性。
-
持久性(Durability)
- 事务一旦提交,它对数据库的改变就是永久性的,即使系统崩溃也不会丢失。
事务的隔离级别
由于完全的隔离性会影响性能,数据库提供了不同的隔离级别来平衡一致性和性能:
-
读未提交(Read Uncommitted)
- 最低的隔离级别,允许读取未提交的数据变更。
- 可能导致脏读、不可重复读和幻读问题。
-
读已提交(Read Committed)
- 只能读取已提交的数据。
- 防止脏读,但可能出现不可重复读和幻读。
-
可重复读(Repeatable Read)
- 在同一个事务中多次读取同一数据会得到相同的结果。
- 防止脏读和不可重复读,但可能出现幻读。
- MySQL的默认隔离级别。
-
串行化(Serializable)
- 最高的隔离级别,完全串行执行事务。
- 防止脏读、不可重复读和幻读,但性能最低。
事务的常见问题
-
脏读(Dirty Read)
- 一个事务读取了另一个未提交事务修改的数据。
- 在读已提交及以上隔离级别可以避免。
-
不可重复读(Non-repeatable Read)
- 在同一个事务中,两次读取同一数据,由于另一个事务的修改导致两次读取结果不同。
- 在可重复读及以上隔离级别可以避免。
-
幻读(Phantom Read)
- 在同一个事务中,两次查询返回的记录数不同,因为另一个事务插入或删除了记录。
- 在串行化隔离级别可以避免。
MySQL InnoDB 引擎中 MVCC
MVCC 解决的核心问题
读写冲突:
传统锁机制下,读操作(共享锁)和写操作(排他锁)相互阻塞。MVCC 通过数据多版本实现:
- 读操作:访问事务开始时的一致性快照(历史版本)。
- 写操作:创建新版本,不影响旧快照的读取。
结果:读操作不会被写操作阻塞(非锁定读),大幅提升并发性能。
二、InnoDB MVCC 实现的核心组件
1. 隐藏字段 (每行记录)
| 字段名 | 长度 | 作用 |
|---|---|---|
DB_TRX_ID | 6字节 | 最后修改此记录的事务ID(插入/更新时写入当前事务ID) |
DB_ROLL_PTR | 7字节 | 回滚指针,指向Undo Log中该记录的上一个版本地址,构成版本链。 |
DB_ROW_ID | 6字节 | 隐式自增行ID(当表无主键时自动生成) |
2. Undo Log(回滚日志)
-
存储位置:位于共享表空间(
ibdata1)或独立Undo表空间。 -
数据结构:
- INSERT Undo Log:存放插入操作的反向操作(删除)。
- UPDATE Undo Log:存放更新前的旧值(前镜像)。
-
核心作用:
- 事务回滚:恢复数据到修改前的状态。
- 构建版本链:
DB_ROLL_PTR指向Undo Log中的历史版本数据。
-
生命周期:事务提交后不会立即删除,需确保无活跃事务依赖该版本。
3. ReadView(一致性视图)
在事务首次执行快照读时创建,决定该事务能看到哪些版本的数据:
| 属性 | 描述 |
|---|---|
m_ids | 生成ReadView时,系统中所有活跃事务ID的列表(未提交的事务)。 |
min_trx_id | m_ids中的最小值(即最早的活跃事务ID)。 |
max_trx_id | 生成ReadView时,系统应分配给下一个事务的ID(即当前最大事务ID + 1)。 |
creator_trx_id | 创建此ReadView的事务ID。 |
📌 关键规则:
通过ReadView判断数据版本的可见性,确定是否可被当前事务访问。
三、MVCC 可见性判断规则
事务根据 ReadView + 版本链 决定看到哪个数据版本:
-
从最新数据版本(
DB_TRX_ID最大)开始遍历版本链。 -
检查每行数据的
DB_TRX_ID(记为 trx_id):- 情况1:
trx_id < min_trx_id
→ 可见(该版本在ReadView创建前已提交)。 - 情况2:
trx_id >= max_trx_id
→ 不可见(该版本在ReadView创建后才修改)。 - 情况3:
trx_id在m_ids中
→ 不可见(修改该版本的事务在ReadView创建时未提交)。 - 情况4:
trx_id不在m_ids中 且trx_id ∈ [min_trx_id, max_trx_id)
→ 可见(该版本在ReadView创建时已提交)。
- 情况1:
-
沿版本链(
DB_ROLL_PTR)找到第一个可见的版本。
示例
数据库初始状态
CREATE TABLE accounts (
id INT PRIMARY KEY,
balance DECIMAL(10, 2)
) ENGINE=InnoDB;
INSERT INTO accounts VALUES (1, 1000.00); -- 初始余额 1000
事务操作序列
| 时间 | 事务A (Trx10) | 事务B (Trx20) | 事务C (Trx30) |
|---|---|---|---|
| T1 | START TRANSACTION; | ||
| T2 | UPDATE accounts SET balance=900 WHERE id=1; | ||
| T3 | START TRANSACTION; | ||
| T4 | SELECT balance FROM accounts WHERE id=1; (生成ReadView) | ||
| T5 | COMMIT; | ||
| T6 | START TRANSACTION; | ||
| T7 | SELECT balance FROM accounts WHERE id=1; (使用原ReadView) | ||
| T8 | UPDATE accounts SET balance=800 WHERE id=1; | ||
| T9 | SELECT balance FROM accounts WHERE id=1; (生成ReadView) | ||
| T10 | COMMIT; | ||
| T11 | COMMIT; |
详细过程解析 (REPEATABLE READ 隔离级别)
T1-T2: 事务A启动并修改数据
-- Trx10
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id=1;
-
数据变化:
- 创建新版本数据:(id=1, balance=900, DB_TRX_ID=10, DB_ROLL_PTR → 指向旧版本)
- Undo Log 记录旧值:(id=1, balance=1000)
-
状态:事务A未提交
T3-T4: 事务B启动并执行查询
-- Trx20
START TRANSACTION;
SELECT balance FROM accounts WHERE id=1; -- 第一次查询
-
生成 ReadView:
m_ids= [10](活跃事务:只有Trx10)min_trx_id= 10max_trx_id= 21(假设下一个事务ID分配20+1)creator_trx_id= 20
-
可见性判断:
- 最新数据版本:
DB_TRX_ID=10(在m_ids中 → 不可见) - 沿版本链找到旧版本:
DB_TRX_ID=null(初始插入,视为0) - 0 < min_trx_id(10) → 可见
- 最新数据版本:
-
结果:事务B 读取到旧值 1000
T5: 事务A提交
- Redo Log 刷盘
- Undo Log 保留(因为事务B的ReadView仍依赖它)
T6-T7: 事务B再次查询
-- Trx20 (继续)
SELECT balance FROM accounts WHERE id=1; -- 第二次查询
-
复用之前的 ReadView(RR隔离级别特性)
-
可见性判断:
-
最新版本:
DB_TRX_ID=10(仍在m_ids=[10]?注意:Trx10已提交!但ReadView是T4时刻生成的,当时Trx10未提交) -
根据原ReadView规则:
- trx_id=10 ∈ [min_trx_id=10, max_trx_id=21)
- 且 trx_id=10 在 m_ids=[10] 中 → 不可见
-
继续找旧版本:trx_id=null(0) < min_trx_id(10) → 可见
-
-
结果:事务B 仍读取到 1000(避免不可重复读)
T8-T9: 事务C启动并修改数据
-- Trx30
START TRANSACTION;
UPDATE accounts SET balance=800 WHERE id=1; -- 修改数据
SELECT balance FROM accounts WHERE id=1; -- 查询
-
数据变化:
- 创建新版本:(id=1, balance=800, DB_TRX_ID=30, DB_ROLL_PTR → 指向balance=900的版本)
-
生成 ReadView:
m_ids= [20](活跃事务:Trx20)min_trx_id= 20max_trx_id= 31creator_trx_id= 30
-
可见性判断:
- 最新版本:
DB_TRX_ID=30(等于creator_trx_id → 可见)
- 最新版本:
-
结果:事务C 读取到自己的修改 800
T10-T11: 事务B和C提交
- 事务B提交:ReadView 失效
- 事务C提交:新数据版本持久化
最终版本链状态
关键点总结
-
版本链构建:
- 每次修改生成新版本
DB_ROLL_PTR指向前一个版本- Undo Log 存储旧数据
-
ReadView 工作机制:
- REPEATABLE READ:第一次读时创建,后续复用
-- 事务B的两次查询使用同一个ReadView SELECT 1: 创建ReadView(活跃事务=[10]) SELECT 2: 复用同一个ReadView -
可见性规则实践:
- 事务B看到
DB_TRX_ID=10不可见(创建ReadView时活跃) - 事务C看到
DB_TRX_ID=30可见(当前事务修改)
- 事务B看到
-
不同隔离级别对比:
操作 REPEATABLE READ (示例) READ COMMITTED 事务B的T7查询 1000 (复用旧ReadView) 900 (新ReadView) 避免不可重复读 ✔ ✖ -
清理机制:
- 当所有事务(包括Trx20)提交后
- Purge线程清理
DB_TRX_ID=10的旧版本(balance=900)