有一次线上告警,两个订单服务同时把同一批数据更新到一半,整条业务链路卡死。我拉出 show engine innodb status,看到两条 UPDATE 语句互相锁住,第一反应是:这两个事务明明操作的是不同订单号,为什么还会互相等待?后来把隔离级别、索引命中情况、表结构和事务里其他的 DML 语句全部拼起来,才真正意识到 MySQL 里事务、MVCC 与锁机制从来不是三个孤立的知识点,而是一整套组合拳。这篇文章想用拆解一条数据更新链路的方式,把这套体系完整过一遍:事务如何靠日志保证不丢不偏,MVCC 怎么让快照读不阻塞写,锁又是如何在当前读和写入时兜底。适合刚接触 InnoDB 原理的人建立整体框架,也适合准备面试、或者正在为线上锁等待和死锁头疼的同学对照排查。
1. 一次 UPDATE 背后的线程:谁在排队,谁在读旧版本
1.1 建一张表,模拟最简单的并发冲突
先给出一张非常容易复现的表结构:
sql复制CREATE TABLE account (
id INT PRIMARY KEY,
balance INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO account VALUES (1, 100);
现在模拟三个事务同时发生:
- 事务 A:
UPDATE account SET balance = balance - 40 WHERE id = 1;,执行后暂不提交 - 事务 B:
UPDATE account SET balance = balance - 30 WHERE id = 1; - 事务 C:
SELECT balance FROM account WHERE id = 1;
如果没有 InnoDB 的并发控制,最终结果完全不可预测。A 算出 60,B 也基于 100 算出 70,最后谁后写谁覆盖,甚至读到中间状态都很常见。实际 InnoDB 内部会把最终结果收敛成一个可控序列:同一时间只能有一个事务持有行锁去修改,而普通的 SELECT 不会被修改阻塞。
关键点在于事务 B 的 UPDATE 会进入锁等待,它必须等 A 提交或回滚后才能继续;但事务 C 的普通 SELECT 不会等待 A,它走的是 MVCC 快照读,直接看历史版本。这点和很多人直觉相反:读和写之间不是靠锁互斥,而是靠版本隔离。
1.2 每行记录里三条不可见字段
InnoDB 的行数据并不是只存用户定义的那些列。在聚簇索引记录里,还藏着几条系统列,平时看不到,却在事务判断中起着决定性作用:
| 隐藏列 | 大小 | 作用 |
|---|---|---|
| DB_TRX_ID | 6 字节 | 最近一次插入或更新该行的事务 ID |
| DB_ROLL_PTR | 7 字节 | 回滚指针,指向 undo log 中该行前一个版本 |
| DB_ROW_ID | 6 字节 | 行 ID,仅当表没有定义主键时才用于聚簇索引组织 |
当一条记录被两次更新后,行内结构可以看成一条向后延伸的链表:
code复制最新记录 balance=60, trx_id=20
^
| DB_ROLL_PTR
旧版本 balance=100, trx_id=18
^
| DB_ROLL_PTR
更旧版本 ...(如果没有则到此为止)
DB_ROLL_PTR 连接起来的正是 undo log 记录。每条 undo 日志保存了一个事务更新前的旧映像,同时也保存了回滚所需的信息。MVCC 的版本链就是靠这套结构搭出来的。
1.3 锁和 MVCC 的边界:快照读与当前读
掌握 InnoDB 并发控制,首先要把 SQL 的读取方式分成两类。
普通 SELECT 走快照读,不加锁,通过 ReadView 决定读取版本链上哪一条记录。UPDATE、DELETE、INSERT,以及 SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE 都算当前读,它们必须读取记录的最新已提交版本,并且对读取到的记录加锁。
为什么 UPDATE 必须锁定而不走 MVCC?因为写操作的最终效果要落回数据页,任何两个写之间必须有先后顺序,否则会出现更新丢失。InnoDB 的做法很简单:写后独占,用锁串行化。而普通读不改变数据,没有破坏一致性的风险,所以放行到历史版本里。
这里有一个排障时特别容易踩坑的思维定式:以为行锁只锁住了“正在修改的那几行”。实际上,更新操作先要找到记录,找记录的过程也会走当前读并加锁。如果 WHERE 条件无法命中有效索引,可能扫描一行就锁一行,甚至整表被锁住,后面再展开说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 事务不丢数据,靠的不只是锁
2.1 redo log:先写日志,再改数据页
InnoDB 的数据页默认 16KB,落在缓冲池(Buffer Pool)里。事务提交时,不可能把整个数据页都立刻刷回磁盘,这样延迟太大;但如果只改内存、不落盘,掉电就丢数据。于是 InnoDB 采用 WAL(Write-Ahead Logging)思路:事务提交前先把本次修改产生的 redo log 写入磁盘。
redo log 记录的是物理变更,比如“把表空间 1 的第 4 号页偏移 1024 处的值改为 88”。这种日志是追加写的,顺序 IO 比随机刷页快得多。当系统崩溃重启后,InnoDB 会扫描 redo log,把尚未刷入数据页的修改重放一遍,这就是崩溃恢复的核心。
和事务安全直接相关的参数是 innodb_flush_log_at_trx_commit:
- 值为 1:每次事务提交都必须把 redo log 刷入磁盘,最安全
- 值为 0:每秒刷一次,可能丢最近 1 秒内已提交事务
- 值为 2:写入 OS 缓存,每秒刷盘,MySQL 崩溃不丢,但 OS 崩溃可能丢
生产环境为了保证已提交事务不丢,通常设置成 1,这也是“双 1”配置中的第一个 1。
2.2 undo log 为什么不能一提交就清掉
redo log 保证持久性,undo log 则服务于两个场景:事务回滚,以及 MVCC 快照读。
当事务把一行从 100 改成 60 时,undo log 里实际写的是旧值 100,以及 DB_TRX_ID、DB_ROLL_PTR 等信息。回滚时 InnoDB 沿 undo log 反向执行补偿操作,把旧值恢复回去。注意 undo log 存的不是“UPDATE 语句的反向 SQL”,而是物理/逻辑层面的旧版本描述,这样回滚更快速、不受其他并发事务干扰。
普通人的一个误解是:事务一提交,undo log 就能删除。实际上,只要还有更早生成的 ReadView 可能引用到旧版本,undo log 就必须保留。比如一个长事务在早上开启,期间其他事务不断修改同一行,长事务每次快照读都可能需要顺着版本链向前回溯,所以旧版本不能提前回收。直到所有持有旧 ReadView 的事务结束后,后台 purge 线程才会把不再需要的 undo 日志和版本链清理掉。
因此大事务之所以可怕,不只是因为它持锁时间长,还因为它的存在会让 undo log 膨胀,甚至把整个历史版本链拖得很长,查询回溯时也更慢。
2.3 binlog 与 redo log 的两阶段提交,在解决什么
redo log 是存储引擎 InnoDB 的日志,binlog 是 MySQL Server 层的日志,负责主从复制和基于时间点的恢复。同一个事务在两条日志里都要留下痕迹,但写入时机不一致时,崩溃恢复就会出问题。
举例来说:如果先写 binlog、再写 redo log,极端情况下可能从库已经应用了这条事务,主库却因为 redo log 缺失回滚了,主从不一致。如果先写 redo log、再写 binlog,可能主库把事务提交成功,从库却没收到变更。
InnoDB 的做法是两阶段提交:
- 事务执行中不断写 redo log,等到提交时先把 redo log 状态置为 prepare
- 写入 binlog 并保证 binlog 落盘
- 事务提交,把 redo log 状态置为 commit
崩溃恢复阶段有一个关键规则:只要 binlog 里存在该事务,则 prepare 状态的事务就提交;如果 binlog 中找不到,prepare 状态的事务就回滚。这是因为 binlog 写成功了,意味着从库或者后续恢复流程可能已经依赖这条变更,主库不能丢掉它。
两阶段提交涉及两个 fsync,因此生产“双 1”配置会显著增加提交延迟。很多高并发系统会把 binlog 的刷盘策略调成 sync_binlog = 0 或 innodb_flush_log_at_trx_commit = 2 来换吞吐,但这种取舍必须建立在能接受少量丢失的基础之上,普通金融类业务不建议改。
3. ReadView 的可见性判断:快照读的“滤镜”
3.1 ReadView 里到底存了哪些东西
MVCC 的全称是 Multi-Version Concurrency Control,多版本并发控制。核心思路是:同一行数据存在多个历史版本,事务读取时从版本链里挑一个“对这个事务可见”的版本。判断可见性不能只靠 DB_TRX_ID,还得知道自己开启快照那一刻有哪些事务正在执行。记录这组信息的就是 ReadView。
ReadView 有四个关键字段:
| 字段 | 含义 |
|---|---|
| m_ids | 生成 ReadView 时当前系统中活跃的读写事务 ID 集合 |
| min_trx_id | m_ids 中的最小值 |
| max_trx_id | 下一个待分配的事务 ID,即当前最大事务 ID + 1 |
| creator_trx_id | 创建这个 ReadView 的事务 ID |
max_trx_id 容易理解错。它不是当前活跃事务的最大 ID,而是一个“未来边界”的概念,用来判断“在快照生成之后才启动的事务”。只有当事务 ID 大于等于这个值时,该版本才一定不可见。
3.2 一个版本对当前事务可见的完整判断流程
拿到版本链上某一版本后,InnoDB 通过以下顺序判断该版本是否可见:
- 如果版本的 trx_id 等于 creator_trx_id,说明这是当前事务自己修改过的版本,必然可见。
- 如果版本的 trx_id 小于 min_trx_id,说明生成 ReadView 前该事务已经提交,版本可见。
- 如果版本的 trx_id 大于等于 max_trx_id,说明这个事务在 ReadView 生成后才开始,版本不可见,需要沿 DB_ROLL_PTR 继续向前找。
- 如果版本的 trx_id 落在 min_trx_id 和 max_trx_id 之间,需要进一步检查该事务 ID 是否在 m_ids 活跃事务列表里。在列表里说明尚未提交,版本不可见;不在列表里说明已提交,版本可见。
如果从最新版本一路回退到版本链尽头仍找不到可见版本,那么查询结果就是记录不存在。这个判断逻辑会应用到每一条需要返回的记录上,所以普通 SELECT 才会被称为快照读:它并不是不加控制,而是由一层“版本滤镜”保证读到的内容始终符合事务开始瞬间的视图。
3.3 RC 和 RR 的差别,只在 ReadView 生成时机
默认 REPEATABLE READ(可重复读)隔离级别下,事务第一次执行快照读时创建 ReadView,之后的普通 SELECT 全部复用同一个 ReadView,所以整个事务期间看到的版本边界一致。
READ COMMITTED(读已提交)隔离级别则不同,每次执行快照读都会重新生成 ReadView。其他事务只要提交,原先不可见的版本就变成了可见版本,于是同一个事务前后两次 SELECT 可能读到不同结果,这就是不可重复读。
用一个例子验证:
sql复制-- 事务 T1
BEGIN;
SELECT balance FROM account WHERE id = 1;
-- 此时读到 100
-- 事务 T2
UPDATE account SET balance = 60 WHERE id = 1;
COMMIT;
-- 事务 T1 再查
SELECT balance FROM account WHERE id = 1;
在 RR 下,第二次 SELECT 仍然读到 100,因为 T1 复用了第一次生成的 ReadView,T2 不在活跃列表里但它的 trx_id 大于等于 max_trx_id 边界,版本不可见。在 RC 下,第二次 SELECT 重新生成 ReadView,T2 已提交,新事务 ID 变成已提交事务,版本可见,所以读到 60。
这也是面试里最常设问的点:RR 不是通过锁来让读变慢,而是通过复用 ReadView 让旧版本“不可见”。
4. InnoDB 加锁的完整路径:从意向锁到临键锁
4.1 第一层拦截:全局锁、MDL 与意向锁
读多写少时 MVCC 能覆盖大部分并发,但写与写之间必须依赖锁。锁不是直接加到“某一行数据”上的,InnoDB 的锁管理有一套完整层次。
全局锁最常见的操作是 FLUSH TABLES WITH READ LOCK,用于对整库加只读锁,备份工具早期经常使用。它会让整个实例的所有写入阻塞,所以一般只允许很短的窗口。
Server 层的 MDL(Metadata Lock)则保护表结构。执行 DDL 时需要 MDL 写锁,执行 DML 时需要 MDL 读锁。生产环境常见的问题是:一个长事务持有了 MDL 读锁,后续执行 ALTER TABLE 的线程等待 MDL 写锁,于是这个写锁又阻塞了后面所有新查询,最终整个表相关的请求全部堆积。排查时看 SHOW PROCESSLIST 看到大量 Waiting for table metadata lock,多半就是这种场景。
InnoDB 自身还有意向锁。事务想对表里的行加共享锁或排他锁时,会先在表上加意向共享锁(IS)或意向排他锁(IX)。意向锁之间并不互相阻塞,它们的主要目的是让后续表级锁操作能快速判断表内是否已有行锁,避免逐个检查行锁。
4.2 行级锁的三种基本形态
InnoDB 的行级锁底层锁的是索引记录,不是抽象的“行”。这是很多问题的根源:如果一张表没有可用的索引,InnoDB 在内部会通过隐藏的 DB_ROW_ID 构造聚簇索引,但用户查询条件只能靠全表扫描定位,加锁范围也就随之扩大。
三种基本行级锁形态分别是:
- Record Lock,记录锁:锁住索引上的某一条具体记录
- Gap Lock,间隙锁:锁住索引记录之间的范围,不允许其他事务在间隙中插入新记录
- Next-Key Lock,临键锁:记录锁与间隙锁的组合
从锁定的空间范围看,Next-Key Lock 是一个左开右闭的区间。它既锁住当前记录,也锁住记录前面的区间,这样一来,其他事务既不能修改这条已有记录,也不能在这个区间插入新记录,因此可以解决当前读下的幻读问题。
间隙锁只在部分隔离级别下存在。RR 下 InnoDB 会使用临键锁来防止幻读;RC 隔离级别下间隙锁基本会被禁用,只保留记录锁。这也是很多团队为了减少锁冲突,主动把默认隔离级别从 RR 调整为 RC 的原因之一。
4.3 等值条件与范围条件加的锁完全不同
以主键查询为例,同样一条 SQL,命中与不命中,加锁情况完全不同。
如果 WHERE id = 20 且 20 这条记录存在,由于主键唯一,其他事务无法再插入一条 id=20 的记录,所以只需要对 id=20 这条聚簇索引记录加记录锁,不需要加间隙锁。如果 WHERE id = 20 但记录不存在,为了阻止其他事务插入 id=20,InnoDB 会对这个空缺区间加间隙锁。
范围查询则更严格。比如:
sql复制SELECT * FROM account WHERE id > 10 AND id < 20 FOR UPDATE;
在 RR 下,这条语句不仅会对命中的记录加记录锁,还会对扫描范围内所有间隙加间隙锁,因为无法预知未来会有哪个新记录落在 10 到 20 之间从而形成幻读。所以范围查询很容易造成大范围的锁冲突,尤其是索引区分度低时。
辅助索引和聚簇索引都有各自的锁结构。更新一条记录时,InnoDB 会对所有涉及的索引记录都加锁,既包括聚簇索引记录,也包括对应的二级索引记录。如果二级索引锁范围判断不清楚,最终看到的现象可能是一小批普通查询全部被阻塞。
4.4 UPDATE 的真正加锁顺序:先当前读,再写
回到第 1 节的例子。执行 UPDATE account SET balance = balance - 40 WHERE id = 1 时,InnoDB 会先在索引上定位到 id=1 的聚簇索引记录,这一步是当前读,需要加排他记录锁。如果这行还有二级索引字段,也必须同时锁住相关二级索引记录。加锁成功后,才把新值写入行并生成 undo log。
对插入语句而言,加锁前需要先通过唯一索引检查是否存在冲突记录,如果存在,就尝试对冲突记录加排他锁;如果不存在,则在索引的间隙位置加上插入意向锁。插入意向锁是一种特殊的间隙锁,多个事务对同一间隙不同位置插入时,彼此不阻塞,但如果某个间隙已经被其他事务加了间隙锁,插入意向锁就会等待,这也是插入操作被卡住的常见原因。
5. 死锁现场复盘:先用工具拿证据,再改代码
5.1 一个互相等待的典型模型
死锁的定义可以概括成一组事务都在持有资源等待对方释放资源,形成循环等待。InnoDB 能自动检测并回滚其中一个事务,但回滚哪个是有代价的,不能只靠运气。
一个经典复现场景:
sql复制-- 事务 A
BEGIN;
UPDATE account SET balance = balance - 50 WHERE id = 1;
UPDATE orders SET amount = amount + 50 WHERE id = 2;
COMMIT;
-- 事务 B
BEGIN;
UPDATE orders SET amount = amount + 30 WHERE id = 2;
UPDATE account SET balance = balance - 30 WHERE id = 1;
COMMIT;
如果 A 先锁住 account 表 id=1 的记录,B 先锁住 orders 表 id=2 的记录,接着 A 又想更新 orders 表 id=2,B 又想更新 account 表 id=1,各自握着对方需要的锁,死锁就产生了。
从业务角度这被称为“加锁顺序不一致”。处理这种问题,最简单有效的办法是所有事务都按同一顺序访问资源,比如都先更新 account 再更新 orders。顺序统一后,等待关系就变成单向队列,不会循环。
5.2 用 show engine innodb status 读死锁现场
死锁发生后,InnoDB 默认会回滚 undo log 量较少的事务,并把死锁现场记录到错误日志中。SHOW ENGINE INNODB STATUS 里专门有一节叫 LATEST DETECTED DEADLOCK,从中能看到两个事务各自的加锁细节:
- 事务 1 当前持有的锁
- 事务 1 正在等待的锁
- 事务 2 当前持有的锁
- 事务 2 正在等待的锁
典型的输出片段里会包含类似这样的描述:
code复制TRANSACTION 4215, ACTIVE 0 sec
LOCK WAIT
...
RECORD LOCKS space id 18 page no 6 n bits 80 index PRIMARY of table `test`.`account`
看到 index PRIMARY of table,说明阻塞点在主键索引记录上。通过记录的具体字段值,可以反推到具体业务主键,再结合时间点和日志定位到调用方代码。这一步非常重要:死锁输出里保存的 SQL 只是当前正在执行的语句,更早的语句可能已经执行完成,需要从业务日志确认事务里完整的 SQL 序列,否则很难看清为什么同一行会同时被两个事务更新。
5.3 不要等死锁发生才排查,主动查询锁等待
生产环境更常见的现象不是死锁,而是锁等待堆积。锁等待通常意味着有一个事务持锁太长时间,后面的请求全部排长队。排查锁等待,可以通过三张系统表拿到直观结果。
在 MySQL 8.0 中可以直接查 sys.innodb_lock_waits 视图:
sql复制SELECT * FROM sys.innodb_lock_waits;
它能展示等待事务、阻塞事务、等待的 SQL、运行的线程等信息。如果想知道每个事务正在等待哪种锁,可以进一步查:
sql复制SELECT * FROM performance_schema.data_locks WHERE LOCK_STATUS = 'WAITING';
data_locks 表会给出锁类型、锁模式(X/S)、索引名和锁所在的记录范围,这是理解问题的第一手资料。拿到阻塞事务的 trx_mysql_thread_id 后,可以查看它完整的事务状态、是否长时间未提交,再决定是否在应用层干预。
我在实际排查中还有一个笨但有效的习惯:发现锁等待告警时,第一时间先把 performance_schema.data_locks 的完整输出和 SHOW ENGINE INNODB STATUS 保存下来,再去看代码。因为很多锁问题无法稳定复现,一旦重启或事务结束,现场就没了。先把证据留全,比急着猜测原因重要得多。
5.4 从锁等待到业务设计的几条反思路线
产生死锁或持久锁等待的业务,通常能归因到几种模式。
最常见的是大事务。一个事务里包含多次网络调用、外部接口等待,或者一次性更新上万行,都会把持锁时间拉得很长,其他事务随之堆积。给事务瘦身不是空话,而是要把真正需要保证原子性的 SQL 集合缩到最小,争取毫秒级提交。
其次是更新同一行频率过高。比如秒杀场景中大量并发扣减同一个商品的库存,这行记录天然成为瓶颈。可以尝试把库存拆成多个子账户,或者在前置层做排队与异步合并,减少对单行 InnoDB 锁的直接竞争。
还有一种情况是索引使用不当,导致明明只应该锁几行,实际却锁了大量记录。检查 SQL 执行计划时重点看 type 是否从 range/ref 退化成了 ALL,以及 key 是否为 NULL。如果出现全表扫描,在 RR 下 InnoDB 可能对大量间隙加锁,比想象中更容易触发死锁。
最后谈一下隔离级别选择。RC 下 InnoDB 不会使用 Next-Key Lock,很多由间隙锁引发的死锁会自然消失。可重复读的需求不一定需要数据库隔离级别来实现,有时在应用层用版本号、唯一约束或分布式锁也能覆盖。如果业务能够接受读已提交,并且幻读场景有兜底方案,切换到 RC 是一种非常有效的降低锁冲突手段。但切换前必须逐一核对业务里对“同一事务多次查询结果稳定”的依赖,不要为了性能牺牲正确性。
