第2章:事务与隔离级别原理
📖 章节概述
本章将深入讲解 MySQL 事务的核心机制,这是数据库系统的基石。通过本章学习,你将掌握:
- 事务的 ACID 四大特性及其实现原理
- Redo、Undo、Binlog 三大日志的协作关系
- 两阶段提交(2PC)机制
- MVCC(多版本并发控制)原理
- 四种隔离级别的特点和实现
- 锁机制和死锁处理
🧩 一、事务的四大特性(ACID)
特性 | 含义 | 实现机制 |
---|---|---|
A 原子性 | 事务中的操作要么全部成功,要么全部失败 | Undo Log |
C 一致性 | 事务执行前后,数据保持一致(符合约束/逻辑) | 事务机制 + 外键约束 |
I 隔离性 | 并发事务之间互不干扰 | MVCC + 锁 |
D 持久性 | 一旦提交,数据不会丢失 | Redo Log + Binlog |
🔄 二、三大日志的协作关系(MySQL 的"魂")
MySQL 事务的完整落盘链路依赖三份日志:
日志类型 | 产生层级 | 内容 | 作用 |
---|---|---|---|
Redo Log | InnoDB 层 | 对数据页的"物理修改" | 保证 crash-safe |
Undo Log | InnoDB 层 | 修改前的旧值 | 实现回滚与 MVCC |
Binlog | Server 层 | 逻辑操作记录(INSERT/UPDATE) | 主从复制、恢复 |
三者协作完成事务的完整生命周期。
🧠 三、两阶段提交(2PC)—— 面试必考题
MySQL 为了保证 Redo 与 Binlog 的一致性,采用了两阶段提交(Two-Phase Commit):
1️⃣ 阶段一:prepare
- InnoDB 写入 redo log(状态为 prepare)
- 保证数据可恢复,但未真正提交
2️⃣ 阶段二:commit
- Server 层写 binlog(完整逻辑)
- 通知 InnoDB 标记 redo log 为 commit
完整流程:
client → MySQL Server
├── SQL解析/优化
├── BEGIN
├── DML操作
├── InnoDB 写 redo(prepare)
├── 写 binlog
├── redo commit
└── COMMIT
👉 若在写 binlog 后 crash:
- Redo 已 prepare → 重启恢复时,MySQL 检查到 binlog 存在 → 补 commit
- ✅ 保证"Binlog 与 Redo 一致",事务不会丢失
📌 面试问法:
- Q: 为什么 MySQL 要用两阶段提交?
- A: 为了保证 redo log(InnoDB 层)和 binlog(Server 层)的一致性,从而在崩溃恢复或主从同步时保持数据不丢、不乱。
🪄 四、MVCC(多版本并发控制)
1️⃣ 概念
MVCC = Multi-Version Concurrency Control 即:读操作不阻塞写,写操作不阻塞读。
它通过 Undo Log + 隐藏列版本号实现。
2️⃣ 隐藏列结构
每条记录有三个隐藏列:
字段 | 含义 |
---|---|
trx_id | 产生该版本的事务 ID |
roll_pointer | 指向 Undo Log 的指针 |
deleted_flag | 逻辑删除标志 |
3️⃣ 版本链结构
当一行被多次更新,会形成一条"时间线":
当前行 → Undo Log(上一个版本) → Undo Log(再上一个版本)
当事务执行一致性读(快照读)时,MySQL 会根据 ReadView 过滤掉对它"不可见"的版本,只返回自己能看到的版本。
4️⃣ MVCC 的关键
- 快照读(非锁定读):SELECT 默认行为
- 当前读(加锁读):SELECT … FOR UPDATE / UPDATE / DELETE
5️⃣ ReadView 结构详解
ReadView 是 MVCC 的核心,决定事务能看到哪些版本:
// ReadView 结构定义
struct read_view_t {
trx_id_t m_low_limit_id; // 最小未提交事务ID
trx_id_t m_up_limit_id; // 最大已提交事务ID
trx_id_t m_creator_trx_id; // 创建该视图的事务ID
trx_id_t* m_ids; // 当前活跃事务列表
ulint m_n_ids; // 活跃事务数量
// 源码路径:storage/innobase/include/read0read.h
};
🔍 可见性判断算法
// 版本可见性判断(简化版)
bool changes_visible(trx_id_t id, read_view_t* view) {
if (id < view->m_up_limit_id) {
return true; // 在快照创建前已提交
}
if (id >= view->m_low_limit_id) {
return false; // 在快照创建后开始
}
// 检查是否在活跃事务列表中
for (ulint i = 0; i < view->m_n_ids; i++) {
if (id == view->m_ids[i]) {
return false; // 事务未提交
}
}
return true; // 事务已提交且不在活跃列表中
}
6️⃣ 事务状态机转换
InnoDB 事务有明确的状态转换过程:
事务状态转换图
┌─────────────┐ BEGIN ┌─────────────┐
│ NOT_START │ ──────────→ │ ACTIVE │
└─────────────┘ └──────┬──────┘
│
│ COMMIT
▼
┌─────────────┐
│ COMMITTED │
└─────────────┘
▲
│
│ ROLLBACK
│
┌──────┴──────┐
│ ROLLED_BACK│
└─────────────┘
📊 监控事务状态
-- 查看当前活跃事务
SELECT
trx_id,
trx_state,
trx_started,
trx_mysql_thread_id,
trx_query
FROM information_schema.innodb_trx;
-- 查看事务历史
SELECT
thread_id,
event_name,
timer_start,
timer_end,
timer_wait
FROM performance_schema.events_transactions_current;
7️⃣ 长事务危害与监控
长事务会带来严重问题:
⚠️ 长事务危害
问题 | 影响 | 原因 |
---|---|---|
Undo Log 膨胀 | 磁盘空间浪费 | 无法清理旧版本 |
锁等待增加 | 并发性能下降 | 长时间持有锁 |
主从延迟 | 复制滞后 | Binlog 无法及时应用 |
内存泄漏 | 系统不稳定 | 事务对象无法释放 |
📊 长事务监控
-- 查看长事务(超过60秒)
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds,
trx_mysql_thread_id,
trx_query
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;
-- 查看Undo Log使用情况
SELECT
TABLESPACE_NAME,
FILE_NAME,
ROUND(INITIAL_SIZE / 1024 / 1024, 2) AS size_mb,
STATUS
FROM information_schema.FILES
WHERE TABLESPACE_NAME LIKE '%undo%';
8️⃣ 分布式事务(XA)原理
MySQL 支持 XA 分布式事务协议:
🏗️ XA 事务流程
-- 1. 准备阶段(Prepare)
XA START 'transaction_id';
UPDATE account SET balance = balance - 100 WHERE id = 1;
XA END 'transaction_id';
XA PREPARE 'transaction_id';
-- 2. 提交阶段(Commit)
XA COMMIT 'transaction_id';
-- 或回滚
-- XA ROLLBACK 'transaction_id';
🔍 XA 状态管理
-- 查看XA事务状态
SELECT
formatID,
gtrid_length,
bqual_length,
data,
state
FROM information_schema.innodb_xa;
-- 恢复未完成的XA事务
XA RECOVER;
🧱 五、隔离级别(Isolation Levels)
隔离级别 | 可见问题 | 实现机制 | 说明 |
---|---|---|---|
READ UNCOMMITTED | 脏读、不可重复读、幻读 | 直接读最新 | 基本不用 |
READ COMMITTED | 不可重复读、幻读 | 每次读新 ReadView | Oracle 默认 |
REPEATABLE READ(默认) | 幻读(部分) | 首次读创建快照 | MySQL 默认 |
SERIALIZABLE | 无 | 加锁读 | 性能最差 |
💡 MySQL 的特别点: InnoDB 的 REPEATABLE READ 通过 MVCC + Next-Key Lock 实际上已经能防止幻读(间隙锁机制)。
⚔️ 六、InnoDB 锁机制深度解析
1️⃣ 锁的分类体系
InnoDB 的锁机制是一个完整的体系,用于保证并发访问的数据一致性:
锁类型 | 粒度 | 作用 | 使用场景 |
---|---|---|---|
行锁(Record Lock) | 行级 | 锁定具体行 | 精确WHERE条件 |
间隙锁(Gap Lock) | 行间隙 | 防止幻读 | RR隔离级别 |
Next-Key Lock | 行+间隙 | 行锁+间隙锁 | RR隔离级别 |
意向锁(IS/IX) | 表级 | 协调表锁与行锁 | 自动加锁 |
表锁 | 表级 | 锁定整张表 | DDL操作 |
2️⃣ 行锁(Record Lock)详解
行锁特点:
- 锁定具体的行记录
- 只有精确匹配主键或唯一索引时才加行锁
- 支持共享锁(S)和排他锁(X)
行锁场景:
-- 精确匹配主键
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 精确匹配唯一索引
SELECT * FROM users WHERE email = 'user@example.com' FOR UPDATE;
-- 自动加行锁的DML操作
UPDATE users SET name = 'new_name' WHERE id = 1;
DELETE FROM users WHERE id = 1;
3️⃣ 间隙锁(Gap Lock)详解
间隙锁特点:
- 锁定索引记录之间的间隙
- 防止其他事务在间隙中插入数据
- 只在REPEATABLE READ隔离级别下使用
间隙锁示例:
-- 假设表中有记录:1, 3, 5, 7
-- 执行范围查询
SELECT * FROM users WHERE id > 2 AND id < 6 FOR UPDATE;
-- 会锁定间隙:(1,3), (3,5), (5,7)
-- 防止插入 id=2,4,6 的记录
4️⃣ Next-Key Lock 详解
Next-Key Lock = 行锁 + 间隙锁
工作原理:
- 锁定记录及其前面的间隙
- 有效防止幻读问题
- 是InnoDB在RR隔离级别下的默认行为
示例:
-- 假设表中有记录:1, 3, 5
-- 执行查询
SELECT * FROM users WHERE id >= 3 FOR UPDATE;
-- Next-Key Lock 锁定:
-- - 记录3(行锁)
-- - 间隙(1,3)(间隙锁)
-- - 记录5(行锁)
-- - 间隙(3,5)(间隙锁)
5️⃣ 意向锁(Intention Lock)详解
核心作用: 协调表级锁与行级锁,避免全表扫描判断锁冲突。
意向锁类型:
- IS(Intention Shared):意向共享锁
- IX(Intention Exclusive):意向排他锁
锁兼容性矩阵:
S | X | IS | IX | |
---|---|---|---|---|
S | ✅ | ❌ | ✅ | ❌ |
X | ❌ | ❌ | ❌ | ❌ |
IS | ✅ | ❌ | ✅ | ✅ |
IX | ❌ | ❌ | ✅ | ✅ |
工作原理:
- 事务要加行锁前,先加对应的意向锁
- 表级锁检查意向锁,快速判断是否有行锁冲突
- 避免全表扫描检查每行的锁状态
💥 七、死锁检测与解决机制
1️⃣ 死锁产生条件
死锁需要同时满足四个条件:
条件 | 说明 | 示例 |
---|---|---|
互斥条件 | 资源不能被多个事务同时使用 | 行锁的排他性 |
请求和保持 | 事务持有锁的同时请求新锁 | 事务A持有锁1,请求锁2 |
不可剥夺 | 锁只能由持有者释放 | 不能强制释放其他事务的锁 |
循环等待 | 事务间形成等待环路 | A等B,B等A |
2️⃣ InnoDB 死锁检测算法
检测机制:
- 使用Wait-for Graph(等待图)检测死锁
- 自动回滚代价最小的事务
- 通过
innodb_deadlock_detect
控制检测开关
检测流程:
1. 构建等待图
┌─────────┐ ┌─────────┐
│ 事务A │───▶│ 事务B │
│ 等待锁1 │ │ 等待锁2 │
└─────────┘ └─────────┘
│ │
└──────────────┘
形成环路 = 死锁
2. 深度优先搜索检测环路
3. 选择victim事务回滚
4. 释放锁资源
3️⃣ 死锁预防策略
策略 | 实现方法 | 效果 |
---|---|---|
统一访问顺序 | 按固定顺序访问表和索引 | 避免循环等待 |
缩短事务时间 | 减少锁持有时间 | 降低死锁概率 |
合理设计索引 | 减少锁冲突 | 提高并发性能 |
避免长事务 | 及时提交事务 | 减少锁竞争 |
4️⃣ 死锁监控与诊断
监控命令:
-- 查看当前锁等待情况
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
-- 查看死锁历史
SHOW ENGINE INNODB STATUS\G
-- 关注:LATEST DETECTED DEADLOCK 部分
-- 查看死锁统计
SHOW STATUS LIKE 'Innodb_deadlocks';
死锁分析示例:
LATEST DETECTED DEADLOCK
------------------------
2024-01-01 12:00:00 0x7f8b8c000700
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 1, OS thread handle 140123456789760, query id 100 localhost root update
UPDATE users SET name = 'A' WHERE id = 1
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 10 page no 3 n bits 72 index PRIMARY of table `test`.`users` trx id 12345 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 80000001; asc ;;
1: len 6; hex 000000000304; asc ;;
2: len 7; hex 82000001220110; asc " ;;
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 8 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 2, OS thread handle 140123456789760, query id 101 localhost root update
UPDATE users SET name = 'B' WHERE id = 2
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 10 page no 3 n bits 72 index PRIMARY of table `test`.`users` trx id 12346 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 80000001; asc ;;
1: len 6; hex 000000000304; asc ;;
2: len 7; hex 82000001220110; asc " ;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 10 page no 3 n bits 72 index PRIMARY of table `test`.`users` trx id 12346 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 80000002; asc ;;
1: len 6; hex 000000000304; asc ;;
2: len 7; hex 82000001220110; asc " ;;
*** WE ROLL BACK TRANSACTION (1)
5️⃣ 死锁调优建议
应用层优化:
- 统一访问顺序:按主键或唯一键排序访问
- 减少事务粒度:拆分大事务为小事务
- 使用乐观锁:版本号控制,减少锁竞争
- 合理设计索引:避免全表扫描升级为表锁
数据库层优化:
-- 调整死锁检测参数
SET GLOBAL innodb_deadlock_detect = ON; -- 启用死锁检测
SET GLOBAL innodb_lock_wait_timeout = 50; -- 锁等待超时时间
-- 监控死锁频率
SHOW STATUS LIKE 'Innodb_deadlocks';
🛠️ 实操演示
实操1:事务基础操作
-- 创建测试表
USE mysql_learning;
CREATE TABLE accounts (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50),
balance DECIMAL(10,2)
) ENGINE=InnoDB;
-- 插入测试数据
INSERT INTO accounts (name, balance) VALUES
('张三', 1000.00),
('李四', 2000.00);
-- 查看当前数据
SELECT * FROM accounts;
实操2:ACID 特性验证
-- 测试原子性:模拟转账操作
START TRANSACTION;
-- 从张三账户扣除100元
UPDATE accounts SET balance = balance - 100 WHERE name = '张三';
-- 向李四账户增加100元
UPDATE accounts SET balance = balance + 100 WHERE name = '李四';
-- 查看事务中的数据(其他会话看不到)
SELECT * FROM accounts;
-- 提交事务
COMMIT;
-- 再次查看数据
SELECT * FROM accounts;
实操3:隔离级别实验
-- 查看当前隔离级别
SELECT @@transaction_isolation;
-- 设置隔离级别(需要重新连接才能生效)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 测试 READ COMMITTED
-- 会话1:开始事务但不提交
START TRANSACTION;
UPDATE accounts SET balance = 1500 WHERE name = '张三';
-- 不要提交,保持事务开启
-- 会话2:查看数据(应该看到原始数据,因为会话1未提交)
SELECT * FROM accounts;
-- 会话1:提交事务
COMMIT;
-- 会话2:再次查看(现在应该看到更新后的数据)
SELECT * FROM accounts;
实操4:MVCC 版本链演示
-- 创建测试表
CREATE TABLE products (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100),
price DECIMAL(10,2),
version INT DEFAULT 1
) ENGINE=InnoDB;
INSERT INTO products (name, price) VALUES ('商品A', 100.00);
-- 会话1:开始事务并更新
START TRANSACTION;
UPDATE products SET price = 120.00, version = version + 1 WHERE id = 1;
-- 会话2:查看数据(应该看到原始价格,因为会话1未提交)
SELECT * FROM products WHERE id = 1;
-- 会话1:提交
COMMIT;
-- 会话2:现在应该看到更新后的价格
SELECT * FROM products WHERE id = 1;
实操5:死锁复现与检测
-- 准备测试数据
INSERT INTO accounts (name, balance) VALUES
('王五', 500.00),
('赵六', 800.00);
-- 会话1:按顺序锁定记录
START TRANSACTION;
SELECT * FROM accounts WHERE name = '张三' FOR UPDATE;
-- 等待几秒
SELECT SLEEP(2);
SELECT * FROM accounts WHERE name = '李四' FOR UPDATE;
-- 会话2:反向顺序锁定记录(同时执行)
START TRANSACTION;
SELECT * FROM accounts WHERE name = '李四' FOR UPDATE;
SELECT * FROM accounts WHERE name = '张三' FOR UPDATE;
-- 查看死锁信息
SHOW ENGINE INNODB STATUS\G
实操6:锁等待和超时
-- 设置锁等待超时时间
SET innodb_lock_wait_timeout = 10;
-- 会话1:锁定记录
START TRANSACTION;
SELECT * FROM accounts WHERE name = '张三' FOR UPDATE;
-- 会话2:尝试锁定同一记录(会等待)
SELECT * FROM accounts WHERE name = '张三' FOR UPDATE;
-- 10秒后会超时并报错
-- 查看当前锁信息
SELECT * FROM information_schema.innodb_locks;
SELECT * FROM information_schema.innodb_lock_waits;
实操7:两阶段提交验证
-- 查看 Binlog 格式
SHOW VARIABLES LIKE 'binlog_format';
-- 查看 Redo Log 状态
SHOW ENGINE INNODB STATUS\G
-- 执行事务并观察日志
START TRANSACTION;
UPDATE accounts SET balance = balance + 50 WHERE name = '张三';
COMMIT;
-- 查看 Binlog 内容
SHOW BINLOG EVENTS;
🎯 实战案例
案例1:银行转账系统设计
场景描述: 设计一个银行转账系统,确保转账操作的原子性和一致性。
解决方案:
-- 创建银行账户表
CREATE TABLE bank_accounts (
account_id VARCHAR(20) PRIMARY KEY,
account_name VARCHAR(100),
balance DECIMAL(15,2) NOT NULL DEFAULT 0.00,
version INT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
-- 创建转账记录表
CREATE TABLE transfer_records (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
from_account VARCHAR(20),
to_account VARCHAR(20),
amount DECIMAL(15,2),
status ENUM('pending', 'success', 'failed') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_from_account (from_account),
INDEX idx_to_account (to_account)
) ENGINE=InnoDB;
-- 插入测试数据
INSERT INTO bank_accounts (account_id, account_name, balance) VALUES
('ACC001', '张三', 10000.00),
('ACC002', '李四', 5000.00);
-- 转账存储过程
DELIMITER //
CREATE PROCEDURE transfer_money(
IN p_from_account VARCHAR(20),
IN p_to_account VARCHAR(20),
IN p_amount DECIMAL(15,2)
)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
START TRANSACTION;
-- 检查余额是否足够
IF (SELECT balance FROM bank_accounts WHERE account_id = p_from_account) < p_amount THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '余额不足';
END IF;
-- 扣除转出账户余额
UPDATE bank_accounts
SET balance = balance - p_amount,
version = version + 1
WHERE account_id = p_from_account;
-- 增加转入账户余额
UPDATE bank_accounts
SET balance = balance + p_amount,
version = version + 1
WHERE account_id = p_to_account;
-- 记录转账记录
INSERT INTO transfer_records (from_account, to_account, amount, status)
VALUES (p_from_account, p_to_account, p_amount, 'success');
COMMIT;
END //
DELIMITER ;
-- 测试转账
CALL transfer_money('ACC001', 'ACC002', 1000.00);
-- 查看结果
SELECT * FROM bank_accounts;
SELECT * FROM transfer_records;
案例2:高并发场景下的锁优化
问题场景: 商品库存扣减在高并发下出现超卖问题。
问题分析:
-- 问题代码(会导致超卖)
UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock > 0;
优化方案:
-- 方案1:使用悲观锁
START TRANSACTION;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- 检查库存
UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock > 0;
COMMIT;
-- 方案2:使用乐观锁
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND stock > 0 AND version = @current_version;
-- 方案3:使用原子操作
UPDATE products
SET stock = GREATEST(stock - 1, 0)
WHERE id = 1 AND stock > 0;
📝 练习题目
基础题
- 什么是事务的 ACID 特性?MySQL 如何保证这些特性?
- Redo Log 和 Undo Log 分别有什么作用?
- 什么是两阶段提交?为什么需要两阶段提交?
进阶题
- MVCC 是如何实现的?ReadView 的作用是什么?
- 四种隔离级别分别解决了什么问题?
- 什么是死锁?如何避免死锁?
实战题
- 设计一个分布式事务场景,如何保证数据一致性?
- 在高并发场景下,如何优化锁的竞争?
- 分析你当前系统的死锁情况,并提出优化建议。
📚 扩展阅读
下一章预告: 第3章:索引底层实现 - 深入理解 MySQL 的索引机制和性能优化原理。