MySQL 事务与锁机制详解:从 MVCC 到 undo log、redo log、binlog

MySQL 事务与锁机制详解:从 MVCC 到 undo log、redo log、binlog

前言

MySQL 的事务和锁,是后端开发绕不开的基础。

平时我们可能只会写:

begin;
update account set balance = balance - 100 where id = 1;
update account set balance = balance + 100 where id = 2;
commit;

或者在 Java 里加一个:

@Transactional

但一旦遇到线上问题,事情就没这么简单了:

  • 为什么明明查询了,别人还是能改?
  • 为什么一个简单 update 卡住几十秒?
  • 为什么可重复读下还能解决大部分幻读问题?
  • MVCC 到底读的是哪里来的旧版本?
  • undo log、redo log、binlog 分别干什么?
  • MySQL 崩溃后为什么能恢复?
  • 主从复制和事务提交有什么关系?

本文把这些问题串起来讲。重点以 InnoDB 为主,因为它是 MySQL 中最常用、最重要的事务存储引擎。

一、事务解决什么问题

事务的目标是让一组数据库操作要么全部成功,要么全部失败。

经典例子是转账:

update account set balance = balance - 100 where id = 1;
update account set balance = balance + 100 where id = 2;

如果第一条成功,第二条失败,钱就凭空少了。所以这两条 SQL 必须放到同一个事务里。

事务有四个基本特性,也就是 ACID:

特性 含义
Atomicity 原子性 一个事务里的操作要么全部成功,要么全部失败
Consistency 一致性 事务执行前后,数据要满足业务和数据库约束
Isolation 隔离性 多个事务并发执行时,彼此不能随意干扰
Durability 持久性 事务提交后,即使数据库崩溃,数据也不能丢

这四个特性并不是凭空来的,它们背后分别依赖不同机制:

原子性 -> undo log
隔离性 -> 锁 + MVCC
持久性 -> redo log
一致性 -> undo log + redo log + 锁 + 约束 + 业务逻辑
复制和恢复 -> binlog

后面会逐个展开。

二、事务隔离级别

多个事务并发执行时,会出现一些典型问题。

2.1 脏读

事务 A 读到了事务 B 尚未提交的数据。

事务 B 修改余额为 900,但还没提交
事务 A 读到 900
事务 B 回滚

此时事务 A 读到的 900 就是脏数据。

2.2 不可重复读

事务 A 在同一个事务内两次读取同一行,结果不同。

事务 A 第一次读 balance = 1000
事务 B 修改 balance = 900 并提交
事务 A 第二次读 balance = 900

同一个事务里,两次读取结果变了,这就是不可重复读。

2.3 幻读

事务 A 按条件查询一批数据,两次查询结果集数量不同。

事务 A 查询 age > 18,有 10 条
事务 B 插入一条 age = 20 的记录并提交
事务 A 再查 age > 18,有 11 条

第二次多出来的那条记录像“幻影”一样出现,所以叫幻读。

2.4 四种隔离级别

SQL 标准定义了四种隔离级别:

隔离级别 脏读 不可重复读 幻读
Read Uncommitted 可能 可能 可能
Read Committed 不可能 可能 可能
Repeatable Read 不可能 不可能 标准意义上可能
Serializable 不可能 不可能 不可能

MySQL InnoDB 默认隔离级别是:

Repeatable Read

InnoDB 在可重复读下,通过 MVCC 和 next-key lock,在很多实际场景中解决了幻读问题。

注意这里要精确理解:普通快照读主要靠 MVCC;当前读、锁定读涉及 next-key lock。不同读法行为不同。

三、MySQL 中的两类读:快照读和当前读

理解锁和 MVCC 前,必须先区分两类读。

3.1 快照读

普通 select 通常是快照读:

select * from user where id = 1;

快照读不加行锁,它读取的是某个时间点的数据版本。

在 InnoDB 中,快照读主要依赖 MVCC 实现。

3.2 当前读

下面这些操作读取的是最新数据,并且通常需要加锁:

select * from user where id = 1 for update;
select * from user where id = 1 lock in share mode;
update user set name = 'Tom' where id = 1;
delete from user where id = 1;
insert into user(id, name) values(1, 'Tom');

当前读读取的是最新已提交版本,并且要保证读到的数据接下来能被安全修改,所以需要锁机制参与。

一句话区分:

快照读:读历史版本,不加行锁,靠 MVCC
当前读:读最新版本,要加锁,靠锁保证并发安全

四、InnoDB 锁体系总览

MySQL 的锁不只一种。常见锁可以按层次分为:

Server 层:
  - Metadata Lock,元数据锁
  - Table Lock,表锁

InnoDB 层:
  - 行锁
  - 间隙锁
  - next-key lock
  - 意向锁
  - 插入意向锁
  - 自增锁

开发中最常遇到的是 InnoDB 行锁、间隙锁、next-key lock 和 MDL。

五、共享锁和排他锁

从读写兼容性看,最基础的是共享锁和排他锁。

5.1 共享锁

共享锁,Shared Lock,简称 S 锁。

多个事务可以同时持有同一行的 S 锁。

select * from user where id = 1 lock in share mode;

拿到 S 锁后,其他事务也可以读,但不能修改这行。

5.2 排他锁

排他锁,Exclusive Lock,简称 X 锁。

select * from user where id = 1 for update;
update user set name = 'Tom' where id = 1;

一个事务拿到某行 X 锁后,其他事务不能再拿这行的 S 锁或 X 锁。

兼容关系可以这样记:

锁类型 S 锁 X 锁
S 锁 兼容 不兼容
X 锁 不兼容 不兼容

六、意向锁:表级锁和行级锁之间的协调机制

InnoDB 支持行锁,但 MySQL 也有表锁。

如果事务 A 已经锁住了表中某一行,事务 B 想给整张表加表锁,MySQL 难道要扫描全表,看每一行有没有被锁吗?

这样成本太高。

所以 InnoDB 引入了意向锁:

  • IS:意向共享锁
  • IX:意向排他锁

含义是:

事务准备在表中的某些行上加 S 锁或 X 锁,所以先在表级别放一个意向标记。

例如 update 某一行前,会先在表上加 IX 锁,再对具体行加 X 锁。

意向锁的作用不是阻塞普通行读写,而是让表锁判断更高效。

一句话:

意向锁是表级锁,用来表示“这张表里某些行已经或即将被加锁”。

七、Record Lock、Gap Lock、Next-Key Lock

这是 InnoDB 锁机制中最容易混淆的部分。

7.1 Record Lock:记录锁

Record Lock 锁的是索引记录。

例如:

select * from user where id = 10 for update;

如果 id 是主键或唯一索引,并且记录存在,InnoDB 通常会锁住 id = 10 这条索引记录。

注意:InnoDB 的行锁是加在索引上的。

如果查询条件没有命中索引,MySQL 可能扫描更多记录,锁范围也可能扩大。

7.2 Gap Lock:间隙锁

Gap Lock 锁的是索引记录之间的间隙。

假设表中有这些 id:

1, 5, 10

那么间隙包括:

(-∞, 1)
(1, 5)
(5, 10)
(10, +∞)

如果事务锁住了 (5, 10) 这个间隙,其他事务就不能在这个间隙插入新记录,比如不能插入 id = 7

间隙锁的核心目的,是防止幻读。

7.3 Next-Key Lock:记录锁 + 间隙锁

Next-Key Lock 是 Record Lock 和 Gap Lock 的组合。

它锁的是一个左开右闭区间:

(前一个索引值, 当前索引值]

假设索引中有:

1, 5, 10

那么 next-key lock 可能锁:

(-∞, 1]
(1, 5]
(5, 10]
(10, +∞)

在可重复读隔离级别下,InnoDB 对范围查询的当前读常使用 next-key lock,防止其他事务在范围内插入新数据。

例如:

select * from user where id > 5 and id <= 10 for update;

它不只要锁已有记录,也要锁住相关间隙,避免其他事务插入符合条件的新记录。

八、为什么索引会影响锁范围

InnoDB 的行锁锁的是索引项,不是抽象意义上的“这一行对象”。

如果 SQL 能精确命中唯一索引,锁范围通常较小:

update user set name = 'Tom' where id = 10;

如果条件是普通索引或范围查询,锁范围会变大:

update user set status = 1 where age between 20 and 30;

如果没有索引,可能扫描大量记录:

update user set status = 1 where phone = '13800000000';

如果 phone 没有索引,MySQL 需要扫描更多记录,导致更多行被锁住,甚至表现得像锁了大半张表。

所以线上 update、delete 的 where 条件必须关注索引。

一个经验规则:

高并发业务中的 update/delete 条件必须命中合适索引,最好命中主键或唯一索引。

九、MDL:为什么改表结构会被普通 SQL 阻塞

除了 InnoDB 行锁,MySQL Server 层还有 Metadata Lock,简称 MDL。

MDL 用来保护表结构元数据。

例如事务 A 正在查询表:

begin;
select * from user where id = 1;
-- 事务不提交

事务 B 想修改表结构:

alter table user add column age int;

这时 DDL 可能被阻塞。

原因是事务 A 持有表的 MDL 读锁,事务 B 需要 MDL 写锁。读写不兼容,所以 DDL 等待。

更危险的是,DDL 等待期间,后续新来的查询也可能排队,导致业务接口大量阻塞。

所以线上 DDL 要非常谨慎:

  • 避免长事务。
  • DDL 放低峰期。
  • 使用在线 DDL 能力或专业变更工具。
  • 先观察是否存在未提交长事务。

十、MVCC 是什么

MVCC,全称 Multi-Version Concurrency Control,多版本并发控制。

它的目标是:

读不阻塞写,写不阻塞读。

如果没有 MVCC,读写冲突只能依赖锁。读的时候锁住,写的时候等待;写的时候锁住,读的时候等待。并发性能会很差。

MVCC 的思路是:

一行数据可以有多个版本。
不同事务根据自己的可见性规则,读取适合自己的版本。

十一、InnoDB 行记录中的隐藏字段

InnoDB 为每行记录维护一些隐藏信息,其中理解 MVCC 最关键的是:

  • DB_TRX_ID:最近一次修改这行记录的事务 ID
  • DB_ROLL_PTR:回滚指针,指向 undo log 中的旧版本

可以想象一行记录是这样:

id = 1, name = 'Tom'
DB_TRX_ID = 100
DB_ROLL_PTR -> undo log old version

当事务修改一行时,InnoDB 不会简单覆盖旧值,而是:

  1. 把旧值写入 undo log。
  2. 在当前记录上写入新值。
  3. 更新当前记录的事务 ID。
  4. 让回滚指针指向旧版本。

于是就形成版本链:

最新记录
  trx_id = 120, name = 'Jerry'
  roll_ptr
     ↓
undo 版本
  trx_id = 100, name = 'Tom'
  roll_ptr
     ↓
undo 版本
  trx_id = 80, name = 'Bob'

MVCC 查询时,会沿着这条版本链找一个对当前事务可见的版本。

十二、Read View:判断版本是否可见

MVCC 不只是有版本链,还需要判断哪个版本对当前事务可见。

这个判断依赖 Read View。

Read View 可以理解为事务执行快照读时生成的一张“活跃事务表”。

里面通常包含几个关键信息:

  • 当前活跃事务 ID 列表
  • 最小活跃事务 ID
  • 下一个将要分配的事务 ID
  • 创建这个 Read View 的事务 ID

判断规则可以简化理解:

  1. 如果记录版本的事务 ID 是当前事务自己,能读。
  2. 如果记录版本的事务 ID 小于最小活跃事务 ID,说明它在快照创建前已经提交,能读。
  3. 如果记录版本的事务 ID 大于等于下一个事务 ID,说明它在快照创建后才出现,不能读。
  4. 如果记录版本的事务 ID 在活跃事务列表里,说明快照创建时它还没提交,不能读。
  5. 其他情况说明已经提交,能读。

如果当前最新记录不可见,就沿着 undo log 版本链继续找旧版本。

十三、RC 和 RR 下 MVCC 的区别

Read Committed 和 Repeatable Read 都使用 MVCC,但生成 Read View 的时机不同。

13.1 Read Committed

RC 隔离级别下:

每次 select 都生成新的 Read View

所以同一个事务里,前后两次查询可能看到不同结果。

这就是为什么 RC 能避免脏读,但不能避免不可重复读。

13.2 Repeatable Read

RR 隔离级别下:

事务中第一次快照读生成 Read View,后续快照读复用它

所以同一个事务里多次普通 select 能看到一致结果。

这就是可重复读的基础。

示例:

事务 A 第一次 select,生成 Read View
事务 B 修改并提交
事务 A 第二次 select,仍然使用第一次的 Read View
所以事务 A 看不到事务 B 后提交的数据

注意:这是普通快照读的行为。如果事务 A 执行 select ... for updateupdate,那是当前读,行为不同。

十四、undo log:回滚和 MVCC 的基础

undo log 主要做两件事:

  1. 支持事务回滚。
  2. 支持 MVCC 读取旧版本。

14.1 回滚如何实现

假设事务执行:

update user set name = 'Jerry' where id = 1;

修改前:

name = Tom

InnoDB 会在 undo log 中记录一条反向操作,大致表示:

把 id = 1 的 name 恢复为 Tom

如果事务回滚,就根据 undo log 把数据改回去。

14.2 MVCC 如何使用 undo log

当其他事务需要读取旧版本时,也会通过回滚指针到 undo log 中找历史版本。

所以 undo log 不只是为了 rollback,也是 MVCC 的版本存储基础。

14.3 undo log 什么时候清理

旧版本不能随便删除。

如果还有事务的 Read View 需要读取旧版本,undo log 就必须保留。

等到没有事务再需要这些旧版本时,purge 线程才会清理。

这就是长事务危险的原因之一:

长事务一直不提交
Read View 一直存在
旧版本不能清理
undo log 持续膨胀

所以线上要尽量避免长事务。

十五、redo log:保证崩溃后能恢复

redo log 是 InnoDB 的重做日志。

它解决的是持久性问题:

事务提交后,即使 MySQL 崩溃,数据也不能丢。

15.1 为什么需要 redo log

MySQL 修改数据时,不会每次都立刻把数据页刷到磁盘。

因为随机刷数据页成本很高。

InnoDB 会先修改 Buffer Pool 中的数据页,这时数据页变成脏页。之后再由后台线程刷盘。

问题是:如果事务已经提交,但脏页还没刷盘,MySQL 崩溃了怎么办?

redo log 就是答案。

提交事务时,只要 redo log 按策略刷盘,即使数据页还没刷盘,崩溃恢复时也能根据 redo log 重做这些修改。

15.2 WAL:先写日志,再写数据页

redo log 使用 WAL 思想:

Write-Ahead Logging
先写日志,再写数据页

流程可以简化为:

修改 Buffer Pool 中的数据页
   ↓
写 redo log buffer
   ↓
提交时 redo log 刷盘
   ↓
事务提交成功
   ↓
后台慢慢刷脏页

这样做的好处是,提交事务时主要写顺序日志,而不是随机刷大量数据页。

15.3 redo log 记录什么

redo log 是物理日志,记录的是数据页层面的变更。

可以粗略理解为:

某个表空间、某个数据页、某个偏移位置做了什么修改

它不是记录一条 SQL,也不是记录完整行数据,而是偏底层的数据页修改信息。

15.4 checkpoint

redo log 文件不是无限大的。

当脏页被刷回磁盘后,对应的 redo log 就不再是崩溃恢复必须依赖的内容。

checkpoint 用来标记:

哪些 redo log 之前的修改已经落盘,可以覆盖复用。

如果写入速度太快,脏页刷盘太慢,redo log 空间快用完,MySQL 就必须推进 checkpoint,可能导致性能抖动。

十六、binlog:复制和恢复的 Server 层日志

binlog 是 MySQL Server 层的二进制日志。

它和 redo log 不同:

对比项 redo log binlog
所属层 InnoDB 引擎层 MySQL Server 层
日志类型 物理日志 逻辑日志
主要用途 崩溃恢复 主从复制、数据恢复、审计
写入方式 循环写 追加写
记录内容 数据页修改 SQL 或行变更事件

16.1 binlog 的三种格式

常见格式:

格式 含义
STATEMENT 记录 SQL 语句
ROW 记录每行数据变更
MIXED MySQL 自动选择 statement 或 row

生产环境更常用 ROW 格式。

因为 ROW 能更准确表达数据变化,避免某些非确定性 SQL 在主从复制时产生不一致。

例如:

update user set updated_at = now() where id = 1;

如果只复制 SQL,不同机器执行时间可能不完全一致。ROW 记录的是行实际变化,更可靠。

16.2 binlog 和主从复制

主从复制大致流程:

主库提交事务,写 binlog
   ↓
从库 IO 线程拉取 binlog
   ↓
写入 relay log
   ↓
从库 SQL 线程或 worker 重放 relay log
   ↓
从库数据追上主库

所以 binlog 是复制的基础。

16.3 binlog 和数据恢复

如果每天凌晨做一次全量备份,上午 10 点误删数据,可以:

恢复凌晨全量备份
   ↓
重放凌晨到误删前的 binlog
   ↓
恢复到指定时间点

这就是 PITR,Point-In-Time Recovery,基于时间点恢复。

十七、redo log 和 binlog 如何保证一致:两阶段提交

既然 redo log 和 binlog 都和事务提交有关,就有一个关键问题:

如果 redo log 写成功,binlog 写失败,会怎样?
如果 binlog 写成功,redo log 写失败,又会怎样?

这会导致主库数据和 binlog 不一致,进而影响崩溃恢复和主从复制。

MySQL 使用两阶段提交协调 redo log 和 binlog。

简化流程:

1. InnoDB 写 redo log,状态为 prepare
2. Server 层写 binlog
3. InnoDB 写 redo log,状态为 commit

也就是:

redo prepare -> binlog -> redo commit

崩溃恢复时,根据 redo log 和 binlog 的状态判断事务是否应该提交。

17.1 为什么不能只写 redo log

redo log 能保证 InnoDB 崩溃恢复,但它不是复制日志。

从库靠 binlog 同步。

如果主库只依赖 redo log,主从复制就没有可靠的数据变更来源。

17.2 为什么不能只写 binlog

binlog 是 Server 层逻辑日志,不能替代 InnoDB 的崩溃恢复机制。

InnoDB 崩溃恢复需要 redo log 这种物理日志快速恢复数据页状态。

所以两者职责不同,必须协同。

十八、一条 update 语句背后发生了什么

以这条 SQL 为例:

update user set name = 'Jerry' where id = 1;

简化流程如下:

1. Server 层解析 SQL,生成执行计划
2. InnoDB 根据索引定位 id = 1 的记录
3. 对目标记录加 X 锁
4. 将旧值写入 undo log
5. 修改 Buffer Pool 中的数据页
6. 写 redo log buffer
7. Server 层写 binlog
8. redo log 两阶段提交完成
9. 事务提交,释放锁
10. 后台线程择机刷脏页到磁盘

如果事务回滚:

根据 undo log 恢复旧值
释放锁

如果 MySQL 崩溃:

重启后根据 redo log 恢复已提交事务
根据 undo log 回滚未提交事务
结合 binlog 状态处理两阶段提交中的事务

这就是事务、锁、undo log、redo log、binlog 的协作关系。

十九、死锁是怎么发生的

死锁通常来自多个事务以不同顺序持有锁。

例如:

事务 A:

begin;
update account set balance = balance - 100 where id = 1;
update account set balance = balance + 100 where id = 2;
commit;

事务 B:

begin;
update account set balance = balance - 50 where id = 2;
update account set balance = balance + 50 where id = 1;
commit;

执行顺序:

A 锁住 id = 1
B 锁住 id = 2
A 等 id = 2
B 等 id = 1

形成循环等待,死锁发生。

InnoDB 会检测死锁,并主动回滚其中一个事务。

19.1 如何减少死锁

常见建议:

  • 多个事务按相同顺序访问资源。
  • update/delete 条件命中索引。
  • 事务尽量短。
  • 不要在事务里做外部 RPC。
  • 批量更新拆小批次。
  • 必要时使用乐观锁或队列串行化。

例如转账场景可以固定先锁较小账户 ID,再锁较大账户 ID:

不管谁给谁转账,都按 account_id 从小到大加锁

这样能显著降低死锁概率。

二十、线上排查锁等待

遇到 SQL 卡住,先判断是不是锁等待。

常用命令:

show processlist;

查看 InnoDB 状态:

show engine innodb status\G

MySQL 8 中也可以查询 performance_schema 和 sys 库。

例如查看锁等待:

select * from sys.innodb_lock_waits\G

排查重点:

  • 谁在等待?
  • 谁持有锁?
  • 等待的 SQL 是什么?
  • 持锁事务执行了多久?
  • 是否有长事务未提交?
  • SQL 是否走了索引?

对于长事务,可以关注:

select
    trx_id,
    trx_state,
    trx_started,
    trx_mysql_thread_id,
    trx_query
from information_schema.innodb_trx;

二十一、常见误区

误区 1:select 一定不会加锁

普通快照读通常不加行锁。

但下面这些会加锁:

select ... for update;
select ... lock in share mode;

此外 DDL、表结构访问还涉及 MDL。

误区 2:行锁就是锁住一行数据

InnoDB 行锁锁的是索引记录。

如果 SQL 没有合适索引,锁范围可能比你想象的大。

误区 3:MVCC 可以解决所有并发问题

MVCC 主要解决快照读的一致性问题。

更新、删除、锁定读仍然需要锁。

误区 4:undo log 只是为了 rollback

undo log 既支持回滚,也支持 MVCC 旧版本读取。

误区 5:redo log 和 binlog 是一回事

redo log 是 InnoDB 的物理日志,用于崩溃恢复。

binlog 是 Server 层逻辑日志,用于复制和基于时间点恢复。

二十二、面试回答模板

如果面试问 MySQL 事务和锁,可以这样组织回答:

MySQL InnoDB 通过 undo log、redo log、锁和 MVCC 支撑事务。
undo log 保证原子性,也为 MVCC 提供历史版本。
redo log 保证持久性,采用 WAL 思想,事务提交时先保证日志落盘,数据页可以后续刷盘。
MVCC 通过隐藏事务 ID、回滚指针、undo 版本链和 Read View 实现快照读,解决读写并发问题。
锁机制负责当前读和写操作的并发控制,包括记录锁、间隙锁、next-key lock、意向锁等。
binlog 是 Server 层逻辑日志,主要用于主从复制和数据恢复。
redo log 和 binlog 通过两阶段提交保证事务提交和复制日志的一致性。

如果继续问 RR 如何避免幻读,可以补充:

普通快照读依赖 MVCC,RR 下第一次快照读创建 Read View,后续复用,所以同一事务内读到一致快照。
对于当前读和范围更新,InnoDB 会使用 next-key lock 锁住记录和间隙,阻止其他事务插入满足条件的新记录,从而避免幻读。

总结

MySQL 事务与锁机制看起来复杂,但主线很清楚:

事务需要 ACID
原子性依赖 undo log
隔离性依赖锁和 MVCC
持久性依赖 redo log
复制和恢复依赖 binlog
redo log 和 binlog 通过两阶段提交保持一致

再把读写行为分清楚:

普通 select 是快照读,主要靠 MVCC
update/delete/select for update 是当前读,需要加锁

再把锁范围看清楚:

InnoDB 行锁锁的是索引
唯一索引等值查询锁范围小
范围查询可能有 next-key lock
无索引可能导致锁范围扩大

最后从工程实践看:

  • 事务要短。
  • SQL 要命中索引。
  • update/delete 条件要精确。
  • 避免长事务阻塞 undo 清理和 DDL。
  • 线上 DDL 要关注 MDL。
  • 死锁要从访问顺序和索引设计上减少。
  • 重要系统必须理解 redo log、binlog 和两阶段提交。

掌握这些机制后,再遇到“锁等待、死锁、幻读、主从不一致、崩溃恢复、长事务导致 undo 膨胀”这类问题,就不再只是背概念,而能沿着 MySQL 的真实执行链路去分析。

posted @ 2026-07-07 09:42  松鼠航  阅读(17)  评论(0)    收藏  举报