1. 从一条报错开始为什么 MySQL 的事务没那么简单做后端开发或者数据库运维的朋友多半都遇到过这样的情况一个跑了很久的存储过程突然报错仔细排查发现是事务中间执行了 DDL导致前面已经写入的数据被自动提交了或者两个并发事务明明都只是 UPDATE 同一条记录却莫名其妙死锁回滚再或者测试环境一切正常上了生产后因为设置了不同隔离级别导致报表数据出现奇怪的重复行。这些问题的根源基本都指向同一个东西MySQL 的事务机制。事务这个概念任何一本数据库教材都会讲 ACID但教材不会告诉你的是InnoDB 底层到底怎么实现这些特性的哪些 SQL 会偷偷帮你提交事务REPEATABLE READ 隔离级别下为什么还会有幻读的讨论分布式事务的 XA 到底是怎么协调多个数据库的这篇文章从 MySQL 事务的控制语句、隐式提交、隔离级别到分布式事务一层层把 InnoDB 的事务内幕扒开来看结合我实际运维和排查过程中踩过的坑把能直接用的知识点和操作经验都写出来。这篇文章适合刚接触 MySQL 的开发者也适合被线上事务问题折磨过的 DBA 和架构师。看完之后你会清楚一个事务从 BEGIN 到 COMMIT 中间究竟发生了什么为什么某些操作绝对不能放进事务里以及如何在多库环境下保证数据一致性。2. 事务控制语句BEGIN、COMMIT、ROLLBACK 背后藏着什么2.1 三条必备语句和它们的变体MySQL 的事务控制语句其实是高度口语化的日常用得最多的就是三件套BEGIN或者 START TRANSACTION、COMMIT 和 ROLLBACK。BEGIN; UPDATE account SET balance balance - 100 WHERE user_id 1; UPDATE account SET balance balance 100 WHERE user_id 2; COMMIT;这三条语句看起来没什么稀奇但有几个细节值得展开第一BEGIN 和 START TRANSACTION 基本等价但 START TRANSACTION 后面还能带修饰符比如START TRANSACTION READ ONLY表示开启一个只读事务START TRANSACTION WITH CONSISTENT SNAPSHOT可以在事务第一句话就建立一致性读视图后续所有普通 SELECT 都基于这个快照不会因为别的事务提交而感知到变化。这一点在我们做报表导出时非常有用。第二ROLLBACK 默认是回滚到事务开始位置但如果在事务中间设置了 SAVEPOINT就可以回滚到指定保存点而不是全部回滚。这个特性对长事务中某一步失败后的局部恢复非常有帮助。BEGIN; INSERT INTO orders (order_id, amount) VALUES (1001, 500); SAVEPOINT sp1; INSERT INTO order_items (order_id, item_id) VALUES (1001, 55); -- 模拟某一步出错 ROLLBACK TO SAVEPOINT sp1; COMMIT;这段事务的效果是order_items 的插入被回滚但 orders 的插入保留下来。执行ROLLBACK TO SAVEPOINT sp1之后保存点 sp1 仍然存在后续还可以继续操作。如果想让保存点失效用RELEASE SAVEPOINT sp1。我实际中见过有人用 SAVEPOINT 实现批量导入任务里的“跳过坏行”逻辑——每处理若干行设置一个保存点出错时只回滚这小批数据效率比整体回滚高得多。第三autocommit 这个默认设置对事务行为的影响比很多人想象的大。MySQL 默认开启 autocommit意味着每条单独的语句都会自动提交。你可以通过SET autocommit 0关闭或者通过SET SESSION autocommit 0只影响当前会话。注意关闭 autocommit 后如果没有显式 COMMIT那么这些操作在会话断开时会怎么样很关键的一点是MySQL 不会在连接断开时自动提交而是所有未提交修改都会回滚。这跟 Oracle 的行为一致但很多从 SQL Server 过来的人会容易搞混。2.2 InnoDB 到底怎么保证“原子性”和“持久性”控制语句只是入口真正让事务可靠的是底层两个日志redo log 和 undo log。redo log 负责持久性。事务每做一次数据修改InnoDB 会先把变更记录写到内存中的 redo log buffer然后按照一定的策略刷到磁盘上的 redo log file。这个策略是由参数innodb_flush_log_at_trx_commit决定的取值 0、1、2。取 1 时每次事务提交都强制刷磁盘性能有所牺牲但最安全取 2 时只写到操作系统缓存由系统决定什么时候落盘虽然性能好但如果机器断电最近的事务提交可能丢失。生产环境默认是 1很多大流量业务为了性能会改成 2这就要在数据安全与性能之间做取舍。我的建议是金融类、交易类业务必须保持 1日志、点赞这类允许少量丢失的业务再考虑 2。undo log 负责原子性和 MVCC。事务修改数据时InnoDB 会为每行记录生成一个反向操作记录到 undo log同时维护一个隐藏的回滚指针。如果事务需要回滚顺着 undo log 的链把数据恢复到之前的状态如果其他并发事务需要读取旧版本数据也是顺着这个链找到对应版本。所以 undo log 不只在 ROLLBACK 时有用也是多版本并发控制MVCC的核心数据结构。这里有个非常容易忽略的点ROLLBACK 其实并不一定能把所有操作都恢复。对于 DDL 语句比如 ALTER TABLE 做了表结构修改MySQL 不支持事务性 DDL执行后无法通过 ROLLBACK 撤销修改。这也是为什么我们在第 3 节要专门讲隐式提交——很多误操作就是没搞清楚哪些语句不参与事务。2.3 事务中一条 UPDATE 的执行路径把控制语句和底层日志串起来一条 UPDATE 在事务中的完整路径大致是事务开启BEGINInnoDB 给事务分配一个事务 ID。UPDATE 命中的行上锁阻止其他事务修改同一条记录。修改前先把旧值写入 undo log。修改数据页并写入 redo log buffer。事务执行成功应用层调用 COMMIT。InnoDB 将 redo log 刷盘取决于刷盘策略。释放行锁事务结束。理解了执行路径你就知道为什么长事务会引发严重问题因为持有锁的时间太长阻塞了其他事务undo log 也不能清理回滚段持续膨胀binlog 和 relay log 产生大量延迟数据。所以事务尽量短小精悍不要把网络请求、外部接口调用、复杂计算都塞在事务里面。有一个非常典型的反例某开发者在事务里调用第三方支付接口等待对方回调后才 COMMIT。结果支付接口响应超时事务挂了几十秒期间所有针对同一行数据的请求全部排队数据库连接池被耗尽最终整个系统雪崩。这种做法等于把不可控的外部依赖转换成数据库锁等待怎么优化 SQL 都没有用。事务里只做数据库内部操作外部依赖一律在事务外处理。3. 隐式提交的 SQL最容易被忽视的“坑王”3.1 哪些语句会触发隐式提交MySQL 区分“显式提交”和“隐式提交”。显式提交是你明确执行 COMMIT隐式提交则是你执行了一些特殊语句MySQL 会在执行之前自动帮你提交当前事务而且这个行为无法通过参数关闭。根据官方文档和实际测试触发隐式提交的语句大致有几类第一类是 DDL 定义语句。CREATE TABLE、ALTER TABLE、DROP TABLE、TRUNCATE TABLE、RENAME TABLE、CREATE INDEX、DROP INDEX、CREATE DATABASE、ALTER DATABASE等都会在当前事务中先隐式提交再执行具体操作。第二类是 DCL 权限管理语句。GRANT、REVOKE、SET PASSWORD、CREATE USER、DROP USER、RENAME USER等同样会触发隐式提交。第三类是表锁和事务管理语句。LOCK TABLES和UNLOCK TABLES本身就会提交当前事务。这一点很多人忽略在一个未提交事务里执行 LOCK TABLES前面积累的操作会自动 COMMIT。第四类是管理类语句比如ANALYZE TABLE、OPTIMIZE TABLE、REPAIR TABLE等维护操作。第五类比较隐蔽SET语句中的某些操作也会触发隐式提交。具体来说SET autocommit 1、SET AUTOCOMMIT 1会把当前事务直接提交。注意这里的细节如果你把autocommit从 0 改为 1MySQL 会提交当前事务但从 1 改为 0不会提交任何东西。同样SET TRANSACTION ISOLATION LEVEL这种修改隔离级别的语句在事务执行过程中是不能使用的MySQL 会直接报错“Cannot change the isolation level during a transaction”所以你必须先提交当前事务再改隔离级别。3.2 一个生动的踩坑案例我见过一个真实的案例某公司做了个会员积分的批量重算任务逻辑是BEGIN; UPDATE member_points SET points points 100 WHERE member_id 1; UPDATE member_points SET points points 100 WHERE member_id 2; -- ... 中间处理很多成员 TRUNCATE TABLE temp_processing_table; COMMIT;开发者的本意是如果中间出错所有积分调整全部回滚。但因为TRUNCATE TABLE触发了隐式提交前面几十万条积分更新在 TRUNCATE 之前就被提交了。后来任务执行到一半程序崩溃事务回滚但前面一部分积分更新已经生效导致数据对不上最后只能从 binlog 逆向排查恢复。这个案例给所有人的教训就是事务里绝对不能混入任何隐式提交语句。如果你在设计阶段就有这种需求要么把 DDL 类操作放到事务外要么改用事务性替代方案。比如 TRUNCATE 可以用 DELETE FROM 代替前提是数据量允许且性能可接受CREATE INDEX 可以提前建好权限变更单独处理。3.3 如何检查和避免隐式提交避免隐式提交最有效的方法是分层思维业务逻辑层事务内只允许 SELECT、INSERT、UPDATE、DELETE绝不执行 DDL、DCL、锁表操作。表结构变更一律通过独立的变更流程执行不要在业务代码里动态建表、建索引。定期巡检通过慢查询日志或者 binlog 分析发现有异常的大量 COMMIT 行为时回溯这些 COMMIT 前的语句看是不是有隐式提交语句混入。还可以利用 MySQL 的performance_schema.events_statements_history表来查看一个事务内实际上执行了哪些语句以及这些语句之间的提交行为。虽然不能直接看到“隐式提交”四个字但通过语句类型和提交时间戳基本能定位问题。我再补充一个容易被坑的场景如果你在存储过程中使用了CREATE TEMPORARY TABLE建立临时表本身不会隐式提交但某些版本和配置下临时表的操作可能会受事务影响。更稳妥的做法是临时表的创建放在事务外事务内只做数据写入和读取。4. 隔离级别READ COMMITTED 与 REPEATABLE READ 的实战差异4.1 四种隔离级别和它们解决的问题SQL 标准定义了四种隔离级别按隔离程度从低到高分别是READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE。READ UNCOMMITTED 允许读到其他事务尚未提交的数据也就是“脏读”。在实际业务里几乎不会使用因为它的隔离性太弱数据完全不可信。READ COMMITTED 解决脏读问题只允许读取已经提交的数据版本。但在一个事务内两次相同的 SELECT 可能因为其他事务提交而产生不同的结果这就是“不可重复读”。REPEATABLE READ 是 MySQL InnoDB 的默认隔离级别它保证在同一事务内多次读取同一数据结果一致。同时InnoDB 通过间隙锁gap lock和 next-key lock 配合 MVCC还在很大程度上解决了“幻读”问题。SERIALIZABLE 是最严格的级别相当于所有事务串行化执行通过加锁和阻塞避免任何并发冲突。它解决全部问题但代价是并发性能极差基本只能用于对一致性要求极高、并发量很小的场景。我写一个表格把这四种级别、是否解决脏读/不可重复读/幻读以及典型场景列出来隔离级别脏读不可重复读幻读典型场景READ UNCOMMITTED可能可能可能几乎不用READ COMMITTED避免可能可能大多数OLTP系统可选REPEATABLE READ避免避免InnoDB基本避免MySQL默认交易系统常用SERIALIZABLE避免避免避免高一致性低并发场景4.2 InnoDB 的 MVCC 和一致性读视图REPEATABLE READ 之所以能在一个事务内保持一致的读取结果靠的是 MVCC 的“一致性读视图”consistent read view。关于这个视图的机制很多文档讲得云里雾里我尽量用通俗的方式讲清楚。在 REPEATABLE READ 下InnoDB 为每个事务在第一次执行 SELECT 时创建一个快照视图。这个视图记录了三组数据当前活跃事务 ID 列表小于最小活跃事务 ID 的版本可见、已提交事务 ID 列表大于最大已分配事务 ID 的版本不可见、以及自己这个事务的 ID。每条记录在 undo log 中保存着历史版本和事务 ID。SELECT 时InnoDB 沿着 undo log 回溯找到第一个“对当前事务可见”的版本。在 REPEATABLE READ 下这个视图在整个事务期间保持不变所以多次读到的数据一致。在 READ COMMITTED 下每次 SELECT 都会重建一个新的视图所以其他事务提交后再次读取就能看到新版本这就是不可重复读的由来。这里面有一个很多人误解的细节MVCC 解决的是“快照读”的一致性问题但它不解决“当前读”的幻读问题。所谓快照读就是普通的SELECT不加 FOR UPDATE、不加 LOCK IN SHARE MODE所谓当前读是SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE以及INSERT、UPDATE、DELETE必须读取最新的已提交版本并且加锁。当前读场景下InnoDB 依赖 next-key lock记录锁 间隙锁来防止幻读。如果你在 REPEATABLE READ 级别下执行了SELECT * FROM t WHERE id 100 FOR UPDATEInnoDB 会锁住所有符合条件记录的间隙阻止其他事务在 id 大于 100 的范围内插入新记录。这也是 REPEATABLE READ 下大量并发插入会被阻塞的原因。4.3 REPEATABLE READ 和 READ COMMITTED 的选型经验MySQL 默认 REPEATABLE READ这从历史兼容性上可以理解但对很多业务来说READ COMMITTED 确实更合适因为它的锁范围更小并发度更高尤其在频繁插入和更新的场景下不容易触发间隙锁导致的死锁。我记得某次排查一个“莫名其妙死锁”的问题从SHOW ENGINE INNODB STATUS里看到的场景是两个事务都在 REPEATABLE READ 级别下对某结构化订单表执行范围查询加范围更新因为间隙锁互相等待。分析后发现业务确实不需要 RR 级别的“可重复读”只需要每一条 SELECT 读到最新已提交数据于是把隔离级别改成 READ COMMITTED死锁立刻消失吞吐量明显提升。在 READ COMMITTED 下InnoDB 的间隙锁会被禁用只保留记录锁这能显著降低死锁概率。但相应地如果一个事务内你需要多次读取同一条件的数据而保证结果不变就不能依赖 READ COMMITTED必须通过FOR UPDATE加锁或者改用 REPEATABLE READ。我个人的经验法则是报表、数据导出、统计分析类任务多用 REPEATABLE READ。因为一次报表查询可能分多个 SQL 拉取不同维度的数据如果隔离级别太低中途别的事务提交会导致报表前后口径不一致。高频 OLTP 交易系统优先考虑 READ COMMITTED。交易本身事务短小对快照一致性要求低反而更需要高并发和低锁冲突。SERIALIZABLE 尽量只在特定存储过程或者特殊业务逻辑中使用不适合作为全局默认级别。另外提醒一下修改隔离级别有全局、会话、事务级三个作用域SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED影响新连接SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED只影响当前会话SET TRANSACTION ISOLATION LEVEL READ COMMITTED只影响下一个事务执行后立刻生效之后 BEGIN 开启的新事务按新级别运行。4.4 隔离级别与锁的交互FOR UPDATE 的坑在 REPEATABLE READ 下SELECT ... FOR UPDATE会加 next-key lock而如果是通过唯一索引精确等值匹配某一行记录存在时 InnoDB 会退化到仅加记录锁。但注意“记录存在”这个前提如果等值匹配的记录不存在InnoDB 会锁住这个值落在的间隙从而阻塞其他事务插入该范围内的数据。这个机制在实际中有个经典的坑某企业在做“唯一手机号注册”功能时并发两个注册请求同时检查手机号不存在然后同时插入。因为 InnoDB 在 REPEATABLE READ 下会对不存在的唯一键值加间隙锁处理不好就会出现死锁。这里的本质是你把“检查再插入”这个过程拆成了多个步骤等于自己制造了锁竞争窗口。正确的做法是直接把唯一约束建好捕获插入时的唯一键冲突异常而不是先 SELECT 再 INSERT。一旦陷入“检查插入”模式任何隔离级别下都难免有并发边界问题。5. 分布式事务XA 协议在 MySQL 里的实际用法与局限5.1 为什么需要分布式事务单体数据库时代一条 UPDATE 可以保证多个表的数据一致性因为所有修改都在一个事务里受同一份 redo/undo log 保护。但当业务拆分到多个数据库或者引入了消息中间件、缓存等其他存储一个业务操作可能要更新两个数据库中的记录或者先写数据库再发消息。这时候单机事务天然失效需要分布式事务来协调多个参与者。MySQL 的分布式事务能力主要体现为对 XA 协议的支持。XA 是一个分布式事务规范它定义了一个全局事务如何协调多个资源管理器各个 MySQL 实例或其他数据库完成原子提交。MySQL 在 InnoDB 层面实现了 XA可以通过XA START、XA END、XA PREPARE、XA COMMIT、XA ROLLBACK这套命令来操作。5.2 MySQL XA 事务的完整流程XA 事务的核心思想是两阶段提交2PC第一阶段事务管理器向所有参与方发送准备提交请求让每个数据库把事务所需资源都锁住并把 redo log 落盘第二阶段如果所有参与方都准备好再发送最终提交指令。下面是一个简单的 XA 事务示例假设数据库 A 和数据库 B 分别存储账户和订单-- 数据库A账户库 XA START xid-1001; UPDATE account SET balance balance - 200 WHERE account_id 1; XA END xid-1001; XA PREPARE xid-1001; -- 数据库B订单库 XA START xid-1001; INSERT INTO orders (order_id, amount) VALUES (555, 200); XA END xid-1001; XA PREPARE xid-1001; -- 两个库都PREPARE成功后两阶段提交 -- 数据库A XA COMMIT xid-1001; -- 数据库B XA COMMIT xid-1001;如果某个参与方 PREPARE 失败就必须对已经 PREPARE 过的参与方执行 XA ROLLBACK实现全体回滚。这个流程保证了要么两个库同时提交要么同时回滚。这里有一个细节MySQL 的 XA PREPARE 之后事务实际上已经进入了“悬置”状态。此时事务持有的行锁不会释放redo log 已经落盘。如果之后协调者崩溃PREPARE 过的事务既没有 COMMIT 也没有 ROLLBACKMySQL 在恢复时会根据 XA RECOVER 的结果帮助 DBA 手动处理。5.3 XA 事务的局限和替代方案XA 给了我们一个看似完美的分布式事务方案但实际落地时限制很多。第一XA 事务会显著提高锁竞争和时间开销。每个参与方都需要完成本地事务并且 PREPARE 阶段需要同步等待所有参与方响应性能不如普通事务。在跨机房、网络不稳的场景下2PC 的可用性非常脆弱。第二MySQL XA 事务存在一个很经典的兼容性问题XID 管理。XID 必须是全局唯一的字符串而且使用相同的 XID 执行多次 XA START 会报错。分布式的协调器必须自己生成并维护 XID 状态如果协调器本身崩溃恢复就很麻烦。第三XA 只解决“原子提交”问题不解决数据一致性中的所有问题。比如一个分布式事务里数据库 A 提交成功、数据库 B 提交失败无论怎么回滚A 上已经产生的 binlog 会包含已提交事务下游订阅 binlog 的系统可能已经处理了这部分数据这就是“最终一致性”要额外处理的场景。所以在实际工程中很多团队并没有大规模使用 MySQL 的 XA而是采用其他方案事务消息利用消息中间件的事务消息能力先把本地事务和发消息放在同一个本地事务里消息成功发送后再由消费者处理其他系统的事务比如利用 RocketMQ 的事务消息。本地消息表 定时任务补偿在每个业务系统内建本地消息表业务操作和写消息表在同一个本地事务完成后台任务扫表投递消息消息消费者执行后续操作失败则重试。TCCTry/Confirm/Cancel将分布式事务拆解成 Try 阶段预留资源、Confirm 阶段确认执行、Cancel 阶段回滚释放适合需要强一致性的业务。Seata 这类分布式事务框架基于全局事务协调器对业务侵入较低支持 AT自动补偿和 TCC 模式。我个人建议是能不用分布式事务就不用。很多业务通过接口幂等、状态机流转、事件溯源等手段完全可以做到最终一致规避 2PC 的性能和容灾问题。如果确实需要强一致性分布式事务先认真评估数据量、并发量、跨库数量和网络条件再决定是否引入 XA 或 TCC。5.4 与分布式事务相关的常见误区我见过不少同学把“主从复制”和“分布式事务”混为一谈。实际上MySQL 主从复制本身并不是分布式事务它只是 binlog 在时间轴上的异步或半同步回放。从库上产生的不一致并不是事务原子性的问题而是复制策略和网络延迟的问题。另一个误区是把连接上的autocommit状态与 XA 混在一起。如果我开着autocommit 0去执行 XA 事务非 XA 的普通事务状态可能和 XA 事务状态互相干扰。具体来说如果全局事务开始前有一个未提交的本地事务执行 XA START 时 MySQL 会先隐式提交本地事务。这个默认行为很容易被忽视需要在实际编码时注意。6. 事务排查实战从理论到应用的避坑指南6.1 用 SHOW ENGINE INNODB STATUS 抓死锁和锁等待我排查事务类问题最常用的命令之一就是SHOW ENGINE INNODB STATUS它会输出当前 InnoDB 引擎的状态信息包含最近一次检测到的死锁场景。如果只是锁等待而不是死锁这个命令的输出就不一定包含有效信息需要配合information_schema.innodb_trx、information_schema.innodb_locks或performance_schema.data_locks来查看当前持有锁和等待锁的会话。一个典型的锁等待排查过程是这样的SELECT * FROM information_schema.innodb_trx\G SELECT * FROM performance_schema.data_locks\G SELECT * FROM performance_schema.data_lock_waits\G先看innodb_trx表里哪些事务处于 RUNNING 状态运行了多久持有多少行锁再看data_locks表里锁的类型和锁定的对象最后看data_lock_waits表确认谁在等待谁的锁。这里有个很实用的“近实时”分析方法如果一个事务长时间没有 COMMIT它持有的锁会一直阻塞其他事务。排查时可以先按 trx_started 排序找出运行时间最长的那个事务看它执行了什么语句是否产生了长事务。通常这种长事务来自业务代码漏写 COMMIT、外部调用超时导致事务挂起、或者应用崩溃后连接未释放。6.2 事务回滚和提交失败后的数据修复事务回滚不是所有情况下都能完美恢复数据。尤其是遇到 DDL 导致隐式提交后前面的事务数据已经落盘此时无法通过 ROLLBACK 恢复只能靠备份或 binlog。稍微总结一下遇到这种问题的应对手段如果业务刚上线发现数据有问题第一时间检查是否开启了 binlog。只要 binlog 存在且记录格式是 ROW 模式可以通过mysqlbinlog解析出具体时间段的 SQL 操作定位被隐式提交的事务操作。如果开启了 GTID还可以用 GTID 精确过滤到某几个事务。但即使如此恢复过程也是痛苦的涉及解析、批量回放和数据校验。所以真正的防线依然是把事务边界设计清楚把 DDL 和 DML 分开把外部依赖在事务外处理。6.3 隔离级别与死锁的关联分析回到死锁问题本身。死锁的产生需要两个或多个事务以不同顺序请求同一组资源而间隙锁会显著扩大“资源”的范围增加死锁概率。我在 REPEATABLE READ 下遇到过的最典型死锁是事务 A 先查后改表 X事务 B 先查后改表 Y然后 A 再改 YB 再改 X。如果按“一致的顺序”访问表比如都先 X 后 Y死锁就不会发生。这个经验对所有事务设计都适用多个事务更新多张表时尽量保持相同的访问顺序单张表内尽量保证相同的索引路径。另一个实用技巧是在死锁发生后让业务侧做自动重试。死锁本身不一定是架构问题有时候就是并发高峰下的正常冲突。MySQL 检测到死锁后会回滚其中一个事务并向客户端返回Deadlock found when trying to get lock; try restarting transaction这个错误。应用层捕获这个错误后做一次重试很多死锁问题就不知不觉被消化了。但如果重试后死锁依然频繁那就要考虑减少锁粒度、拆分事务、改变隔离级别了。6.4 隔离级别选择与参数调整的几条建议如果你问我一个全新的业务系统MySQL 隔离级别应该选什么我大概率会建议先理解业务再做决定但可以给几条经过大量实践验证的建议默认保留 REPEATABLE READ不要随手把全局隔离级别改成 READ COMMITTED。除非你确实遇到锁竞争或死锁频繁并且业务上不要求可重复读。如果生产环境要在两种级别之间切换一定要先在压测环境对比验证。某些框架对隔离级别有默认假设切换后可能产生不可预期的行为。比如一些 ORM 在 REPEATABLE READ 下依赖快照读取切换后数据变化会让缓存失效或产生更新丢失问题。监控长事务是重中之重。通过performance_schema的events_transactions_current表可以查看每个事务的状态和持续时间设置告警超过阈值比如 10 秒就报警人工介入。谨慎调整innodb_lock_wait_timeout。默认是 50 秒如果业务对锁等待敏感可以调小到 5~10 秒让锁冲突的请求快速失败避免拖垮整个应用。但调小之后短暂的锁波动也可能导致业务失败率上升需要结合重试机制一起设计。6.5 关于双写和分布式事务的最后一句话如果你已经在跨库双写比如同时写 MySQL 和 Redis一定要明白这根本不是事务问题而是“分布式一致性问题”。MySQL 事务管不了 RedisXA 也管不了 Redis除非 Redis 实现了 XA 接口但实际极少用。这种情况下正确做法是先更新 MySQL再通过事务消息或 binlog 订阅触发 Redis 更新或者反过来先写 Redis 作为预占再更新 MySQL再异步删除或更新 Redis。任何一步失败都要有补偿机制靠“重试 幂等”来兜底。这是我处理过无数次双写问题后的最深刻体会分布式世界没有银弹最终一致性才是常态保证数据最终不丢、能对账、能纠正比追求一次提交的强一致更现实。7. 最后分享一个排障技巧如果让我只留一个排查事务问题的习惯那就是在任何事务边界不清晰的地方先打印出当前的session.autocommit值和事务状态再把事务内每一条语句前后的时间戳记录到日志中。有了这两个信息九成以上的事务问题都能在日志阶段就定位到而不需要等到线上出一个慢查询事故才去排查。我自己就是这么操作的。早些年处理过一单“数据库锁等待导致系统瘫痪”的线上问题最后定位到原因是某个开发框架在请求结束时主动执行了 COMMIT而业务代码里又嵌套了 BEGIN导致事务边界完全失控。从那以后我在所有项目的数据库工具类里都强制要求记录的日志必须包含事务开启、提交、回滚三个节点任何异常的事务生命周期波动都能通过日志回溯。这个习惯帮我省掉了无数不眠之夜各位也可以试试。