我在一个数据库事务里先改了几张表的数据、又顺手执行了一条建临时表的 DDL,本以为整段都在事务保护下出错能一起回滚,结果后面一步失败我 rollback 时,前面改的数据竟然怎么都退不回去、已经实实在在落库了,排查很久才知道那条 DDL 早就把我的事务偷偷提交掉了
这是一次让我把 MySQL 里"事务里执行 DDL"这件事,从"它和前面的操作一起在事务里",重新理解成"它会触发一次隐式提交、把我的事务从中间斩断"的事故。我在一个事务里先改了几张表的数据(DML),又顺手执行了一条建临时表的 DDL,本以为整段都在事务保护下、出错能一起回滚。结果后面一步失败,我 ROLLBACK 时,前面改的数据竟然怎么都退不回去、已经实实在在落库了。我排查了很久才知道:那条 DDL,早就把我的事务偷偷提交掉了。这篇就把这次"以为能回滚、其实早被提交"的事故,从头到尾复盘一遍。
故障现场:rollback 之后,前面改的数据却没退回去
我有个操作,需要在一个事务里完成好几步:先更新 A 表的几行、再更新 B 表的几行,中间为了做点临时计算,我顺手 CREATE TEMPORARY TABLE 建了张临时表,最后再写 C 表。我用 START TRANSACTION 开了事务,心想:万一哪一步出错,我 ROLLBACK 一下,所有改动就都干净地撤销了——这是事务的基本保证嘛。
结果写 C 表那步因为一个约束冲突失败了,我老老实实 ROLLBACK。可回头一查,A 表和 B 表的改动还在,一行没退回去。我大惊:事务的原子性呢?要么全成、要么全不做啊?我先怀疑是不是自动提交(autocommit)没关,检查了确实在事务里;又怀疑是不是连接池复用了别的连接,也排除了。直到我把那段 SQL 一句句捋,盯上了中间那句 CREATE TEMPORARY TABLE,查了文档才如遭雷击——在 MySQL 里,绝大多数 DDL 语句(CREATE/ALTER/DROP/TRUNCATE 表等)会导致隐式提交(implicit commit):执行 DDL 之前,MySQL 会先把当前事务自动 COMMIT 掉,然后 DDL 自己也在一个独立事务里执行并提交。也就是说,我那句建表 DDL,在执行的那一刻,就把它前面的 A 表、B 表改动统统提交了;等我后面 ROLLBACK 时,能回滚的只有 DDL 之后的操作,前面那些早已落库,神仙也退不回来了。
-- 我以为: 整段都在一个事务里, 出错 ROLLBACK 全部撤销
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1; -- DML, 在事务里
UPDATE account SET balance = balance + 100 WHERE id = 2; -- DML, 在事务里
CREATE TEMPORARY TABLE tmp_calc (...); -- ★ DDL! 触发【隐式提交】!
-- 执行到这一行的瞬间, MySQL 先 COMMIT 了上面两条 UPDATE!
-- 那两条 UPDATE 此刻已经永久落库, 不再属于"可回滚"的事务
INSERT INTO log_table (...); -- 这步若失败...
ROLLBACK; -- 只能回滚 DDL 之后的 INSERT;
-- 上面两条 UPDATE 早被 DDL 隐式提交, ROLLBACK 对它们无效!
-- 结果: 钱扣了/加了, 想回滚却回不去 —— 因为事务边界被 DDL 从中间斩断了
问题被钉死在这个认知错位上:我以为"从 START TRANSACTION 到 ROLLBACK 之间的所有语句,都在同一个事务里、要么一起成功要么一起回滚",但事实是:某些语句(典型就是 DDL)会隐式地提交当前事务,从而在我没察觉的情况下,把我以为完整的一个事务,从中间切成了两段——DDL 之前的那段,在 DDL 执行时就被提交、永久生效了;只有 DDL 之后的那段,才是我 ROLLBACK 真正能撤销的。我的事务边界,并不像我想象的那样由 START TRANSACTION 和 ROLLBACK 这对括号牢牢框定;那句不起眼的 DDL,在括号中间悄悄塞进了一个我看不见的 COMMIT。我以为我把所有操作都关进了一个保险箱,其实中间那句 DDL,趁我不注意把保险箱门打开过一次、把前半截东西放了出去。
第一件事:想明白哪些语句会"隐式提交",把事务从中间斩断
把这次事故彻底想清楚,关键是理解在 MySQL 里,并不是"事务开始到结束之间的一切都受事务保护";有一类语句会触发隐式提交:执行它们之前,MySQL 会自动把当前活动事务 COMMIT 掉。最典型的就是 DDL(CREATE/ALTER/DROP/TRUNCATE TABLE、建删索引、建删库等),此外还有 START TRANSACTION 本身(开新事务前提交旧的)、锁表 LOCK TABLES、以及某些管理语句。这意味着:你不能假设"把语句都包在 BEGIN/COMMIT 里就一定原子"——夹在中间的隐式提交语句,会把你的事务拦腰截断。
这背后的原因是:DDL 在 MySQL(尤其早期版本)里大多不是事务性的——它改的是表结构这类元数据,无法像普通数据行那样被 MVCC/undo log 简单地回滚。为了让这些"不可回滚"的操作有个干净的边界,MySQL 干脆规定:执行 DDL 前先把手头的事务提交掉、DDL 自己独立提交。于是 DDL 就成了一道"事务的强制分界线"。关键认知是:事务的边界,不只由你显式写的 BEGIN/COMMIT/ROLLBACK 决定,还会被这些"隐式提交语句"悄悄地、强制地改变;一段看似连续的事务,可能被它们切成了好几个独立提交的小事务,而你以为的"整体原子性"早已不复存在。
-- 会触发隐式提交(执行前先 COMMIT 当前事务)的典型语句:
-- DDL: CREATE/ALTER/DROP/TRUNCATE TABLE, CREATE/DROP INDEX,
-- CREATE/DROP DATABASE, RENAME TABLE 等
-- 事务控制: START TRANSACTION / BEGIN (会先提交上一个事务)
-- 锁/管理: LOCK TABLES, UNLOCK TABLES, 部分 ALTER USER / GRANT 等
-- 验证: 在事务里夹一条 DDL, 看前面的改动是否还能回滚
START TRANSACTION;
INSERT INTO t1 VALUES (1);
TRUNCATE TABLE t2; -- ★ DDL, 隐式提交! 上面的 INSERT 此刻已提交
ROLLBACK;
SELECT * FROM t1; -- 那条 INSERT 还在! ROLLBACK 没能撤销它
-- 正确做法: DDL 与需要原子性的 DML 分开;
-- 把 DDL(如建临时表)放到事务【开始之前】, 别夹在事务中间
CREATE TEMPORARY TABLE tmp_calc (...); -- 先建好(在事务外)
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
INSERT INTO log_table (...);
COMMIT; -- 这三条 DML 才是一个完整、可一起回滚的事务
想通这一层,我才明白自己错在哪:我把"事务的边界"完全等同于"我写的 BEGIN 和 ROLLBACK",而没意识到事务里某些语句会自带一个隐式的 COMMIT、强行改变这个边界。我顺手在一串需要原子性的 DML 中间插了一句 DDL,就等于在它们中间插了一个 COMMIT,把"一个事务"变成了"两个事务";前一个早就提交了,我再 ROLLBACK,自然撤不回它。事务能不能整体回滚,不取决于你有没有写 ROLLBACK,而取决于这中间有没有谁偷偷替你提交过。
第二件事:正解——把 DDL 移出事务,需要原子性的 DML 单独成一个干净事务
找到根因,正解就清晰了:不要在一个需要原子性的事务中间夹任何会触发隐式提交的语句(尤其是 DDL)。把建临时表、改表结构这类 DDL 放到事务开始之前(或之后)单独执行;让真正需要"要么全成、要么全回滚"的那组 DML,独占一个不含任何隐式提交语句的干净事务。
-- 错误: DDL 夹在 DML 中间, 把事务从中间斩成两段
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
CREATE TEMPORARY TABLE tmp (...); -- ✗ 隐式提交, 上面的 UPDATE 已落库
UPDATE account SET balance = balance + 100 WHERE id = 2;
ROLLBACK; -- 撤不回第一条 UPDATE
-- 正解1: DDL 提到事务外面, DML 独占一个干净事务
CREATE TEMPORARY TABLE tmp (...); -- 建表在事务【之前】, 与原子性无关
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
INSERT INTO log_table (...);
COMMIT; -- 这三条要么一起成, 要么一起 ROLLBACK, 中间没有隐式提交
-- 正解2: 用普通表/CTE/变量替代"事务中途建临时表"的需求
WITH calc AS ( SELECT ... ) -- 用 CTE 做中间计算, 不需要 DDL
UPDATE account a JOIN calc c ON ... SET a.balance = ...;
-- 正解3: 用应用层事务时, 确认 ORM/框架没在事务里偷偷执行 DDL
-- (如某些"自动建表""自动迁移"逻辑别和业务事务混在一起)
这套做法的精髓,是守住事务的"纯净性":一个需要原子性的事务,里面只放真正能被一起提交/回滚的 DML,绝不混入会触发隐式提交、把事务拦腰截断的语句。DDL 该在事务之外做的就在外面做;中间计算用 CTE、普通表、应用层变量等不破坏事务的方式替代。这样,START TRANSACTION 到 COMMIT/ROLLBACK 之间才真正是一个不可分割的整体,原子性的承诺才作数。不是不能用 DDL,而是别让它出现在你指望整体回滚的那段事务中间。
【保证事务真能整体回滚, 我现在认死的几条】
1. 事务边界不只由 BEGIN/COMMIT 决定, 还会被"隐式提交语句"改变
2. DDL(CREATE/ALTER/DROP/TRUNCATE TABLE 等)在 MySQL 会触发隐式提交
3. 隐式提交 = 执行该语句前, 先把当前事务自动 COMMIT 掉
4. 别在需要原子性的 DML 中间夹 DDL, 否则前半段被提交、回滚不掉
5. DDL 放事务【之前/之后】单独做; 中间计算用 CTE/普通表/应用层变量
6. TRUNCATE 不是 DELETE: 它是 DDL, 隐式提交且不可回滚
7. 用框架/ORM 时, 确认它没在你的业务事务里偷偷执行迁移类 DDL
第三件事:其他"以为在事务保护内、其实边界已被破坏"的同类坑
顺着"事务的边界没你以为的那么牢、被某些操作悄悄改变了"这条线,我把同类的坑都排查了一遍:
第一个,TRUNCATE 当成 DELETE 用,删了想回滚却回不去。TRUNCATE 是 DDL,隐式提交且不可回滚;要可回滚地清空,得用 DELETE(在事务里)。
第二个,autocommit 开着,每条语句各自成一个事务。没显式 BEGIN 时,默认 autocommit=1,每条 DML 自己提交;你以为几条语句是一组,其实它们各提交各的,中间崩了就是半成品。
第三个,跨多个数据库连接的操作不在同一事务。事务是连接级的,你以为的"一个事务"若跨了两个连接(连接池随机分配),根本不是一个事务,无法一起回滚。
第四个,嵌套事务/保存点理解错。MySQL 不支持真正的嵌套事务,内层"BEGIN"会先提交外层;要分段回滚得用 SAVEPOINT,而不是想当然地嵌套。
第四件事:DDL vs DML、可回滚 vs 不可回滚——一张对照表
我把常见语句按"是不是 DDL、会不会隐式提交、能不能回滚"归了类,用前先对一眼:
| 语句 | 类别 | 触发隐式提交 | 本身可回滚 | 清空表用哪个 |
|---|---|---|---|---|
| INSERT/UPDATE/DELETE | DML | 否 | 是(在事务内) | DELETE 可回滚 |
| SELECT | DQL | 否 | 不涉及 | - |
| CREATE/ALTER/DROP TABLE | DDL | 是 | 否 | - |
| TRUNCATE TABLE | DDL | 是 | 否 | 清空但不可回滚 |
| CREATE/DROP INDEX | DDL | 是 | 否 | - |
| START TRANSACTION/BEGIN | 事务控制 | 是(提交上一个) | - | - |
看清这张表,就不会再踩坑了:DML 在事务里可回滚;DDL 一律触发隐式提交、自身也不可回滚,绝不能夹在需要原子性的 DML 中间;要可回滚地清空表用 DELETE 而非 TRUNCATE。我这次踩坑,就是把一句建临时表的 DDL 当成普通语句夹进了事务,它的隐式提交把前面的 DML 提交了。区分 DDL 和 DML、知道谁会隐式提交,是安全使用事务的前提。
第五件事:我曾经对事务边界想当然的几个误区
这次事故也把我对事务的一堆"想当然"照了个底朝天:
| 我以为 | 实际上 |
|---|---|
| BEGIN 到 ROLLBACK 之间一切都能回滚 | 中间的 DDL 会隐式提交, 把事务拦腰截断 |
| 事务里加一句建临时表无伤大雅 | 它是 DDL, 执行前先 COMMIT 掉你前面的改动 |
| ROLLBACK 能撤销事务里所有改动 | 只能撤销最近一次隐式/显式提交之后的 |
| TRUNCATE 和 DELETE 差不多, 能回滚 | TRUNCATE 是 DDL, 隐式提交且不可回滚 |
| 只要写了 START TRANSACTION 就有原子性 | 原子性会被中途的隐式提交语句破坏 |
这些误区的根子是同一个:我把"事务的边界"想象成一对由我亲手写下的、绝对可靠的括号,却没意识到括号内部某些语句,会偷偷在中间补一个我看不见的提交点,把一个大事务拆成几个小事务。我以为只要把操作都写在 BEGIN 和 ROLLBACK 之间,它们就自动获得了"同生共死"的原子性;可这份原子性,会被一句不起眼的 DDL 悄无声息地瓦解。把"我画了边界"当成"边界一定生效",而忽略了边界可能被内部操作改变,是这类事务事故的共同根源。
第六件事:写事务、排查"rollback 没退回去"时,我现在的自检习惯
现在每当我写带事务的多步操作、或排查"明明 ROLLBACK 了改动却还在",我都会先按这张图问自己:
这张图的精髓,是"事务边界不只由 BEGIN/COMMIT 决定;DDL 等语句会隐式提交、把事务从中间斩断;需要原子性的 DML 要独占一个不含隐式提交语句的干净事务"。设计就把 DDL 移出事务、需要原子性的 DML 单独成一个干净事务、排查就逐句看事务里有没有夹着 DDL/TRUNCATE 这类隐式提交语句。这套习惯,让我从"写了 BEGIN 就以为有原子性"变成了"先确认事务里没有谁会偷偷提交"——核心始终是:在 MySQL 里事务的边界并不只由你显式写的 START TRANSACTION/COMMIT/ROLLBACK 决定:有一类语句会触发隐式提交(implicit commit)——执行它们之前,MySQL 会先把当前活动事务自动 COMMIT 掉,最典型的就是 DDL(CREATE/ALTER/DROP/TRUNCATE TABLE、建删索引、建删库、RENAME 等),此外还有 START TRANSACTION/BEGIN 本身(开新事务前先提交旧的)、LOCK TABLES 以及部分管理语句;之所以如此,是因为 DDL 改的是表结构等元数据、大多不是事务性的、无法像数据行那样简单回滚,MySQL 便规定执行 DDL 前先提交手头事务、DDL 自身独立提交,使其成为一道强制的事务分界线;由此带来的后果是:你不能假设"把语句都包在 BEGIN/COMMIT 里就一定原子"——一旦在一组需要原子性的 DML 中间夹了一条 DDL,这条 DDL 会在执行的那一刻把它前面的所有改动隐式提交、永久落库,等你后面 ROLLBACK 时只能撤销 DDL 之后的操作、前半段早已无法回滚,你以为的一个完整事务被从中间斩成了两个独立提交的小事务;正解是守住事务的纯净性——把建临时表、改表结构这类 DDL 放到事务开始之前或之后单独执行,中间计算改用 CTE、普通表或应用层变量,让真正需要要么全成要么全回滚的那组 DML 独占一个不含任何隐式提交语句的干净事务;要可回滚地清空表用 DELETE 而非 TRUNCATE,要分段回滚用 SAVEPOINT 而非嵌套事务。
我立下的几条规矩
这场"ROLLBACK 却退不回去"的事故,换来了我写数据库事务时,刻进骨子里的几条铁律:
- 事务边界不只由 BEGIN/COMMIT 决定,会被"隐式提交语句"悄悄改变。
- DDL(CREATE/ALTER/DROP/TRUNCATE TABLE 等)在 MySQL 会触发隐式提交。
- 隐式提交 = 执行该语句前,先把当前事务自动 COMMIT 掉,前面的改动落库。
- 别在需要原子性的 DML 中间夹 DDL,否则前半段被提交、回滚不掉。
- DDL 放事务外单独做;中间计算用 CTE/普通表/应用层变量替代。
- TRUNCATE 是 DDL,隐式提交且不可回滚;要可回滚清空用 DELETE。
- 要分段回滚用 SAVEPOINT;用 ORM/框架时确认它没在业务事务里偷偷执行 DDL。
附:我现在保证事务真原子的"纯净事务 + 应用层校验"骨架
这是我现在写多步事务固定套的骨架——把这次踩坑的教训(DDL 移出事务、事务里只放纯 DML、用 SAVEPOINT 分段)固化成一套结构,让"ROLLBACK 退不回去"那种坑再不会埋进代码:
-- 写法一: DDL 全部前置, 业务事务里只剩纯 DML(最干净)
-- 1) 所有 DDL(建临时表/改结构)在事务【之前】做完
CREATE TEMPORARY TABLE IF NOT EXISTS tmp_calc (...);
-- 2) 真正需要原子性的一组 DML, 独占一个不含隐式提交的干净事务
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
INSERT INTO transfer_log (from_id, to_id, amount) VALUES (1, 2, 100);
COMMIT; -- 这三条 DML 之间没有任何 DDL, 要么全成、要么 ROLLBACK 全退
-- 写法二: 需要分段回滚时, 用 SAVEPOINT(而非嵌套事务/DDL)
START TRANSACTION;
INSERT INTO orders (...) VALUES (...);
SAVEPOINT after_order; -- 设一个保存点
INSERT INTO order_items (...) VALUES (...);
-- 若明细出错, 只回滚到保存点, 订单主记录还在
-- ROLLBACK TO SAVEPOINT after_order;
COMMIT;
// 写法三: 应用层显式管事务, 并守住"事务内不执行 DDL"的纪律
// (用 try/catch 保证要么 commit、要么整体 rollback)
Connection conn = dataSource.getConnection();
try {
conn.setAutoCommit(false); // 显式开启事务
// 事务内只做业务 DML, 绝不在这里 CREATE/ALTER/TRUNCATE
deduct(conn, fromId, amount);
credit(conn, toId, amount);
writeLog(conn, fromId, toId, amount);
conn.commit(); // 全部成功才提交
} catch (Exception e) {
conn.rollback(); // 任一步失败, 整体回滚(无 DDL 阻断)
throw e;
} finally {
conn.setAutoCommit(true);
conn.close();
}
这套骨架把我这次的教训钉死在了结构里:所有 DDL(建临时表、改结构)一律前置到事务之外,业务事务里只剩纯 DML、绝无隐式提交语句;需要分段回滚就用 SAVEPOINT 而非嵌套或 DDL;应用层用 try/commit/catch-rollback 守住"要么全提交、要么整体回滚",并刻意维持"事务内不执行 DDL"的纪律。这样,START TRANSACTION 到 COMMIT 之间就是一个不会被任何隐式提交从中间斩断的、真正原子的整体,而不再是当初那个被一句建表 DDL 偷偷劈成两半的"假事务"。把"让原子区域只容纳服从它的操作、把破坏边界的操作挪到外面"这个道理,沉淀成写事务的固定骨架,这是我对这次"退不回去的转账"最实在的交代——毕竟,事务承诺的"同生共死",不该被一句不起眼的 DDL,在我看不见的地方悄悄解除。
写在最后
回头看,这场由"事务中途的 DDL 隐式提交"引发的"ROLLBACK 退不回去"事故,真正教给我的,远不止"把 DDL 移出事务"这一个技巧。它让我对"当我们划下一个'边界'、把一组东西圈进去、以为它们从此作为一个不可分割的整体受这个边界保护时,我们往往默认这个边界是稳固的、由我们说了算的;可实际上,被圈进来的内容里,某些成员自带'打破边界'的能力——它们会在我们不知情时,从内部把这个整体悄悄切开,让'整体性'的承诺在最关键的时候落空",有了一次刻骨的体会。我栽跟头,是因为我把"事务的原子性边界"当成了一个只由我亲手写下的 BEGIN/ROLLBACK 决定、且坚不可摧的东西——我以为把几步操作圈进 BEGIN...ROLLBACK,它们就自动获得了"同生共死"的契约;我没意识到,被我圈进去的那句 DDL,它本身就携带着一个"我执行前必须先提交一切"的强硬规则,这个规则凌驾于我画的边界之上、会在我的事务中间强行插入一个提交点;于是我精心圈定的"一个整体",被它从内部斩成了两半,前半截在我毫不知情时就被永久兑现,而我手里那个 ROLLBACK,只对后半截有效。这让我领悟到一个关于"边界的稳固性与内部成员的破坏力"的深刻认知:我们用来保证"一组操作作为整体成立或失败"的边界(事务、锁、临界区、原子块、批次),其有效性不仅取决于我们如何划定它,更取决于被划进来的每一个成员是否都"服从"这个边界;有些操作天生就带着改变或突破边界的语义(隐式提交的 DDL、释放锁的调用、提前返回、抛出异常、副作用),把它们混进一个本应原子的区域,就等于在区域内部埋了一个"边界破坏者",它会让"整体性"在我们最依赖它的时刻悄然失效;更危险的是,这种破坏往往是隐式的、不报错的——边界看起来还在(BEGIN 和 ROLLBACK 都好端端写着),但它保护的整体早已被从中间掏空,而我们浑然不觉,直到回滚那一刻才发现退不回去。这给了我一种看待"一切'把一组操作圈进某个原子/整体边界'之事"时的清醒:每当我把一组操作放进一个本应"要么全成、要么全不做"的边界里时,要追问"这里面的每一条,是不是都真的服从这个边界?有没有哪一条,自带打破边界、提前提交、提前释放、改变全局状态的能力?如果有,它会不会在中间把我的整体性悄悄瓦解"——让原子边界保持纯净,只放真正服从它的成员,把那些会破坏边界的操作挪到边界之外;"认清边界会被内部某些成员悄悄破坏、让原子区域只容纳服从它的操作",是用对数据库事务、也是守住一切原子性保证的关键。认清 DDL 会隐式提交、事务边界会被中途的隐式提交斩断、需要原子性的 DML 要独占干净事务——这,是我用一次"ROLLBACK 退不回去"的事故,换来的、关于数据库、也关于如何守护一个整体性边界的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次想在事务中间顺手写一句 CREATE TEMPORARY TABLE 或 TRUNCATE 时,先停一秒想想"这句会不会触发隐式提交、把我前面的改动提交掉?",并把它挪到事务外面去,那我对着那笔"扣了却 ROLLBACK 不回来的钱"排查的大半天,就值了。
—— 别看了 · 2026