MySQL事务机制与工程实践:从ACID、MVCC到长事务排查

发布时间:2026/10/31 8:16:00
MySQL事务机制与工程实践:从ACID、MVCC到长事务排查 1. 一次钱扣了、余额没加的对账把我拉回事务的本质几年前处理过一个转账对账不平的问题现象很怪流水表里扣款记录写进去了收款方的余额却没变两边差了几十块钱一天只出现一两次。第一反应是网络抖动导致的重试加了幂等键之后问题还在。最后把执行链路一段段打日志才发现问题根本不在数据库配置而在业务代码里——扣款和加款分别调了两个独立的方法每个方法各自有自己的Transactional中间抛了个被catch住的异常第一个方法提交了第二个方法回滚了。说白了这两个操作压根就不在同一个 MySQL 事务里。这件事让我重新把 MySQL 事务从头捋了一遍。很多人对事务的理解停留在要么全成功要么全失败考试背得出 ACID写代码时加个Transactional就以为万事大吉。但真正在线上跑起来事务的边界在哪、锁加了多大范围、快照读看到的是哪个版本的数据、commit 的时候到底写了哪几个文件这些细节才是决定系统稳不稳的东西。这篇东西写的不是教科书目录而是我在实际项目里反复踩过、反复验证过的 MySQL 事务知识ACID 背后各自靠什么机制撑着、四种隔离级别到底能挡住哪类异常、MVCC 的版本链和 ReadView 是怎么算可见性的、为什么加锁范围总比你以为的大、redo log 和 binlog 的两阶段提交防的到底是什么、长事务为什么是慢性毒药、以及应用层那些注解加了但事务没生效的坑。不管你是刚学 MySQL 不久、能写 CRUD 但说不清隔离级别的同学还是已经带项目、需要处理死锁和长事务的老手下面这些内容都应该能对得上你的实际场景。2. ACID 不是四个口号而是四份互相打架的承诺ACID 这四个字母几乎每本数据库教材第一章就摆出来但真正理解它们之间互相制约关系的人不多。原子性和持久性其实是一对矛盾体——要原子就要能回滚要持久就要立刻落盘隔离性和性能也是一对矛盾体——隔离得越狠并发度越低。InnoDB 的设计本质上是在这四份承诺之间做精巧的平衡理解了这个平衡你才知道为什么某些配置项一改性能曲线会突然掉下来。2.1 原子性的底牌是 undo log而不是代码写得小心原子性说的是一个事务里的多条语句要么全部生效要么全部不生效。很多人下意识觉得这是应用层保证的写代码时小心翼翼地判断每一步的结果出错就手动补偿。这种做法不是不行但它极其脆弱——进程被 kill、机器断电、连接超时断开任何一种情况都会让你精心写的手动补偿逻辑跑不到。InnoDB 的做法是在修改数据之前先把怎么撤销这次修改记下来写进 undo log。比如你执行一条UPDATE account SET balance balance - 100 WHERE id 1InnoDB 会在 undo log 里记下把 id1 这行的 balance 改回原来的值。这个过程有个细节值得注意undo log 记录的是逻辑上的反向操作而不是数据页的物理副本所以它体积小、写入快。当事务执行ROLLBACK或者连接异常断开、实例崩溃恢复时InnoDB 就顺着 undo log 一条条把改动撤回去。这里有个实操中很容易被忽略的点undo log 本身也是要写磁盘的但它跟数据页一样走缓冲池不是每次修改都刷盘。真正保证崩溃后还能回滚的是 redo log 对 undo log 修改的记录。这两者的配合关系我在第 6 节会详细拆。你只要先记住一句话原子性不是靠代码纪律守出来的是靠日志系统兜住的。2.2 隔离性是用并发度换来的不是越高越好隔离性解决的是多个事务同时跑互相别看见对方没提交的中间状态。听起来很美好但代价是什么最严格的隔离级别SERIALIZABLEInnoDB 会把普通的SELECT也隐式转成SELECT ... LOCK IN SHARE MODE也就是读也加锁。两条会话同时对同一行做读操作后一个就得排队等。并发量一上来等待链拉长连接池瞬间被占满接口超时雪崩。所以真实的工程选择从来不是选最安全的而是选业务能接受的最低隔离级别。大部分互联网业务跑在READ COMMITTED上原因是它没有间隙锁锁冲突少而且很多业务本身就能容忍同一事务内两次读结果不一样。而金融类、库存类业务更倾向REPEATABLE READ因为它能保证同一事务内的快照读结果稳定。这里需要纠正一个常见误解不少人以为把隔离级别调低事务就不安全了。其实隔离级别只约束你能看到什么不约束你的写会不会丢。写丢失的问题靠锁和唯一约束解决跟隔离级别关系不大。把这两件事混在一起就容易做出错误的架构决策。2.3 持久性的代价每次 commit 都在跟磁盘较劲持久性说的是事务一提交数据就不会丢哪怕下一秒机房断电。InnoDB 靠的是 redo log事务提交时把这次修改对应的 redo 记录刷到磁盘就算完事数据页本身可以晚点再刷这就是所谓的 WALWrite-Ahead Logging。为什么不能直接刷数据页因为数据页是离散的一次事务可能改了好几个页每个页刷盘都是一次随机 IO成本极高而 redo log 是顺序追加写一次write就能把一批记录连续落盘效率差着数量级。这个设计思路跟很多日志型系统是一样的把随机写转成顺序写。但持久性也不是白给的。控制它刷盘时机的参数是innodb_flush_log_at_trx_commit取值 0、1、2取值行为丢数据风险性能0每秒刷一次提交时不刷实例崩溃丢最近 1 秒最高1每次提交都刷盘默认基本不丢最低2每次提交写到 OS 缓存不调 fsync实例崩溃不丢机器断电丢居中我见过不少团队为了压测数据好看把这个值改成 0 或者 2压测报告确实漂亮了QPS 涨了 30%然后上线之后某次宿主机异常重启丢了几个订单最后对账对了三天。这种参数不是不能调但你得清楚自己在拿什么换什么而且要有配套的对账和补偿机制。没有对账能力的业务老老实实保持 1。3. 四种隔离级别到底挡得住哪一类异常隔离级别这个话题最容易出现的情况是背得出四个名字说不清具体差异。SQL 标准定义了三种异常现象——脏读、不可重复读、幻读然后给出四个隔离级别理论上后者依次解决前者。但 InnoDB 的实现跟标准并不完全一致尤其是REPEATABLE READ下幻读的处理这里面的细节值得单独说清楚。3.1 三类异常的定义与现场复现先把概念摆清楚用具体的会话场景说明白比抽象定义管用得多。脏读事务 A 读到了事务 B 还没提交的修改。如果 B 后来回滚了A 读到的就是一份从未真实存在过的数据。在READ UNCOMMITTED下会发生。这个级别基本没人用因为它的结果完全不可信。不可重复读事务 A 内两次读同一行结果不一样因为中间事务 B 修改并提交了。在READ COMMITTED下会发生。注意这里针对的是同一行的内容被改了。幻读事务 A 内两次执行同一个范围查询第二次多出了几行因为中间事务 B 插入并提交了新数据。同样在READ COMMITTED下会发生。它针对的是结果集的行数变了。用两个会话复现一下不可重复读在READ COMMITTED下-- 会话 A SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT balance FROM account WHERE id 1; -- 结果 1000 -- 会话 B此时执行 UPDATE account SET balance 500 WHERE id 1; COMMIT; -- 会话 A 再次查询 SELECT balance FROM account WHERE id 1; -- 结果 500两次不一致 COMMIT;把隔离级别换成REPEATABLE READ同样的操作序列会话 A 第二次查询仍然是 1000。这个差异的来源就是 MVCC 的快照机制第 4 节会讲透。3.2 可重复读并没有彻底消灭幻读这是最容易出错的一个认知点。很多资料说REPEATABLE READ解决了幻读严格来讲只说对了一半它解决的是快照读下的幻读没解决当前读下的幻读。快照读就是普通的SELECT走 MVCC读的是事务开始时的那个版本所以看不见别人新插入的行自然不会幻读。当前读是SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE这类需要读最新数据的语句它们走的是加锁读靠的是临键锁Next-Key Lock来阻止其他事务在区间内插入。问题在于这两种读在同一个事务里混用的时候幻读照样会出现。举个例子-- 会话 A隔离级别 REPEATABLE READ START TRANSACTION; SELECT * FROM orders WHERE user_id 100; -- 快照读返回 3 行 -- 会话 B INSERT INTO orders (user_id, amount) VALUES (100, 99); COMMIT; -- 会话 A 试图更新这批数据 UPDATE orders SET status 1 WHERE user_id 100; -- 这条 UPDATE 是当前读会看到 4 行包括 B 刚插入的那行 SELECT * FROM orders WHERE user_id 100; -- 快照读还是 3 行这里出现了典型的数据不一致UPDATE明明影响到了 4 行紧接着的SELECT却只看到 3 行。如果你在业务代码里先查一遍再更新然后根据两次结果做判断逻辑就会错乱。提示在一个事务里混用快照读和当前读是幻读类 bug 的高发场景。如果业务逻辑需要先查后改建议在查询时就加FOR UPDATE走当前读保证前后看到的是同一份数据。3.3 隔离级别怎么定先看业务能不能容忍再看性能选隔离级别不是技术洁癖是业务需求的直接映射。我的判断顺序一般是这样先问同一个事务里两次读同一份数据结果不一样业务能不能接受。比如一个报表汇总任务先统计总数再统计明细两者对不上就是 bug那必须REPEATABLE READ以上。再比如一个商品详情页的展示前后读到的库存数差一点无所谓那READ COMMITTED完全够用还能省掉间隙锁带来的锁冲突。然后看锁竞争情况。REPEATABLE READ因为有间隙锁在并发插入密集的表上容易出死锁。典型场景是批量插入任务跟范围更新任务撞在一起。如果你的表写入非常频繁而且业务上不需要事务内快照一致把隔离级别降到READ COMMITTED是性价比很高的优化。修改隔离级别有两个层级需要注意SET GLOBAL只影响之后新建的连接已有的连接不受影响SET SESSION只影响当前连接。生产环境改全局隔离级别要谨慎因为很多框架和中间件的默认行为是依赖REPEATABLE READ的突然改掉可能引发意料之外的问题。比较稳妥的做法是在连接池配置里针对特定数据源设置或者干脆在代码层面的特定事务上加注解指定。4. MVCC同一条记录为什么能同时看到两个版本MVCC多版本并发控制是 InnoDB 实现高并发的核心机制也是面试里被问得最多、实际工作中被理解得最少的部分。它的核心思想其实很朴素写操作不覆盖原来的数据而是生成一个新版本读操作根据某种规则决定自己该看哪个版本。这样读写之间就不需要互相阻塞了。4.1 隐藏字段与 undo 版本链InnoDB 的每一行记录除了你定义的列之外还藏着几个看不见的字段。跟事务相关的有两个DB_TRX_ID6 字节记录最后一次修改这行的事务 ID。DB_ROLL_PTR7 字节回滚指针指向 undo log 里这条记录的上一个版本。每当事务修改一行InnoDB 会把旧值写进 undo log然后让当前行的DB_ROLL_PTR指向这个旧版本同时更新DB_TRX_ID。多次修改之后就形成了一条版本链链头是最新的数据链尾是最老的版本。这条链的意义在于不同的事务可以根据自己的视野沿着链往回想找到自己该看的那个版本。比如一行数据被事务 100、事务 200、事务 300 依次修改过现在事务 150 来读它就应该看到事务 100 修改之后、事务 200 修改之前的状态——也就是沿着链找到DB_TRX_ID 100的那个版本。这里有个实践上的推论一个行被修改的次数越多版本链越长读操作回溯的成本越高。这就是为什么长事务是灾难——它会阻止 undo log 被清理导致版本链无限增长后面所有读这行的操作都要沿着长链回溯。这个在第 7 节展开。4.2 ReadView 的四条判断规则版本链解决了旧版本存在哪的问题ReadView 解决的是该看哪个版本。事务在执行快照读的那一刻会生成一个 ReadView里面有几个关键信息m_ids当前系统中所有活跃未提交的事务 ID 列表。min_trx_idm_ids里最小的事务 ID。max_trx_id系统下一个将要分配的事务 ID注意是下一个不是当前最大。creator_trx_id创建这个 ReadView 的事务自己的 ID。拿到一个版本的DB_TRX_ID之后按下面四条规则判断可见性如果DB_TRX_ID等于creator_trx_id说明这行是自己改的可见。如果DB_TRX_ID小于min_trx_id说明修改它的事务在 ReadView 创建之前就提交了可见。如果DB_TRX_ID大于等于max_trx_id说明修改它的事务在 ReadView 创建之后才开启不可见。如果DB_TRX_ID落在min_trx_id和max_trx_id之间就看它在不在m_ids里在说明还没提交不可见不在说明已经提交了可见。不可见怎么办顺着DB_ROLL_PTR找上一个版本重复这套判断直到找到一个可见的版本或者链走到底。这套规则听起来绕但用一句话概括就是你只能看到在你生成 ReadView 之前就已经提交的修改以及你自己做的修改。理解了这句话很多看似诡异的现象都能解释。4.3 RR 和 RC 的差异只在一处ReadView 什么时候生成前面说的规则REPEATABLE READ和READ COMMITTED完全一样唯一的区别是 ReadView 的生成时机READ COMMITTED每次快照读都生成一个新的 ReadView。REPEATABLE READ只在事务中第一次快照读时生成一次之后整个事务复用这一个。差异虽小效果天差地别。在READ COMMITTED下第二次查询生成新 ReadView此时别的事务如果已经提交它的DB_TRX_ID就不在m_ids里了于是新数据可见——这就是不可重复读的由来。在REPEATABLE READ下整个事务用同一个 ReadView别的事务即使后来提交了它的 ID 依然在m_ids里新数据对当前事务始终不可见。这里有个坑值得单独提醒REPEATABLE READ下ReadView 的生成时机是第一次快照读不是START TRANSACTION。如果你执行了START TRANSACTION但迟迟没有执行第一条SELECT中间隔了十秒那么这十秒内其他事务提交的数据你是能看到的。因为你的 ReadView 是在第一条SELECT时才生成的。想严格控制快照起点可以用START TRANSACTION WITH CONSISTENT SNAPSHOT它会立即生成 ReadView。还有一点显式开启事务START TRANSACTION或BEGIN跟直接执行SELECT的差别。如果你不开事务直接执行SELECTInnoDB 会为这条语句单独生成一个 ReadView语句结束就释放。这种情况下即使隔离级别是REPEATABLE READ两次独立的SELECT也会看到不同结果因为它们根本不在一个事务里。5. 锁的范围由索引决定行锁、间隙锁、临键锁的真实边界事务的隔离性靠 MVCC 和锁共同实现。MVCC 管读锁管写。很多人对锁的理解止步于行锁锁一行表锁锁全表实际上 InnoDB 的锁粒度比这个复杂得多而且加锁范围经常跟你的直觉不符——你写的是锁一行实际可能锁了一片。5.1 加锁的单位不是行是索引区间这是理解 InnoDB 锁最关键的一句话。InnoDB 的行锁是加在索引记录上的不是加在数据行本身。如果你的WHERE条件走的是索引锁的就是索引上对应的记录如果没走索引或者索引失效那就只能全表扫描扫描过程中碰到的所有记录都会被锁上——本来想锁一行结果锁了整张表。这个现象在实际项目中造成的后果非常严重。一个典型场景是某张表加了个新字段代码里按新字段做条件更新忘了加索引。上线之后这条UPDATE会锁住全表所有行其他所有涉及这张表的操作全部排队等待几秒钟内就会出现大面积超时。具体加锁的时候InnoDB 有三种锁形态锁类型锁的范围出现场景记录锁 Record Lock单条索引记录唯一索引等值查询命中记录间隙锁 Gap Lock两条索引记录之间的开区间REPEATABLE READ下范围查询、等值查询未命中临键锁 Next-Key Lock记录锁 前面的间隙范围查询的默认加锁方式临键锁是左开右闭区间。举个例子索引上有值 5、10、15执行SELECT * FROM t WHERE id 5 AND id 15 FOR UPDATE被锁住的是(5, 10]和(10, 15]两段也就是锁住了 10 这条记录和它前面的间隙以及 15 这条记录和它前面的间隙。间隙锁有个非常特殊的地方它之间不冲突。两个事务可以同时对同一个间隙加间隙锁因为它们的目的不是互斥而是阻止别人往这个间隙里插数据。这解释了为什么REPEATABLE READ下插入密集的表容易出死锁——两个事务各自持有一段间隙锁然后都想插入互相等待。5.2 临键锁在 RR 下的额外代价间隙锁是REPEATABLE READ才有的READ COMMITTED下没有间隙锁除了外键和唯一性检查的特殊场景。这个差异带来的性能影响在写密集的业务里非常明显。我做过一个对比测试同一张日志表两个并发任务任务 A 不断按时间范围更新状态任务 B 不断插入新记录。在REPEATABLE READ下任务 A 的范围更新会锁住一段间隙任务 B 的插入如果落在这段间隙里就得等等待超时默认innodb_lock_wait_timeout是 50 秒之后报错回滚。跑了几分钟就积压了几百个等待。切到READ COMMITTED之后同样压力下锁等待基本消失。所以我的经验是写密集、范围更新多、业务能容忍不可重复读的表优先考虑READ COMMITTED。反过来如果业务里有先统计再明细必须对得上的逻辑那就老老实实REPEATABLE READ靠索引优化和分批处理来降低锁冲突而不是靠降隔离级别。还有一种常见的优化思路把范围更新改造成按主键批量更新。比如UPDATE orders SET status 1 WHERE create_time 2024-01-01可以先用SELECT id捞出一批主键再按主键分批UPDATE ... WHERE id IN (...)。主键等值查询走的是记录锁范围小得多锁冲突自然就降下来了。5.3 死锁排查的完整链路死锁在 InnoDB 里是必然会发生的因为它是检测到就回滚代价小的那个事务而不是避免发生。所以你的系统里偶尔出现Deadlock found when trying to get lock是正常现象关键是要能快速定位并确认这不是设计问题。第一步拿到死锁信息。执行SHOW ENGINE INNODB STATUS\G输出里找LATEST DETECTED DEADLOCK这一段它会告诉你两个事务各自执行了什么 SQL、持有什么锁、等待什么锁。如果死锁发生得比较频繁光看最近一次不够可以打开全量记录SET GLOBAL innodb_print_all_deadlocks ON;这样每次死锁都会被写进错误日志。第二步读懂锁信息。关键看TRANSACTION段落里的HOLDS THE LOCK(S)和WAITING FOR THIS LOCK TO BE GRANTED。前者说明已经拿到了什么锁后者说明在等什么锁。两边对照就能还原出循环等待的路径。第三步找根因。我处理过的死锁绝大多数可以归到四类加锁顺序不一致。事务 A 先更新 id1 再更新 id2事务 B 反过来。解决办法是统一加锁顺序比如永远按主键升序更新。批量操作时先对 ID 列表排序这个习惯能消掉一大半死锁。间隙锁冲突。两个事务在同一个间隙上都想插入。解决办法是降低隔离级别或者把插入操作串行化。索引缺失导致锁范围扩大。这个前面说过加索引就能解决。唯一键冲突加锁。INSERT ... ON DUPLICATE KEY UPDATE和INSERT IGNORE在并发下容易产生死锁因为它们会先加共享锁检查唯一性再升级成排他锁中间有个窗口。第四步验证修复。改完之后不要只看死锁没再出现要把innodb_print_all_deadlocks打开跑一段时间确认死锁日志确实清零了。有时候死锁频率下降但没消失说明还有遗漏的路径。注意死锁回滚是 InnoDB 自动做的应用层收到死锁异常时正确的处理方式是重试而不是直接抛给用户。重试次数建议 2 到 3 次并且加随机退避避免两个事务同时重试又撞上。6. redo log、undo log、binlog 与两阶段提交事务的四个特性背后是三种日志在配合工作。这部分内容看起来偏底层但它直接决定了两件事崩溃之后数据能不能恢复以及主从数据会不会不一致。理解了这套机制你才知道为什么有些配置不能随便改。6.1 三种日志各管什么先做个清晰的职责划分日志归属层类型作用redo logInnoDB 引擎层物理日志崩溃恢复保证持久性undo logInnoDB 引擎层逻辑日志事务回滚 MVCC 版本链binlogServer 层逻辑日志主从复制 数据归档恢复redo log 记录的是在某个数据页的某个偏移量上做了什么修改它是循环写的空间固定写满之后会覆盖最老的部分前提是那部分对应的脏页已经刷盘了。这个循环结构决定了 redo log 不能用来做长期归档它只是崩溃恢复的工作区。binlog 记录的是执行了什么 SQL或者某行数据的前后值它是追加写的文件可以一直增长可以归档、可以重放。主从复制就是靠从库拉取主库的 binlog 然后重放实现的。两者的定位差异带来一个关键问题一个事务提交时既要写 redo log 又要写 binlog这两个写入必须保证一致。如果 redo log 写了但 binlog 没写崩溃恢复之后主库有数据但从库没有如果 binlog 写了但 redo log 没写主库恢复后没数据但从库有。两种都是数据不一致。6.2 两阶段提交防的是主从数据不一致InnoDB 用两阶段提交解决这个问题流程是这样的事务执行过程中redo log 不断写入但状态是prepare。事务提交时把 binlog 写入磁盘。binlog 写成功后把 redo log 的状态改成commit。关键在于崩溃恢复时的判断逻辑如果 redo log 是prepare状态但 binlog 已经完整写入那就提交事务。如果 redo log 是prepare状态binlog 没有写或者写不完整那就回滚事务。怎么判断 binlog 完整binlog 的每个事务结尾都有一个XID事件redo log 的prepare状态里也存了XID两者能对上就说明 binlog 完整。这套机制保证了主库和从库的数据一致性。我见过有人为了性能优化把sync_binlog设成 1000每 1000 次事务才刷一次 binlog压测数据确实好看很多但一旦机器异常重启binlog 可能少了几百个事务主从数据直接错位。这种不一致极难修复因为从库上已经有了一部分数据你没法简单重放。6.3 双 1 配置与性能的取舍所谓双 1指的是innodb_flush_log_at_trx_commit 1和sync_binlog 1。这两个都设成 1意味着每次事务提交都要把 redo log 和 binlog 各刷一次盘数据安全性最高性能最低。真实场景怎么选我的经验是按数据重要性分层交易、订单、资金类必须双 1没有商量余地。丢了数据要赔钱性能再差也得扛。日志、埋点、行为记录类可以放宽到innodb_flush_log_at_trx_commit 2sync_binlog 100。这类数据丢几秒可以接受真有需要还能从上游重新采集。中间状态、缓存类甚至可以设成 0因为数据本身可以从源数据重建。还有一层优化叫组提交group commit。当多个事务同时提交时InnoDB 会把它们的 redo log 合并成一次fsyncbinlog 同理。这意味着并发越高单次提交的实际刷盘成本越低所以双 1 很慢这个结论在高并发下并不完全成立。真正慢的是低并发场景——每个事务都要单独等一次磁盘。如果确实遇到写入瓶颈正确的优化顺序是先看是不是磁盘 IO 能力不足换 NVMe 效果立竿见影再看是不是大事务导致单次刷盘数据量过大最后才考虑调整刷盘参数。倒过来做先动参数再找原因往往是把问题从慢变成了数据丢了。7. 长事务比死锁更隐蔽的慢性病死锁会报错会回滚会被监控捕获你能立刻知道出了问题。长事务不会。它安安静静地跑着不报错不告警但它在后台一点点耗掉实例的资源直到某天系统突然大面积变慢你才发现 undo 表空间已经涨到几十个 GCPU 全耗在 purge 线程上。7.1 把长事务揪出来的三条命令第一条查当前活跃事务SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec, trx_mysql_thread_id, trx_rows_locked, trx_rows_modified FROM information_schema.innodb_trx ORDER BY trx_started ASC LIMIT 20;trx_started是最关键的字段duration_sec超过 60 的基本都值得看一眼。trx_rows_locked和trx_rows_modified能告诉你这个事务锁了多少行、改了多少行。第二条看是谁在跑SELECT * FROM information_schema.processlist WHERE id IN (SELECT trx_mysql_thread_id FROM information_schema.innodb_trx);这能拿到执行的 SQL、客户端地址、连接状态。有时候你会发现一个休眠连接Command是Sleep持有事务半小时没动那就是典型的开了事务忘了提交。第三条看锁等待SELECT * FROM performance_schema.data_lock_waits\G能直接看到哪个事务在等哪个事务被阻塞的 SQL 是什么。这个视图比innodb_trx更直观尤其是在等待链比较长的时候。7.2 长事务为什么会拖垮整个实例长事务的危害不止占着锁不放这一条它有多个层面的影响而且这些影响会叠加。第一层undo log 无法清理。undo log 的清理purge有个前提这个版本不能比任何活跃事务的 ReadView 更老。只要有一个长事务还开着它可能还需要看很老的版本那么从它开始之后的 undo log 全都不许清理。undo 表空间持续膨胀磁盘被吃掉更严重的是版本链变长后续所有对同一行的读操作都要沿着链回溯读性能断崖式下降。第二层锁持有时间过长。即使这个长事务只锁了几行只要它不提交这几行就一直被锁着。所有需要改这几行的操作全部排队等待超时默认 50 秒之后失败。如果这几行是热点数据比如某个热门商品的库存行那基本等于把这部分业务打停了。第三层主从延迟。大事务在主库上执行完才写 binlog从库拿到的是一整个大事务重放的时候要花很长时间期间主从延迟持续上涨。如果这个事务涉及百万行更新从库可能追半小时。第四层连接池占满。事务不结束连接就不释放。连接池里的连接被长事务占用新的请求拿不到连接接口超时上游重试雪崩。我在实际项目里见过最典型的长事务来源是三种一是批处理任务一次性UPDATE几十万行二是应用层开了事务之后中间调了一个慢接口比如第三方 API把事务时间拉长到几秒三是连接池开启了自动提交之外的模式但代码里某个分支忘了commit。7.3 拆分与兜底从 SQL 到应用层的控制手段对付长事务思路是预防为主监控兜底。预防层面最有效的做法是把大操作拆小。批量更新不要一条 SQL 干完按主键分批每批几百到几千行每批单独提交。这样锁的范围可控undo log 能及时清理主从延迟也能控制住。批与批之间可以做短暂休眠给其他事务让出资源。应用层面有几条硬性建议事务里绝对不要做远程调用。HTTP 请求、RPC 调用、消息发送全部挪到事务外面。事务里只留数据库操作而且要保证操作足够快。显式设置事务超时。Spring 的Transactional(timeout 5)可以设置超时秒数超时会回滚。这个能防止某个异常分支把事务挂死。数据库层面设置innodb_lock_wait_timeout。默认 50 秒太长了很多业务设置成 3 到 5 秒就够等不到就快速失败让用户重试比一直挂着强。给连接池加maxLifetime和leakDetectionThreshold。前者是连接的最长存活时间后者是连接被借出多久没归还会告警。这两个能兜住忘了关连接这类低级错误。监控层面我一般会在监控系统里加两条规则innodb_trx中duration_sec 30的事务数量持续大于 0 就告警trx_rows_modified 10000的事务出现就告警。前者是长事务后者是大事务两类都要管。提示如果真的遇到了已经跑飞的长事务KILL会话之前先想清楚——回滚一个大事务可能比它本身跑得还慢。InnoDB 的回滚是异步的KILL之后事务进入回滚状态期间依然占着资源。所以最好的处理是止血 观察而不是直接一刀切。8. 应用层最容易翻车的地方那些加了注解却没生效的事务数据库这边的事务机制再完善也架不住应用层压根没把操作放进同一个事务里。开头提到的那个对账问题就是这类。这一节我把实际项目里遇到过的、以及帮别人排查过的失效场景整理一下基本覆盖了大部分情况。8.1 Spring 声明式事务失效的几种典型写法Spring 的Transactional是基于 AOP 代理实现的理解了这一点很多失效场景就都能推导出来。自调用失效。同一个类里方法 A 调用方法 BB 上有Transactional这个注解不生效。原因是 A 调用 B 走的是this.b()绕过了代理对象AOP 根本没机会介入。解决办法是把 B 挪到另一个 Bean 里或者注入自己的代理对象或者用AopContext.currentProxy()。方法不是 public。Spring 的代理默认只拦截 public 方法private、protected上加注解不生效而且不会报错静默失效。这个坑特别隐蔽因为代码看起来完全正常。异常被吞了。代码里try-catch把异常捕获了没有重新抛出事务管理器感知不到异常自然就提交了。这是开头那个问题的直接原因。如果确实需要捕获异常做业务处理记得处理完再抛出去或者手动调TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。异常类型不匹配。Transactional默认只对RuntimeException和Error回滚受检异常Exception的子类但不是RuntimeException不回滚。如果你的业务代码抛的是自定义的受检异常事务会照常提交。解决办法是显式写Transactional(rollbackFor Exception.class)。我建议所有业务事务都加上这个参数省得后面踩坑。传播行为用错。默认是REQUIRED意思是有事务就加入没有就新建。如果你的本意是这个操作必须独立提交不受外层事务回滚影响那要用REQUIRES_NEW。用反了会导致外层回滚时把内层一起带走或者内层回滚把外层标记成 rollback-only 导致外层提交时报UnexpectedRollbackException。这个异常特别容易让人困惑因为日志显示事务明明成功了最后却报错。类没有被 Spring 管理。自己new出来的对象或者没加Service、Component的类代理不会生效。这个在工具类里比较常见。我把这些整理成一张自查表排查事务问题时对着过一遍命中率很高现象可能原因排查方式明明抛异常但数据没回滚rollbackFor不匹配 / 异常被 catch看异常类型检查 catch 块部分操作生效部分没生效自调用 / 方法非 public检查调用链路是否经过代理报UnexpectedRollbackException内层REQUIRES_NEW回滚影响了外层标记检查传播行为配置事务完全不生效类未被 Spring 管理检查Service注解和包扫描路径8.2 事务里做远程调用和批量操作即使Transactional正常生效了事务边界的设计也经常有问题。最常见的是在事务里调远程接口。一个典型的写法是开启事务 → 写订单表 → 调库存服务扣减 → 写流水表 → 提交。这个链路里调库存服务可能耗时几百毫秒甚至几秒如果对方超时整个事务就挂在那里数据库连接被占用锁被持有等待时间完全不可控。正确的做法是把远程调用挪到事务外面用本地事务 最终一致的方式来处理。比如先在本地事务里把订单状态置为待扣减提交事务然后异步调库存服务成功了再更新状态。这样本地事务很短锁持有时间可控远程调用的失败也有独立的重试机制。另一个高频问题是批量操作。saveBatch这类方法在循环里执行上千条插入全在一个事务里。这个事务的执行时间长、undo log 大、锁范围广。更好的做法是分批提交每批 500 条左右批与批之间独立事务。业务上如果允许部分成功那每批单独处理如果不允许那就在最后做一次状态校验和补偿。还有一个容易被忽略的点是事务里做查询。很多人觉得查询不加锁没关系但查询本身也会占用连接、消耗资源。如果一个事务里先做一堆耗时的聚合查询再执行写入那写入之前的所有时间都是白占资源。把只读查询挪到事务外面或者用独立的只读连接是个很实用的优化。8.3 一个可复用的自查清单我给自己项目定了一套事务检查清单新代码提交前过一遍能挡掉大部分问题事务边界是否最小化——只包含必须原子生效的数据库操作。事务内是否有 HTTP、RPC、消息发送、文件读写。Transactional是否加了rollbackFor Exception.class。是否存在自调用调用链路是否经过代理。事务内是否有大循环、大批量操作能否拆分。涉及更新的 SQLWHERE条件是否走了索引。批量更新是否统一了加锁顺序按主键排序。是否有配套的超时设置和重试机制。这套清单不复杂但坚持执行下来事务相关的线上问题能少一大半。我个人的体会是事务问题的根源往往不在数据库而在于写代码的人对事务边界这件事没有清晰的概念——他不知道事务从哪里开始、到哪里结束、中间包含了哪些操作。把这件事想清楚了剩下的都是细节。最后分享一个小习惯。我排查事务相关问题时第一件事永远是打开general_log或者用抓包工具把那个时间段的完整 SQL 序列抓出来按时间戳排好。因为绝大多数事务问题看代码是看不出来的——代码里可能调了三层封装事务注解散落在不同位置只有把真实的 SQL 执行序列摆出来你才能确认哪些语句真的在同一个事务里哪条语句执行了多久中间有没有夹着别的操作。这个动作花不了几分钟但比对着代码猜要高效得多。另外把innodb_trx和data_lock_waits做成定时采集写进监控比出事之后再登机器上查要主动得多这套东西搭一次能用很久。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询