先用一个场景把问题说清楚:你在秒杀系统里扣库存,业务逻辑往往是“先查库存,再写订单,最后扣减库存”。如果这三步中间任何一步失败,前面成功的数据都要一并撤销,否则就会出现“订单生成了,库存却扣不动”或者“库存扣了,订单不存在”这类资损事故。这就是事务存在的最直接理由。而要真正搞清楚“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放到事务引擎里的执行链路串起来:
- 事务开始,分配一个事务ID(trx_id)。
- 找到目标行的聚簇索引记录,加上锁(这里暂时假设是排他锁)。
- 把修改前的旧值写入 undo log,并让行上的回滚指针指向这个undo记录。
- 在内存里修改数据页,同时记录 redo log。
- 更新二级索引等相关结构,同样记录redo。
- 事务提交时,redo log 按配置策略落盘,然后写 binlog(这里还会发生 redo 和 binlog 的两阶段提交,保证两份日志一致)。
- 如果事务回滚,则根据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 一致性读影响业务的三种情况
实际项目中,因为一致性读造成的“闹鬼”现象往往和可重复读配合出现:
- 事务A第一次查询得到结果集,事务B插入新行并提交,事务A再查同一范围,结果仍是旧集合。这符合隔离要求,但如果你拿着这个快照去统计数据、去跑二次核对,可能永远等不到最新数据。
- 事务A读了某行,事务B删除该行并提交,事务A再读,还是能读到那条已删除的记录。因为快照里保留着旧版本。
- 长期开着事务不提交,会导致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的,原理是引入一个协调者,让所有参与节点分成两阶段:
- 准备阶段:协调者通知各个节点执行事务,执行完成但先不提交,各节点返回“准备好了”。
- 提交阶段:协调者收到所有“准备好”后,再通知所有节点真正提交。
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,优先解决全表扫描,再谈锁优化。我见过很多“死锁频繁”的问题,调完索引之后直接就消失了,连事务代码都没动。这个顺序如果搞反,会走很多弯路。