# 事务

作者: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) 都是为实现一致性提供的保障手段

# 触发与并发问题

# 触发时机

事务可以在两种情况下出现:

  1. 单条 DML(insert、update、delete)语句执行时
    • 数据库会自动开启 隐式事务
    • 在执行时,事务会经过两阶段提交的第二阶段,确保 redo logbinlog 一致,保证操作的持久性和可复制性。
  2. 手动开启事务
    • 当业务需要将多条操作放在一个事务中,保证整体一致性时,需要显式开启事务并在完成后提交(如 Spring 的 @Transactional)。

# 事务并发问题

# 脏读

假设数据库中有如下数据:

1  张三  
2  李四
1
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 ❌ 无 ❌ 无 ❌ 无 ❌ 慢

说明:

  1. Read Uncommitted(读未提交)
    • 可以读取其他事务未提交的数据,可能产生脏读
    • 几乎不会用于生产环境。
  2. Read Committed(读已提交)
    • 只读取已经提交的数据,避免了脏读。
    • 但可能出现不可重复读幻读
    • Oracle 默认使用这个级别, 大部分业务系统用这个隔离级别更好, 并发写的问题通过加锁
  3. Repeatable Read(可重复读)
    • 同一事务内多次读取相同记录结果一致,防止不可重复读。
    • 但仍可能出现幻读。
    • InnoDB 默认使用,且通过 间隙锁(Gap Lock) 机制避免幻读
  4. 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 回滚指针,指向旧版本的数据
# 🔹 版本链
  • 每次事务更新一行数据时,会:
    1. 在 Undo Log 中保存修改前的旧版本;
    2. 更新当前行记录,同时 DB_TRX_ID 写入当前事务 ID;
    3. DB_ROLL_PTR 指向刚生成的 Undo Log 条目。

这样,当前行记录作为链头,通过 DB_ROLL_PTR 依次串起旧版本,形成 版本链

[最新行记录] trx_id=105
    roll_ptr ---> [Undo Log v104]
                       roll_ptr ---> [Undo Log v103]
                                          roll_ptr ---> NULL (最早版本)
1
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

可见性判断

读取某行数据时,判断该版本是否可见:

  1. 当前版本的 trx_id == creator_trx_id:表示是当前事务创建的 → 可见
  2. trx_id < min_trx_id:表示版本创建时事务已提交 → 可见
  3. trx_id >= max_trx_id:该版本由之后的事务创建 → 不可见
  4. trx_id ∈ (min_trx_id, max_trx_id)trx_id ∈ m_ids:事务仍未提交 → 不可见
  5. 否则 → 可见

如果当前版本不可见,则沿着 DB_ROLL_PTR 指向的 undo log 查找上一个版本,继续判断,直到找到可见版本或无可见版本。

事务隔离级别与ReadView关系

隔离级别 ReadView 创建时机
Read Committed (RC) 每次 select 都创建新的 ReadView
Repeatable Read (RR) 第一次 select 时创建,整个事务期间复用

# 具体案例

  1. 第一个事务插入数据(事务ID=1)

    begin;
    insert into mvcctest values(NULL,'zs');
    insert into mvcctest values(NULL,'ls');
    commit;
    
    1
    2
    3
    4
    id name trx_id roll_ptr
    1 zs 1 undefined
    2 ls 1 undefined
  2. 第二个事务开始并查询(事务ID=2)

    begin;
    select * from mvcctest;  -- 生成 ReadView,ID=2
    
    1
    2

    ReadView:

    • min_trx_id = 3
    • max_trx_id = 3
    • m_ids = [](当前无活跃事务, 不包括自身)
    • creator_trx_id = 2

    结果:能看到 ID=1 和 2

  3. 第三个事务插入数据(事务ID=3)

    begin;
    insert into mvcctest values(NULL,'ww');
    commit;
    
    1
    2
    3
    id name trx_id roll_ptr
    3 ww 3 undefined
  4. 第二个事务再次查询(事务ID=2)

    select * from mvcctest;
    
    1

    结果仍为:ID=1、2,看不到 ID=3(因为事务3启动时间 > 当前 ReadView)

  5. 第四个事务删除 id=2(事务ID=4)

    begin;
    delete from mvcctest where id=2;
    commit;
    
    1
    2
    3
    id name trx_id roll_ptr
    2 ls 1 4
  6. 第二个事务第三次查询(事务ID=2)

    select * from mvcctest;
    
    1

    仍能看到 ID=2,因为它的删除版本是 4 > 当前事务ID=2,不影响可见性。

  7. 第五个事务更新 id=1(事务ID=5)

    begin;
    update mvcctest set name='zl' where id=1;
    commit;
    
    1
    2
    3
    id name trx_id roll_ptr
    1 zl 5 undefined
    1 zs 1 5
  8. 第二个事务第四次查询(事务ID=2)

    select * from mvcctest;
    
    1

    结果:仍然看到旧版本 zs,新版本 zl 的事务ID=5 > 当前ID=2 → 不可见