1. 事务的本质先把ACID从口号变成可执行的机制聊到 mysql 事务mysql 事务很多人的第一反应是 ACID 四个字母、四种隔离级别、加个事务注解就完事。但真到线上出问题的时候这些概念往往帮不上忙——你看到一个接口超时、一批数据对不上账、或者某个凌晨报警说事务日志快满了脑子里浮现的还是那四个字母手头却不知道该看哪条命令。这篇文章想做的事很简单把 mysql 事务从一个面试概念拉回到可排查、可优化、可落地的实操层面。不管你是在写业务接口的开发还是负责线上库稳定性的 DBA或者正在准备事务相关的后端面试这篇文章里都有可以直接拿去用的东西。1.1 一致性不是MySQL自己兜底应用层也得参与先说一个经常被误解的点很多人以为数据库保证了原子性所以一个事务里的多条SQL要么全成功要么全失败业务就“一致”了。这话只对了一半。举个最典型的转账例子A给B转账100元。业务代码写两步——update account set balance balance - 100 where id A然后update account set balance balance 100 where id B。这两个update放在同一个事务里事务提交后数据库层面保证要么两条都生效要么都不生效。这是原子性没毛病。但如果业务代码自己写错了比如把B的加钱写成了balance balance - 100或者干脆忘了第二条SQL那事务照样正常提交数据库不会帮你检查“转账后总金额是否守恒”。所以一致性是应用代码和数据库共同保证的数据库负责把一组操作打包成原子单元业务负责把操作序列写对。这也是为什么我在代码评审里特别关注事务边界的理由。事务不是越短越好也不是越长越好而是正好覆盖“必须原子生效的那组操作”就好。少一条SQL数据会不一致多包了一堆无关查询锁持有时间变长并发能力反而下降。1.2 InnoDB靠什么同时撑起原子性和持久性redo log与undo log很多人会背“用redo log保证持久性用undo log保证原子性”但问到底层怎么配合就卡住了。这里用一个实际写入过程把它讲透。当你执行update t set name new where id 1这条语句时InnoDB做的事情大致是先把id1这行数据从磁盘读入内存中的缓冲池Buffer Pool如果不在的话。注意这一步不一定发生可能数据页已经在内存里。在内存里修改这行数据把name改成new同时把这行修改前的值old写入undo log这样万一事务回滚能恢复老值。生成一条redo log记录“在某个数据页的某个偏移位置发生了什么样的修改”并把它写入redo log buffer。提交时根据innodb_flush_log_at_trx_commit参数的设置把redo log刷到磁盘。如果这个参数是1每次提交都强制刷盘如果是0每秒刷一次如果是2写入操作系统缓存但每秒刷盘。这个设计最妙的地方在于MySQL不需要在提交时把修改后的数据页立刻刷到磁盘只要redo log落盘了事务就算持久化成功。万一数据库崩溃重启时用redo log重放就能把内存里还没来得及落盘的数据恢复回来。所以持久性真正的核心是redo log的落盘。我见过不少生产事故都是因为有人把innodb_flush_log_at_trx_commit改成0来提升性能结果服务器断电丢了好几秒的事务。这个参数调优要谨慎不是所有业务都能接受每秒级别的数据丢失。undo log这边的作用则是回滚和MVCC。回滚时根据undo log恢复数据页上的老值MVCC多版本读时通过undo log构建历史版本链让一个事务读到事务开始前的旧快照。这俩日志是InnoDB事务机制的左右手少一个都不行。1.3 隔离级别的实现不是一把大锁而是一套组合机制隔离级别的底层实现经常被简化成“加锁”但真实情况是锁配合MVCC多版本并发控制一起工作。读操作尽量走MVCC快照读不加锁不阻塞读到的是一致性视图下的旧版本数据。写操作走当前读必须加锁保证不会有两个事务同时改同一行。需要更强隔离时再引入记录锁、间隙锁、next-key lock这类锁机制把读操作也变成当前读。以MySQL默认的REPEATABLE READ为例事务中第一次执行普通select时InnoDB会生成一个一致性快照Read View这个事务后续的普通select都基于这个快照读取所以同一个事务内多次查询结果一致不会出现“自己改了自己看不到”的问题。而另一个事务提交的新数据在这个快照里是看不到的——这也就是可重复读的含义。到了READ COMMITTED级别每次select都会生成新的快照所以同一事务内两次查询可能看到不同的已提交数据这就是不可重复读的来源。MVCC加上Undo Log靠的是版本链和Read View这套组合而不是一把大锁把所有读都锁死。把这一层理解透了后面看锁等待、看死锁日志才不会一头雾水。2. 隔离级别与锁一张表说清楚MVCC、间隙锁和死锁网上关于“事务级别”和“锁的分类”的资料浩如烟海但大部分是互相抄的表格没有演示过程。这一章我带你把隔离级别的差异亲手验证一遍顺便把InnoDB的锁体系梳理清楚。2.1 动手验证四种隔离级别的差别准备一张简单的表CREATE TABLE t_account ( id INT PRIMARY KEY AUTO_INCREMENT, balance INT NOT NULL ) ENGINEInnoDB; INSERT INTO t_account(balance) VALUES (100);打开两个终端会话模拟两个并发事务。先测脏读。把两个会话的隔离级别都改成READ UNCOMMITTEDSET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;会话A开启事务把balance改为200但不提交BEGIN; UPDATE t_account SET balance 200 WHERE id 1;会话B去查询SELECT balance FROM t_account WHERE id 1;读到了200。这就是脏读读到了别人还没提交的数据。如果A事务回滚B就读了个不存在的值。再把级别换成READ COMMITTED重复上面步骤。这次B读到的还是100因为A未提交。然后A提交B再查一次读到200。同一个事务里两次查询结果不同说明不可重复读。继续换成REPEATABLE READA开启事务并select一次B插入一条新记录并提交A再select还是查不到B插入的新行。这里有个细节值得注意很多人以为幻读在REPEATABLE READ下被完全解决了。严格说InnoDB在RR级别下通过快照读解决了普通select的幻读问题但如果你把读写混在一起比如先查、再根据结果去update其他行仍有可能撞上next-key lock的范围加锁问题进而出现锁等待甚至死锁。四级别对比起来就一张表隔离级别脏读不可重复读幻读实现核心READ UNCOMMITTED可能可能可能直接读最新版本READ COMMITTED不可能可能可能每次select生成新快照REPEATABLE READ默认不可能不可能基本解决事务内首次select生成快照间隙锁防范围写SERIALIZABLE不可能不可能不可能读也加锁并发最低2.2 锁的分类从行锁到间隙锁锁的不只是行很多人把InnoDB锁理解成“锁住一行”这是最朴素的认知但实际要复杂得多。按锁的兼容性分共享锁S锁允许多个事务同时读同一行排他锁X锁互斥别人不能再加任何锁。select默认不加锁是快照读但select ... for update和select ... for share是当前读会真的加锁。按锁的粒度分行锁之外还有表级别的意向锁。事务想在某个行上加锁前会先在表上加意向锁用来告诉别人“我可能要在某些行上加锁了”。两个事务分别锁不同的行意向锁之间兼容但一个事务要给整张表加排他锁时要等所有行锁释放。行锁里最值得讲的是间隙锁和next-key lock。间隙锁锁的是一个范围区间而不是某一行。比如表里只有id1和id100两条记录你在REPEATABLE READ下执行select * from t_account where id between 10 and 50 for updateInnoDB会把(1, 100)这个间隙给锁上阻止其他事务在这个区间内插入新记录。next-key lock则是记录锁加上前面的间隙锁锁的是一个左开右闭区间前面例子锁的就是(1, 100]。间隙锁是MySQL在RR级别下防幻读的关键但也是最容易引发线上死锁的元凶之一。两个事务各自持有不同间隙的锁又想插入对方间隙里的数据就会互相等待。DBA在排查锁等待问题时经常发现不是要更新同一行而是撞在了间隙上。2.3 死锁是怎么来的以及如何从错误日志里读线索死锁的英文是deadlockInnoDB检测到死锁后会回滚其中一个事务让另一个继续执行。业务方看到的报错通常长这样Deadlock found when trying to get lock; try restarting transaction这时候第一反应不是重试就完事而是要去SHOW ENGINE INNODB STATUS里看最新的死锁日志。日志里会列出两个事务各自持有什么锁、在等什么锁、在哪个索引的哪个记录上。举个例子事务A持有id1的锁想获取id2的锁事务B持有id2的锁想获取id1的锁。日志里会清楚展示两个WAITING FOR THIS LOCK TO BE GRANTED的段落。看到这种典型的循环等待解决办法通常是调整并发控制顺序——比如让所有事务都按照id升序加锁保证顺序一致就能大幅降低死锁概率。另一个常见死锁来源是配合间隙锁的插入操作。两个事务都执行了select ... for update锁了同一个间隙然后都尝试插入数据双双等待对方释放间隙锁。这种情况下光靠“按顺序访问”不顶用需要评估是否真的需要在RR级别下做这种范围锁定或者改用唯一索引避免间隙锁范围扩大。3. 实战排查事务不生效、回滚失败与日志爆满理论讲完进入我最想写的部分——线上真实遇到过的坑。3.1 事务不生效最常见的引擎和连接问题先别急着怀疑Spring的事务注解配错了先检查最基础的两件事。第一表引擎是不是InnoDB。MySQL 5.5之后默认引擎是InnoDB但迁移老库的时候经常能碰到MyISAM表。MyISAM不支持事务你给它包了事务、加了注解它照样一条条提交回滚一点用都没有。排查办法很简单SELECT table_name, engine FROM information_schema.tables WHERE table_schema your_db;只要有一张业务表是MyISAM事务对这张表就是不完整生效的。第二autocommit设置有没有被改掉。MySQL默认开启autocommit每条SQL自动提交。如果某个连接把autocommit关了又没有显式commit事务会一直挂着锁不放。很多“接口突然全部超时”的事故根因往往就是一个连接池里的连接带着未提交的事务被复用。至于Spring的事务注解最常见的问题有三个自调用。同类中一个方法a调另一个方法bb上有Transactional但a和b都在同一个类的内部Spring代理默认不拦截内部调用b上的注解直接失效。异常被吞。try-catch把异常捕获了事务感知不到异常自然不会回滚。让事务回滚的前提是异常抛出了事务边界。默认不回滚checked异常。Spring默认只在抛RuntimeException和Error时回滚如果业务抛的是自定义checked exception又不指定rollbackFor看起来事务“没生效”其实是没回滚。我处理过的一个典型案例一个批量导入方法循环里每行数据try-catch住记录失败原因最后统一返回失败列表。设计本意是“单行失败不影响其他行”但因为异常在事务内部被吞了某行数据写了一半后面的校验失败更新操作照样提交了。这类问题靠日志是查不出来的得靠数据对比才能发现。3.2 事务日志已满一个真实到令人头疼的案例热词里有一条特别真实的报错某数据库提示“事务日志已满”。先说个前提那条“消息 9002, 级别 17, 状态 2”的格式是SQL Server的不是MySQL的大家别搞混。MySQL下常见的事务日志相关报警有两类redo log满了写入阻塞。MySQL 8.0.30之前redo log大小由innodb_log_file_size决定固定大小、循环写入。如果设置太小或者脏页刷新跟不上就会报“REDO LOG capacity reached”之类的告警。undo表空间膨胀磁盘占满。大事务长时间不提交undo log一直保留膨胀到占满磁盘。排查redo相关参数的SQLSHOW VARIABLES LIKE innodb_log_file_size; SHOW VARIABLES LIKE innodb_redo_log_capacity;MySQL 8.0.30之后redo log改由innodb_redo_log_capacity控制默认100MB。如果业务高峰期写入量巨大100MB很容易写满建议事先根据数据量评估一般生产环境我会设置到4GB以上配合innodb_log_files_in_group和innodb_buffer_pool_size一起看。undo膨胀的情况更隐蔽。查一下undo表空间大小SELECT tablespace_name, file_name, allocated_extents * 1024 * 1024 AS allocated_mb FROM information_schema.files WHERE file_type UNDO LOG;如果发现undo文件持续膨胀优先排查是否有长事务或者大事务一直没有提交。长事务会让undo log无法被purge线程清理这是最常见的原因。处理这类问题的思路是三层先查当前活跃事务定位阻塞源再评估能否kill掉异常会话最后调整参数和业务逻辑避免大事务再次出现。千万别一上来就重启数据库重启可能触发崩溃恢复耗时可能比想象中长得多。3.3 大事务和长事务的坑从慢查询到锁等待字节、连接、锁等待是三个典型表象但根子往往都是一个长事务。长事务的危害比很多人意识到的更大持有锁的时间长阻塞其他会话更新同一行导致接口变慢堆积锁等待。undo log一直得不到清理版本的旧链越来越长读操作要回滚多次才能拿到目标版本读性能下降明显。主从复制里大事务的binlog分发耗时更长容易造成主从延迟。定位长事务用这条SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx;trx_started字段如果比当前时间早了很多分钟甚至几小时基本可以断定有长事务。trx_mysql_thread_id可以帮你在performance_schema或者SHOW PROCESSLIST里找到对应连接看看它到底在跑什么。我有个习惯给业务库配一个定时任务每隔30秒采集一次information_schema.innodb_trx把运行超过120秒的事务写入监控表。这样一旦出现锁等待或者连接数暴涨回溯历史数据马上能定位是哪个应用、哪条SQL、从几点开始挂的。不要等出事了才手忙脚乱去查。4. 分布式事务怎么选订单与库存场景的取舍标题虽然只是“mysql 事务”但这几年搜这个关键词的人十有八九还带着一个诉求订单和库存这种跨库、跨服务的操作要怎么保证一致性。热词里“订单与库存分布式事务”“分布式事务一致性”都排得很靠前所以这一章专门聊聊这块。4.1 为什么本地事务到了微服务就不够用本地事务的边界是单库。一个事务里可以操作多张表因为它们在同一个数据库里redo log和undo log能保证原子性。但微服务拆了以后订单库和库存库可能分属两个实例甚至两个不同的应用和服务本地事务没法跨库生效。最典型的下单场景订单服务写订单表库存服务扣库存如果订单写成功、库存扣失败用户就会看到“下单成功但库存没扣”的奇怪状态。反过来也一样。这就是分布式事务要解决的问题。很多人会幻想有一个中间件能像本地事务一样做到强一致但分布式系统的现实是跨网络通信天然存在不确定性没法做到单机数据库那样的ACID强一致只能在“最终一致”和“牺牲可用性换强一致”之间做取舍。4.2 主流方案的原理对比与适用边界目前主流的方案就四类各自适用的场景差别很大方案核心思想一致性强度适合场景2PC/XA准备阶段所有参与方锁资源提交阶段统一提交强一致并发低、业务简单、能接受锁阻塞TCCTry锁定资源Confirm提交Cancel补偿最终一致业务补偿精确资金类、高价值订单本地消息表定时任务业务表和消息表同一本地事务写入定时扫描发送最终一致异步化、可容忍秒级延迟SAGA正向链式操作反向补偿操作最终一致长流程、低一致性要求2PC看着最“完美”实际坑最多协调者单点故障会阻塞所有参与者资源在准备阶段就锁住了长事务风险极高所以互联网业务很少直接用标准XA。TCC的Try阶段需要预留资源。比如库存服务在Try阶段冻结用户要买的库存数量Confirm阶段扣减冻结数量Cancel阶段解冻。这个模型对业务侵入很强每个参与方都要写三套逻辑开发量不小但能保证类似“库存只有100件两个用户同时买100件只有一个能成功”这种精确控制。4.3 事务消息的正确姿势从本地消息表到消息中间件事务消息是热词里的重点。它解决的是“发消息和改业务数据不能保证原子性”的问题——先发消息后改数据消息发出去了数据没改会造成下游多干活先改数据后发消息消息没发出去下游永远不知道有变更。本地消息表的思路最直观业务操作和消息插入放在同一个本地事务里然后定时扫描消息表把状态为“待发送”的消息投递到MQ投递成功后标记为“已发送”。Transactional public void createOrder(OrderDTO order) { // 1. 写订单表 orderMapper.insert(order); // 2. 写本地消息表状态为 SENDING messageMapper.insert(new OutboxMessage(order.getId(), ORDER_CREATED)); }注意关键点orderMapper.insert和messageMapper.insert必须在一个事务里这样才能保证“订单一定有对应的消息”。后面的定时任务做这些事扫描所有SENDING状态且创建时间超过30秒的消息。调用MQ发送接口投递消息。投递成功后把状态改成SENT。如果MQ发送成功但数据库更新失败或者反过来消息发了但状态没改就需要幂等机制兜底——下游消费端要按业务主键去重保证重复消息不产生副作用。RocketMQ的事务消息把上面这套逻辑搬到了消息中间件侧先发送一条半消息消费者不可见接着执行本地事务根据事务结果向MQ提交rollback或commit如果中途崩溃MQ会反向查询本地事务状态这就是“事务回查”。它的好处是业务不用自己维护本地消息表和定时任务坏处是强依赖中间件能力不是每个团队都有条件引入。订单和库存场景如果要求不高我最常用的组合是订单创建走本地事务写订单表和支付流水下单成功后发一条MQ消息库存服务消费消息异步扣减超卖靠库存预占服务或Redis预减兜底。这套方案放弃强一致但把下单接口的响应时间压下去了用户体验更好。4.4 关于SAGA和数据最终一致性的实践经验SAGA的思路是把一个长事务拆成多个正向事务每个正向事务配备反向补偿事务。比如下单拆成创建订单、扣库存、加积分三步如果扣库存失败就执行“取消订单”的补偿操作。SAGA的优点是每个参与者都是本地事务锁持有时间短性能好。缺点是业务补偿逻辑要自己写而且补偿未必能完全抵消正向操作——比如支付成功后要退款退款可能因为支付渠道限制而失败最终还是需要人工介入。我在实践中最大的感受是分布式事务方案没有银弹关键是事前想清楚业务能容忍多长的不一致窗口。能异步就异步能本地消息表就别上TCC非要强一致那就接受性能和复杂度代价。至于“订单和库存”这种经典场景我给的答案通常是预占库存异步扣减定时对账加上一条扫单补偿作业把漏单和超卖控制在极小概率范围内。5. 事务相关的性能监控与日常调优最后聊点日常维护里可以直接用的东西。MySQL事务这块调优没那么多玄学大部分时间就是盯住几个指标、查几张大表。5.1 如何快速找出当前正在跑的事务线上突然锁等待飙升你要做的第一件事就是锁定罪魁祸首。三步走-- 第一步看当前所有事务 SELECT * FROM information_schema.innodb_trx\G拿到trx_mysql_thread_id和trx_started重点是事务开始时间和当前状态。接着看连接层SHOW FULL PROCESSLIST;找到对应线程ID看它当前正在执行的SQL是什么卡在哪一步。如果还看不太清楚去performance_schema.events_statements_current查这个连接最近执行的完整语句有时候问题SQL被分号截断只有这张表能看到全貌。如果是死锁或者锁等待别忘了SHOW ENGINE INNODB STATUS;重点看LATEST DETECTED DEADLOCK和TRANSACTIONS这两个段落。里面会给出事务持有和等待的锁配合SHOW ENGINE INNODB MUTEX可以进一步定位行锁和表锁之间的竞争。这里有个容易忽略的点innodb_trx只显示当前正在执行或开启的事务那些已经提交但连接还挂着的空事务是看不到的。空事务才是最可怕的——它不干活但持有锁。要查这类连接得看连接最后一条SQL的时间。5.2 事务日志调优与容量的日常管理日常维护里我建议重点盯四个参数SHOW VARIABLES LIKE innodb_log_file_size; SHOW VARIABLES LIKE innodb_log_buffer_size; SHOW VARIABLES LIKE innodb_flush_log_at_trx_commit; SHOW VARIABLES LIKE innodb_flush_method;innodb_log_file_size决定redo log文件大小太小会导致频繁切换刷盘写入抖动严重。MySQL官方建议值经历了几次变化实际线上我更倾向于把redo log大小配到能容纳高峰期5到10分钟写入量的水平。你可以通过SHOW ENGINE INNODB STATUS里的日志相关段落估算每秒写入量。innodb_flush_log_at_trx_commit这个参数前面提过等于1最安全等于2比0略微好一点等于0在断电时会丢数据。很多团队为了性能改成0我的建议是宁可把binlog和其它参数调优也别在这个值上动刀除非你明确知道断电丢个几秒数据对业务无所谓。undo表空间日常可以观察膨胀速度。MySQL的purge线程负责清理不再需要的undo日志但如果并发写入量很大或者存在长事务undo面积会持续增加。这一点可以通过information_schema.files查表空间分配情况也可以看Innodb_history_list_length这个状态值数字越大说明待清理的undo版本越多。5.3 隔离级别的实际选择建议最后的调优问题是线上到底用哪个隔离级别很多人张口就是“MySQL默认RR但Oracle用RC所以RC更先进”。这个说法太粗糙了。MySQL 8.0之前RR是默认值有历史原因8.0之后依然默认RR是因为改了可能会有兼容性问题但实际业务里很多团队已经把级别调成了RC。我的建议是这样如果业务对“同一个事务内两次查询结果必须完全一致”有明确要求用RR。如果主要做OLTP、高并发读写且业务逻辑能接受已提交的数据变化用RC可以减少间隙锁带来的死锁概率并发能力通常更好。千万别为“提升性能”把级别调成READ UNCOMMITTED脏读的坑比性能收益大得多。改全局隔离级别是动线上数据库的敏感操作要提前和开发团队确认所有SQL的行为不能只看着并发测试结果决定。最好的方式是在连接层或MyBatis配置里针对单个业务数据源设置而不是全局一刀切。我在实际项目里见过一个很典型的案例一个报表模块在RR下频繁死锁排查发现SQL用了范围条件加for update间隙锁把整个范围锁住两个并发任务互相卡死。后来把该模块的数据源隔离级别改成RC死锁立刻消失报表数据本身就是要实时的不存在“必须可重复读”的场景。日常打交道的经验多了就会形成一套自己的排查节奏。我个人的习惯是遇到事务相关的线上问题先看连接的活跃事务状态再看innodb status锁等待最后回看binlog确认事务生命周期三步走下来大部分问题都能定位。再棘手的那就是分布式事务层面的取舍问题了按上一章的思路去权衡多想想业务能接受的不一致窗口答案自然会浮现。