有一次线上告警,两个订单服务同时把同一批数据更新到一半,整条业务链路卡死。我拉出 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 的做法很简单:写后独占,用锁串行化。而普通读不改变数据,没有破坏一致性的风险,所以放行到历史版本里。
这里有一个排障时特别容易踩坑的思维定式:以为行锁只锁住了“正在修改的那几行”。实际上,**更新操作先要找到记录,找记
