☰
MySQL事务底层原理与实战:undo log、redo log、MVCC与隔离级别选型
2026/10/20 22:28:24 网站建设 项目流程

1. 从一次转账超扣说起:事务到底在解决什么问题

很多同学第一次接触事务,是在写转账逻辑的时候。A账户扣100块,B账户加100块,两条UPDATE语句,中间如果程序崩了、数据库连接断了、或者服务器突然断电,A扣了钱B没收到,账就平不了。这种场景下,事务就是那个“要么全成、要么全不成”的保险栓。

但事务远不止“转账”这么简单。我在实际项目里见过更隐蔽的问题:一个批量任务循环插入5000条数据,跑到第3000条时报了主键冲突,结果前2999条已经写进去了,业务方看到的是“一半数据”,排查了半天才发现是没包事务。还有更坑的,两个并发请求同时扣同一张优惠券的库存,代码里明明写了“先查再扣”,结果还是超扣了——因为查和扣之间,另一个请求插进来了。

这些问题的根子,都在事务的原子性(Atomicity)和隔离性(Isolation)上。原子性保证一组操作不可分割,隔离性保证并发事务之间互不干扰。再加上一致性(Consistency)和持久性(Durability),就是常说的ACID。

这篇文章不打算把ACID四个词各写一段定义就完事。我想聊的是:MySQL的InnoDB引擎到底怎么实现这些特性的,undo log和redo log各自扮演什么角色,MVCC是怎么让读写不互相阻塞的,隔离级别选哪个才不踩坑,以及那些“看起来没问题但实际会出事”的写法。适合已经会写SQL、但对事务底层机制还比较模糊的后端开发同学,也适合正在准备面试、想把事务这块讲清楚的人。

2. undo log与redo log:事务的两本账

2.1 undo log不是“回滚日志”这么简单

很多人把undo log理解成“记录反向操作的日志”,这个说法对,但太浅了。undo log真正的作用有两个:回滚和MVCC快照读。第二个作用才是它在InnoDB里地位这么高的原因。

当你执行一条UPDATE时,InnoDB会把被修改行的旧版本写进undo log。比如把name从“张三”改成“李四”,undo log里就记一条“把name改回张三”。如果事务回滚,就顺着undo log反向执行。但更重要的是,当另一个事务在RR隔离级别下要读这行数据时,如果这行已经被改了但还没提交,它就去undo log里找旧版本——这就是MVCC快照读的底层依据。

undo log分两种:insert undo log和update undo log。insert类型的undo log在事务提交后就可以直接删掉,因为插入的行本来对其他事务就不可见,不需要保留旧版本。update类型的undo log则不能马上删,因为可能还有别的事务在用它做快照读,得等purge线程判断没有事务再需要它了才清理。

这里有个实操中容易忽略的点:长事务会把undo log撑爆。我遇到过一个大事务跑了40分钟没提交,undo表空间从几百MB涨到十几个GB,磁盘直接告警。原因是这个事务开始之后,所有其他事务产生的update undo log都不能被purge,因为可能这个长事务还要读旧版本。所以第一条经验:能拆小的事务别攒着一起提交。

2.2 redo log:为什么提交了就不会丢

redo log解决的是持久性问题。InnoDB修改数据时,不会立刻把脏页刷到磁盘(那样随机IO太慢),而是先写redo log,然后后台线程慢慢刷脏页。redo log是顺序写的,速度快得多。

关键点在于:事务提交时,redo log必须刷盘。这个由参数innodb_flush_log_at_trx_commit控制:

取值行为数据安全性性能
0每秒刷一次可能丢1秒数据最高
1每次提交都刷不丢最低
2每次提交写OS缓存,每秒刷盘操作系统崩溃会丢中等

生产环境金融类业务必须用1,这个没得商量。但很多互联网业务为了性能用2,也能接受——因为机器断电的概率远小于数据库进程崩溃,而进程崩溃时OS缓存还在。

redo log是物理逻辑日志,记录的是“在某个页的某个偏移量写了什么”。它采用**WAL(Write-Ahead Logging)**机制:先写日志,再写数据页。这样即使脏页还没刷盘,崩溃后也能通过redo log恢复。

还有一个细节:redo log是循环写的,固定大小(由innodb_log_file_size控制),写满一圈就从头覆盖。如果写得太快、脏页刷得太慢,就会触发“checkpoint age”过高,写入被阻塞。我见过一个批量导入场景,redo log设了默认的48MB,结果导入时频繁卡顿,后来调到1GB才顺畅。所以第二条经验:写入密集的业务,redo log别用默认值。

2.3 两阶段提交:binlog和redo log怎么对齐

如果开了binlog(主从复制必须开),事务提交时还要写binlog。问题来了:redo log和binlog是两套独立的日志,怎么保证它们一致?

MySQL用两阶段提交解决:先写redo log并标记为prepare状态,然后写binlog,最后把redo log标记为commit。如果写完prepare后崩溃,恢复时检查binlog是否完整——完整就提交,不完整就回滚。

这个机制保证了主从数据一致。如果不用两阶段提交,可能出现主库redo log写了但binlog没写,从库就少了这条数据;或者反过来,从库多了主库没有的数据。

注意:sync_binlog参数控制binlog刷盘频率,和innodb_flush_log_at_trx_commit配合使用。双1配置(两个都设为1)是最安全的,但性能损耗也最大。很多团队用sync_binlog=1加innodb_flush_log_at_trx_commit=2的折中方案。

3. MVCC:让读写不打架的那套机制

3.1 隐藏字段与版本链

InnoDB每行数据都有两个隐藏字段:DB_TRX_ID(最后修改这行的事务ID)和DB_ROLL_PTR(指向undo log的指针)。每次修改都会把旧版本串到undo log里,形成一条版本链。

举个例子。事务100把某行name改成“A”,事务200改成“B”,事务300改成“C”。版本链从新到旧就是:C(trx 300)→ B(trx 200)→ A(trx 100)→ 原始值。每个版本都带着自己的事务ID。

当一个事务要读这行数据时,它拿着自己的Read View,顺着版本链找第一个“可见”的版本。可见性判断规则大致是:

  • 如果版本的事务ID小于Read View中的最小活跃事务ID,说明这个版本在Read View创建前就提交了,可见。
  • 如果版本的事务ID大于等于Read View中的最大事务ID,说明这个版本是Read View创建后才开始的,不可见。
  • 如果在活跃事务列表里,说明还没提交,不可见。

顺着链找到第一个可见版本,就是快照读的结果。

3.2 Read View的生成时机:RC和RR的分水岭

这是理解隔离级别的关键。**RC(读已提交)**下,每次SELECT都会生成一个新的Read View。所以同一个事务里两次查询,如果中间有别的事务提交了,第二次能看到新数据。

**RR(可重复读)**下,Read View只在第一次SELECT时生成,之后整个事务都用这一个。所以两次查询结果一致,这就是“可重复读”的含义。

但RR有个例外:当前读(SELECT ... FOR UPDATE、UPDATE、DELETE)总是读最新版本。所以RR下可能出现“快照读看到旧值,当前读看到新值”的情况,这个后面会细说。

3.3 当前读与幻读:RR到底解没解决幻读

网上很多文章说“RR解决了幻读”,这个说法不严谨。准确地说:RR下的快照读不会出现幻读,但当前读可能出现幻读。

幻读是指同一事务内两次查询,第二次看到了第一次没看到的行。比如事务A查“age > 20”有3条,事务B插入一条age=25并提交,事务A再查变成4条——这就是幻读。

RR下,因为Read View不变,第二次快照读还是用旧视图,看不到新插入的行,所以不会幻读。但如果事务A执行的是SELECT ... FOR UPDATE,它读的是最新数据,就能看到新行。更麻烦的是,如果事务A先快照读没看到,然后执行UPDATE,UPDATE是当前读,会更新到那条“看不见”的行,之后再用快照读就能看到了——这就出现了幻读。

InnoDB用Next-Key Lock(记录锁+间隙锁)来尽量防止当前读的幻读。间隙锁锁住记录之间的“缝隙”,阻止其他事务在缝隙里插入。但间隙锁只在RR下生效,RC下没有。

实操建议:如果你的业务依赖“同一事务内多次查询结果一致”,用RR。如果更在意并发性能、能接受不可重复读,用RC。互联网业务大量用RC,因为间隙锁容易导致死锁。

4. 隔离级别选型:别只会背四个名词

4.1 四个级别的真实差异

隔离级别脏读不可重复读幻读实现方式
READ UNCOMMITTED可能可能可能直接读最新
READ COMMITTED不可能可能可能每次SELECT新Read View
REPEATABLE READ不可能不可能快照读不可能首次SELECT固定Read View
SERIALIZABLE不可能不可能不可能加锁串行

MySQL默认是RR,但很多团队会改成RC。原因有几个:RC没有间隙锁,死锁概率低;RC的undo log可以更快purge;很多互联网业务本身就不要求可重复读。

4.2 改RC之前必须确认的事

改成RC不是改个参数就完事。我见过一个团队把隔离级别从RR改成RC后,出现了一个诡异问题:某个统计任务在同一事务里查了两次同一张表,结果两次数字不一样,报表对不上。原因是这个任务依赖了RR的可重复读特性。

所以改RC之前,要排查:

  • 有没有业务逻辑依赖“同一事务内多次读结果一致”?
  • 有没有依赖间隙锁来防并发插入的?
  • 有没有用SELECT ... FOR UPDATE做“查不到就插入”的逻辑?RC下这个逻辑可能失效,因为两个事务可能都查不到然后都插入。

第三条尤其常见。正确做法是用INSERT ... ON DUPLICATE KEY UPDATE或者唯一索引兜底,而不是靠间隙锁。

4.3 隔离级别与主从复制的关系

binlog格式也影响隔离级别的选择。STATEMENT格式下,RC可能出问题,因为主库和从库执行顺序可能不同。ROW格式下没这个问题。所以如果要用RC,binlog_format建议设为ROW。

MySQL 5.7之后默认就是ROW,这个坑现在踩的人少了。但如果你维护的是老系统,还是要确认一下。

5. 那些“看起来没问题”的事务写法

5.1 事务里做远程调用

这是最经典的坑。事务里调RPC、发消息、调第三方接口,一旦远程调用超时,事务持有锁的时间就被拉长,其他事务全堵着。更糟的是,如果远程调用成功了但事务回滚了,远程那边的操作没法撤销。

正确做法是把远程调用挪到事务外。先提交本地事务,再发消息。如果要求“本地成功远程也必须成功”,用本地消息表或事务消息,而不是把RPC塞进事务。

5.2 大事务:锁、undo、主从延迟三杀

大事务的危害是全方位的:

  • 锁持有时间长:其他事务等锁,连接池被占满。
  • undo log膨胀:前面说过,长事务阻止purge。
  • 主从延迟:一个大事务在从库执行也要很久,延迟飙升。
  • 回滚代价大:回滚一个改了百万行的事务,可能要跑几十分钟。

我处理过一个案例:一个定时任务一次性更新20万行,跑了3分钟,期间从库延迟到15分钟。后来改成每批1000行、分批提交,延迟降到秒级。

经验值:单事务操作行数控制在几千以内,执行时间控制在1秒以内。超过就考虑拆分。

5.3 事务里的SELECT ... FOR UPDATE用错索引

FOR UPDATE加的是行锁,但前提是走索引。如果WHERE条件没走索引,InnoDB会锁全表——不是锁所有行,是锁所有间隙,效果接近锁表。

我见过一个场景:SELECT * FROM orders WHERE order_no = 'xxx' FOR UPDATE,order_no没索引,结果整个表被锁,所有订单操作全卡住。加个索引就好了。

所以用FOR UPDATE之前,先用EXPLAIN确认走索引。这个习惯能省很多事。

5.4 嵌套事务的误解

MySQL没有真正的嵌套事务。SAVEPOINT可以模拟部分回滚,但外层回滚时内层也全回滚。而且很多框架的“嵌套事务”其实是传播行为,不是数据库层面的嵌套。

比如Spring的REQUIRES_NEW会开一个新事务,但如果外层回滚,内层已经提交的不会回滚。这个行为容易让人误解,用之前要清楚框架的传播机制。

6. 死锁:从排查到预防

6.1 一次真实的死锁排查

某次线上告警,两个事务互相等待。看SHOW ENGINE INNODB STATUS的LATEST DETECTED DEADLOCK段:

事务A持有订单表的行锁,等待用户表的行锁;事务B持有用户表的行锁,等待订单表的行锁。典型的加锁顺序不一致。

根因是两个业务方法加锁顺序相反:方法1先更新订单再更新用户,方法2先更新用户再更新订单。并发时就死锁了。

修复方式很简单:统一加锁顺序。所有涉及这两张表的操作,都按“先订单后用户”的顺序。

6.2 死锁的四个必要条件与打破

死锁需要四个条件同时满足:互斥、持有并等待、不可抢占、循环等待。数据库层面能做的就是打破“循环等待”——按固定顺序加锁。

但实际业务里很难完全统一顺序。所以还要配合:

  • 缩短事务:锁持有时间越短,死锁概率越低。
  • 降低隔离级别:RC没有间隙锁,死锁概率比RR低。
  • 加锁前先查:用普通SELECT确认数据存在再加锁,减少锁范围。
  • 设置锁等待超时:innodb_lock_wait_timeout默认50秒,可以调小到几秒,让死锁快速暴露而不是长时间挂起。

6.3 死锁不是bug,是常态

要接受一个事实:死锁不可能完全避免。InnoDB会自动检测死锁并回滚其中一个事务,牺牲一个保另一个。所以应用层要能处理死锁异常——捕获Deadlock found错误码,重试事务。

重试要注意:不是所有死锁都能靠重试解决。如果是加锁顺序问题,重试还是会死锁。所以重试要有限制,比如最多3次,超过就告警。

7. 事务与连接池:那些被忽略的细节

7.1 连接池里的“僵尸事务”

连接池借出连接后,如果应用忘了提交或回滚,连接归还时事务还开着。下次借到这个连接,可能读到上一个事务的未提交数据,或者带着锁。

解决办法:连接池配置autoCommit=true,归还时自动提交。或者用rollbackOnReturn。但更根本的是代码里确保事务有始有终,用try-finally包住。

7.2 事务超时设置

innodb_lock_wait_timeout控制等锁超时,但事务本身没有超时。一个事务可以跑很久。有些框架支持事务超时,比如Spring的@Transactional(timeout=5),超时后抛异常回滚。

这个设置很有用,能防止慢事务拖垮系统。但要注意:超时后事务回滚也需要时间,不是立刻停。

7.3 只读事务的优化

如果事务里只有SELECT,可以声明为只读事务:START TRANSACTION READ ONLY。InnoDB会做一些优化,比如不分配事务ID、减少undo log。对于报表类查询有帮助。

但只读事务里不能写,包括临时表。用之前确认业务逻辑。

8. 监控与调优:让事务问题提前暴露

8.1 关键监控指标

指标含义告警阈值建议
活跃事务数当前运行的事务数量突增时关注
长事务数运行超过N秒的事务超过60秒告警
锁等待次数每秒等锁次数持续高位关注
死锁次数每秒死锁次数大于0就关注
undo表空间大小undo log占用空间持续增长告警

information_schema.INNODB_TRX表可以查当前活跃事务,包括开始时间、状态、锁等待情况。定期查这个表,能发现长事务。

8.2 长事务的发现与处理

SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec, trx_state, trx_query FROM information_schema.INNODB_TRX WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60 ORDER BY duration_sec DESC;

查到长事务后,先看trx_query在跑什么。如果是业务SQL,联系业务方确认;如果是空闲事务(trx_query为NULL),说明应用忘了提交,需要kill掉。

kill事务用KILL <trx_mysql_thread_id>,不是trx_id。这个别搞混。

8.3 参数调优清单

  • innodb_flush_log_at_trx_commit:安全与性能的权衡,金融用1,互联网可考虑2。
  • innodb_log_file_size:写入密集业务调大,减少checkpoint频率。
  • innodb_lock_wait_timeout:调小到5-10秒,快速暴露锁问题。
  • transaction_isolation:按业务需求选RC或RR。
  • innodb_undo_tablespaces:undo独立表空间,便于管理。

调参不是越多越好,每次只调一个,观察效果。我见过一次性调了七八个参数,出问题后根本不知道是哪个引起的。

9. 一些容易混淆的概念澄清

9.1 事务ID不是自增主键

DB_TRX_ID是InnoDB内部的事务ID,和业务表的自增主键完全两回事。事务ID是全局递增的,但会回绕(用完一轮后从头开始)。所以不能拿事务ID做业务排序。

9.2 快照读不是“读缓存”

MVCC的快照读不是把数据缓存起来,而是通过版本链和Read View实时计算可见版本。所以快照读也有开销,只是不需要加锁。

9.3 提交不等于落盘

事务提交时redo log落盘,但数据页可能还在内存里。所以“提交成功”只保证日志持久,不保证数据页持久。崩溃恢复时用redo log重放,把数据页补上。

9.4 回滚不是“反向执行SQL”

回滚是顺着undo log把数据改回去,不是重新执行一遍反向SQL。所以回滚的代价和修改的数据量成正比,大事务回滚很慢。

10. 我在实际项目中的几条硬经验

第一条,事务里只放数据库操作。RPC、消息、文件IO全部挪出去。这条看起来简单,但违反的人特别多,尤其是刚接手老代码的时候。

第二条,批量操作必须分批提交。每批1000到5000行,根据行大小调整。批与批之间可以加短暂sleep,给从库追赶的时间。

第三条,FOR UPDATE之前先EXPLAIN。确认走索引,确认锁范围。这个习惯帮我避免了好几次全表锁。

第四条,死锁重试要有限制。无限重试等于把问题藏起来。重试3次还失败就告警,让人来看。

第五条,监控长事务。超过60秒的事务就要关注,超过5分钟的必须处理。长事务是很多问题的源头。

第六条,隔离级别改动要全链路评估。不只是改个参数,要检查业务逻辑、binlog格式、框架配置。改完要观察一段时间再全量。

第七条,undo表空间要独立。用innodb_undo_tablespaces把undo放到独立表空间,方便监控和清理。默认放在ibdata里,出问题不好处理。

这些经验不是从文档里抄的,是踩坑踩出来的。每一条背后都有至少一次线上问题。事务这块东西,光看原理不够,得在实际场景里磨。希望这些内容能帮你少走点弯路。

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

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

立即咨询