上个月我处理了一个印象特别深的线上问题:用户在 App 里完成了支付,订单却卡在“待支付”,客服手动补单补到手软。后面翻日志才发现,扣款接口和订单接口之间的数据写入没有放到同一个 MySQL 事务里。第一个 UPDATE 成功了,第二个 INSERT 因为一个字段超长直接抛异常,程序只打印了 error,没有任何回滚动作,于是两边数据永远对不上。
这个事故让我重新把 MySQL 事务从头到尾捋了一遍,从默认自动提交、ACID 的底层实现,到隔离级别、锁、MVCC,再到代码里怎么正确圈定事务边界。这篇文章不打算写成一板一眼的官方文档,更像是我自己排查问题、复习原理、重构代码之后的一次完整复盘。无论你是刚学 MySQL 的初学者,还是被“事务隔离级别”“不可重复读”“next-key lock”这些概念折磨过的后端开发,应该都能从这里找到能直接落地的认知。
1. 事务的边界感:不是写了 BEGIN 就算完事
1.1 autocommit 是很多事务事故的第一根源
我第一次带项目时,最想不通的一件事是:明明自己在代码里写了三条 SQL,执行完第一条之后第二条报错,为什么数据库里第一条的数据还是变了?后来才发现,问题出在 MySQL 的默认提交策略上。
MySQL 默认 autocommit = 1,这意味着每条单独的 SQL 语句执行完,都会被当作一个独立事务直接提交。有人可能觉得这没什么,反正大多数查询又不涉及数据变更。但只要涉及写操作,这个默认行为就会埋雷。你看下面的代码逻辑:
java复制// 伪代码:支付回调处理
jdbcTemplate.update("UPDATE account SET balance = balance - 100 WHERE user_id = 1");
jdbcTemplate.update("INSERT INTO order_record (order_id, user_id, amount) VALUES (?, ?, ?)", orderId, userId, 100);
如果没有显式开启事务,第一条 UPDATE 成功提交后,第二条 INSERT 因为 order_id 超长抛了异常。程序捕获异常后记了一条日志,但数据库里用户的余额已经扣了。用户没有拿到订单,钱却少了,这就是典型的“事务边界缺失”。
我建议所有刚接触 MySQL 事务的人,先在自己的环境里验证一次 autocommit 的行为,比背十遍概念都管用:
sql复制SHOW VARIABLES LIKE 'autocommit';
SET autocommit = 0;
UPDATE account SET balance = balance - 100 WHERE id = 1;
SELECT * FROM account WHERE id = 1; -- 自己能看到修改,但其他连接看不到
ROLLBACK;
当你把 autocommit 临时关掉,执行完更新后不提交,再用另一个会话查询,会发现数据没有变化。这个现象能直观告诉你:事务边界不是从你写第一条 SQL 开始,而是从你显式控制提交或回滚才开始。
1.2 显式事务的正确姿势以及 DDL 的隐式提交陷阱
真正在业务代码里,我不建议依赖修改全局 autocommit,而是用显式事务把边界画清楚。以 JDBC 为例,核心逻辑是这样:
java复制Connection conn = null;
try {
conn = dataSource.getConnection();
conn.setAutoCommit(false); // 关闭自动提交,开启事务
// 执行多条DML
updateBalance(conn, userId, -100);
insertOrder(conn, orderId, userId, 100);
conn.commit(); // 全部成功,提交
} catch (Exception e) {
if (conn != null) {
conn.rollback(); // 任何一步失败,全部回滚
}
// 记录错误,抛出业务异常
} finally {
if (conn != null) {
conn.setAutoCommit(true); // 恢复默认,归还连接
conn.close();
}
}
很多年轻同事只记住了 commit 和 rollback,却忽略了一个细节:MySQL 对 DDL 语句(CREATE TABLE、ALTER TABLE、TRUNCATE TABLE 等)存在隐式提交。哪怕你前面开启了一个事务,只要执行了一条 DDL,它会把当前事务先提交掉,然后 DDL 本身也无法回滚。
我碰到过有人在初始化数据的脚本里写了 START TRANSACTION,然后中间混了一条 ALTER TABLE,结果事务被悄悄截断,后面的数据修复逻辑没按预期回滚。排查半天才发现是 DDL 隐式提交导致的。所以一个简单的经验是:事务里只放 DML,不要把 DDL 和事务混在一起用。
1.3 SAVEPOINT 可以让你不用“一错到底”
有时候一个事务里的操作比较多,并不希望因为最后一个操作失败,把前面所有操作全部回滚。这时可以借助 SAVEPOINT 做部分回滚:
sql复制START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
SAVEPOINT sp1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
-- 假设这里出错了
ROLLBACK TO SAVEPOINT sp1;
-- 仍然可以继续其他操作,最终提交
COMMIT;
ROLLBACK TO SAVEPOINT sp1 会回滚到保存点之后的操作,但前面第一个 UPDATE 还在当前事务里,没有被撤销。这个能力在复杂批处理里比较实用,但它也会让代码的可读性变差。非必要不建议频繁使用,更多时候我们应该把一个事务拆小,而不是依赖保存点做嵌套控制。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 事务出问题时,InnoDB 靠什么把数据翻回来
2.1 原子性不是“自动回滚”,而是 undo log 里的后悔药
很多新手理解原子性,以为数据库会聪明地记住你执行了哪些语句,失败了就自动“撤销”。实际上,InnoDB 的回滚能力靠的是 undo log,它记录的是逻辑日志。比如你执行了:
sql复制UPDATE account SET balance = balance - 100 WHERE id = 1;
原本 id = 1 这一行 balance 是 500,InnoDB 会在 undo log 里记录“要把 balance 改回 500”这个操作,或者更准确地说,记录更新前的数据版本。
当事务回滚时,InnoDB 会根据 undo log 里的信息,把数据页恢复到更新前的状态。注意,它做的不是简单的“反向执行一条更新”,而是从版本链上找到对应的历史版本。这也是 MVCC(多版本并发控制)能工作的基础——每行记录上可能同时存在多个版本,不同事务能看到不同的版本。
所以你可以把 undo log 理解为事务的“后悔药”。但后悔药不是无限保存的,事务一旦提交,对应的 undo log 就不再承担回滚职责,会逐渐被 purge 线程清理。这解释了一个经典问题:为什么长事务或者大事务会导致 undo log 膨胀,甚至让磁盘涨得很快?因为只要事务不结束,那些历史版本就不能被彻底清理,后续查询可能还需要它们来构造快照。
2.2 一致性是“业务规则 + 数据库约束”共同的产物
面试的时候最喜欢问“ACID 分别是什么”,很多人把一致性背得很流利,但问“数据库是怎么保证一致性的”,就答不上来了。其实一致性不是 InnoDB 某个独立机制直接产生的,它更像一个结果,是原子性、隔离性、持久性配合业务约束之后达到的状态。
举个例子:转账场景要求“扣款方少 100,收款方多 100”,如果只执行了扣款,没有执行加款,那么无论数据库的隔离级别多高、redo log 多可靠,数据仍然不一致。数据库能做的是提供事务机制,保证“扣款”和“加款”要么都成功,要么都失败。但“余额不能为负数”“一个订单只能被支付一次”这类规则,需要你在表结构上建唯一索引、加 CHECK 约束,或者在应用代码里判断。
我见过很多人过度信任事务,以为只要方法上加了事务注解,脏数据就永远不会产生。实际上事务只能管“操作是否完整提交”,管不了“业务逻辑是否合理”。所以当线上出现数据不一致时,不能只盯着事务有没有生效,还要看业务规则是否在正确的位置做了兜底。
2.3 持久性不是“每次提交都刷盘”,而是 redo log 先落地的 WAL
继续往下挖一层,InnoDB 保证崩溃后数据不丢失的核心机制,是 redo log。它记录了“物理层面修改了哪些页”,比如某个页的某个偏移位置被写成了什么值。当执行一条更新语句时,InnoDB 并不是立刻把数据页从内存刷到磁盘,而是先把变更记录写入 redo log buffer,在事务提交时把 redo log 刷到磁盘。这就是 WAL(Write-Ahead Logging)技术——日志先行。
为什么一定要先写日志而不是先刷数据页?因为数据页是随机写入,磁盘的随机 IO 很慢;而 redo log 是追加写入,顺序 IO 快得多。如果每次提交都要把数据页立刻刷新到磁盘,数据库的写性能会低到无法接受。所以 InnoDB 先把低成本、易恢复的日志写牢固,数据页可以留在内存里慢慢刷,哪怕数据库突然崩溃,重启时也能根据 redo log 把已经提交的事务重放出来。
这里有一个常见的误解:以为 redo log 是“备份”或“binlog”。其实 redo log 是 InnoDB 存储引擎层的日志,主要解决崩溃恢复;binlog 是 MySQL Server 层的日志,主要服务于主从复制和数据归档。两者记录的内容不同,用途也不同。排查主从数据不一致时,经常要把 redo log、binlog、undo log 分开看,不要混为一谈。
3. 隔离级别真的决定了你能看到什么,以及锁多久
3.1 从一场“同时读和写”的混乱说起
假设有一张订单表,里面有一个订单状态字段。A 事务要读取一批订单,B 事务同时修改了其中一条订单的状态。A 事务到底应该看到修改前的状态,还是修改后的状态?如果没有任何隔离规则,数据库里的数据会乱套。
SQL 标准定义了四种隔离级别,从宽松到严格依次是:READ UNCOMMITTED(读未提交)、READ COMMITTED(读已提交)、REPEATABLE READ(可重复读)、SERIALIZABLE(串行化)。理解它们最好的方式,是看它们分别堵住了哪一类问题:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不会 | 可能 | 可能 |
| REPEATABLE READ | 不会 | 不会 | 可能(InnoDB 通过 next-key lock 可避免) |
| SERIALIZABLE | 不会 | 不会 | 不会 |
这里最容易被忽略的是“读已提交”和“可重复读”的区别。读已提交表示在一个事务内,每次普通 SELECT 都会重新生成一个快照,所以如果其他事务提交了修改,你下一次 SELECT 能看到新的值;可重复读则表示事务内第一次普通 SELECT 时生成快照,之后整个事务都读这个固定快照,其他事务提交的修改对你不可见。
3.2 用两个会话现场复现“不可重复读”
我自己学习隔离级别时,光看概念完全记不住,手动跑一遍才真正明白。下面这套操作可以原样复现,建议你拿一个测试库试:
准备一张表:
sql复制CREATE TABLE `txn_test` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(20) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
INSERT INTO txn_test (id, name) VALUES (1, 'A');
会话 1 开启事务并查询:
sql复制START TRANSACTION;
SELECT * FROM txn_test WHERE id = 1; -- 结果:A
会话 2 执行:
sql复制START TRANSACTION;
UPDATE txn_test SET name = 'B' WHERE id = 1;
COMMIT;
回到会话 1,在 READ COMMITTED 隔离级别下再次查询同一条记录:
sql复制SELECT * FROM txn_test WHERE id = 1; -- 结果:B
同一个事务里,两次读到同一个主键的值不一样,这就是“不可重复读”。如果把会话 1 的隔离级别改成 REPEATABLE READ(MySQL 默认隔离级别),再重复上面的流程,第二次查询结果仍然是 A。说明快照读下,事务隔离已经把数据版本固定住了。
很多人在这一步会犯迷糊:事务 1 一开始查询的时候,事务 2 的 UPDATE 还没提交,所以它读到 A;后面事务 2 提交了,为什么事务 1 还读 A,不会读到最新的 B?这其实就是 MVCC 的作用,后面我会专门讲。这里先记住结论:默认 RR 级别下,普通 SELECT 是快照读,事务内多次读取结果一致。
3.3 幻读并没有被“普通 SELECT”完全消灭
继续说幻读。它指的是事务内执行了两次范围查询,第二次多出来一些第一次没有看到的记录。比如你查所有 age > 18 的用户,第一次查到 2 条;另一个事务插入了一条 age = 20 的记录并提交;你接着再查一次,如果读到 3 条,就发生了幻读。
在 MySQL 默认的 REPEATABLE READ 级别下,快照读不会发生幻读,因为整个事务内读的是固定快照。但如果你执行的是“当前读”,比如 SELECT ... FOR UPDATE、UPDATE、DELETE,它们每次都会读取最新已提交的数据,并且会对扫描到的记录加上锁。在这种情况下,如果没有间隙锁,仍然可能被其他事务插入新数据,从而产生幻读。
InnoDB 解决这个问题的方式是 next-key lock,也就是行锁 + 间隙锁的组合。它对扫描区间内的记录加行锁,同时对记录之间的空隙加间隙锁,阻止其他事务在空隙里插入数据,这样当前事务就能在一个稳定的范围内做操作。所以 MySQL 的 REPEATABLE READ 级别在绝大多数场景下已经被强化到接近 SERIALIZABLE 的效果,这也是它成为默认隔离级别的重要原因。
但要注意:不是所有操作都会触发 next-key lock,它跟查询条件是否用到索引、隔离级别设置、当前读还是快照读有非常密切的关系。理解到这层,才算是摸到了锁机制的门边。
4. 并发卡死和死锁:事务背后的锁机制与排查
4.1 快照读与当前读:同一张表里存在两个世界
上面反复提到“快照读”和“当前读”,这两个概念如果不掰开,后面看锁和死锁一定是一头雾水。
快照读就是普通的 SELECT,它通过 MVCC 读取一个历史快照,不加锁。对于 RR 隔离级别而言,ReadView 是事务内第一次执行快照读时生成的,之后一直复用,所以能看到的数据版本是固定的;对于 RC 隔离级别,每次 select 都会重新生成 ReadView,所以能看到其他事务最新已提交的数据。
当前读则不同,SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE 都属于当前读。它们读取的是当前最新已提交版本,并且对涉及的数据行加锁,防止其他事务同时修改。
写过秒杀系统或库存系统的同学应该深有体会:如果只用普通 SELECT 判断库存是否充足,然后执行 UPDATE 扣减库存,两个并发请求可能同时读到库存还剩 1 件,最后都扣减成功,库存变成 -1。这就是典型的“快照读判断 + 非原子更新”问题。正确做法是直接依赖数据库的行锁,把判断和更新放进同一个当前读操作里,或者先 SELECT ... FOR UPDATE 锁住库存行,再在事务里做业务判断和更新。
4.2 行锁、间隙锁、next-key lock 到底锁住了什么
为了理解 InnoDB 锁的影响范围,我建议不要只看概念,可以建一个测试表:
sql复制CREATE TABLE `t_lock` (
`id` int NOT NULL,
`age` int NOT NULL,
`name` varchar(20) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_age` (`age`)
) ENGINE=InnoDB;
INSERT INTO t_lock VALUES
(1, 10, 'A'),
(3, 20, 'B'),
(5, 30, 'C'),
(7, 40, 'D');
如果执行:
sql复制START TRANSACTION;
SELECT * FROM t_lock WHERE age BETWEEN 20 AND 30 FOR UPDATE;
在 RR 隔离级别下,这条当前读不仅会锁住 age = 20 和 age = 30 对应的两行记录,还会锁住它们之间的间隙,也就是 (20, 30) 这个范围,防止其他事务插入 age = 25 这样的记录。同时,它还会对范围边界的上下间隙加锁,范围可能更大,具体要看索引结构。
如果查询条件没有命中任何索引,或者优化器决定全表扫描,InnoDB 会对聚簇索引里的每一条记录都加锁,相当于锁了整张表。这是一个非常常见的线上事故场景:开发人员觉得只是一条普通的 UPDATE,结果因为 WHERE 条件字段上没有索引,把整张表的数据都锁住了,并发一高,数据库连接全部堆积,最终服务雪崩。
所以一个看起来没毛病的事务代码,可能因为缺少索引而变成“表锁级”的开销。这条经验我建议写进团队的代码规范:任何 UPDATE、DELETE 的 WHERE 条件,都必须先确认能走索引,并且尽量让锁范围精确到目标行。
4.3 死锁现场:从 SHOW ENGINE INNODB STATUS 找答案
死锁指的是两个或多个事务互相持有对方需要的锁,谁都不肯释放,谁也无法继续推进。最经典的发生方式是事务 A 先更新 id=1 再更新 id=2,事务 B 先更新 id=2 再更新 id=1:
sql复制-- 事务A
START TRANSACTION;
UPDATE t_lock SET age = age + 1 WHERE id = 1;
-- 事务B
START TRANSACTION;
UPDATE t_lock SET age = age + 1 WHERE id = 2;
-- 事务A继续
UPDATE t_lock SET age = age + 1 WHERE id = 2; -- 等待B释放id=2的锁
-- 事务B继续
UPDATE t_lock SET age = age + 1 WHERE id = 1; -- 等待A释放id=1的锁
两边互相等待,InnoDB 的死锁检测机制会立刻发现这种循环等待,并选择回滚其中一个事务,让另一个事务继续执行。所以死锁并不会无限卡住,它会表现为其中一个事务抛出类似 Deadlock found when trying to get lock; try restarting transaction 的异常。
遇到这种异常,我的习惯是立刻执行:
sql复制SHOW ENGINE INNODB STATUS\G
在输出里找到 LATEST DETECTED DEADLOCK 段落,里面会记录两个事务的 SQL、持有锁和等待锁的资源,这通常能快速定位到是哪两条更新语句产生了循环等待。对于高并发业务,即使代码已经很小心,也难以完全避免死锁,所以业务层必须做好“捕获 Deadlock 异常并发起重试”的兜底,而不是直接把异常抛给用户。
另外还可以通过调大 innodb_lock_wait_timeout 来避免锁等待超时太敏感,但这不是根治办法。根治方向通常是:让所有事务按照同一个顺序访问资源;把大事务拆小;确保更新条件走索引;必要的时候用 SELECT ... FOR UPDATE 显式控制锁顺序。
5. 事务代码里最容易被忽视的几个实操细节
5.1 不要在事务里做远程调用、发消息、大查询
我代码评审时,最常给的第一个意见就是:事务方法里不要写 httpClient 调用,不要发 MQ 消息,不要执行一个几秒钟的慢查询。
为什么?因为事务的提交意味着数据库锁要一直持有到事务结束。如果事务执行到一半,去调用外部接口,外部的响应需要 3 秒,这 3 秒里,当前事务更新的行锁不会释放。所有想更新同一行数据的其他事务都得排队等 3 秒,接口 RT 飙高算是轻的,严重时数据库连接池被打满,整个服务不可用。
正确的做法是:先在事务里完成必要的数据库更新,提交事务后,再异步去发消息或调用远程接口。如果远程调用失败,用本地消息表 + 定时重试来保证最终一致。很多人问分布式事务难,其实很多问题根本不是分布式事务的问题,而是把不该放进本地事务的事情硬塞了进来。
5.2 事务注解的三个隐藏风险:自调用、传播行为、异常吞掉
Spring 项目里经常用 @Transactional,但它不是用了就一定生效。我自己踩过三个坑。
第一个坑是自调用。同一个类里方法 A 调用方法 B,但 B 上标了 @Transactional,A 没有标,B 的事务不会生效。因为 Spring 的声明式事务默认通过代理对象实现,自调用走的是 this 本身,不会经过代理。解决办法是不要在同类里调用事务方法,或者把事务方法拆分到另一个 Bean。
第二个坑是传播行为。默认 REQUIRED 表示如果当前没有事务就新建一个,有事务就加入当前事务。但有时候你希望一个方法不管外面有没有事务,都独立提交,比如记录审计日志,就需要用 REQUIRES_NEW。如果不理解传播行为,把日志写入方法也加进主事务,一旦主事务回滚,日志也会跟着没掉,排查问题会非常痛苦。
第三个坑是最常见的——自己把异常吞了:
java复制@Transactional
public void updateOrder(Order order) {
try {
orderMapper.update(order);
} catch (Exception e) {
log.error("更新失败", e);
// 没有抛出异常,事务照样提交
}
}
事务要回滚,前提是异常能传播到事务代理层。如果在方法内部把异常 catch 住并且不抛出,Spring 根本不知道操作失败了,事务会正常提交。这会造成一种“看起来加了事务,实际上跟没加一样”的假象。正确做法是捕获异常后,记录日志,再抛出一个能让事务回滚的运行时异常,或者直接不捕获让调用方处理。
5.3 大事务是如何拖垮主从延迟的
很多人对“大事务”没有概念,以为只要不会造成死锁就没事。实际上,一个事务里如果更新了几十万行,或者锁住一张很大的表超过几秒,它带来的问题远远不止锁等待。
InnoDB 的 MVCC 需要保留事务开始前的旧版本数据。大事务执行期间,其他事务为了读取一致性快照,可能需要沿着 undo log 版本链寻找历史版本。如果这个版本的链条特别长,查询性能会明显退化。更麻烦的是,主库提交大事务后,binlog 会把整个事务发给从库,从库执行也要占用资源。如果主库并发跑了好几个大事务,从库的 SQL 线程 可能会延迟得非常厉害,主从延迟一高,读写分离场景下用户就会读到明显滞后的数据。
所以运维规约里常写“禁止一次性 UPDATE 全表”,不只是怕锁表,还怕从库扛不住。数据批量修改要分批提交,每一批控制在一两百行,中间加一点 sleep,给主从同步留出时间。这听起来很原始,但往往是最稳定、最不容易出事的方案。
5.4 事务超时和连接池配置,决定了故障恢复速度
生产环境我最喜欢确认两个参数:innodb_lock_wait_timeout 和 Spring 的事务超时时间。
innodb_lock_wait_timeout 默认是 50 秒,意思是当前事务等待其他事务释放锁,最多等 50 秒。超过这个时间就会抛锁等待超时异常。如果你发现线上频繁出现 Lock wait timeout exceeded,不要简单粗暴地调大这个值,因为调大只是把故障时间拉长了。更合理的做法是排查是否有长事务、慢查询、热行竞争,然后缩小锁范围、缩短事务时间。
Spring 的 @Transactional(timeout = 5) 可以控制事务执行的总时间,超过 5 秒就自动回滚。这个配置在外部接口不稳定、数据库抖动时,能防止事务无限期占用连接。但是注意,timeout 是基于事务开始时间计算的,不是每条 SQL 的执行时间,别把含义理解反了。
连接池方面,maxActive 设置过大不一定是好事。每个连接都可能对应一个未提交事务,如果应用重启或者连接被错误归还,未提交的连接会持有锁很长时间。连接池最好配置连接空闲检测、回收策略,同时确保代码里用 try-with-resources 或在 finally 里归还连接,避免连接泄漏。
6. 从单机事务到分布式事务:一套思路的延伸
6.1 为什么多个数据库操作不能靠“嵌套事务”解决
当系统拆分微服务后,一个用户下单操作可能要同时调用订单服务、库存服务、账户服务,每个服务有自己独立的数据库,本地 MySQL 事务就只能覆盖自己服务里的那几张表。跨服务、跨数据库的多个本地事务,无法再用一个 BEGIN...COMMIT 包起来。
有人想当然地说:我把多个服务的本地事务嵌套起来不就行了?实际上做不到,因为每个服务的数据库连接不同,事务上下文也不同。哪怕每个服务内部都有本地事务,也没办法保证全局的原子性。于是就有了分布式事务的问题。
需要先明确一点:分布式事务不是 MySQL 本身能独立解决的,它需要协调多个资源。业界常见方案包括两阶段提交(2PC)、三阶段提交(3PC)、TCC(Try-Confirm-Cancel)、Saga、本地消息表、事务消息等。它们的核心思想是相通的:把一个全局操作拆成多个可补偿的本地操作,通过某种协调机制让所有参与者最终达到一致状态。
6.2 从实际业务形态去选方案,别被“最终一致性”绑架
我个人在实践中的建议是:优先从业务形态上判断能不能避免分布式事务。比如库存扣减,如果不需要强一致,可以考虑用 Redis 扣减库存加异步同步数据库的方式;比如订单和支付回调之间,很多团队用本地消息表,先把业务数据和消息放在同一个本地事务里写入,再通过后台任务不断把消息发给下游。这样既保住了本地事务的原子性,又解决了跨服务的数据最终一致问题。
如果业务确实需要比较强的实时一致性,而且并发量可控,TCC 会是常见选择。TCC 需要业务方提供 Try、Confirm、Cancel 三个方法,对代码侵入性比较大,通常需要借助 Seata 这样的分布式事务框架落地。Seata 的 AT 模式其实很像 MySQL 的 undo log 思路:它在全局事务中对每个参与的数据源记录前后镜像,需要回滚时根据镜像恢复数据。理解了 MySQL 事务的 undo log、redo log,再去看 Seata 的提交回滚流程,会觉得特别亲切。
但也有一个非常现实的经验:分布式事务越重,系统可用性越低,排障复杂度越高。在没有想清楚业务补偿方案之前,不要急着上一套完整的事务框架。很多时候,最终一致性方案里需要的只是“对账 + 补偿”两个机制,而不是一个复杂的事务中间件。
6.3 理解 MySQL 事务,是理解一切事务体系的基石
很多刚接触中间件的新人,一上来就研究 Seata、RocketMQ 事务消息,问他们 MySQL 的隔离级别底层怎么实现的、为什么 REPEATABLE READ 能防幻读,反而说不清楚。但分布式事务、消息事务这些方案的设计思路,几乎都是从数据库事务的实现里长出来的。
比如事务消息里“先本地写业务表,再发半消息,消息达到一半状态时确认;如果本地事务回滚,就删除半消息”,这个流程本质上就是想办法模拟本地事务和消息发送的原子性。如果你不能理解本地事务“要么全做、要么全不做”的边界在哪里,就很难理解为什么消息系统要设计出这样一套 Confirm 和回查机制。
再比如 TCC 的 Confirm 和 Cancel,和 MySQL 里 COMMIT、ROLLBACK 的意图完全一致。数据库系统研究了这么多年的事务机制,很多问题的答案早就藏在 InnoDB 引擎的代码和日志结构里。先把手里的单机事务吃透,比你盲目套用十个分布式事务方案都更有价值。
在我这些年排查过的“事务事故”里,最后真正的问题大多并不在数据库本身,而是开发者对事务边界、锁范围、隔离级别、代码异常处理这些基础概念理解得不够扎实。如果你能把这篇文章里提到的链路自己动手验证一遍,再用自己的话解释给旁边同事听,我相信后面处理线上问题时,会比以前从容很多。
