资讯动态

MySQL事务底层原理与实战:undo log、redo log、MVCC与隔离级别选型

发布时间:2026/10/20 22:28:34 来源:尧图企业网站定制
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是物理逻辑日志记录的是“在某个页的某个偏移量写了什么”。它采用**WALWrite-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_binlog1加innodb_flush_log_at_trx_commit2的折中方案。3. MVCC让读写不打架的那套机制3.1 隐藏字段与版本链InnoDB每行数据都有两个隐藏字段DB_TRX_ID最后修改这行的事务ID和DB_ROLL_PTR指向undo log的指针。每次修改都会把旧版本串到undo log里形成一条版本链。举个例子。事务100把某行name改成“A”事务200改成“B”事务300改成“C”。版本链从新到旧就是Ctrx 300→ Btrx 200→ Atrx 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插入一条age25并提交事务A再查变成4条——这就是幻读。RR下因为Read View不变第二次快照读还是用旧视图看不到新插入的行所以不会幻读。但如果事务A执行的是SELECT ... FOR UPDATE它读的是最新数据就能看到新行。更麻烦的是如果事务A先快照读没看到然后执行UPDATEUPDATE是当前读会更新到那条“看不见”的行之后再用快照读就能看到了——这就出现了幻读。InnoDB用Next-Key Lock记录锁间隙锁来尽量防止当前读的幻读。间隙锁锁住记录之间的“缝隙”阻止其他事务在缝隙里插入。但间隙锁只在RR下生效RC下没有。实操建议如果你的业务依赖“同一事务内多次查询结果一致”用RR。如果更在意并发性能、能接受不可重复读用RC。互联网业务大量用RC因为间隙锁容易导致死锁。4. 隔离级别选型别只会背四个名词4.1 四个级别的真实差异隔离级别脏读不可重复读幻读实现方式READ UNCOMMITTED可能可能可能直接读最新READ COMMITTED不可能可能可能每次SELECT新Read ViewREPEATABLE READ不可能不可能快照读不可能首次SELECT固定Read ViewSERIALIZABLE不可能不可能不可能加锁串行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格式下没这个问题。所以如果要用RCbinlog_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 UPDATEorder_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 连接池里的“僵尸事务”连接池借出连接后如果应用忘了提交或回滚连接归还时事务还开着。下次借到这个连接可能读到上一个事务的未提交数据或者带着锁。解决办法连接池配置autoCommittrue归还时自动提交。或者用rollbackOnReturn。但更根本的是代码里确保事务有始有终用try-finally包住。7.2 事务超时设置innodb_lock_wait_timeout控制等锁超时但事务本身没有超时。一个事务可以跑很久。有些框架支持事务超时比如Spring的Transactional(timeout5)超时后抛异常回滚。这个设置很有用能防止慢事务拖垮系统。但要注意超时后事务回滚也需要时间不是立刻停。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_tablespacesundo独立表空间便于管理。调参不是越多越好每次只调一个观察效果。我见过一次性调了七八个参数出问题后根本不知道是哪个引起的。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里出问题不好处理。这些经验不是从文档里抄的是踩坑踩出来的。每一条背后都有至少一次线上问题。事务这块东西光看原理不够得在实际场景里磨。希望这些内容能帮你少走点弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑