☰
InnoDB事务机制全解:从redo/undo log到MVCC与死锁排查
2026/10/10 10:08:58 网站建设 项目流程

1. 先从整体上理解InnoDB事务到底在解决什么问题

做后端开发这几年,我几乎每天都要和 InnoDB 事务打交道。刚入行的时候觉得事务很简单:BEGIN开个事务,中间执行 SQL,最后COMMIT提交,出错就ROLLBACK回滚。但后来线上出现死锁、主从延迟、undo 空间暴涨这些问题时,我才意识到,如果不理解 InnoDB 引擎内部究竟是怎么实现事务的,出了问题就只能靠猜。

InnoDB 事务表面上只是一组 SQL 的集合,实际上它同时牵扯到 redo log、undo log、锁、MVCC、Read View、buffer pool、binlog 协同等一大堆机制。它们各管一段,共同保证事务的四大特性:原子性、一致性、隔离性、持久性。这篇文章不是给你念一遍概念,而是把这些机制串起来讲清楚,再附上我这些年踩过的坑和排查经验。

1.1 从一个转账场景看事务的四个承诺

用一个最经典的转账场景来说明。A 给 B 转账 100 元,在数据库里至少要执行两条 SQL:

UPDATE account SET balance = balance - 100 WHERE id = A; UPDATE account SET balance = balance + 100 WHERE id = B;

如果两条 SQL 没有事务包裹,第一条成功、第二条失败,钱就凭空消失了。事务要保证的就是:

  • 原子性:两条 SQL 要么都成功,要么都失败,不允许只执行一半。
  • 一致性:转账前后,两人的余额总和不变,任何约束都不能被破坏。
  • 隔离性:两个并发事务同时操作同一批数据,不能互相看到对方的中间状态。A 扣款但还没给 B 加钱时,别的会话不应该看到“总金额少了 100”这种中间结果。
  • 持久性:事务一旦提交,即使数据库瞬间宕机,数据也不能丢。

这四个承诺里,原子性和持久性主要靠 undo log 和 redo log 来实现,隔离性靠锁和 MVCC 来实现,一致性则更多是业务逻辑和约束设计的问题。后面所有复杂机制,本质上都是在为这四个承诺服务。

1.2 InnoDB实现事务的“四大件”

为了方便理解,我把 InnoDB 事务相关的核心组件归纳成四类:

组件主要作用类比
undo log记录数据被修改前的值,用于回滚和 MVCC 版本链后悔药
redo log记录物理页面的修改日志,用于崩溃恢复游戏存档
lock 系统控制并发访问,防止互相覆盖红绿灯
buffer pool缓存数据页,延迟刷盘提升性能快递暂存点

redo log 是物理日志,记录的是“某个数据页的某个偏移位置改成了什么值”。undo log 是逻辑日志,记录的是“这条记录修改前长什么样”。生产环境里,redo log 通常比数据文件小得多,写起来也快,所以 InnoDB 采用 WAL 机制,先写日志再写数据页。

我当年犯过一个理解错误:以为事务提交时要把数据页真的刷到磁盘才算成功。实际上 InnoDB 只要把 redo log 刷到磁盘,事务就可以返回成功了,数据页可以继续留在内存的 buffer pool 里,慢慢由后台线程刷盘。这个区别是理解 InnoDB 高性能的关键。

2. 事务隔离级别:并发能力和一致性怎么权衡

2.1 先看懂三种并发读问题

InnoDB 支持多个事务同时读写,为了让开发者能选择“严不严格”,SQL 标准定义了三种读异常:

  • 脏读:事务 A 修改了数据但未提交,事务 B 读到了这个未提交的值。如果事务 A 回滚,B 读到的就是“不存在”的数据。
  • 不可重复读:事务 B 第一次读到余额 100,事务 A 提交后把余额改成 200,B 在同一次事务里再次查询,读到了 200。前后不一致。
  • 幻读:事务 B 按某个条件查询,第一次查到 5 条记录,事务 A 插入一条新记录并提交,B 再查同一个条件,多了一行,像出现幻觉一样。

要注意,不可重复读强调的是“同一条记录内容变了”,幻读强调的是“结果集行数变了”。两者解决手段也不同:不可重复读靠行锁和版本链,幻读靠间隙锁或 MVCC 快照。

2.2 四个隔离级别分别怎么选

SQL 标准把隔离级别分成四种,InnoDB 默认使用可重复读,这一点和标准不完全一致,但在实现上做了很多补强:

隔离级别能解决仍会出现
读未提交无脏读、不可重复读、幻读
读已提交脏读不可重复读、幻读
可重复读脏读、不可重复读幻读(InnoDB 的 RR 下快照读不会幻读,当前读靠 next-key lock 尽量解决)
可串行化脏读、不可重复读、幻读并发性能极低,基本变成串行

实际业务里,读已提交和可重复读是两个主流选项。读已提交适合对一致性要求不高的报表查询,可重复读适合需要事务内多次读取结果一致的核心账务类业务。可串行化我基本只在代码 review 里见过,生产环境没人用它。

2.3 读已提交和可重复读在InnoDB里到底差在哪

很多人背过隔离级别定义,但说不清 InnoDB 是怎么实现的。这里有个关键点:Read View 的生成时机不同。

可重复读下,事务第一次执行普通SELECT时才生成 Read View,之后整个事务都复用这一个版本视图。所以不管事务内查多少次,看到的都是同一个快照。

读已提交下,事务里每一条普通SELECT都会生成一个新的 Read View,所以事务内第二次查询能看到别人新提交的数据,也就产生了不可重复读。

锁方面也有差异。可重复读的当前读,比如UPDATE、DELETE、SELECT ... FOR UPDATE,会加 next-key lock,锁住记录本身以及记录前面的间隙。读已提交下,InnoDB 只加行记录锁,不加间隙锁,锁的范围更小,并发度高,但对应的会有幻读风险。

我最早做订单系统时使用的是读已提交,因为想着并发高一点。后来发现一个统计批次里,前后两条 SQL 查同一个订单状态却不一样,排查半天才意识到是隔离级别导致的。从那以后,凡是事务内有“先查后改”逻辑的,我都尽量不默认用读已提交,而是按业务场景逐个确认。

3. MVCC 与 undo log:快照读的底层原理

3.1 隐藏列和版本链是怎么组织起来的

MVCC 全称是多版本并发控制。简单说,同一行记录在 InnoDB 里可能存在多个版本,每个版本对应一个事务的修改。普通SELECT通过版本链找到“当前事务可见的那个版本”,这就是快照读。

每一行记录默认带有两个隐藏列:DB_TRX_ID记录最近一次修改这条记录的事务 ID,DB_ROLL_PTR指向 undo log 里上一个版本的记录。如果表没有主键,还有隐藏主键DB_ROW_ID。

每当事务修改一条记录,InnoDB 不会直接覆盖旧值,而是先把旧值写入 undo log,让DB_ROLL_PTR指向旧版本,再更新当前行的DB_TRX_ID。这样多个版本就串成了一个链表。查询时顺着版本链向后找,一直找到一个“当前事务可见”的版本为止。

3.2 Read View 怎么判断版本可见性

Read View 是一个事务“能够看到哪些版本”的判定依据。它在生成那一刻,会记录一组事务 ID 状态:

  • m_ids:当前活跃的、未提交事务 ID 列表。
  • min_trx_id:活跃事务中最小的 ID。
  • max_trx_id:下一个将要分配的事务 ID。
  • creator_trx_id:创建这个 Read View 的事务自己的 ID。

判断一条记录版本是否可见时,看它的DB_TRX_ID:

  • 小于min_trx_id,说明这个版本是已经提交的事务产生的,可见。
  • 大于等于max_trx_id,说明这个版本是将来才产生的事务改的,不可见。
  • 在min_trx_id和max_trx_id之间,需要判断DB_TRX_ID是否在m_ids列表里。如果在,说明事务还没提交,不可见;如果不在,说明事务已提交,可见。

再结合creator_trx_id,如果是自己改的版本,当然可见。这套机制保证了可重复读下,事务开始那一刻的数据视图被固定下来,不管别人怎么提交,读取结果都不会变。

3.3 undo log 版本链和 purge 线程

版本链不能无限膨胀,否则 undo 文件会炸掉。InnoDB 有一个后台 purge 线程,负责清理“已经没有事务会用到”的旧版本。

判断标准是:当前系统中最小的活跃 Read View 之前的版本,都不再被任何活跃事务需要,可以回收。换成人话说,只要还有一个老事务没结束,它可能会读的那些旧版本就得留着。

这也是长事务最要命的地方。一个只读事务开着两小时不提交,它持有的 Read View 会让所有旧版本都无法回收。我遇到过一晚上 undo 表空间涨了几十 GB 的情况,最终定位到一个定时任务里一个查询跑了很久,外层事务却一直开着,导致大量版本堆积。

3.4 长事务为什么会让 select 变慢

长事务除了撑大 undo 之外,还会让你的普通查询变慢。因为查询需要顺着版本链回溯,版本链越长,比较和回滚定位的时间就越长。虽然 InnoDB 做了一些优化,不会无脑遍历全部版本,但版本链过长时,CPU 消耗依然会上升。

如果业务上报错日志频繁出现History list length过大,基本就是旧版本没有被及时清理。我看到数据库监控里的history list length长时间超过几万,就该去查是不是有长事务了。这个时候首先要做的不是调参数,而是去找那个“挂”了很久的事务会话,让它提交或者杀掉。

4. 事务的持久性:redo log、binlog 与两阶段提交

4.1 WAL 机制为什么是 InnoDB 的命根子

WAL 全称是 Write-Ahead Logging,核心思想是:数据页先写 redo log,然后再真正写数据页,提交时必须保证 redo log 落盘。

这样做的原因很现实:数据页是随机写,redo log 是顺序追加写。顺序写磁盘的速度比随机写快几个数量级,所以先把随机写变成顺序写,事务提交的耗时就能大幅缩短。万一数据库宕机,内存里的数据页可能还没来得及落盘,但 redo log 在磁盘上,重启时可以通过 redo log 把丢失的修改重放回来。

redo log 本身是循环写的一组文件,写完最后一个会回到第一个继续写。极端情况下,如果 redo log 全部被覆盖且数据页还没刷盘,就会出问题。因此 InnoDB 有一个 checkpoint 机制,确保早于 checkpoint 的数据页已经刷盘,redo log 才能被覆盖。

4.2 redo log 的刷盘策略怎么选

innodb_flush_log_at_trx_commit参数控制事务提交时 redo log 的刷盘方式:

参数值行为风险
1每次事务提交都把 redo log 刷到磁盘最安全,性能开销最大
0提交时不主动刷,每秒刷一次性能最好,操作系统或数据库崩溃时可能丢最多 1 秒数据
2提交时写入操作系统缓存,每秒刷盘数据库崩溃不丢,操作系统崩溃可能丢最多 1 秒数据

我维护的账务系统使用 1,因为不能接受任何事务丢失。非核心的日志写入场景,为了性能可能用 2。需要提醒的是,参数为 1 时也不等于每个事务都全量 fsync,InnoDB 有 group commit 优化,多个事务的提交日志可以合并到一次 fsync,所以实际吞吐量并没有想象中那么差。

4.3 redo log 和 binlog 的两阶段提交

光有 redo log 还不够,因为我们平时还会开启 binlog 用于主从复制和备份恢复。redo log 是 InnoDB 引擎层的日志,binlog 是数据库上层记录的逻辑日志,两边如果不一致,崩溃恢复后主从数据就会错乱。

所以 InnoDB 在事务提交时采用了类似内部 XA 的两阶段提交:

  1. prepare 阶段:InnoDB 把事务标记为 prepare 状态,并把 redo log 刷盘。
  2. 写 binlog:数据库层把事务的 binlog 写出并落盘。
  3. commit 阶段:InnoDB 把事务标记为 commit,并写入 commit 标志。

崩溃恢复时,如果一个 redo log 里的事务有 prepare 但没有对应的 binlog,就回滚;如果 prepare 后 binlog 已经写出,就提交。这个顺序保证了 redo log 和 binlog 不会出现一边有、一边没有的中间情况。

我排查过一次主从数据不一致的问题,最后发现是 binlog 参数配置不合理导致的。所以只要你的环境开了主从复制,sync_binlog=1和innodb_flush_log_at_trx_commit=1这两个参数最好保持默认,别为了性能随意调整。

5. 锁机制、死锁和安全update的实战经验

5.1 快照读和当前读要分清

很多人把 MVCC 理解为“数据库不加锁”,这是错的。普通SELECT确实不加锁,走的是快照读。但一旦出现以下操作:

SELECT ... FOR UPDATE SELECT ... LOCK IN SHARE MODE UPDATE ... DELETE ... INSERT ...

就变成了当前读,必须读取记录的最新版本,并且对记录加锁。当前读一定会和别人的写操作产生锁竞争,这也是死锁最容易发生的地方。

业务里如果只需要“查询结果一致”,用普通SELECT就够了。如果同时还想防止别人修改这个数据,才需要FOR UPDATE。不要在每次查询后面都加FOR UPDATE,那是并发自杀。

5.2 InnoDB 的锁类型和加锁范围

InnoDB 的锁可以按粒度分成表级锁和行级锁:

  • 意向锁:表级别,表示事务准备在表内加行锁,用于快速判断表是否有行锁。
  • 记录锁:锁定单条记录。
  • 间隙锁:锁定一个范围,但不包含记录本身,用来阻止其他事务在间隙里插入数据。
  • next-key lock:记录锁和前面的间隙锁合并,范围是“左开右闭”,既锁记录,也锁间隙。

可重复读隔离级别下,普通的唯一索引等值查询,走的是记录锁。如果等值查询扫描到的是普通索引或者没有索引,InnoDB 会扩大加锁范围。如果是范围查询,哪怕数据不存在,也会在范围内加间隙锁,这也是幻读被抑制的原因。

我见过一个典型案例:一个插入操作不是等值条件,而是一个非唯一索引的范围条件,导致锁范围覆盖了几千条记录。一个看似普通的INSERT把所有相同状态的行都锁住了,最终引发大面积阻塞。排查半天才发现,表上一个普通索引选择性太低,InnoDB 无法精确定位,只能用间隙锁把大范围“圈住”。

5.3 一条 UPDATE 到底加了哪些锁

举一个最典型的加锁分析场景:

UPDATE user SET status = 2 WHERE user_id = 1001;

如果user_id是主键,且当前隔离级别是可重复读:

  • 在主键索引上找到user_id = 1001这个记录,加记录锁。
  • 如果这个条件没有命中记录,会在对应间隙上加间隙锁。
  • 如果表上有二级索引被更新,也会在二级索引记录上加锁。

如果 WHERE 条件用的是普通索引,那就麻烦了。InnoDB 先扫描二级索引,给命中的二级索引记录加锁,再回表给主键记录加锁。二级索引记录和主键记录都可能被锁。如果普通索引不唯一,还会给多个间隙加间隙锁。

最糟糕的是 WHERE 条件没有索引。这时 InnoDB 会扫描全表,每一行都要判断是否满足条件,并给所有扫描过的记录加锁。这等于把一个 UPDATE 变成全表锁,瞬间拖垮并发。

给 UPDATE 和 DELETE 的 WHERE 条件尽量用高性能索引,是一个 DBA 的基本功。复杂条件至少保证其中一个条件能通过索引快速定位到很小范围,否则不要上线。

5.4 死锁排查实录

死锁是 InnoDB 事务里最常见也最让人头疼的问题。它本质上是两个或多个事务互相持有对方需要的锁,并且都在等待对方释放,形成循环等待。

排查死锁时,我是这么做的:

  1. 打开死锁日志,查看LATEST DETECTED DEADLOCK部分。
  2. 看两个事务各自的 SQL 和持有、等待的锁信息。
  3. 确定加锁顺序,找出循环点。
  4. 回看业务代码,调整 SQL 执行顺序。
SHOW ENGINE INNODB STATUS\G

这段输出里能看到死锁发生的时间、涉及的事务 ID、每个事务当前执行的 SQL、持有的锁和等待的锁。一般看到锁模式是X还是S,配合表名和索引名,就能判断问题位置。

处理死锁还有一个比较偷懒但有效的方向:让事务变短。很多死锁是事务里先查后改,同时处理多条数据,导致锁范围交错。尽量把一个大事务拆成多个小事务,每个事务只更新确定范围内的少量记录。我后来写批量更新任务时,统一按照主键 ID 排序后再更新,死锁率明显下降。

5.5 死锁和锁等待的区别

死锁和普通锁等待不一样。死锁 InnoDB 会自动检测,并回滚代价较小的一方,业务会收到Deadlock found错误。普通锁等待则是一直等,直到innodb_lock_wait_timeout超时,默认是 50 秒。

每次遇到业务方反馈“SQL 卡了 50 秒才报错”,我第一反应都是去看等待的锁是谁持有了。可以用这条 SQL 查看当前锁等待:

SELECT trx_id, trx_state, trx_query, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_runtime FROM information_schema.innodb_trx WHERE trx_state = 'LOCK WAIT';

再配合performance_schema.data_locks或者data_lock_waits,可以找到阻塞源头。定位到持锁事务后,先判断它是正常业务还是异常遗留会话,异常就把对应连接 kill 掉。

6. 事务使用过程中最常见的坑和避坑清单

6.1 长事务怎么发现和治理

长事务是 InnoDB 事务管理的头号敌人。它除了让 undo 版本链堆积,还会导致锁长时间持有,阻塞其他会话,甚至拖垮主从复制。

我在生产环境发现某个库的磁盘空间持续上涨,第一件事就是执行:

SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY duration_seconds DESC;

特别注意trx_started特别早但trx_query为 NULL 的事务,这种往往是事务已经执行完 SQL、但应用层还没提交,属于“挂起事务”。排查下来百分之八十是业务代码里忘记调用 commit,或者事务包装层异常后没有释放连接。

治理手段上,我总结了三步:第一步监控,定期检查innodb_trx里超过 60 秒未结束的事务;第二步限制,给事务设置合理的锁等待超时;第三步代码侧规范,service 方法里开启事务的边界要小,禁止在事务里执行远程调用、大循环、长时间等待。

6.2 autocommit 和隐式提交的坑

不少人说“我明明没开事务,为什么还有锁?” 其实多数情况是没理解autocommit。MySQL 默认autocommit=1,每一条 SQL 都会自动提交,所以单条UPDATE也会有行锁,但只要执行完就释放,大家感觉不明显。

怕的是开发同学在同一个连接里这么做:

SET autocommit = 0; UPDATE ...; -- 忘记 COMMIT

之后这个连接里所有 SQL 都在同一个事务里,锁越积越多。等应用连接池把这个连接还回去,下一个人继续用,可能就踩进这个“隐形事务”里。

更坑的是隐式提交。某些 SQL 在执行前会自动提交当前事务,比如CREATE TABLE、DROP TABLE、ALTER TABLE、LOCK TABLES、事务管理语句等。也就是说,你在一个事务里已经执行了好几条UPDATE,中途跑了一个 DDL,前面的修改就被默默提交了。想要原子执行多个 DML,千万不能在事务中间夹 DDL。

6.3 大事务为什么会影响主从复制

大事务是指一个事务里包含了大量数据的修改。它带来的问题不仅是持锁时间长,还会把主从复制延迟拉高。

事务在主库提交后,binlog 被同步到从库。从库默认采用单线程或者多线程但并行度有限的方式重放事务日志。一个包含百万行更新的超大事务,在主库可能只需要几十秒,但从库重放可能要几分钟。在此期间主库继续产生新事务,从库越积越多,延迟就上去了。

我见过一个批量清理任务,一次 DELETE 了 500 万行,主库跑完不到半分钟,从库延迟一路飙到几十分钟。后来把 DELETE 改成按主键范围分批,每批 1000 行,加 1 秒限速,延迟才稳定下来。给所有批量操作设定行数上限,是大事务治理的最有效手段。

6.4 事务里不要做这几件事

  • 不要在事务里查询大量数据再逐条更新,锁的持有时间会过长。
  • 不要在事务里发外部消息、调用接口,的一次网络延迟可能让锁多持有几百毫秒。
  • 不要让一个事务跨越多个网络请求,长连接里的事务尽量做到方法内开启、方法内结束。
  • 不要为一个“只读报表”开启事务,只读查询完全不需要事务包裹。
  • 不要把所有业务操作塞进一个大事务里“图省事”,一旦需要回滚,代价是整个事务所有 SQL 全部重做。

6.5 参数层面的优化建议

如果你负责的数据库事务并发比较高,可以关注这几个参数:

参数建议说明
innodb_flush_log_at_trx_commit账务=1,日志类=2在可靠性和性能之间取舍
sync_binlog有主从=1和 redo log 刷盘配合,防止主从不一致
innodb_lock_wait_timeout按业务设置,比如 5-10 秒太长容易让请求堆积
innodb_deadlock_detect默认开启高频死锁环境可评估关闭,但风险很高
transaction-isolation核心业务用 RR,报表查询用 RC不要全局一把梭
max_binlog_size按写入量调大控制 binlog 切换频率和复制压力

注意,改参数不是目的,解决问题才是。遇到问题先看业务代码,再查事务状态,最后才考虑调参。很多数据库故障,都是代码层面埋的雷,被参数放大而已。

最后分享一个我自己的习惯:每次上线涉及批量更新的需求,我都会先问三个问题——这个事务会持锁多久?会锁住多少行?最坏情况下会不会和别人形成交叉等待?这三个问题答不上来,我就不敢上线。InnoDB 事务本身并不神秘,真正考验人的是你能不能在设计代码时,就提前预判它内部的锁与日志行为。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询