ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL事务底层机制:从redo/undo到MVCC与锁的实践解析

MySQL事务底层机制:从redo/undo到MVCC与锁的实践解析

先用一个场景把问题说清楚:你在秒杀系统里扣库存,业务逻辑往往是“先查库存,再写订单,最后扣减库存”。如果这三步中间任何一步失败,前面成功的数据都要一并撤销,否则就会出现“订单生成了,库存却扣不动”或者“库存扣了,订单不存在”这类资损事故。这就是事务存在的最直接理由。而要真正搞清楚“MySQL是怎么实现事务的”,不能只看几条命令,得深入到InnoDB引擎的日志、锁和版本链里去看。

这篇文章我不打算把官方文档复述一遍。我会结合自己实际跑过的压测、排查过的事故,把MySQL事务从底层机制到生产实践拆开来讲。适合正在做后端开发、数据库运维、或者对InnoDB原理感兴趣的同学,看完之后你会明白四个问题:事务凭什么能回滚、凭什么能持久化、凭什么读写不冲突、以及它在分布式环境下为什么不够用。

1. 先从一场“库存扣减事故”说起:为什么需要事务

1.1 没有事务的并发现场

先假设MySQL没有事务,你的库存表里有条记录stock = 10。两个用户同时下单,各自执行“库存 - 1”:

用户A:UPDATE stock_table SET stock = stock - 1 WHERE sku = 'xxx'; 用户B:UPDATE stock_table SET stock = stock - 1 WHERE sku = 'xxx';

两条SQL同时对同一行做“读改写”,如果没有加锁和隔离机制,可能出现的结果是:A读到10,B也读到10,A写回9,B写回9,最终库存只剩8,但实际卖了2件。这就是经典的“丢失更新”。

可能有人会说:“我给UPDATE加个条件WHERE stock > 0不就行了?”但问题没这么简单——如果用户A下完单后要写订单表,还要扣用户积分,积分表写入失败了,库存这边已经扣掉。整个流程没有一个统一的“要么全成功、要么全失败”的边界,数据依然会乱。

1.2 ACID到底在保护什么

事务要解决的核心问题就是:让一组操作具备原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。很多人背这四个词很熟,问到底怎么实现的就卡住了。

简单对应关系是这样的:

  • 原子性依赖undo log。事务执行过程中,所有对数据的修改都会被记录成 undo 日志,一旦发生回滚,就按 undo 里的旧值把数据恢复回去。
  • 持久性依赖redo log和binlog。事务提交时,不是直接刷数据页,而是先把日志落盘。即使数据库崩溃,重启后也能通过日志重放,找回已经提交的数据。
  • 隔离性依赖锁和MVCC。锁控制“同一时刻只允许一个事务修改一行”,MVCC 则让“读”和“写”尽量不互相阻塞。
  • 一致性是最上层的目标,靠前面三者共同约束,同时还需要业务代码本身遵守约束条件,比如金额不能为负、库存不能超卖。

1.3 事务和一条SQL不是一回事

还有一个常见的误区:认为“每条SQL天然就是事务”。严格来说,MySQL里有一条autocommit参数,默认是开启的。开启状态下,每条SQL确实会被当作独立事务自动提交。但真正的业务事务通常是一段代码块:

START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE user_id = 1; UPDATE account SET balance = balance + 100 WHERE user_id = 2; COMMIT;

只有把多条SQL包在START TRANSACTION和COMMIT之间,它们才算是一个整体。很多事故的根源就在于:代码里开了@Transactional注解,但底层因为各种原因没有真正走同一个数据库连接,导致事务失效。这一点先记住,后面讲长事务的时候还会提到。

2. redo log与undo log:事务持久性和回滚的物理基石

2.1 为什么先写日志、再动数据文件

直接修改磁盘数据文件当然可以,但很慢。你想想,一次UPDATE可能涉及聚簇索引页、二级索引页,每个页还要保证落盘时数据页里的校验值正确。如果每次更新都做一次随机磁盘写,并发一高,磁盘IO就成为瓶颈。

InnoDB的做法是:先把“内存里改完的数据页”标记为脏页,同时把这次修改的顺序写入到redo log。因为日志是顺序追加写的,磁盘顺序写的速度远快于随机写,所以整体性能能大幅提升。这个思路就是常说的WAL(Write-Ahead Logging)——先写日志,再写数据页。

2.2 redo log的写入时机与崩溃恢复逻辑

redo log 有几个很重要的细节:

  • 它是物理日志,记录的是“在某个页的某个偏移量上,改成了什么值”。所以它不关心SQL语句本身,只关心页的最终变化。
  • 它有LSN(Log Sequence Number),用来标识日志写入的递增位置。数据页上也有LSN,落盘时如果发现页的LSN比日志LSN旧,说明这个页还没跟上,崩溃恢复时就需要重放。
  • 刷盘时机由innodb_flush_log_at_trx_commit控制:
取值行为说明安全性
0事务提交时不主动刷redo log,由后台线程每秒刷一次宕机会丢最多1秒数据
1每次事务提交都把redo log刷到磁盘不丢已提交事务
2提交时写入操作系统缓存,由系统决定何时刷盘宕机不丢,但宕操作系统可能丢

生产环境里,如果追求数据零丢失,一般设成1。如果严格要求性能、能接受最多1秒数据丢失,可以设成0或2。这里需要特别提醒:innodb_flush_log_at_trx_commit = 0不是“事务没持久化”,而是“提交时晚一点刷盘”,一旦进程崩溃,已提交事务可能还没落盘,就会出现数据丢失。

崩溃恢复时,InnoDB会从redo log的检查点(checkpoint)开始,把后续所有日志应用到对应数据页。所谓checkpoint,就是“这些LSN之前的数据页已经刷入磁盘了”的标记点。恢复过程只是把脏页补上,不会回滚已提交事务,这保证了持久性。

2.3 undo log的年轮与回滚行为

undo log 和 redo log 正好相反:redo 记录“怎么重做”,undo 记录“怎么撤销”。它属于逻辑日志,里面存的是“之前的值”。

举个例子,你执行:

UPDATE user SET age = 25 WHERE id = 100;

假设原来的age是22,InnoDB在修改之前会在 undo log 里写入一条操作,记录“id=100这行的age旧值是22,update之前还有哪些历史版本”。一旦事务需要回滚,就直接拿着undo里的旧值,生成反向操作,把数据恢复成原来的样子。

这里有个重点:undo log 不只用于回滚,它还是MVCC版本链的数据来源。后续要判断“某个事务能不能看到某条数据”,就要借助undo log 找到该行的历史版本。所以 imagine 一条记录如果被反复更新多次,undo log 会形成一个长链,链越长,查询时判断版本的代价就越高。

2.4 一条UPDATE语句在事务引擎里的完整路线

可以把一次UPDATE放到事务引擎里的执行链路串起来:

  1. 事务开始,分配一个事务ID(trx_id)。
  2. 找到目标行的聚簇索引记录,加上锁(这里暂时假设是排他锁)。
  3. 把修改前的旧值写入 undo log,并让行上的回滚指针指向这个undo记录。
  4. 在内存里修改数据页,同时记录 redo log。
  5. 更新二级索引等相关结构,同样记录redo。
  6. 事务提交时,redo log 按配置策略落盘,然后写 binlog(这里还会发生 redo 和 binlog 的两阶段提交,保证两份日志一致)。
  7. 如果事务回滚,则根据undo log 做反向操作;如果崩溃,则根据redo log 做前滚恢复。

这条链路上每一环缺一不可。之前我见过一个项目,DBA发现磁盘IO异常高,排查下来是innodb_flush_log_at_trx_commit被设置成0后经常丢数据,业务方又要求强一致,双方没对齐参数语义,导致频繁回滚重建数据。所以说,日志参数的背后,其实是性能和可靠性的权衡。

3. InnoDB锁的设计与隔离级别:并发控制里的取舍

3.1 行锁、间隙锁、next-key lock各管哪一段

InnoDB的锁是挂在索引记录上的,不是挂在表上。大致分几类:

  • 记录锁(Record Lock):锁住一条具体的索引记录。
  • 间隙锁(Gap Lock):锁住一个范围,但不锁记录本身,目的是防止别的事务在这个范围内插入新记录。
  • 临键锁(Next-Key Lock):记录锁和间隙锁的结合体,左开右闭区间,比如(10, 20]。

间隙锁和临键锁是理解“可重复读为什么能防幻读”的关键。举个例子,一张表里有记录id:10、20、30。在可重复读隔离级别下执行一条带条件的当前读:

SELECT * FROM t WHERE id > 15 FOR UPDATE;

InnoDB会给(15, 20]、(20, 30]以及(30, +∞)这些区间加上间隙锁。另一个事务试图插入一条 id=25 的记录,必须先在 id=20 和 id=30 之间的间隙申请插入意向锁,结果发现这个间隙被锁住了,只能等待。这样就阻止了新记录的插入,也就是阻断了“幻影行”的产生。

3.2 隔离级别真正改的是什么

MySQL提供四种隔离级别,区别在于“不同事务之间能互相看到什么程度的数据”:

隔离级别脏读不可重复读幻读
读未提交(RU)可能可能可能
读已提交(RC)不会可能可能
可重复读(RR,MySQL默认)不会不会基本不会
串行化(S)不会不会不会

很多人把隔离级别当成一个纯粹的配置项,其实它对底层实现影响很大:

  • 读未提交:SELECT 读到别人未提交的数据。也就是说,InnoDB的快照读逻辑基本被绕过,直接读最新版本。这种场景一般只用在“丢弃数据也无所谓”的统计查询里。
  • 读已提交:每次SELECT都会生成新的Read View,事务内两次读取可能因为其他事务提交了而看到不同结果。
  • 可重复读:事务第一次SELECT时固定Read View,后续读取都基于这个快照。在MySQL里,默认用它对标Oracle的隔离标准。
  • 串行化:读写都要加锁,性能最差,只适合极低并发的强一致场景。

3.3 可重复读为什么能防住幻读

MySQL在可重复读级别下,使用MVCC + 临键锁配合来解决幻读。普通SELECT走快照读,基于undo版本链看到的是一个固定快照;如果别的事务插入了一行,快照里看不见,也不会产生“本次事务内多出一行”的错乱。

如果执行的是SELECT ... FOR UPDATE、UPDATE、DELETE这类当前读,就必须走真实索引记录,加临键锁把扫描区间的间隙全部堵住。这样其他事务无法插入新行,幻读问题也就被锁机制挡住了。

需要说明的是:MySQL的可重复读并没有像SQL标准那样彻底封死所有幻读场景,但在绝大多数业务场景下,它表现的是“没有幻读”。实际项目里不建议去挑战这个边界,比如先做一次快照读合否定某个数据不存在,再依赖别的事务不会插入数据,这种假设很容易踩坑。

3.4 锁等待和死锁的真实案例

我处理过很多次死锁,最典型的小案例是两个事务互相等待:

事务A持有id=1的行的锁,想要id=2;事务B持有id=2的行的锁,想要id=1。两边互不相让,就形成死锁。InnoDB会检测到死锁,并回滚代价较小的事务。

在应用层,经常看到这类错误:

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

解决死锁的经验是:让所有事务都按照同一个顺序访问资源。比如“先扣库存,再写订单”,所有请求都严格走这个顺序,死锁概率会大幅下降;再把事务做小,缩短锁持有时间。另外一个容易忽略的情况是:一个事务里执行了外部接口调用,比如HTTP请求,手里握着数据库锁等待外部返回,此时另一个事务也在等这把锁,很容易触发超时和死锁。

4. MVCC版本链:普通SELECT为什么不阻塞

4.1 隐藏列和版本链怎么串起来

MVCC(Multi-Version Concurrency Control)是InnoDB实现隔离的核心机制。它让“读”走快照,“写”走当前版本,从而读写不互相阻塞。

每一行记录除了业务字段,还有几个隐藏列:

  • DB_TRX_ID:最近一次修改该行的事务ID。
  • DB_ROLL_PTR:回滚指针,指向undo log中该行的上一个版本。
  • DB_ROW_ID:如果表没有主键,InnoDB会生成一个递增的行ID。

每次UPDATE,先把旧版本连同事务ID挂到undo链上,再在数据页上写入新值。多次更新后,这行数据在内存里只有一个最新值,但通过DB_ROLL_PTR可以一路找到所有历史版本。这就是“版本链”。

4.2 Read View是怎么裁剪版本的

当一个事务执行快照读时,InnoDB会生成一个 Read View,它包含三个关键部分:

  • 所有未提交事务的ID集合。
  • min_trx_id:未提交事务中最小的ID。
  • max_trx_id:下一个将要分配的事务ID,也就是“当前系统见过的最新事务ID”。

判断某行版本是否可见的逻辑是:

  • 如果行上的DB_TRX_ID小于min_trx_id,说明这个版本在Read View创建之前就提交了,可见。
  • 如果DB_TRX_ID大于等于max_trx_id,说明这个版本是Read View创建之后才产生的,不可见。
  • 如果DB_TRX_ID在min_trx_id和max_trx_id之间,需要判断它是否还处于活跃事务中:活跃则不可见,已提交则可见。
  • 如果DB_TRX_ID等于当前事务ID,自己改的数据当然可见。

如果可见性判断失败,就沿着DB_ROLL_PTR找上一个版本,继续判断,直到找到满足条件的版本。

4.3 快照读与当前读:一个反直觉的例子

很多人以为“可重复读就是所有读都会重复”,其实不对。看这个例子:

事务A开始,先执行快照读SELECT * FROM t WHERE id = 1,读到的余额是100。与此同时,事务B把余额改成0并提交。

然后事务A再执行SELECT * FROM t WHERE id = 1 FOR UPDATE,这属于当前读,走的是最新数据,读到的余额是0,不是100。

也就是说:普通SELECT是快照读,FOR UPDATE、LOCK IN SHARE MODE、UPDATE、DELETE都是当前读。如果业务在同一个事务里“先查后改”,尤其依赖查到的旧值做判断,就可能在两步之间出现数据已经变化的情况。解决方法是把查询改成FOR UPDATE,或者用条件UPDATE直接锁住目标行。

4.4 一致性读影响业务的三种情况

实际项目中,因为一致性读造成的“闹鬼”现象往往和可重复读配合出现:

  1. 事务A第一次查询得到结果集,事务B插入新行并提交,事务A再查同一范围,结果仍是旧集合。这符合隔离要求,但如果你拿着这个快照去统计数据、去跑二次核对,可能永远等不到最新数据。
  2. 事务A读了某行,事务B删除该行并提交,事务A再读,还是能读到那条已删除的记录。因为快照里保留着旧版本。
  3. 长期开着事务不提交,会导致undo链不断增长,无法清理,进而让磁盘膨胀,查询变慢。

这些现象不是Bug,是MVCC的设计结果。理解之后,你才会知道什么时候该用SELECT FOR UPDATE,什么时候该用普通SELECT,以及为什么长事务会拖垮实例。

5. 生产环境里最实用的事务经验:长事务、大事务与死锁排查

5.1 大事务为什么会拖垮主从

大事务指的不一定是“数据量大”,而是“持有锁的时间长、执行步骤多、涉及数据行数多”的事务。它带来的问题有:

  • 锁持有过久:同一张表或同一批行的其他更新全部排队。
  • 回滚成本高:事务回滚时,需要根据undo log逆向恢复所有数据。大事务回滚的时间可能比正常执行还长。
  • 主从延迟:binlog里记录的是一个个事务。主库提交一个超大事务后,从库要按顺序把整个事务重放到本地。在从库执行期间,从库的SQL线程只有这一个任务在跑,其他事务全被堵在后面,于是出现明显延迟。
  • 内存压力:事务执行过程涉及的数据页都驻留在内存的缓冲池里。大事务产生的脏页过多,会挤占缓存,影响整体命中率。

之前处理过一个极端的例子:某订单导出功能,在事务里做了全表扫描并批量更新了几十万行,结果主库执行了十来秒,从库延迟从几秒涨到了十几分钟。后来拆成一批批的短事务,延迟才降下来。

5.2 长事务排查:从information_schema看起

长事务指的是“长时间不提交,也不回滚”的事务。它可能没有锁住任何行,但只要它开启过,它的Read View就会一直存在,导致undo log无法清理,历史版本一直保留。

排查长事务,我最常用的是这几条SQL:

SELECT * FROM information_schema.innodb_trx WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60\G

看trx_started(事务开始时间)、trx_state(RUNNING还是LOCK WAIT)、trx_mysql_thread_id(线程ID,可以关联到 processlist)。

要处理长事务,第一步是找到发起会话,确认它在等待什么:

SELECT * FROM performance_schema.data_lock_waits\G SELECT * FROM sys.innodb_lock_waits\G

如果确定是死等或失控事务,就要在应用层杀掉对应线程。直接KILL线程会造成事务回滚,但短痛优于一直卡着系统。

这里有个特别注意点:很多长事务不是业务没提交,而是事务代码里混入了“发消息”“写文件”“调用外部接口”等操作。事务应该只包含数据库操作,外部调用一律放到事务提交之后。

5.3 死锁日志的阅读姿势

MySQL的innodb_print_all_deadlocks=ON打开之后,死锁信息会记录到错误日志。日志里通常有两段信息:

  • LATEST DETECTED DEADLOCK,包含两个事务的加锁顺序。
  • 每个事务的WAITING FOR THIS LOCK TO BE GRANTED和HOLDING THE LOCK(S)。

阅读时关注的不是哪条SQL写错了,而是“锁的申请顺序”。比如日志显示事务A持有行锁在等待表级锁,事务B持有表级锁在等待行锁,那就说明两边获取资源的顺序不一致。解决方式可以是统一加锁顺序,也可以把其中一个事务改成快照读,避免申请写锁。

还有一个小技巧:如果死锁频繁出现,不要只盯着死锁本身,先看索引是不是合理。没有索引的UPDATE会把扫描范围内所有行都锁住,形成“表锁效果”,死锁概率会指数级上升。加一个合适的二级索引,把更新范围缩小,很多死锁能直接消失。

5.4 我总结的事务边界设计清单

综合考虑上面的经验,我在实际项目里会要求团队遵守这套清单:

  • 事务范围最小化:只把必须保证原子性的写操作放进事务,读多写少的查询尽量放到事务外。
  • 事务内禁止外部调用:HTTP、RPC、文件操作、消息发送全部放到事务提交后。
  • 控制事务内数据量:批量更新建议分批,例如每批500~1000行,分批提交。
  • 优先使用主键或唯一索引更新:避免大范围扫描造成的间隙锁扩散。
  • 设置合理超时:innodb_lock_wait_timeout默认50秒,业务侧一般配置为小一点的数值,比如3秒或5秒,避免请求无限期悬挂。
  • 统一资源访问顺序:涉及多行更新的场景,先按主键排序再逐个更新,能显著减少死锁。
  • 开启监控:sys.schema_unused_indexes能帮你找到没被利用的索引,锁范围过大的问题往往从这里暴露。

6. 事务的横向延伸:单机事务到了分布式环境怎么走

6.1 为什么说“单机无忧,分布式处处是坑”

在单机MySQL里,redo log、undo log、锁、MVCC是一套完整的闭环,由InnoDB统一管理。一旦拆成多个服务,比如订单服务用订单库、库存服务用库存库、用户服务用用户库,每个数据库都有自己的事务,单机事务就管不住“跨库一致性”了。

举个最常见的例子:一个下单操作要扣库存数据库的库存,同时写订单数据库的订单。库存库的事务已提交,订单库的事务失败回滚,两个库的状态就永远对不上了。这不是MySQL本身能解决的问题,它只能保证单个节点上的ACID。

6.2 XA两阶段提交和它的问题

MySQL本身是支持XA的,原理是引入一个协调者,让所有参与节点分成两阶段:

  1. 准备阶段:协调者通知各个节点执行事务,执行完成但先不提交,各节点返回“准备好了”。
  2. 提交阶段:协调者收到所有“准备好”后,再通知所有节点真正提交。

XA的优点是强一致性有保障,但它有个致命弱点:在准备阶段,所有参与者都要持有资源锁,协调者在提交阶段如果宕机,所有参与者都会卡在“已准备,等待提交”的状态,释放不了锁。此时整个系统可用性大打折扣。

纯XA在跨服务间用得不多,更多是同一套MySQL主从环境做跨实例数据迁移时用。生产环境里大家更愿意接受“最终一致”而非“强一致”。

6.3 柔性事务方案:本地消息表、事务消息、TCC

既然强一致代价太高,业务侧一般退让为最终一致。常见方案有三种:

  • 本地消息表:把“写业务数据”和“写消息记录”放在同一个本地事务里。业务提交后,通过一个定时任务把消息表里未发送的消息推送到MQ,消费方处理成功后回执,发送方再更新消息状态。两个系统各自保证本地事务,最终靠消息重试对齐。
  • 事务消息:以消息队列自带的事务消息机制为例,发送方先发一个“半消息”,业务逻辑执行完后提交事务,再确认这个消息可投递。如果事务回滚,半消息会被删除,消费者自然收不到。这样避免了业务数据与消息状态“两张皮”。
  • TCC(Try-Confirm-Cancel):把每个服务操作拆成预留(Try)、确认(Confirm)、取消(Cancel)三步。Try阶段只做资源预留,全部预留成功后再执行Confirm,任何一个节点失败则执行Cancel回滚。TCC需要业务代码配合实现,侵入性较强,但能覆盖绝大多数跨服务一致性场景。

我对TCC的体会是:它把“事务”下沉到了业务层,对每个参与方的接口设计能力要求很高。如果团队对业务边界不够清晰,强行上TCC,代码复杂度会成倍增加,维护成本远高于普通接口调用。多数项目其实用不到TCC,本地消息表加上消息补偿任务已经能覆盖99%的订单类场景。

6.4 有时候,不强一致才是正确的降级

关于分布式事务,我的另一个观点是:不是所有场景都必须保证一致。比如“用户修改个人资料”和“生成推荐位缓存”,两者之间没有严格的数据依赖,完全可以拆成两个独立操作,用一个异步任务去刷新缓存。如果因为“想保证强一致”而强制引入分布式事务,结果往往是把简单问题复杂化。

判断要不要引入分布式事务,可以先问三个问题:如果数据不一致,用户是否立刻感知?不一致能否在短时间内自动纠正?恢复手段是人工补偿还是系统补偿?只要允许短时间不一致,并且有可靠的补偿流程,最终一致就是更优的架构选择。

我自己在实际项目里,通常的做法是:把核心链路上的强一致需求降到最低,对必须强一致的少量场景,采用“本地事务 + 事务消息”的方式,并且为所有外部调用准备幂等键。这样既避免了XA的性能风险,也能在大多数情况下保证上下游数据收敛。如果你正打算为系统引入分布式事务,建议先不要急着选框架,把业务里真正需要强一致的点找出来,很可能发现根本不需要那么复杂。

再补充一个日常调优经验:不少年轻人一开始做MySQL事务调优,喜欢盯着锁、隔离级别、参数一顿猛调,但往往忽略了一个基础问题——SQL没走索引,导致锁扩散。先把慢日志打开,把long_query_time设为1秒,每周检查一次TOP慢SQL,优先解决全表扫描,再谈锁优化。我见过很多“死锁频繁”的问题,调完索引之后直接就消失了,连事务代码都没动。这个顺序如果搞反,会走很多弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进