1. 先搞清楚:事务到底帮你挡下了哪些灾难
做业务开发的同学应该都遇到过这类问题:订单扣了库存却生成了失败的订单,转账扣了钱对方没收到,取消订单后库存莫名其妙被改回去了。这些问题的根源往往只有一个——对事务的理解停留在"知道有这回事"的层面,等到线上出问题时才开始怀疑人生。
先说结论:事务的本质不是让操作变快,而是让操作在出错时能"完整地退回原地"。我见过太多初级开发者把事务当成性能优化工具,这是认知层面的误解。举个例子,一个订单创建流程涉及用户表、订单表、库存表、余额表,四张表的写入操作都必须在同一个事务边界内。任何一张表写入失败,其他三张表都要跟着回滚。没有事务,你只能写一堆if判断然后手动delete,数据一致性只能靠运气。
MySQL中InnoDB引擎对事务的支持是最成熟的,MyISAM连事务都没有,崩溃恢复时可能出现半写状态。这也是为什么从MySQL 5.5开始InnoDB成为默认引擎——不是MyISAM不行,而是业务对数据一致性的要求已经变成了底线。你在面试中回答"事务是什么"的时候,如果只说出"一组操作要么全部成功要么全部失败"这句话,基本已经输了一半。因为面试官更想听到的是:事务解决的是并发场景下数据正确性的问题,以及崩溃场景下数据持久性的问题。
你真正需要建立的认知体系是这四件事:验证一个操作是否成功需要什么判定条件?并发访问时不同隔离级别会看到什么数据?崩溃恢复时InnoDB如何借助日志保证不丢数据?以及业务代码中哪些场景会导致事务注解失效?这四个问题串起来,才是对MySQL事务相对完整的理解。
2. 从一条Update语句出发,拆解InnoDB事务的底层运作机制
很多资料一上来就贴ACID四大特性的定义,读者看完就忘。我的习惯是从一条具体的Update语句讲起,把InnoDB在后台做的工作摊开给你看。
假设你要执行这条SQL:UPDATE user SET balance = balance - 100 WHERE id = 1。这条语句在事务内部大概会触发这样的流程:先检查id=1的行是否存在,不存在或者行被其他事务锁住时等待;命中了就加排他锁(X锁);然后修改缓冲池(Buffer Pool)中的数据页,把旧值写入Undo Log;生成Redo Log记录这次修改到Log Buffer;事务提交时把Redo Log刷入磁盘。整个链条里,事务的原子性靠Undo Log兜底,持久性靠Redo Log保证,隔离性靠锁和MVCC维持。
2.1 Undo Log:你的"后悔药"
事务执行过程中修改了数据,一旦中途出错或者你主动回滚,需要把数据恢复成修改前的状态。Undo Log记录的是逻辑反操作——INSERT对应DELETE、UPDATE对应反向UPDATE。这条日志在崩溃恢复时至关重要:如果事务还没提交系统就崩了,重启后要利用Undo Log把修改过的页面回滚到事务开始前的状态。
我见过一个容易混淆的点:Undo Log回滚的是事务本身未提交的修改,不是把已经提交的事务撤销。已提交的事务属于持久性的范畴,由Redo Log负责保障。
2.2 Redo Log:崩溃后能站起来的底气
缓冲池里的数据页修改完,不可能每次都立刻刷盘——那样性能太差。但如果一直不刷盘,MySQL一旦崩溃,修改过的数据就丢了。Redo Log解决的就是这个问题:事务提交时只要Redo Log落盘,哪怕数据页还没刷盘,MySQL也敢向客户端返回"提交成功"。崩溃后重放Redo Log就能把数据恢复出来。
这里有个经典的性能细节:innodb_flush_log_at_trx_commit参数设成1时每次事务提交都刷盘,最安全但性能开销大;设成2时只写操作系统的page cache,MySQL崩溃不丢数据但操作系统崩溃可能丢;设成0时交给后台线程定时刷,性能最好但可能丢最近一秒的修改。生产环境默认用1,能接受性能损失换数据安全。
2.3 崩溃恢复时的两段式博弈
恢复过程本质上是先重放Redo Log把数据推进到最新状态,再利用Undo Log回滚掉崩溃时尚未提交的事务。打个比方:Redo Log是草稿纸上的最终答案,Undo Log是答案下方的修改痕迹,恢复流程就是先按最终答案抄一遍,再根据修改痕迹把没写完的部分擦掉。
我在排查线上问题时发现,很多开发对"提交成功"存在误解。MySQL返回事务提交成功,不代表数据已经进了磁盘上的数据文件,只能说Redo Log已经落盘。这两者的时间差是正常的,数据文件最终会在后台由刷盘线程追赶。理解这个逻辑,你就明白为什么删库跑路前要小心,也明白为什么数据库突然断电后有些已提交事务的数据会短暂看不到——不是丢了,是还没从Redo Log重放完。
3. 隔离级别与MVCC:并发场景下谁先看到谁的数据
隔离级别是事务最容易考倒人的地方,也是实际业务中最常踩坑的环节。MySQL InnoDB默认的隔离级别是REPEATABLE READ(可重复读),这一点和其他数据库不太一样——Oracle、PostgreSQL默认用READ COMMITTED。为什么要特意区分?因为InnoDB实现了MVCC(多版本并发控制),天然让读写互相不阻塞,即便在REPEATABLE READ级别下也能避免大部分幻读问题。
3.1 四种隔离级别,从宽到严
- READ UNCOMMITTED:读未提交。事务还没提交的修改,其他事务能直接看到。脏数据漫天飞,基本没人生产环境用。
- READ COMMITTED:读已提交。事务只能读到其他事务已经提交的数据,解决脏读,但同一个事务内两次相同查询可能结果不同(不可重复读)。
- REPEATABLE READ:可重复读。事务开始后,读到的是事务启动时的一致性快照,解决不可重复读。但理论上还会有幻读问题——InnoDB通过间隙锁把这个洞基本堵上了。
- SERIALIZABLE:串行化。读写都加锁,完全串行执行,隔离最强但并发能力最差。
3.2 快照读和当前读的区别是你最需要记住的
MVCC在REPEATABLE READ级别下,普通SELECT是快照读,拿的是事务开始那一刻的版本快照,后续其他事务提交了也不影响你看到的结果。而UPDATE、DELETE、SELECT ... FOR UPDATE属于当前读,永远读取数据当前已提交的最新版本,同时会加锁。
这个区别直接决定了一件事:两个事务先读同一行数据,另一个事务删除了这行,前一个事务再UPDATE这行会发生什么?快照读看不到删除,但当前读会发现行已经没了,更新0行。业务代码里如果没处理更新0行的情况,就会出现"订单状态改了但实际没改到"的诡异现象,排查半天发现是隔离级别快照读的锅。
3.3 间隙锁与幻读的实战边界
引入REPEATABLE READ后,InnoDB还会加间隙锁加临键锁(Next-Key Lock)来防止幻读。注意这只在索引扫描条件下生效:因范围条件命中的是索引项,间隙锁锁住的是两个索引记录之间的间隙,别的会话不能往这个间隙插入新数据。
举个例子,表里有id=1和id=5两行,你执行SELECT * FROM user WHERE id BETWEEN 2 AND 4 FOR UPDATE,即使没有实际行的数据被命中,间隙也会被锁定,其他线程想插入id=3的行必须等这个锁释放。这就是"当前读没有幻读"的机制。
如果业务隔离级别是READ COMMITTED,间隙锁会被禁用,只锁住命中的实际行,幻读的隐患就实打实地存在了。所以MySQL的REPEATABLE READ能抗住大部分需要用SERIALIZABLE才能防御的并发场景,代价是间隙锁对插入性能的影响。低并发写入场景可忽略,高并发插入场景要小心死锁和阻塞升温。
4. 事务的代码落地:手动控制与Spring注解的边界感
纸上谈兵到这里,需要落到代码层面了。MySQL事务可以通过SQL语句显式控制:START TRANSACTION或BEGIN开启事务,COMMIT提交,ROLLBACK回滚,SET autocommit = 0可以让后续所有SQL合并在一个事务里直到显式提交。但实际工程中这段逻辑大概率会被Spring的@Transactional代理掉,这里面的坑比想象中多。
4.1 最容易翻车的四个事务失效场景
场景一:私有方法上的注解不生效。Spring事务代理本质是通过AOP生成代理类来接管方法调用,如果方法被private修饰,代理类拿不到调用入口,注解形同虚设。
场景二:同一个类内部自调用。一个方法A没加事务,内部直接调用加了事务的方法B,这属于内部直接调用,走的是this指针,不是代理对象,事务同样不生效。解决办法是注入自己的代理对象,或者把B方法拆到另一个类中。
场景三:异常被吞了。事务方法内catch住异常后没有重新抛出,Spring感知不到执行失败,理所当然不会回滚。代码里出现try { ... } catch (Exception e) { log.error(...) }后,@Transactional的默认回滚逻辑完全失效。这个坑我见过太多次,回滚时机要由Spring的TransactionInterceptor来判定,它只看你有没有抛出运行时异常。
场景四:事务只作用于当前线程内。子线程内部新启的事务,或者通过this调用的异步方法,都和主线程的事务隔离。这也是为什么事务管理不能跨线程传播,分布式事务框架才应运而生。
4.2 传播行为怎么选:REQUIRED足够应付绝大多数场景
Spring事务传播行为是个高频考点。默认的REQUIRED表示如果有事务就加入,没有就新建。大多数业务都应该用默认值——业务方法本身需要独立事务,又要与外部调用方保持同一个原子边界时,REQUIRED最合理。
需要特殊处理的场景是:一个流程中某个环节失败不影响主体逻辑,这个环节需要自己独立提交或回滚。这时候用REQUIRES_NEW,外层事务不感知内层事务的回滚状态。但要注意,内层事务持有数据库连接的话,外层事务回滚后内层事务已经提交的数据不会跟着回滚,业务上要有对应的补偿机制。
4.3 隔离级别在事务代码里怎么设
MySQL默认的REPEATABLE READ在大多数业务场景下够用,但高并发读多写少的系统,可以考虑把隔离级别降到READ COMMITTED,换取更短的锁等待时间和更少的死锁概率。Spring中可以用@Transactional(isolation = Isolation.READ_COMMITTED)指定,实际执行时事务管理器会发出一条SET TRANSACTION ISOLATION LEVEL命令。生产环境改隔离级别前要做压测,因为数据库整体设置变了,SQL执行计划可能跟着变化。
5. 锁的几种类型:共享锁、排他锁、间隙锁和它们的归宿
理解了隔离级别,就该看锁的具体分类了。锁是事务并发的底层支撑,面试题里"MySQL锁的分类"排得进前五名。分类其实不复杂:按模式分共享锁(S锁)和排他锁(X锁);按粒度分表级锁和行级锁;行级锁中又分记录锁、间隙锁和临键锁。加上意向锁(Intention Lock)这个小家子,就是全部内容。
5.1 行锁的加锁规则与索引陷阱
InnoDB行锁锁的是索引记录,不是单纯的"行"。你在无索引列上加锁查询,InnoDB会退化成锁整张表——因为找不到索引项来定位具体行,只能锁全表防止并发冲突。这就是为什么看到线上某条UPDATE操作把整个表堵死了,第一反应就该查这条SQL有没有走对索引。
记录锁(Record Lock)锁住单个索引记录;间隙锁(Gap Lock)锁住两行之间的空缺;临键锁(Next-Key Lock)是两者结合体,既锁记录又锁间隙,是REPEATABLE READ下防止幻读的主武器,也是死锁的主力制造机之一。
5.2 死锁产生的标准配方与破解套路
死锁是事务并发最臭名昭著的问题。经典场景是两条SQL互相持有锁等待对方释放,举一个实际可复现的例子:
事务A:UPDATE account SET balance = balance - 100 WHERE id = 1;然后UPDATE account SET balance = balance + 100 WHERE id = 2;
事务B:UPDATE account SET balance = balance - 100 WHERE id = 2;然后UPDATE account SET balance = balance + 100 WHERE id = 1;
如果两事务同时执行,A拿了id=1的行锁,B拿了id=2的行锁,接着A再要id=2的行锁发现被B持有,B再要id=1的行锁发现被A持有,双双死锁。InnoDB会立刻检测到死锁,牺牲一个事务回滚并抛出Deadlock found when trying to get lock; try restarting transaction,另一个事务继续执行。
破解套路很朴素:所有事务按固定的顺序访问资源。A和B都先处理id=1再处理id=2,死锁概率降为零。还有一个经验:把事务做得短、做得小,锁持有时间短,死锁概率自然下降。遇到死锁日志,别急着骂数据库,先看事务访问的表和行的顺序是否能统一。
5.3 锁等待与排查触手
innodb_lock_wait_timeout默认50秒,意思是等待锁超过50秒就放弃并报错。排查锁等待的关键命令是SHOW ENGINE INNODB STATUS,它会把当前持有锁和等待锁的事务链完整列出来,配合performance_schema.data_locks和data_lock_waits两张表能拼出完整的阻塞图谱。实际战斗中我经常在sys.innodb_lock_waits视图里直接查,它把事务ID、锁住的表、等待时间、SQL语句拼成一行,定位问题比看原生日志快太多。
需要区分的是锁等待和死锁:锁等待是单方面阻塞,等超时会抛Lock wait timeout exceeded;死锁是两个事务互相等待,MySQL主动解开,两者都算正常的并发机制,不是bug。
6. 长事务与大事务:性能瓶颈和运维噩梦的重灾区
事务讲到现在,全是隔离和锁,还有一个隐藏但致命的维度:事务长度。我接手过几次"数据库突然CPU飙升、主从延迟暴涨"的线上事故,根因都是长事务占住了行锁和Undo Log,导致其他任务大面积锁等待,从库重放Redo Log越来越慢。
6.1 大事务的三大恶果
- 锁持有时间过长:一行数据被锁十几分钟,其他写操作全部排队,业务响应时间直接报警。
- Undo Log膨胀:长事务期间所做的所有修改都需要保留Undo,防止回滚需要,导致Undo表空间只增不减,磁盘占用飙升。
- 从库延迟加剧:主库事务提交产生的Binlog一次性传输到从库,从库单线程/并行回放都有一致性限制,大事务会把延迟差拉得很惊人。
6.2 如何发现和终止长事务
MySQL有现成的SQL查当前运行中的事务和它们的时间:SELECT * FROM information_schema.innodb_trx WHERE trx_state = 'RUNNING'。能直接看到trx_started字段,算一算时间就知道哪些事务已经跑了很久。老版本还能从PROCESSLIST里看有事务的事务连接长时间处于Sleep状态,排查时重点看Time字段大的连接是"真的在干活"还是"占了连接睡觉"。
终止长事务要谨慎:直接KILL对应线程会回滚掉整个事务,如果这个事务已经跑了很长时间,回滚本身也会消耗大量时间。所以最稳妥的姿势是在代码层面限制事务方法执行时间,用@Transactional的超时属性timeout兜底,Spring会在超时后抛出TransactionTimedOutException并触发回滚。配合数据库侧的MAX_EXECUTION_TIME优化器提示去限制SQL执行时间,两条防线结合起来才稳。
6.3 大事务的拆解思路
业务逻辑上能拆就拆:把大循环里逐条更新拆成批量、分批提交;报表类的写入放在业务低峰期执行;批量更新加LIMIT分段推进。ORM框架中特别注意:循环里每次查询都开启事务,长时间开SHOW时间就拉长了。我习惯的做法是,事务方法里只做与本次业务强相关的写操作,像计算、日志、外呼接口这种非关键动作放到事务外异步处理。
7. 分布式事务:从单库走向多库,一致性的新战场
微服务化铺开后,本地事务已经覆盖不了跨库跨服务场景。最典型的例子就是订单服务扣库存+库存服务减少库存,两个数据源分开部署,本地事务没法保证两者原子性。这一领域的核心思想是把"强一致"降级为"最终一致"。
7.1 2PC/XA:分布式下的强一致尝试
两阶段提交(2PC)分准备阶段和提交阶段。协调者先让所有参与者完成本地事务但先不提交,全部准备成功后再统一提交,任一准备失败则全部回滚。MySQL原生支持XA,有XA START、XA END、XA PREPARE、XA COMMIT命令。缺点是协调者单点故障,参与者全程阻塞等待协调者指令,性能代价大,生产系统自己实现2PC的很少。
7.2 TCC:Try、Confirm、Cancel三段式的工程化选择
TCC模式是目前国内互联网最常自研的分布式事务方案之一。Try阶段冻结资源(比如冻结库存),Confirm阶段真正扣减,Cancel阶段释放冻结。业务方自己实现补偿逻辑,协调者只需要按状态机调度。这套方案代码量大、侵入性高,但是能应对资金类等需要较强一致性的场景。业界有成熟的TCC框架,比如Seata的TCC模式,虽是开源项目但就事论事不推荐品牌,你自己评估即可。
7.3 消息事务:最终一致性的主流路径
用消息中间件做最终一致性是目前订单和库存场景的首选方案。流程是:本地事务里写业务表并向消息表插入一条消息,提交后后台定时任务把未发送的消息投递到MQ,消费端收到后再处理自己的业务。这里关键点是保证"本地事务与消息发送"的原子性,否则会出现业务成功但消息没发出去,或者业务失败但消息发出去了的情况。
另一种是MQ的事务消息机制:发送半消息,本地事务执行完再提交或回滚半消息;如果长时间没有二次确认,MQ反向查询事务状态来决定是投递还是丢弃。这个机制依赖业务方提供反查接口,落地成本不高,但能有效解决本地事务和消息发送不一致的问题。
7.4 什么时候才需要分布式事务
不是所有系统上来就该上分布式事务。绝大多数单体应用用本地事务就足够了,引入分布式事务往往带来性能开销和运维复杂度。建议只有一个原则:当你确实把数据拆到了不同数据库或不同服务,且无法容忍哪怕短暂的数据不一致时,才去考虑TCC、事务消息或SAGA。能用定期对账+人工补偿解决的,没必要把架构复杂度顶上天。
8. 排查事务问题的三板斧:日志、状态表、执行计划
写到这里,到了最实战的部分。排查事务问题,我总结的顺序是:确认当前事务状态,查看谁阻塞了谁,分析为什么会阻塞,然后对症下药。
8.1 第一板斧:看事务和锁等待
一条SQL定位所有正在运行的事务:
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx;再配合锁等待信息:
SELECT * FROM performance_schema.data_lock_waits;或者用更友好的系统视图,直接把阻塞链打出来:
SELECT * FROM sys.innodb_lock_waits;这个视图会显示等待锁的事务ID、持有锁的事务ID、锁的表名和索引名、等待时间(秒)。一眼就能看出是谁堵住了谁。
8.2 第二板斧:看InnoDB引擎状态
SHOW ENGINE INNODB STATUS\G是InnoDB的体检报告,LATEST DETECTED DEADLOCK段会记录最近一次死锁涉及的SQL语句和锁持有顺序。死锁发生时,这段日志会非常清楚地列出事务A持有哪把锁、等哪把锁;事务B持有哪把锁、等哪把锁。拿到这两行信息后,按固定顺序访问资源的原则去调整代码就好了。
8.3 第三板斧:看SQL执行计划
锁等待频繁,极大概率是索引没走好。对可疑SQL执行EXPLAIN,检查type字段是否出现ALL(全表扫描)——一旦是全表扫描,InnoDB会锁大量行甚至全部扫描区间。常用的优化手段是给WHERE条件和UPDATE条件涉及的列建合适的索引。索引的区分度也很重要,选择性太低(比如性别列)的索引意义不大。
8.4 一个真实案例复盘
我处理过一个库存扣减的线上事故,表象是高峰期大量下单失败,报错信息是Lock wait timeout exceeded; try restarting transaction。第一步用sys.innodb_lock_wait看锁等待,发现积压了三十多个事务等同一行库存记录。第二步EXPLAIN分析扣减SQL,type=ALL,表示每次扣减都全表扫描定位记录。第三步加索引后,问题立刻缓解,锁粒度从全表退化成单行。这个小案例说明:锁问题一半以上其实是索引问题。
9. 我在生产环境踩过的坑和总结出的三个习惯
回看这些年跟MySQL事务打交道的经历,真正改变我编码习惯的有三件事:
第一个习惯是在事务方法里尽量不碰外部调用。HTTP调用、RPC调用、Redis操作放进事务里,意味着锁持有时间等于外部接口耗时加网络延迟。一旦对端服务慢,数据库锁被拖住,整个数据库连接池很快就满了。
第二个习惯是允许例外发生时要有明确的回滚判断。我见过太多用@Transactional都救不了的代码:方法内部先执行一段写操作,再抛业务异常,异常被外层catch后吞掉,结果写操作没有回滚。现在我在事务方法里会强制要求,非预期异常必须重新抛出,或者明确用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()标记当前事务回滚,不能用日志代替异常处理。
第三个习惯是给关键写操作用上显式锁提示。Redis分布式锁做不到数据库层面的操作原子性,所以即使有分布式锁,我会在SQL里加上FOR UPDATE,以前置数据库锁为最终兜底,防止并发穿透。反过来,高并发读多写少的场景,我会评估把隔离级别调低一点,以空间换时间。
MySQL事务是那种"入门容易、精通极难"的主题。理解原理是一层,会用来解决业务问题是另一层,能扛住线上高并发下的锁竞争才是终极层次。这篇文章写到这里,核心思路就是:先清楚InnoDB在后台做的Undo、Redo、锁和MVCC这几件事,再回到业务代码里谨慎地把事务边界画准确。真遇到问题不要慌,SQL查状态、日志看死锁、执行计划补索引,三板斧下来,大部分事务问题都能找到根因。