我为了保证原子性把几万条记录的批量处理塞进了一个数据库事务,结果这个事务跑了好几分钟,期间长时间持锁阻塞了其他业务、占满了连接池、还把回滚段撑得老大、主从延迟飙升:一次大事务拖垮整个库、误以为事务越大越安全的深度复盘
那次"整个库都变慢、好多业务报超时"的故障,根子在我一个"为了安全"的设计。我有个批量数据处理任务,要更新几万条记录。我想"这些操作得保证原子性,要么全成功要么全回滚,那就放在一个事务里呗",于是写成了:开启事务 → 循环几万次逐条 update → 提交。功能上没问题,可一上线跑这个任务,整个数据库就开始抖:其他业务大面积报锁等待超时、接口变慢、连接池被占满、监控显示主从延迟从毫秒飙到几十秒、DBA 还告警说回滚段(undo)暴涨。复盘这件事,我才真正理解了"大事务"的危害,以及我犯的认知错误:问题出在我以为"事务越大、包得越全,越安全"。一个数据库事务,在它开始到提交/回滚的整个期间,会持有它修改过的行的锁、占用一个数据库连接、并在 undo log 里保留回滚所需的旧版本数据;我把几万条 update 塞进一个事务,意味着这个事务持续了好几分钟——在这几分钟里:①它持有的行锁一直不释放,其他要改这些行(或因间隙锁波及的行)的事务全在排队等锁、直至超时;②它一直占着那个数据库连接不放,连接池很快被这类长事务占满;③undo log 要保留这几分钟里所有改动的旧版本(供回滚和 MVCC),越积越大、撑爆回滚段;④这一大坨改动到提交才一起写 binlog、同步给从库,从库重放这个大事务期间主从延迟剧增;也就是说,事务持续越久、改动越多,它霸占关键公共资源(锁、连接、undo、复制)的时间和量就越大,对整个库的拖累就越严重。根本原因是:事务在其存续期间持有锁、占用连接、累积 undo、延迟复制;我为了"原子性"把海量操作塞进一个长时间运行的大事务,使它长期霸占这些关键公共资源,从而长时间阻塞其他业务、占满连接、撑大 undo、拖垮主从延迟。问题的根,是大事务——把过多操作放进一个长事务,使其长时间持锁、占连接、累积 undo、延迟复制,拖垮整个库;根源是误以为事务越大越安全,忽视了事务越长占用公共资源越久危害越大。这篇就把这次"大事务"的坑,从头到尾复盘一遍。
故障现场:一个几分钟的事务,拖垮整个库
问题在于一个长时间运行的大事务长期霸占关键资源:
-- 我的写法: 为了"原子性", 把几万条更新塞进一个事务
START TRANSACTION;
-- 循环几万次, 逐条更新(伪代码)
UPDATE orders SET status = 'archived' WHERE id = 1;
UPDATE orders SET status = 'archived' WHERE id = 2;
-- ... 几万条 ...
UPDATE orders SET status = 'archived' WHERE id = 50000;
COMMIT; -- 几分钟后才提交
/*
这个大事务在运行的几分钟里, 对整个库做了什么"坏事":
① 持有大量行锁不放: 它改过的每一行都加了锁, 直到COMMIT才释放;
→ 其他要改这些行的事务全在等锁 → Lock wait timeout exceeded(锁等待超时);
→ 还可能因间隙锁/Next-Key锁波及更多行, 阻塞范围更大; 死锁概率也上升;
② 长期占用一个数据库连接: 事务期间这条连接不能复用;
→ 这类长事务多了, 连接池被占满 → 其他请求拿不到连接 → 大面积超时;
③ undo log(回滚段)暴涨: 为了能回滚 + 支持MVCC, 要保留事务期间所有改动的旧版本;
→ 长事务保留的旧版本越来越多, undo膨胀, 还会阻碍purge(清理), 拖慢全库;
④ 复制延迟: 事务直到COMMIT才一次性写binlog、传给从库, 从库要重放这一大坨;
→ 主从延迟从毫秒飙到几十秒, 读从库的业务读到严重过期数据;
⑤ 万一中途失败要回滚: 回滚几万条改动非常慢, 期间还在持锁。
★ 核心: 事务从开始到提交的整个期间, 都在"持有锁 + 占用连接 + 累积undo + 待复制";
事务越大(改动越多)、越长(持续越久), 霸占这些"关键公共资源"的量和时间就越大,
对整个数据库的阻塞和拖累就越严重——"大事务"是数据库性能和稳定性的大敌。
我的认知错误: "把所有操作放一个事务里, 原子性最强, 所以最安全。"
真相: 原子性是要的, 但不该用"一个巨大的长事务"来实现; 大事务用"长时间霸占公共资源"
换来的"强原子性", 代价是拖垮所有人——安全要的是"小而快的事务 + 合理的一致性设计"。
*/
看着监控上"锁等待飙升、连接池占满、主从延迟几十秒"三条曲线同时报警,我又懊恼又后怕:"我以为'包在一个事务里=最安全',谁知道这个几分钟的大事务,像个霸占着公共道路几分钟不挪窝的大卡车,把整条路都堵死了……原子性是对的,但用这么大的事务去实现,代价太大了。"这个坑最反直觉的地方在于:它源于一个"看似负责任"的good intention——"保证原子性";事务本身、原子性本身都没错,错的是"把事务做得太大太长";而且它在数据量小、测试环境下(几十条、秒级)完全正常,只有数据量一大、并发一高才暴露成灾难。下面就来拆解,大事务该怎么治。
第一件事:搞懂事务持有的资源与大事务的危害
我顺着这次事故,把事务的代价和大事务的危害彻底理清了。
事务在存续期间占用什么? 为什么大事务危害大?
【核心: 事务从开始到提交期间持有行锁、占用连接、累积undo、改动待复制; 事务越大越长, 霸占这些
关键公共资源的量和时间越大, 阻塞其他业务/占满连接/撑大undo/拖垮主从延迟; 正解是拆成小而快的事务】
1. 一个事务在"存续期间"占用的关键资源:
- 锁: 它改过的行(及波及的间隙)被加锁, 直到提交/回滚才释放 → 阻塞其他事务;
- 连接: 占用一条数据库连接全程不放 → 连接池资源;
- undo log: 保留事务期间所有改动的旧版本(供回滚和MVCC)→ 越长越大, 阻碍清理;
- binlog/复制: 改动到提交才一次性写binlog传从库 → 大事务=大延迟。
2. 大事务(改动多/持续久)的危害(都来自"长时间大量霸占上述资源"):
- 长时间持锁 → 锁等待超时、阻塞面广、死锁概率高;
- 长时间占连接 → 连接池耗尽、其他请求拿不到连接;
- undo膨胀 → 回滚段暴涨、purge受阻、全库变慢、磁盘压力;
- 主从延迟 → 从库重放大事务期间, 延迟剧增, 读到过期数据;
- 回滚慢 → 万一失败, 回滚几万条很慢且持续持锁。
3. 正解方向(化大为小):
① 分批提交: 把几万条拆成每批N条(如500/1000), 每批一个小事务, 分批commit;
→ 每个事务都短、持锁少、很快释放, 不再长期霸占资源;
② 事务里别放慢操作: 事务内绝不夹远程调用/HTTP/发消息/大计算/Thread.sleep
(这些会拉长事务时间, 把"网络/外部"的不确定性带进持锁期间);
③ 事务尽量短小: 只把"必须原子"的几步放进事务, 其他移到事务外;
④ 一致性用别的手段: 不必强求"一个大事务", 可用分批+幂等(同586篇)+补偿/状态机
达到"最终一致", 而非靠一个巨型事务的"强原子";
⑤ 批量操作用批量语句: 能用一条批量UPDATE/INSERT...(set-based)就别循环逐条。
4. 分批的权衡(要想清):
- 分批提交后, "整体"不再是一个原子单元(可能批1成功批2失败);
- 所以要配合: 幂等(可重跑)、记录进度(断点续批)、补偿/对账, 保证最终正确;
- 这是"用最终一致 + 可恢复"换"避免大事务拖垮库", 是值得的取舍。
一句话: 事务存续期间持有锁/占连接/累积undo/待复制, 大事务会长时间大量霸占这些公共资源、拖垮全库;
正解是拆成小而快的事务(分批提交)、事务内不放慢操作/远程调用、用分批+幂等达成最终一致而非巨型事务。
这套认知,是整个坑的根。事务存续期间占用的资源:行锁(到提交才放)、连接、undo log(保留旧版本)、binlog/复制。大事务的危害:都来自"长时间大量霸占上述资源"——锁等待超时、连接池耗尽、undo 膨胀、主从延迟、回滚慢。正解方向:分批提交(每批小事务)、事务里别放慢操作/远程调用、事务尽量短小、一致性用分批+幂等+补偿达成最终一致、批量操作用批量语句。分批的权衡:分批后整体不再是一个原子单元,要配合幂等、进度记录、补偿对账保证最终正确——用最终一致+可恢复换避免大事务拖垮库。一句话:事务存续期间持有锁/占连接/累积 undo/待复制,大事务会长时间大量霸占这些公共资源、拖垮全库;正解是拆成小而快的事务(分批提交)、事务内不放慢操作/远程调用、用分批+幂等达成最终一致而非巨型事务。
第二件事:正解——分批提交、事务短小、最终一致
知道了大事务的危害,正解就清楚了:把大事务拆成小而快的事务,事务里只留必须原子的。
// 正解1: 分批提交(把几万条拆成每批N条, 每批一个小事务)
int batchSize = 1000;
long lastId = 0;
while (true) {
// 每批一个独立的短事务
List ids = jdbc.queryForList(
"SELECT id FROM orders WHERE id > ? AND need_archive = 1 ORDER BY id LIMIT ?",
Long.class, lastId, batchSize);
if (ids.isEmpty()) break;
txTemplate.execute(status -> { // 每批一个小事务, 很快提交
jdbc.batchUpdate("UPDATE orders SET status='archived' WHERE id=?", ids, ...);
return null;
}); // ← 每批commit, 锁很快释放, 不长期霸占
lastId = ids.get(ids.size() - 1);
// 可在这里记录进度lastId(断点续批), 控制速率(别打满DB)
}
// 每个事务只锁1000行、几十毫秒就提交 → 不再长时间阻塞别人。
// 正解2: 事务里绝不放慢操作 / 远程调用(把外部不确定性挡在事务外)
// ✗ 错误: 事务内夹远程调用, 把网络耗时算进持锁期间
// @Transactional void bad(){ db.update(...); httpClient.call(...); db.update(...); }
// ✓ 正确: 远程调用移到事务外
@Transactional void updateDb(Order o){ db.update(o); } // 事务只包DB操作, 短
void process(Order o){
updateDb(o); // 短事务: 进出很快
httpClient.notify(o); // 远程调用在事务外, 不占着锁等网络
}
// 正解3: 能用批量语句就别循环逐条(set-based)
// ✗ for(id: ids) update(id); // 逐条, 慢且事务长
// ✓ UPDATE orders SET status='archived' WHERE id IN (...); // 一条搞定一批
// 正解4: 一致性用"分批 + 幂等 + 进度/补偿"达成最终一致(同586篇幂等)
// - 任务可重跑(幂等: archived再设archived无副作用);
// - 记录处理进度(lastId), 中断后从断点续跑;
// - 失败的批次单独重试/对账; 整体最终一致, 而非靠一个巨型原子事务。
// 核心: 化大为小——分批提交让每个事务短小快速释放资源; 事务内不放慢操作/远程调用;
// 用批量语句; 一致性靠分批+幂等+可恢复达成最终一致, 而非用长事务硬扛强原子。
这套正解的关键,是把"一个长时间霸占资源的大事务"拆成"许多个快速进出、及时释放资源的小事务"。分批提交:把几万条拆成每批 N 条、每批一个小事务,每个事务只锁少量行、几十毫秒就提交释放,不再长期阻塞——这正是本次该做的。事务里别放慢操作/远程调用:把网络、外部调用挡在事务外,别把它们的不确定耗时算进持锁期间。用批量语句:能一条 set-based 语句搞定就别循环逐条。一致性靠最终一致:用分批 + 幂等 + 进度记录 + 补偿对账(同 586 篇)达成最终正确,而非靠一个巨型事务硬扛强原子。
第三件事:其他几个"长期霸占共享资源"的坑
顺着这次大事务,我把"长时间独占公共资源"的几类坑也一并理了:
几类"长期霸占共享资源"的坑(核心都是"独占范围太大太久, 拖累所有人"):
坑1: 长事务里夹远程调用——网络一慢, 锁就持有很久, 把外部不确定性带进DB(上文);
正解: 远程调用移到事务外。
坑2: 锁的粒度太大/持有太久——synchronized锁了一大段、或锁里做IO/慢操作;
正解: 缩小临界区, 只锁真正需要互斥的最小代码; 锁里别做IO/远程调用(同分布式锁562)。
坑3: 数据库连接借了不还/借太久——拿了连接做一堆慢活, 或忘了关; 连接池耗尽;
正解: 尽快用完归还, try-with-resources; 别在持有连接时做无关慢操作。
坑4: 一次性全表扫/全量加载——SELECT * 几百万行进内存, 占内存又锁表久(同546深度分页);
正解: 分页/游标/流式处理, 别一次性全量。
坑5: 批处理不限速打满DB——分批是对的, 但每批之间不歇、并发太高, 也会压垮DB;
正解: 控制批大小和速率(限流), 给在线业务留资源。
坑6: 全局锁/表锁做长时间维护——DDL/大批量操作锁住整表, 在线业务全卡;
正解: 用在线DDL工具(pt-osc/gh-ost)、低峰期、分批。
共同的根: 锁、连接、事务、临界区, 都是"独占某种公共资源"的机制; 独占的"范围越大、时间越久",
被你挡在门外、排队等待的其他人就越多、越久; 凡独占公共资源, 都应"范围尽量小、占用尽量短、
及时释放", 别贪图"一把全包"的简单而长期霸占、拖垮整体。
这些坑看似不同,根却是同一个:锁、连接、事务、临界区,本质都是"独占某种公共资源"的机制;你独占的"范围越大、时间越久",被你挡在门外、排队等待的其他人就越多、越久。认清这个根("凡独占公共资源,范围尽量小、占用尽量短、及时释放"),很多性能和稳定性问题就能从源头预防。
第四件事:大事务 vs 分批小事务——两张对照表
我把大事务和分批小事务在各维度的对比、以及事务该放什么不该放什么,整理成对照表,贴在了团队的 DB 规范里:
| 维度 | 一个大事务 | 分批小事务 |
|---|---|---|
| 持锁时间 | 几分钟,阻塞面广 | 几十毫秒,快速释放 |
| 连接占用 | 长时间独占一条 | 每批用完即还 |
| undo/回滚段 | 暴涨,阻碍清理 | 小,及时回收 |
| 主从延迟 | 提交时一次性,延迟大 | 分批同步,延迟小 |
| 对其他业务 | 大面积锁等待超时 | 影响小 |
| 原子性 | 整体强原子 | 批内原子,整体最终一致 |
| 失败恢复 | 全回滚,慢 | 从断点续跑(需幂等) |
| 该不该放进事务 | 说明 |
|---|---|
| ✓ 必须原子的几条 DB 写 | 事务的本职 |
| ✗ 远程调用 / HTTP | 网络耗时会拉长持锁,挪到事务外 |
| ✗ 发消息 / MQ | 移到事务外或用事务消息 |
| ✗ 大计算 / sleep | 无谓拉长事务 |
| ✗ 海量逐条循环 | 改批量语句或分批提交 |
| ✗ 读外部接口拿数据 | 先在事务外取好再开事务 |
这两张表的核心,第一张是分批小事务在几乎所有运行维度都优于大事务,只在"整体强原子"上让步(用最终一致+可恢复补上);第二张是事务里只放必须原子的 DB 写,远程调用/发消息/大计算/慢操作一律挪到事务外。记住一条:事务要"短、快、纯"——时间短、提交快、只包必须原子的 DB 操作。
第五件事:关于事务的几组容易想当然的认知
这次事故也让我厘清了几组关于事务的、容易想当然的概念:
| 直觉以为 | 实际上 |
|---|---|
| 事务包得越全越安全 | 大事务长期霸占资源,拖垮全库 |
| 放一个事务里才能保证一致 | 分批+幂等+补偿也能保证最终一致 |
| 事务只是"要么全成要么全败" | 它运行期间一直持锁/占连接/累 undo |
| 事务里夹个远程调用没关系 | 网络一慢,锁就持有很久,危害大 |
| 批量逐条 update 在事务里很正常 | 事务长、慢;应批量语句或分批提交 |
| 测试没问题就没问题 | 数据量小时大事务也快,量大才暴露 |
| 分批就是放弃了一致性 | 是换成"最终一致 + 可恢复",仍正确 |
这张表里,我栽的是第一行和第二行:把"事务包得越全"等同于"越安全",以为"非得一个大事务才能保证一致",没意识到这个大事务正在长时间霸占资源、拖垮所有人。厘清这些,核心是一个意识:事务不是"越大越安全"的护身符,而是一个"运行期间持续占用关键公共资源"的东西;一致性要保证,但要用"小而快的事务 + 合理的最终一致设计"来实现,而不是用"一个长时间霸占全库资源的巨型事务"硬扛。
第六件事:设计事务 / 批处理时,我现在的自检习惯
现在每当我要写一个事务、或一个批处理任务,我都会先按这张图问自己:
这张图的精髓,是"量大就分批小事务、事务里别放慢操作远程调用、用幂等+进度保最终一致"。先问改动量大不大(大就分批)、事务里有没有慢操作(有就挪出去)、分批后怎么保证正确(幂等+进度)。这套习惯,让我从"为了原子性包一个大事务"变成了"小而快的事务 + 最终一致设计"——核心始终是:事务存续期间持锁/占连接/累 undo/待复制,大事务会长期霸占公共资源拖垮全库;拆成小而快的事务(分批提交)、事务内不放慢操作/远程调用、用分批+幂等达成最终一致。
我立下的几条规矩
这场"大事务拖垮整个库"的事故,换来了我写数据库操作时,刻进骨子里的几条铁律:
- 事务在存续期间持有行锁、占用连接、累积 undo、改动待复制;事务越大越长,危害越大。
- 大事务会长时间持锁阻塞、占满连接池、撑大 undo、拖垮主从延迟、回滚慢。
- 海量操作要分批提交:每批 N 条一个小事务,快速提交释放资源。
- 事务里绝不放远程调用/HTTP/发消息/大计算/sleep,只留必须原子的 DB 写。
- 批量操作优先用批量语句(set-based),别在事务里循环逐条。
- 分批后用幂等 + 进度记录 + 补偿对账保证最终一致,而非靠巨型事务的强原子。
- 事务要"短、快、纯";一切独占公共资源的东西(锁/连接/临界区)都要范围小、时间短、及时释放。
附:排查与定位大事务/长事务的命令
最后,把我现在排查"谁在搞大事务/长事务"常用的命令贴成一份清单(以 MySQL 为例)。
-- 1. 查当前正在运行、且持续时间长的事务(揪出长事务的元凶)
SELECT trx_id, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
trx_rows_modified, trx_query
FROM information_schema.innodb_trx
ORDER BY duration_sec DESC; -- duration_sec 很大的就是长事务
-- 2. 查锁等待(谁在等谁的锁, 大事务常是阻塞源)
SELECT * FROM performance_schema.data_lock_waits; -- MySQL 8
-- 或老版本: SELECT * FROM information_schema.innodb_lock_waits;
-- 3. 查主从延迟(大事务会推高它)
SHOW SLAVE STATUS\G -- 看 Seconds_Behind_Master
-- 4. 监控 undo / 历史列表长度(History List Length, 长事务会让它居高不下)
SHOW ENGINE INNODB STATUS\G -- 找 "History list length", 持续很大说明有长事务阻碍purge
-- 5. 设置兜底: 限制单事务最长执行时间 / 锁等待超时, 别让长事务无限拖
-- innodb_lock_wait_timeout = 50 -- 锁等待超时(秒)
-- 应用侧: 给批处理事务设超时; 监控告警长事务(duration > 阈值)
-- 排查思路: 业务变慢/锁超时 → 先看 innodb_trx 里有没有 duration 很大的事务 →
-- 定位到那个大事务的 SQL 和来源 → 改成分批小事务。
这份清单的核心,是information_schema.innodb_trx 里那个 duration_sec——它能一眼揪出"跑了很久还没提交"的长事务元凶。我现在还给批处理事务都加了"单事务时长监控告警":一旦有事务跑超过阈值就报警,把大事务消灭在"拖垮全库"之前,而不是等到锁等待、连接池、主从延迟三条线一起报警才回头查。
写在最后
回头看,这场由"一个为了原子性而塞得过大的事务"引发的、拖垮整个库的故障,真正教给我的,远不止"大事务要拆成分批小事务"这一个技巧。它让我对"当你为了'把一件事一次性、完整地办妥'而长时间独占一份大家共用的资源时, 你个体的'周全', 可能正是整体的'灾难'——因为在你独占的这段时间里, 所有其他需要这份资源的人, 都被你挡住了、堵死了",有了一次刻骨的体会。我栽跟头,是因为我只从"我这件事"的角度,追求它"尽善尽美的原子性"——"一个大事务全包, 要么全成要么全败, 多干净利落";可我完全没站在"整个数据库、所有其他业务"的角度看: 我这个"干净利落"的大事务, 是靠"几分钟死死攥住一大把锁和连接不放"换来的;在这几分钟里, 整个库的其他使用者, 都在为我这一个事务的"周全"而排队、超时、饿死——我用"全局的拥堵"换来了"我这件事的局部完美"。这让我领悟到一个关于"独占、粒度与共享"的深刻认知:在一个有大量参与者共享有限资源的系统里(数据库、操作系统、团队、社会),任何一方"独占公共资源"的行为,其'独占的范围和时长',都直接决定了它对整体的'阻塞代价'——独占得越大、越久,挡住的人就越多、越久;所以"持有共享资源"的黄金法则是:"尽可能小的范围、尽可能短的时间、用完立刻释放"——不是因为你的事不重要, 而是因为"资源是共享的", 你多占一秒, 别人就多等一秒;那种"我把事一次性大包大揽办彻底"的简单和爽快, 在共享系统里往往是自私且危险的——真正的高手, 会把大任务切成小块、快进快出, 让出资源给别人, 用"许多次短暂的占用"替代"一次漫长的霸占"。这给了我一种在共享系统里行事的自觉:每当我要"占用一份大家共用的资源"来完成我的任务时,要时刻意识到"我占着它的时候, 别人都在等",从而主动地把我的占用切小、缩短、尽快释放——哪怕这让我的代码/流程不那么"一气呵成", 也好过让我一个人的"周全"拖垮所有人;"以最小的范围和最短的时间占用共享资源、用完即还",是在任何共享系统里做一个'不拖累整体的好公民'的关键。认清独占公共资源的范围和时长决定对整体的阻塞代价、持有共享资源要范围小时间短及时释放、把大任务切小快进快出而非一次长霸占——这,是我用一次大事务拖垮库的事故,换来的、关于数据库、也关于如何在共享系统里行事的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次想把一大批操作塞进一个事务时,顿一下、把它拆成分批提交的小事务,那我对着那三条同时报警的监控曲线排查的那个下午,就值了。
—— 别看了 · 2026