Develop

数据库事务与隔离级别深度解析:从 ACID 到 MVCC 的完整实战指南

✎ -- 字 🕐 -- 分钟
字号

数据库事务与隔离级别深度解析:从 ACID 到 MVCC 的完整实战指南

事务是数据库最核心的概念之一,也是面试和生产中踩坑最多的地方。很多人背得出 ACID 四个字母,却搞不清脏读、不可重复读、幻读到底在什么场景下发生;知道 InnoDB 用了 MVCC,却说不出 Undo Log 和 Read View 是怎么配合工作的。这篇文章从原理到代码,把事务机制一次性讲透。

数据库事务与隔离级别封面

一、为什么需要事务

想象一个转账场景:A 账户扣 100 元,B 账户加 100 元。如果扣完 A 之后系统挂了,B 没加上,这 100 元就凭空消失了。事务的作用就是把这两步操作打包成一个不可分割的单元——要么全成功,要么全回滚。

不只是金融场景,电商下单扣库存、优惠券核销、积分变更,但凡涉及多表多行的一致性问题,都离不开事务。没有事务的保护,并发一上来,数据错乱只是时间问题。

-- 电商下单的完整事务示例
START TRANSACTION;

-- 1. 扣减库存(行锁锁定库存记录)
UPDATE products SET stock = stock - 1 WHERE id = 1001 AND stock > 0;

-- 2. 创建订单
INSERT INTO orders (user_id, product_id, amount, status, created_at)
VALUES (42, 1001, 299.00, 'pending', NOW());
SET @order_id = LAST_INSERT_ID();

-- 3. 扣减用户积分
UPDATE users SET points = points - 100 WHERE id = 42 AND points >= 100;

-- 4. 记录积分流水
INSERT INTO point_logs (user_id, order_id, delta, reason, created_at)
VALUES (42, @order_id, -100, 'order_discount', NOW());

-- 5. 如果第 1 步影响的行数为 0(库存不足),回滚
-- 应用程序层检查 ROW_COUNT(),决定 COMMIT 还是 ROLLBACK
COMMIT;

上面这个事务里,如果库存扣减失败(比如 stock 已经归零),整个事务都应该回滚,订单和积分流水不该生成。这就是原子性的实际意义。

二、ACID 到底在说什么

ACID 是事务的四个基本属性,但真正理解它们的人不多。

2.1 原子性(Atomicity)

原子性保证事务内的操作要么全部生效,要么全部不生效。实现的核心是 Undo Log——InnoDB 在修改数据前,先把旧值写入 Undo Log。如果事务回滚,就按 Undo Log 把数据恢复回去。

-- 开启一个事务
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 如果这里出错了,执行 ROLLBACK 即可恢复
ROLLBACK;

2.2 一致性(Consistency)

一致性最容易被误解。它不是指数据"正确",而是指事务执行前后,数据库的约束(外键、唯一索引、CHECK 约束等)不会被破坏。原子性、隔离性、持久性共同保障了一致性。

2.3 隔离性(Isolation)

隔离性解决的是并发问题。多个事务同时读写同一份数据,不加控制就会出现脏读、不可重复读、幻读。隔离级别和锁机制是实现隔离性的两大手段,后面会详细展开。

2.4 持久性(Durability)

持久性保证事务提交后,数据不会丢失。InnoDB 通过 Redo Log 实现——修改先写 Redo Log(顺序写,速度快),再异步刷脏页到磁盘。即使数据库崩溃,重启后也能通过 Redo Log 恢复。

Redo Log 采用 WAL(Write-Ahead Logging)机制,数据页修改先写日志,再写磁盘。日志文件是循环使用的(ib_logfile0、ib_logfile1),写满后从头覆盖,前提是对应的脏页已经刷盘。崩溃恢复时,InnoDB 会扫描 Redo Log,把已提交但尚未刷盘的事务重做一遍。

-- 查看 Redo Log 配置
SHOW VARIABLES LIKE 'innodb_log%';

-- 关键参数
-- innodb_log_file_size    单个日志文件大小(默认 48MB,生产建议 1-2GB)
-- innodb_log_files_in_group  日志文件个数(默认 2)
-- innodb_flush_log_at_trx_commit  提交时刷盘策略

innodb_flush_log_at_trx_commit 这个参数值得单独说:

  • 0:每秒把 Redo Log Buffer 刷到磁盘,事务提交时不刷。性能最好,但崩溃可能丢失最近 1 秒的数据
  • 1(默认):每次事务提交都把 Redo Log 刷到磁盘。最安全,金融场景必用
  • 2:事务提交时刷到 OS Buffer,每秒由 OS 刷到磁盘。折中方案,比 0 安全,比 1 快
属性核心机制崩溃恢复角色
原子性Undo Log回滚未提交事务
持久性Redo Log重做已提交事务
隔离性锁 + MVCC
一致性约束检查

三、四种隔离级别与并发异常

SQL 标准定义了四种隔离级别,从低到高分别是:读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)、串行化(Serializable)。级别越高,并发性能越差,但数据一致性越好。

3.1 三种并发异常

脏读(Dirty Read):事务 A 读到了事务 B 尚未提交的数据。如果 B 回滚,A 读到的就是"脏"数据。

不可重复读(Non-repeatable Read):事务 A 两次读取同一行数据,期间事务 B 提交了修改,导致 A 两次结果不一致。

幻读(Phantom Read):事务 A 两次执行同一范围查询,期间事务 B 插入或删除了符合条件的行,导致 A 两次结果集不同。

隔离级别脏读不可重复读幻读实现方式
Read Uncommitted可能可能可能不加锁,直接读最新
Read Committed不会可能可能每次读生成新 Read View
Repeatable Read不会不会可能(InnoDB 已解决)事务开始时生成 Read View
Serializable不会不会不会所有操作加排他锁

3.2 MySQL 默认为什么是 Repeatable Read

Oracle、PostgreSQL 的默认隔离级别是 Read Committed,但 MySQL InnoDB 默认用 Repeatable Read。不是因为 MySQL 更保守,而是 InnoDB 的 MVCC 实现让 Repeatable Read 的并发性能足够好,同时还能解决幻读——通过 Gap Lock 锁住记录之间的间隙,阻止其他事务插入。

不过要注意,InnoDB 的 Repeatable Read 只是解决了"快照读"的幻读,"当前读"(SELECT ... FOR UPDATE)仍然可能遇到幻读,需要靠 Gap Lock 来解决。

3.3 动手实验:三种异常的复现

下面用两个 MySQL 会话,一步步复现脏读、不可重复读和幻读。

-- ========== 实验 1:脏读(Read Uncommitted) ==========
-- 会话 A
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
UPDATE accounts SET balance = 500 WHERE id = 1;  -- 不提交

-- 会话 B(同一隔离级别)
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;  -- 读到 500(脏读!)

-- 会话 A 回滚
ROLLBACK;
-- 会话 B 再读,balance 变回原来的值 —— 刚才读到的 500 就是脏数据
-- ========== 实验 2:不可重复读(Read Committed) ==========
-- 会话 A
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;  -- 读到 100

-- 会话 B 提交:UPDATE accounts SET balance = 200 WHERE id = 1; COMMIT;

-- 会话 A 再次查询
SELECT balance FROM accounts WHERE id = 1;  -- 读到 200(不可重复读!)
COMMIT;
-- ========== 实验 3:幻读(Serializable 才能完全避免) ==========
-- 会话 A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM accounts WHERE balance > 50;  -- 假设查到 3 条

-- 会话 B 提交:INSERT INTO accounts (balance) VALUES (100); COMMIT;

-- 会话 A 再次查询(快照读)
SELECT * FROM accounts WHERE balance > 50;  -- 仍然是 3 条(MVCC 解决了)

-- 但如果用当前读
SELECT * FROM accounts WHERE balance > 50 FOR UPDATE;  -- 读到 4 条(幻读!)
COMMIT;

这组实验建议自己在测试库跑一遍,比看十遍文章都管用。

四、MVCC 机制深度拆解

MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现高并发读写的核心。它的基本思想是:写操作不阻塞读操作,读操作不阻塞写操作。每个事务看到的数据版本可能不同。

4.1 隐藏字段与 Undo Log 链

InnoDB 的每行记录都有三个隐藏字段:

  • DB_TRX_ID:最后修改这行数据的事务 ID
  • DB_ROLL_PTR:指向 Undo Log 的指针,形成版本链
  • DB_ROW_ID:隐藏主键(如果没有显式主键)

当一个事务修改数据时,不会直接覆盖旧数据,而是生成一个新版本,旧数据通过 Undo Log 保留。这样,不同事务可以根据需要读取不同的历史版本。

4.2 一个具体的版本链例子

假设表中有 id=1 的记录,初始 balance=100,由事务 ID=50 插入。随后发生以下操作:

时间线:
T1: 事务 100 开始,UPDATE balance = 200  -- 生成 Undo 记录(balance=100, trx_id=50)
T2: 事务 101 开始,UPDATE balance = 300  -- 生成 Undo 记录(balance=200, trx_id=100)
T3: 事务 102 开始,SELECT balance         -- 生成 Read View

此时 id=1 的版本链是这样的(从最新到最旧):

版本 3: balance=300, DB_TRX_ID=101, DB_ROLL_PTR → 指向版本 2
版本 2: balance=200, DB_TRX_ID=100, DB_ROLL_PTR → 指向版本 1
版本 1: balance=100, DB_TRX_ID=50,  DB_ROLL_PTR → NULL(最早版本)

事务 102 的 Read View 假设是:m_ids=[100,101], min_trx_id=100, max_trx_id=103。

事务 102 读 id=1 时,从最新版本开始判断:

  • 版本 3 的 DB_TRX_ID=101,在 m_ids 列表中 → 未提交,不可见
  • 顺着指针找到版本 2,DB_TRX_ID=100,在 m_ids 列表中 → 未提交,不可见
  • 顺着指针找到版本 1,DB_TRX_ID=50,小于 min_trx_id=100 → 已提交,可见

所以事务 102 读到的 balance 是 100。即使最新数据已经是 300,它依然看到事务开始前的快照。

MVCC Read View 可见性判定流程

Read View 是事务快照读时生成的一个"视图",决定了当前事务能看到哪些版本的数据。它包含四个关键信息:

  • creator_trx_id:创建该 Read View 的事务 ID
  • m_ids:生成 Read View 时,所有活跃(未提交)事务的 ID 列表
  • min_trx_id:m_ids 中的最小值
  • max_trx_id:生成 Read View 时,系统将要分配的下一个事务 ID

判断某行数据版本是否可见的规则如下:

1. 如果 DB_TRX_ID == creator_trx_id:自己改的,可见
2. 如果 DB_TRX_ID < min_trx_id:在 Read View 生成前已提交,可见
3. 如果 DB_TRX_ID >= max_trx_id:在 Read View 生成后才开始,不可见
4. 如果 min_trx_id <= DB_TRX_ID < max_trx_id:
   - 如果在 m_ids 列表中:未提交,不可见
   - 如果不在 m_ids 列表中:已提交,可见

如果不可见,就顺着 DB_ROLL_PTR 找到上一个版本,再次判断,直到找到可见版本或追溯到头。

4.3 RC 与 RR 的差异根源

Read Committed 和 Repeatable Read 的根本区别就在于 Read View 的生成时机:

  • RC:每次 SELECT 都生成新的 Read View,所以能读到其他事务最新已提交的数据
  • RR:事务第一次 SELECT 时生成 Read View,之后一直复用,保证整个事务内看到的数据是一致的
-- 会话 A(RR 级别)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM products WHERE id = 1;  -- 读到 price = 100,生成 Read View

-- 会话 B 提交修改:UPDATE products SET price = 200 WHERE id = 1;

SELECT * FROM products WHERE id = 1;  -- 仍然读到 price = 100(同一个 Read View)
COMMIT;

五、锁机制与死锁排查

MVCC 解决了读写冲突,但写与写的冲突还得靠锁。InnoDB 的锁按粒度分为行锁和表锁,按类型分为共享锁(S 锁)和排他锁(X 锁)。

5.1 行锁的三种类型

  • Record Lock:锁住某条具体记录
  • Gap Lock:锁住记录之间的间隙,防止幻读
  • Next-Key Lock:Record Lock + Gap Lock,锁住记录及其前面的间隙

锁的兼容性也很重要。两个事务能不能同时持有同一份数据的锁,取决于锁的类型:

X 锁(排他)S 锁(共享)
X 锁(排他)冲突冲突
S 锁(共享)冲突兼容

简单说:共享锁和共享锁可以共存(多个事务同时读),但只要有一个排他锁,其他锁都得等。

-- 对 id=5 的记录加排他锁(Next-Key Lock,RR 级别下)
SELECT * FROM accounts WHERE id = 5 FOR UPDATE;

-- 对范围加锁,会锁住 id 在 (1, 10] 区间及间隙
SELECT * FROM accounts WHERE id > 1 AND id <= 10 FOR UPDATE;

5.2 死锁的产生与排查

死锁发生在两个事务互相等待对方持有的锁。InnoDB 有死锁检测机制,发现后会选择一个代价较小的事务回滚。

-- 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS;

-- 开启死锁日志(MySQL 8.0)
SET GLOBAL innodb_print_all_deadlocks = ON;

死锁日志的关键信息在 LATEST DETECTED DEADLOCK 部分,能看到两个事务持有的锁和等待的锁,以及回滚了哪个事务。

5.3 避免死锁的实战策略

  • 固定加锁顺序:所有事务按相同的顺序访问表和行
  • 减小事务粒度:事务越短,冲突概率越低
  • 使用乐观锁:版本号控制,适合读多写少场景
  • 降低隔离级别:Read Committed 不使用 Gap Lock,死锁概率更低

六、生产环境配置与监控

6.1 事务超时设置

-- 查看当前隔离级别
SELECT @@transaction_isolation;

-- 查看锁等待超时时间(默认 50 秒)
SELECT @@innodb_lock_wait_timeout;

-- 设置会话级超时
SET SESSION innodb_lock_wait_timeout = 10;

6.2 监控事务与锁

-- 查看当前正在执行的事务
SELECT * FROM information_schema.INNODB_TRX;

-- 查看当前锁信息
SELECT * FROM performance_schema.data_locks;

-- 查看锁等待关系
SELECT * FROM performance_schema.data_lock_waits;

6.3 长事务的危害与治理

长事务是生产环境的大敌。它会一直持有锁和 Read View,导致 Undo Log 无法清理,进而造成:

  • 表空间膨胀(ibdata1 或 undo_001 文件暴涨)
  • 查询性能下降(需要遍历更长的版本链)
  • 锁冲突加剧(其他事务长时间等待)
-- 查找运行超过 60 秒的事务
SELECT 
    trx_id, 
    trx_mysql_thread_id, 
    trx_state, 
    TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_seconds
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;

-- 查看 Undo Log 表空间使用情况(MySQL 8.0)
SELECT 
    tablespace_name, 
    file_name, 
    ROUND(total_extents * extent_size / 1024 / 1024, 2) AS size_mb
FROM information_schema.innodb_tablespaces
WHERE tablespace_name LIKE '%undo%';

如果发现 Undo 表空间持续增长,通常是因为存在长事务或者查询速度太慢,导致旧版本无法清理。这时候需要定位并终止罪魁祸首事务:

-- 终止指定线程(trx_mysql_thread_id 对应的线程 ID)
KILL 12345;

七、常见陷阱与最佳实践

7.1 陷阱一:在循环里逐条处理却不提交

# 错误示范:十万条记录在一个事务里
conn.begin()
for row in huge_dataset:
    cursor.execute("UPDATE items SET status = 1 WHERE id = %s", (row['id'],))
conn.commit()  # 事务太大,锁持有时间极长,Undo Log 爆炸

# 正确做法:分批提交
batch_size = 1000
for i in range(0, len(huge_dataset), batch_size):
    conn.begin()
    for row in huge_dataset[i:i+batch_size]:
        cursor.execute("UPDATE items SET status = 1 WHERE id = %s", (row['id'],))
    conn.commit()

7.2 陷阱二:SELECT 后业务处理太久才 UPDATE

# 错误示范:先查出来,业务逻辑处理 5 秒,再更新
row = cursor.execute("SELECT * FROM orders WHERE id = 1 FOR UPDATE").fetchone()
time.sleep(5)  # 模拟业务处理
cursor.execute("UPDATE orders SET status = 'paid' WHERE id = 1")
conn.commit()

# 正确做法:把业务逻辑放到锁外面,或者尽量减少锁持有时间

7.3 陷阱三:忽略索引导致锁升级

没有索引的 UPDATE 或 DELETE 会触发全表扫描,InnoDB 会把所有扫描过的行都加锁(即使不符合条件),很容易变成"锁表"。

-- 危险:phone 没有索引,会锁全表
UPDATE users SET status = 0 WHERE phone = '13800138000';

-- 解决:给查询条件加索引
ALTER TABLE users ADD INDEX idx_phone (phone);

7.4 最佳实践 Checklist

  • 显式声明事务边界(BEGIN / COMMIT / ROLLBACK),避免隐式事务陷阱
  • 总是按相同顺序访问资源,预防死锁
  • 事务里不要调外部 API 或执行耗时计算
  • 大事务拆成小批次,及时提交
  • 为所有 WHERE 条件字段建立合适索引
  • 监控长事务和锁等待,设置告警阈值
  • 根据业务场景选择合适的隔离级别,不要无脑用最高级别
  • 使用 SELECT ... FOR UPDATE 时,确保查询条件能走索引

八、总结

事务不是简单的 BEGIN 和 COMMIT,它背后是一套精密的并发控制体系。ACID 四个属性分别由 Undo Log、Redo Log、MVCC 和锁机制共同保障。四种隔离级别对应不同的并发异常容忍度,而 MVCC 通过版本链和 Read View 实现了高效的读写隔离。生产环境中,长事务和锁冲突是最常见的性能杀手,需要通过合理的索引设计、事务拆分和监控告警来防范。理解了这些底层机制,才能在排查问题时有的放矢,而不是靠猜。