MySQL事务底层原理:隔离级别、MVCC与锁机制实战解析 📅 发布时间:2026/9/7 19:43:48 👁 浏览次数: 先说一个我踩过的真实场景凌晨两点线上订单表出现了几行“幽灵数据”用户明明只提交了一单数据库里却能看到两条状态完全不同的记录。当时第一反应是代码写错了查了一晚上发现业务逻辑没有任何问题最后定位到是事务隔离级别和锁的细节没吃透。从那以后我就养成了一个习惯凡是涉及 MySQL 线上问题先不谈业务先把事务、锁、日志这三件事在心里过一遍。很多人觉得 MySQL 事务就是BEGIN加COMMIT背出 ACID 四个单词就万事大吉。可真到了排查死锁、分析主从数据不一致、设计秒杀扣库存方案的时候那点背下来的概念完全不够用。这篇文章不聊怎么建表、怎么写 SQL而是直接在存储引擎层面拆开 InnoDB 的事务机制undo log 版本链怎么支撑多版本读、ReadView 的可见性判断为什么在不同隔离级别下结果不同、redo log 为什么要两阶段提交、行锁和间隙锁在什么时机才起作用。每一块我都会给出能从日志和information_schema里验证的实操方法而不是停留在原理图上。这套内容适合谁看被面试官问过“RR 级别到底能不能防幻读”却答不利索的人线上出现过死锁、主从延迟、数据对不上但不知道从哪下手的人以及准备在分布式事务方案里做技术选型的后端开发。看完你可以照着里面的实验步骤在自己的实例上完整复现一遍把“底层原理”四个字变成自己能讲清楚的东西。1. 事务隔离级别的本质它管的不是“读”是“读什么版本”1.1 三个并发异常不是平行概念而是一层套一层的递进先回到最基础的问题非安全事务下并发执行到底会出什么问题教科书会给你一张包含脏读、不可重复读、幻读的表格但很多人没意识到这三种异常不是并列关系而是串在一条链上的升级。脏读是事务 A 读到了事务 B 未提交的修改。这事最离谱因为 B 完全可能回滚A 读到的是一个根本不存在的中间状态。不可重复读比脏读高一级B 已经提交了A 在同一个事务里第二次读同一行发现值变了。幻读是再高一级B 提交的新增或删除操作导致 A 的同一个查询两次返回的结果集行数不一样。注意重点幻读不要求“同一条记录的值变化”它要求的是“原本不存在的新行凭空出现或者原本存在的行凭空消失”更像是对一个范围产生了幻觉。理解这个递进关系才能明白隔离级别的设计逻辑。读未提交允许一切问题出现所以它只保证写不互相覆盖读已提交把脏读拦掉了因为它要求必须读到已提交的版本可重复读在已提交的基础上拦掉了同一条记录的重复读变化串行化最暴力直接用锁把所有读写变成排队让幻读也无从谈起。这里有个很多入行两三年的人都搞混的点在 InnoDB 的可重复读级别下普通的快照读是看不到幻读的但如果是带FOR UPDATE的当前读不配合间隙锁依然可能幻读。所以“RR 能不能防幻读”这个问题标准答案永远是“看你怎么读”。后面我会专门解释这两类读在机制上的差异。1.2 InnoDB 永远只做一件事帮你挑一个“合适的版本”前面说的隔离级别归根到底是一个语义问题事务在执行某条查询时它有权看到什么时间截面上的数据。InnoDB 的方案叫多版本并发控制MVCC。它做的事情是当一行数据被修改时不是把旧值直接擦掉而是把旧值留下来形成一个版本链条然后让事务根据需要去链条上挑一个版本读。读未提交只需要拿最新版本不管提交没提交读已提交和可重复读则依赖 ReadView 来判断“这个版本对我是否可见”串行化干脆放弃多版本直接加锁读写互斥。所以你在SHOW VARIABLES LIKE transaction_isolation里改了隔离级别本质上改的是 InnoDB 构造 ReadView 的策略以及加锁的范围其他什么都没变。为了说清楚这件事我先建一个非常简单的演示表CREATE TABLE account ( id int NOT NULL AUTO_INCREMENT, balance int NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB; INSERT INTO account (id, balance) VALUES (1, 100);注意这里有个容易被忽略的细节SELECT transaction_isolation只能看到当前会话的隔离级别MySQL 8.0 里默认是REPEATABLE-READ。如果你在改代码时用了连接池而某个连接一直没释放它可能保留着旧的隔离级别这也是线上一种很隐蔽的“配置没生效”错觉。有了基础表我们进入核心内容这条 balance100 的记录在经历并发修改时底层是怎么被一个事务读到的。2. undo log 版本链事务回滚和 MVCC 读共同依赖的基石2.1 一条 UPDATE 语句在页里留下的痕迹比你想象得多执行UPDATE account SET balance 50 WHERE id 1时InnoDB 做的工作可以粗略分为三层先在索引中找到 id1 的那条聚簇索引记录然后对这条记录加锁最后修改它。但修改它之前引擎会先把旧值写入 undo log。写入 undo log 的本质是什么它生成的是“怎么把这条记录回滚到旧状态”的反向操作记录。如果事务回滚就把 balance 从 50 改回 100如果事务提交这部分 undo log 会在一段时间后被后台 purge 线程清理。除了回滚undo log 还有一个更重要的用途它的每条日志里带有DB_ROLL_PTR回滚指针这个指针会把同一条记录的历史版本串成一个链表也就是常说的版本链。我画不出流程图但可以用一个非常接地气的例子说清楚把一条记录的聚簇索引想象成一张工作证证件上有一个字段指向我自己上一份工作的档案袋档案袋里又留着我上上一份的档案袋互相用绳子串着。即使我现在的工作单位改了HR 查档案时依然能把我的历史经历全部翻出来。每次 UPDATE 都会在记录头部更新这个指针指向最新的 undo 日志所以版本链的头部永远是“当前值”越往尾部越是“古老值”。看一下实际的内部结构这组字段是 InnoDB 在每个聚簇索引记录里隐藏的隐藏字段作用大小DB_ROW_ID行ID无主键时用于生成隐藏主键6字节DB_TRX_ID最近一次修改这条记录的事务ID6字节DB_ROLL_PTR回滚指针指向 undo log 中的上一个版本7字节也就是说你去看一条物理记录它自身就带着“谁改过我”和“我改之前长什么样”的信息。事务 ID 是全局递增的这个序号在判断版本可见性时是核心比较依据。2.2 ReadView 的判断逻辑为什么同一个 SELECT 在 RC 和 RR 里结果不同事务执行普通的快照读时InnoDB 会生成一个 ReadView可以理解成事务开始执行“拍照”时对整个可见事务集合的快照。ReadView 里有四个关键属性m_ids生成 ReadView 那一刻当前活跃的、未提交的事务 ID 列表min_trx_idm_ids里的最小值max_trx_id生成 ReadView 时下一个将要分配的事务 ID也就是“已分配的最大事务 ID 1”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里说明事务已提交可见在m_ids里则不可见当前版本不可见就顺着DB_ROLL_PTR继续找上一版直到找到可见版本或者版本链到底。这里就是 RC 和 RR 产生本质区别的地方。RC 级别下事务里每一次普通的 SELECT 都会生成一个新的 ReadView所以它能读到其他事务最新提交的数据但也因此同一事务里两次 SELECT 结果可能不同出现不可重复读。RR 级别下只有事务里第一次快照读会生成 ReadView之后所有普通 SELECT 都是复用这个 ReadView所以整个事务期间看到的是一个固定截面天然规避了不可重复读。实操时可以这样验证开两个会话都执行START TRANSACTION在会话 A 先执行一次SELECT然后让会话 BUPDATE并提交回会话 A 再执行同一条SELECT在 RC 和 RR 下面分别看结果。你会发现 RC 下数据会变RR 下不会。如果 A 在 B 提交之后第一次执行 SELECT而不是在之前执行过那即使是 RRA 也会读到 B 的新数据因为 ReadView 是在 A 第一次读时才生成的。这个“首次读的时机”经常被忽略却是理解许多事务隔离“怪现象”的钥匙。3. 事务提交后真的一劳永逸吗redo log 与崩溃恢复3.1 WAL 不是“先写日志再写数据”这么简单说完了读多版本我们再来看写。很多人对 WAL 的理解是“为了防止数据丢失先写日志后写数据页”这没错但只说对了一半。另一半是如果要保证每次事务提交都稳稳地把数据页刷到磁盘性能会惨不忍睹。因为 InnoDB 的数据页默认 16KB而你一条 UPDATE 可能只是改了 16KB 里的几个字节把整个页随机写到磁盘的成本极高。redo log 解决的就是这个随机写问题。它是一个追加写的日志文件记录的是“物理逻辑”级别的变化比如“把表空间 12 页偏移量 200 处的值从 100 改成 50”。追加写天然是顺序 IO比随机刷脏页快一两个数量级。事务提交时只需要把这个 redo log 刷到磁盘就能对外宣称“提交成功了”对应的脏页可以留在内存里慢慢刷。这个动作叫 force log at commit是 InnoDB 保证持久性的核心。具体到参数上就是innodb_flush_log_at_trx_commit参数值行为崩溃时可能丢的数据1每次事务提交redo log 刷入磁盘不丢最安全2每次事务提交redo log 写入操作系统缓存每秒批量刷盘最多丢 1 秒数据0提交时不主动写 redo log由后台线程每秒刷最多丢 1 秒数据风险更高注意2和0的区别在字面上很容易混2是事务提交时把日志写到 OS page cache由 OS 兜底0是连 OS 缓存都不保证只靠 InnoDB 自己的后台线程每秒刷。生产环境我个人的建议是只要不是磁盘性能实在顶不住永远设置为 1没有例外。很多“MySQL 重启后丢了几条数据”的故障排查到最后几乎都是这个参数被人改成了 0 或 2。3.2 崩溃恢复怎么做到不重不漏假设 MySQL 在某个时刻宕机内存里已经没有这些脏页磁盘上的数据文件还是老版本的。重启时InnoDB 会从 redo log 里把提交过的事务重放一遍让数据页回到最新状态。这个机制叫前滚恢复。那么它怎么知道哪些页需要恢复这就涉及 LSN日志序列号。每个数据页头部都记录了自己最后一次被刷新到的 LSNredo log 里每条记录也带 LSN。恢复时只需要找最后一个 checkpoint 点之后的所有日志重放即可之前的部分已经被刷盘线程持久化过了。恢复过程中还有个关键问题有些 redo log 对应的事务可能并没有提交。崩溃前事务还没执行到 commit它的修改已经写了 redo log但事务状态是未提交的。InnoDB 恢复时会根据事务表里的状态来做判断对应这个未提交事务在数据页上产生的修改会依赖 undo log 里的回滚记录把它撤销掉。所以你看undo log 不仅仅服务于多版本读它也是崩溃恢复里清理未提交事务所必需的。在这个体系里你每次执行提交整体动作是先修改内存中的记录并生成 undo log然后生成 redo log事务提交时把 redo log 刷盘。这个过程听起来不复杂但它落到了“日志先行”这条铁律上。任何怀疑 MySQL 丢数据的 case我建议你第一步先确认这两件事innodb_flush_log_at_trx_commit是否为 1以及 redo log 文件是否有足够空间导致系统进入了“人为降速”状态。3.3 一个经常被忽略的容量问题redo log 是通过一组固定大小的文件循环写入的MySQL 8.0.30 之前默认是ib_logfile0和ib_logfile1两个文件8.0.30 起引入了 redo log 容量自动调整的能力。如果写入量特别大checkpoint 跟不上 redo log 的写入位置InnoDB 就会被迫停止接受新写入先把脏页刷掉才能继续。这属于一种保护机制但线上表现就是“TPS 突然掉到 0连查询都在排队”。遇到这种问题光看SHOW ENGINE INNODB STATUS还不一定能一眼看出来要综合看LOG段的Log sequence number和Last checkpoint at两者差值长期接近容量上限就是危险信号。日常工作里我一般会加监控盯这个差值超过 redo log 总容量的 75% 就要开始排查是不是有大事务或慢刷盘问题。4. redo log 与 binlog 的两阶段提交一份数据两个日志怎么保持一致4.1 为什么不能只依赖 redo log如果你只用单机 MySQLredo log 已经足够保证崩溃恢复了。但现实世界中几乎都有主从复制或基于 binlog 的同步这时问题就来了redo log 是 InnoDB 引擎层的日志binlog 是 MySQL Server 层的逻辑日志。前者服务于崩溃恢复后者服务于主从复制和时间点恢复。两个日志是独立写的如果写完 redo log 还没来得及写 binlog 就崩溃了主库恢复后数据是最新的但从库拿到的 binlog 里却没有这条事务主从数据就不一致了。这其实是一个跨组件的一致性问题不是 MySQL 独有的任何有“本地状态 对外通知”两套写入的系统都会遇到。MySQL 的解法很直接把提交动作拆成 prepare 和 commit 两个阶段让 redo log 和 binlog 在崩溃恢复时可以对齐。4.2 完整提交链路到底是怎么走的一条事务执行到COMMIT时InnoDB 内部大致经历如下几个阶段事务在 InnoDB 层已经把数据和 undo log、redo log 都准备好了但此时 redo log 里的事务状态是PREPAREServer 层把这条事务产生的 binlog 写入 binlog 文件并完成刷盘InnoDB 把 redo log 里对应事务的状态改成COMMIT事务才算真正提交。从外部看事务是等 binlog 写入完成之后才提交的。为什么要先 prepare 再写 binlog 再 commit因为崩溃可能发生在任意两步之间恢复时只需要看两个日志的状态就能决定事务到底算不算数。如果崩溃时 redo log 是 prepare 状态binlog 里也找不到这条事务那说明 binlog 没写成功事务必须回滚如果 redo log 是 prepare 状态但 binlog 里已经有完整事务说明 binlog 写完了只是没来得及改 commit 状态此时事务应该提交并且要保证从库和主库数据一致。这个设计意味着主从复制的一致性不依赖“运气”而是依赖恢复时的一套裁决机制。理解了这一点再去看 MySQL 8.0 的binlog_group_commitsync_binlog这些参数就很好懂了。生产环境我要求sync_binlog1和innodb_flush_log_at_trx_commit1搭配是最稳的组合。如果允许每秒刷 binlog那在极端崩溃下可能丢一部分 binlog但 redo log 已经提交的事务却恢复成功主从就会开始出现差异。实操中可以做一个验证开启general_log或者直接看SHOW ENGINE INNODB STATUS里的LOG部分但最直观的实验是查看 MySQL 的 error log在非正常 kill 进程后重启里面会出现类似“Starting crash recovery”的记录。如果你在压测时反复 kill -9 实例再对比主库和从库的数据能非常直观地感受到不同sync_binlog和innodb_flush_log_at_trx_commit组合下的差异表现。5. 锁和隔离级别的最后一公里当前读、间隙锁与幻读的真相5.1 快照读不需要加锁当前读必须加锁现在我们把“读”拆成两类普通SELECT是快照读它不加锁走的是 MVCC 版本链这也是为什么高并发下 SELECT 不会阻塞 UPDATE。但SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT都是当前读。当前读读的是记录的最新版本并且必须对读取的记录加锁防止在读取和操作之间被别人修改。当前读与隔离级别是联动的。RC 级别下当前读只对命中的记录加行锁不锁范围RR 级别下InnoDB 除了对命中的记录加行锁还会在涉及的索引范围上加间隙锁防止幻读产生。这个差异非常容易在生产中造成死锁因为两个事务可能在完全没有交集的数据区间上因为间隙锁互相等待。间隙锁锁的是记录之间的间隙区间是开区间。比如表里只有 id10、20、30 三行间隙锁可能锁的是 (10,20)、(20,30)、(30,正无穷)。如果事务 A 在 RR 下执行SELECT * FROM account WHERE id BETWEEN 15 AND 25 FOR UPDATE它除了锁住已有的 id20还会锁住 (15,20) 和 (20,25) 这两个间隙。此时事务 B 要插入一条 id18 的记录就必须等 A 提交或回滚。间隙锁和记录锁合在一起称为 next-key lock它是 RR 级别下防幻读的关键。这里有一个很多人踩过的坑如果 WHERE 条件里的列没有索引InnoDB 会对聚簇索引的所有记录加锁再对所有间隙加锁等于把整张表锁住。某些线上“偶尔特别慢”的问题排查到最后发现是 UPDATE 语句的 WHERE 列没走索引在 RR 级别下被间隙锁放大了。优化手段就一句话当前读的 WHERE 条件尽量走唯一索引或主键这样 InnoDB 能精确确定加锁范围。5.2 一个可以在本机复现的幻读实验自己亲手验证一次效果远胜于看十篇博客。用两个会话隔离级别都设为 RR-- 会话 A START TRANSACTION; SELECT * FROM account WHERE id 2 FOR UPDATE; -- 此时只会锁住已存在的记录和它们之间的间隙 -- 会话 B START TRANSACTION; INSERT INTO account (id, balance) VALUES (3, 300); -- 这条语句会被阻塞直到会话 A 提交或回滚如果会话 B 的 INSERT 没有立刻报错而是卡住说明间隙锁生效了。此时在会话 A 里再执行一次SELECT * FROM account WHERE id 2 FOR UPDATE结果集和第一次一样仍然没有 id3因为插入被间隙锁挡住了。但如果你把隔离级别改成 RCB 的插入会立刻成功因为 RC 下 InnoDB 只做记录锁不启用间隙锁。这个实验不仅验证了幻读原理也是理解死锁报告的最佳温床。读SHOW ENGINE INNODB STATUS时里面 LATEST DETECTED DEADLOCK 段落会列出等待的锁和持有锁的事务。看多了会发现绝大多数死锁的构成都是“事务 A 持有一行的锁等待另一行的间隙锁事务 B 持有了后者等待前者的行锁”。这时候不要只靠代码 review 去猜把两条 SQL 按执行顺序画出来把锁的范围列出来谁等谁一目了然。6. 事务底子在项目里的连锁反应分布式一致性、事务失效、长事务危害6.1 spring 事务失效底层往往是代理和连接边界问题很多后端项目里用Transactional注解管理事务但注解没生效的 case 经常出现。有一个很经典的坑是同类内部方法调用比如 Service 的 public 方法 A 调用了同类里的方法 BB 标了Transactional最后 B 的事务根本没生效。原因在于 Spring 的事务是通过 AOP 代理实现的外部调a()时进入的是代理对象但a()内部调b()是直接调当前对象的方法绕过了代理所以注解就失效了。从数据库视角看就是根本没有开启事务。还有一种情况是异常被吞掉了。Transactional默认只在 RuntimeException 和 Error 时回滚如果代码里把业务异常 catch 住没有重新抛出事务照样提交。很多做过支付的同行应该都遇到过订单状态更新了但账户流水没写进去查半天发现是 catch 块把业务异常给吞了。看事务是否回滚可以去看 binlog 或 redo log 中改动的行数但从排查效率出发先看代码是最快的。更隐蔽的问题出现在事务边界和连接边界不匹配时。Transactional开启的事务绑定的是当前线程持有的数据库连接。如果方法内部自己用DataSource获取了另一个连接或者通过Async开了一个新线程去执行数据库操作那个新线程是不会加入原事务的。跨数据源的多写操作如果期望用一个本地事务来保证原子性也只是拆东墙补西墙必须引入分布式事务方案。6.2 长事务不只是锁等待它会让 undo log 版本链膨胀长事务是我在高并发系统里最忌惮的东西。一个事务跑太久它持有的 ReadView 会一直不释放导致 undo log 里的旧版本无法被 purge 线程清理。版本链越拉越长每次读都要顺着链走更远查询延迟逐渐恶化。更糟糕的是如果这个长事务还做了很多更新其他事务要读取旧版本时就得遍历巨大版本链系统整体性能会被拖垮。排查长事务有一个很直接的方法SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds, trx_state, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started ASC;看到持续时间特别长的空闲事务一定要往前端代码查大概率是连接池里拿着连接没提交也没回滚。MySQL 8.0 里还可以结合performance_schema.data_lock_waits来定位锁等待链找到阻塞源头。6.3 从本地事务到分布式事务理解底层再选方案标题相关的热搜词里频繁出现“分布式事务”这里多说一句我的看法。分布式事务从来不是某种框架的银弹它是在本地事务无法跨多个资源多个数据库、数据库加消息队列时的一种妥协方案。理解了本地事务的 redo/undo 设计后你再看市场上的方案就会很清楚它们的本质两阶段提交协议在数据库层面做协调者是把 prepare/commit 思路推广到多节点事务消息是把“本地消息表”和 binlog 订阅结合让业务先落库再异步通知TCC 模式则是在业务层面对 Try、Confirm、Cancel 三种操作做补偿。选型时不要只看名字要回到一致性需求来问自己允许短暂不一致吗允许最终一致吗宕机后补偿链路能不能自动恢复我处理过一个订单与库存的分布式场景最终选择了本地消息表加定时任务补偿而不是强一致 2PC。原因很简单库存允许短暂的超卖提示用户不会因为几十毫秒的库存数据延迟而投诉但 2PC 的协调者宕机后长期锁资源带来的故障运维成本太高。这个决策背后不是“哪个方案高级”而是“我能承受多长的故障恢复时间”和“数据在什么窗口内不一致是可以接受的”。没有对底层事务原理有完整理解面对这些问题是很难做出判断的。7. InnoDB 锁模式速查做死锁分析前先背熟这张表分析死锁时经常要判断“当前锁是共享还是排他”“能不能和已有锁兼容”。这里整理一张常用速查表配合performance_schema.data_locks表使用会非常省力。锁类型兼容性说明出现场景查询关键词共享锁 S多个 S 可以共存S 与 X 互斥SELECT ... LOCK IN SHARE MODELOCK_MODES排他锁 X与任何其他锁都互斥UPDATE、DELETE、SELECT ... FOR UPDATELOCK_MODEX记录锁锁的是索引记录本身主键或唯一键命中的精确匹配LOCK_MODEX,REC_NOT_GAP间隙锁锁记录之间的区间多个间隙锁可以共存RR 级别锁定范围查询LOCK_MODEX,GAP临键锁记录锁间隙锁左开右闭区间RR 级别的范围当前读LOCK_MODEX插入意向锁插入前先获取的意向锁多个不同间隙的插入意向锁不冲突INSERT 遇到间隙锁时等待LOCK_MODEX,INSERT_INTENTION我观察到不少同行看死锁日志时只会搜索关键字“deadlock”其实更有价值的是日志里LOCK WAIT那几行。它会直接告诉你哪个事务正在等哪个事务的锁配合data_locks表就能把完整的等待环画出来。有一次我排查一个库存扣减的死锁表面看是两条 UPDATE 的顺序不同实际是其中一个事务先查了汇总表再更新明细另一个事务反过来形成了一个小环。改掉事务内的操作顺序后死锁再没出现过。对于线上死锁我的经验是“尽量让所有事务按照同一顺序访问资源”这句老话背后其实藏着底层的锁等待逻辑如果两条事务都按 id 从小到大访问记录持有的锁就是有序的不会形成环路反之A 先锁 1 再锁 2B 先锁 2 再锁 1就很容易互相卡死。代码 review 时就把这种顺序问题揪出来比事后分析死锁日志高效得多。8. 排查事务问题时的实际心得与操作建议这套底层原理落到日常工作时我一般会形成一套固定的排查顺序给大家做个参考。第一步先确认隔离级别和关键刷盘参数顺序执行SHOW VARIABLES LIKE transaction_isolation; SHOW VARIABLES LIKE innodb_flush_log_at_trx_commit; SHOW VARIABLES LIKE sync_binlog;第二步看当前有哪些正在运行的事务和锁等待SELECT * FROM performance_schema.data_lock_waits\G SELECT * FROM sys.innodb_lock_waits\G第三步如果是性能问题看长事务、redo log 的 checkpoint 距离以及磁盘 IO 的延迟数据。有了这些客观数据再回到代码里定位具体是哪个方法持有事务太久、哪段逻辑没有提交。我在实际项目里发现一个高频误区很多人以为把autocommit设置为 0 就等于开启了事务但其实 MySQL 默认autocommit1如果应用层没有显式提交连接被归还到连接池时未提交的事务会被回滚。这会导致一些“偶发数据丢失”的问题。稳妥做法是在代码里始终显式使用BEGIN/COMMIT或者依赖框架的事务管理而不是依赖全局autocommit0。还有一个容易忽略的点MySQL 8.0 的默认隔离级别是 RR但如果你用云数据库厂商的默认参数某些实例可能被设为 RC。业务上线前最好先用SELECT transaction_isolation确认而不是直接相信代码配置。这里多说一句如果你在用 sharding 中间件或读写分离组件隔离级别和事务传播行为最终都会被映射到底层连接上排查时必须同时看中间件日志和数据库端的innodb_trx两边时间戳对上才能还原完整事务链路。最后分享一个压测时的实用技巧压测前清空performance_schema里的历史事件数据压测结束后重点看events_statements_summary_by_digest里那些平均耗时高、执行次数多的 SQL再结合sys.innodb_lock_waits找到锁等待热点。事务性能问题大多数都能从“锁等待时间占总耗时比例过高”这个特征上发现而不是看慢查询日志里某条 SQL 自身执行得多慢。抓到这个关键指标之后再往底层去看锁类型和索引使用情况基本就离真正的原因不远了。我个人这几年最有价值的一次实践就是在一个订单系统的压测环境里完整走了一遍这套分析从一条 UPDATE 的慢查询开始逐步看到当前读加了间隙锁再到事务 B 的插入被阻塞最后定位到是某条统计 SQL 没走索引导致锁范围扩大到整张表。整个过程用到的知识就是这篇文章里的内容隔离级别、版本链、redo log、锁的范围。没有玄学也没有靠运气。把每个模块串起来之后再看任何“MySQL 事务”相关的问题都是一张清晰的地图而不是零散的知识点。