我在一个事务里反复查同一行数据,想等它被别的流程改成功就继续,结果别人明明早就改了、也提交了,我这边却怎么查都还是旧值、活活卡死在那里,排查半天发现是可重复读隔离级别下的快照读在作怪的深度复盘
这是一次让我对"我看到的'现在',到底是哪一刻的'现在'"有了刻骨认知的事故。我有段业务逻辑:开一个事务,做了些前置操作,然后轮询数据库里的某一行——等待另一个流程(另一个事务/服务)把它的状态改成"已完成",一旦查到"已完成"就继续往下走。我想当然地认为:别人改了并提交后,我这边再查一次,自然就能查到最新值了。
可上线后,这段逻辑诡异地卡死了:我明明能确认那个流程早就把状态改成了"已完成"、而且事务也提交成功了(我用另一个连接直接查,清清楚楚就是"已完成"),可我这个事务里的轮询,无论查多少次,读到的状态永远是那个旧的"处理中",像被冻住了一样,死活等不到"已完成",最后超时。我一度怀疑是缓存、是主从延迟、是别人根本没提交,逐一排除后全都对不上号。直到我把隔离级别和读取方式翻出来,才恍然大悟:问题出在我这个事务自己身上——在可重复读(REPEATABLE READ)隔离级别下,我事务里的普通 SELECT,读的根本不是"此刻数据库里最新的值",而是"我这个事务第一次查询那一刻的数据快照"。
故障现场:别人早改了并提交,我事务里却永远读到旧值
我把这个"读不到别人提交"的过程还原出来,时序一目了然:
-- 我的事务 A(MySQL InnoDB, 默认隔离级别 REPEATABLE READ)
START TRANSACTION;
SELECT status FROM job WHERE id = 1; -- 读到 "处理中"
-- ↑ 这一刻, 事务 A 建立了一个"一致性快照", 之后的普通 SELECT 都基于它
-- ===== 此时, 另一个事务 B 把它改完并提交 =====
-- 事务B: UPDATE job SET status='已完成' WHERE id=1; COMMIT; (已成功提交!)
-- 回到事务 A, 继续轮询:
SELECT status FROM job WHERE id = 1; -- 还是 "处理中" ?!
SELECT status FROM job WHERE id = 1; -- 仍然 "处理中" !!
-- ↑↑↑ 无论查多少次, 读到的都是【事务A开始时那个快照】里的旧值,
-- 看不到事务B提交的新值 —— 因为 RR 下普通 SELECT 是"快照读"
-- 事务A 永远等不到"已完成", 卡死、超时 ✗
-- 用另一个新连接查, 却是最新的:
SELECT status FROM job WHERE id = 1; -- "已完成"(新事务, 新快照)
看着"另一个连接查是已完成、我这个事务里查永远是处理中"的诡异对比,我才彻底明白:我以为"查询"就是"去看数据库此刻最新的样子",可在可重复读隔离级别下,一个事务里的普通 SELECT 看到的,是它第一次读取时凝固下来的那个一致性快照——为了保证"同一个事务里多次读同一数据,结果始终一致(可重复)",InnoDB 让后续的读都基于这个快照,而无视别的事务在此期间的提交。所以我在事务里反复轮询、指望读到别人的更新,根本是缘木求鱼:我这个事务的"世界",在我第一次查询的那一刻就被定格了,外面的变化照不进来。我读到的"现在",其实是我事务开始时的那个"过去"。
第一件事:搞懂隔离级别与快照读——事务里看到的是"一致快照",不是"实时最新"
冷静下来,我去把"事务隔离级别与 MVCC 快照读"这一课认真补了,才明白这个"读不到提交"的根源:
【为什么事务里读到的不是"最新值"——快照读与隔离级别】
MySQL InnoDB 默认隔离级别: REPEATABLE READ(可重复读, RR)
关键机制: MVCC 多版本 + 一致性快照(快照读 / 普通 SELECT)
- 事务里的普通 SELECT, 读的是一个【一致性快照(snapshot)】
- 在 RR 下, 这个快照在【事务第一次快照读时】建立, 之后整个事务都用它
- 于是: 同一事务里多次读同一行, 结果【始终一致】(这就是"可重复读"),
哪怕期间别的事务改了并提交了, 你也【看不到】——快照把你和外界隔开了
这不是 bug, 是【可重复读这个隔离级别的定义】:
- 它牺牲了"读到最新", 换取了"事务内读取的一致性/可重复性"
- 想在事务内读到别人的最新提交, 在 RR 下普通 SELECT 做不到
各隔离级别对"读到别人提交"的态度:
- READ COMMITTED(RC): 每次 SELECT 读"该语句开始时"的最新已提交
→ 同一事务里两次读可能不同(不可重复读), 但能读到别人新提交
- REPEATABLE READ(RR): 整个事务用同一个快照, 读不到别人后续提交
- 我的错: 在 RR 事务里轮询, 却指望读到外部提交 → 机制上就不可能
【想在事务内读到最新值, 怎么办】:
- 当前读(加锁读): SELECT ... FOR UPDATE / LOCK IN SHARE MODE 读的是最新版本
- 或: 别在长事务里轮询; 每次轮询用独立的短事务/新连接(各自新快照)
- 或: 改用 READ COMMITTED 隔离级别
这一下点醒了我:我把"在事务里查询"理解成了"去看数据库此刻的最新状态",可可重复读隔离级别的核心承诺恰恰相反——它保证的是"你这个事务里看到的数据是一致的、不会变的",代价就是"你看不到事务开始后别人的改动"。这是隔离级别有意的设计权衡:用"读不到最新"换"读取一致"。我却在一个本该"等待外部变化"的场景里,用了一个"把外部变化挡在门外"的机制,自然永远等不到。我以为我打开的是一扇能看到外面实时风景的窗,其实那是一张事务开始那一刻拍下的、再也不会更新的照片。
第二件事:正解——要读外部最新值,别在长事务里轮询,用短事务/当前读
找到根因,正解就清晰了:"等待并读取外部变化"这类需求,绝不能放在一个长事务里用快照读做;要么每次轮询用独立的短事务/新连接(各自建立新快照),要么用当前读(FOR UPDATE 读最新版本),要么把隔离级别调成 READ COMMITTED。让"读"真正读到当下,而不是事务开始时的过去。
-- 错误: 在一个长事务里轮询, 快照锁死在开始那一刻, 永远读不到外部提交
START TRANSACTION;
-- ... 前置操作 ...
LOOP: SELECT status FROM job WHERE id=1; -- 永远是旧快照值 ✗
-- 正解1: 每次轮询用【独立的短事务/新连接】, 每次都是新快照, 能读到最新
-- (轮询循环在应用层, 每轮:)
SELECT status FROM job WHERE id=1; -- 不在长事务里, 每次新事务 → 读到最新 ✓
-- (查到"已完成"再开真正要做事的事务)
-- 正解2: 当前读(加锁读)——读的是最新已提交版本, 不走快照
START TRANSACTION;
SELECT status FROM job WHERE id=1 FOR UPDATE; -- 读最新值(且加锁) ✓
-- 注意: 会加锁, 可能阻塞别的写; 按需使用
-- 正解3: 用 READ COMMITTED 隔离级别, 每条 SELECT 读该语句时的最新已提交
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
这套做法的精髓,是让"读取的时机/范围"和"需求"对齐:我需要的是"不断看到外部的最新变化",那就不能把读关在一个"快照早已凝固"的长事务里——用短事务/新连接,每次读都重新建立一个反映当下的快照;或者干脆用当前读,直接读最新版本。而真正需要"事务内读取一致"的场景(比如一份报表里多次读同一数据要前后一致),才适合让快照锁定。不是可重复读不好,而是我把它用在了一个本该"拥抱变化"、它却"隔绝变化"的地方。
【读数据, 先想清楚"我要哪种读"】
1. 要"事务内多次读一致、不被外部干扰" → RR 快照读(默认), 别指望读到最新
2. 要"读到外部刚提交的最新值":
- 别在长事务里轮询; 用独立短事务/新连接, 每次新快照
- 或用当前读 SELECT...FOR UPDATE(读最新+加锁)
- 或用 READ COMMITTED
3. "等待某状态变化"这类轮询 → 天然属于第2类, 绝不能用长事务快照读
4. 想清楚: 我的"读", 是要"一个不变的一致视图", 还是"当下的最新真相"?
这两者在不同隔离级别/读法下, 是相互排斥的取舍
5. 长事务本身要少用: 既容易快照过期读旧值, 又长时间持锁、撑大 undo
第三件事:其他"我以为读到的是最新、其实是某个旧快照"的同类坑
顺着"读到的不一定是最新"这条线,我把系统里同类的坑都排查了一遍,它们都源于"把某个时刻的视图当成了实时真相":
第一个,主从延迟下读从库。写主库后立刻读从库,从库还没同步过来,读到旧值——和快照读异曲同工:你读的是"从库当前(滞后的)状态",不是主库最新。要求最新就读主库。
第二个,缓存读到旧值。数据库改了但缓存没失效,读缓存拿到旧值。本质同样是"读到的是某个旧时刻的副本"。要靠缓存失效/更新策略保证一致。
第三个,应用层把对象读出来后一直用。把一行查成对象缓在内存里,之后数据库改了,内存里那个对象还是旧的——它也是"某一刻的快照",不会自动更新。
第四个,长事务里做了耗时操作。事务开头查了数据,中间做了很久的计算/调用,等回来再基于开头的数据写,这期间数据可能早变了(快照还停在开头),造成基于过期数据的更新。
第四件事:隔离级别 × 读法,谁能读到外部最新提交
我把不同隔离级别和读法下"能不能读到别人刚提交的最新值"整理成一张表,这是我现在判断"这样读到底读的是什么"的依据:
| 隔离级别 / 读法 | 读的是什么 | 能读到外部新提交吗 | 适合 |
|---|---|---|---|
| RR + 普通 SELECT(快照读) | 事务首次读时的快照 | ✗ 整个事务都看不到 | 事务内读取一致 |
| RC + 普通 SELECT | 该语句开始时的最新已提交 | ✓ 每条语句能读到 | 要读最新、容忍不可重复读 |
| RR/RC + FOR UPDATE(当前读) | 最新已提交版本(并加锁) | ✓ 读最新 | 读后要改、要锁 |
| 独立短事务/新连接 | 每次新建快照 | ✓ 每次都读到最新 | 轮询等待变化 |
| 读从库 | 从库当前(可能滞后)状态 | △ 受同步延迟影响 | 容忍延迟的读 |
这张表让我看清:"读"这个动作的结果,不只取决于数据库此刻的真实状态,更取决于我在什么隔离级别下、用什么读法、在哪个事务里读的。同样一行数据,RR 快照读看到的是过去、当前读看到的是现在、读从库看到的是滞后的版本。要想读对,先得想清楚"我要的是哪一种读"。
第五件事:我对"在事务里查询"的几个想当然
这次事故,本质是我把"查询"想当然地等同于了"读到此刻最新真相"。把这些想当然列出来,每一条都值得警惕:
| 我曾经的想当然 | 事故教我的真相 |
|---|---|
| "查询就是去看数据库此刻最新的样子" | RR 事务里读的是事务开始时的快照,不是此刻 |
| "别人改了并提交,我再查就能看到" | RR 下同一事务用同一快照,看不到外部后续提交 |
| "在事务里反复轮询能等到状态变化" | 快照锁死,永远读旧值;轮询要用独立短事务 |
| "读到旧值肯定是缓存/主从延迟" | 也可能是自己事务的快照读机制导致 |
| "可重复读是为了让我读到最新" | 恰相反:它保证读取一致,代价是读不到外部新提交 |
| "长事务无所谓,反正最后能读对" | 长事务快照易过期读旧值,还长期持锁撑大 undo |
第六件事:在事务里读数据时,我现在的自检习惯
现在每当我在事务里读数据,或排查"明明改了却读到旧值",我都会先按这张图问自己:
这张图的精髓,是"先分清我要的是'一致不变的视图'还是'当下最新的真相';要最新就别在长事务里快照读,用短事务/当前读"。写时就轮询等待用独立短事务、读后要改用当前读、要事务内一致才用默认快照读、排查就看读到旧值是不是自己事务的快照锁死在了开始那一刻。这套习惯,让我从"以为查询就是读最新"变成了"清楚不同隔离级别/读法读的是哪一刻"——核心始终是:MySQL InnoDB 默认可重复读(RR),事务里的普通 SELECT 是快照读,读的是事务首次读取时建立的一致性快照、整个事务沿用它,期间别人提交的新值看不到(这是 RR 用"读不到最新"换"读取一致"的有意设计);要在事务里读外部最新提交,正解是别用长事务轮询、改用独立短事务/新连接(每次新快照)、或当前读 FOR UPDATE、或 READ COMMITTED 隔离级别。
我立下的几条规矩
这场"事务里读不到别人提交、活活卡死"的事故,换来了我用数据库事务时,刻进骨子里的几条铁律:
- MySQL InnoDB 默认 RR,事务里普通 SELECT 是快照读——读的是事务开始时的快照,不是此刻最新。
- RR 保证"事务内多次读同一数据一致",代价就是读不到事务开始后别人的提交,这是设计不是 bug。
- "轮询等待外部状态变化"绝不能放在长事务里用快照读,要用独立短事务/新连接,每次建立新快照。
- 事务内确实要读最新值,用当前读(SELECT...FOR UPDATE,读最新版本且加锁)。
- 要每条语句都读到最新已提交,可用 READ COMMITTED 隔离级别(代价是不可重复读)。
- 读到旧值别只怪缓存/主从延迟,先想想是不是自己事务的快照锁死了。
- 读之前先想清楚:我要的是"一个不变的一致视图",还是"当下的最新真相"——它们是相互排斥的取舍。
附:我现在写"等待外部状态变化"的正确轮询骨架
这是我现在写任何"等待外部把某状态改好再继续"逻辑时,固定遵循的轮询骨架——把"轮询查询"和"真正干活的事务"严格分开,每次查询都用独立短事务,绝不在一个长事务里一边等一边查:
// 错误示范: 在一个长事务里轮询, 快照锁死, 永远读不到外部提交
// tx.begin();
// while (!"已完成".equals(queryStatus())) { sleep(1000); } // ✗ 永远卡死
// doRealWork(); tx.commit();
// 正解: 轮询用独立短事务(每次新连接/新事务 = 新快照, 读到最新)
boolean waitUntilDone(long jobId, Duration timeout) {
long deadline = System.currentTimeMillis() + timeout.toMillis();
while (System.currentTimeMillis() < deadline) {
// 每次查询都是一个【独立的短事务】, 用完即提交/关闭
String status = jdbc.queryForObject(
"SELECT status FROM job WHERE id = ?", String.class, jobId);
if ("已完成".equals(status)) {
return true; // 读到了外部最新提交的状态 ✓
}
sleep(1000); // 轮询间隔
}
return false; // 超时
}
// 业务流程: 先等外部就绪(短事务轮询), 再在一个新事务里干自己的活
if (waitUntilDone(jobId, Duration.ofMinutes(5))) {
txTemplate.execute(s -> { doRealWork(jobId); return null; }); // 真正的事务
}
这个骨架把我这次的教训钉死在了结构里:"等待/轮询"和"事务性地干活"是两件事,必须分开——轮询用一个个独立的短事务,每次都建立反映当下的新快照,这样才能看到外部的最新提交;等真正确认就绪了,再开一个新事务去做需要原子性的工作。这样既不会被某个长事务的旧快照锁死,也不会因为长时间持有事务而拖累数据库。核心就一句话——想"看到外面的变化",就别把自己关进一个"开始时就把世界拍成照片、之后再不更新"的长事务里;每次想看清当下,就重新睁一次眼。
写在最后
回头看,这场由"可重复读快照读"引发的"事务里读不到外部提交、卡死"事故,真正教给我的,远不止"轮询用短事务"这一个技巧。它让我对"我们以为自己看到的是'世界此刻的真实样子', 可我们看到的, 往往只是'在某个时刻、透过某种机制, 为我们定格下来的一个视图'; 这个视图为了某种目的(比如保持稳定一致)而被刻意冻结, 于是外面世界后来的变化, 就再也照不进我们眼里——而我们还以为自己看的是实时直播",有了一次刻骨的体会。我栽跟头,是因为我把'一个为了'保持一致'而刻意冻结的视图', 当成了'实时反映最新真相的窗口'——我打开事务、查询数据, 以为我看到的就是数据库'此刻'的样子, 还会随着外面的变化而更新;我没意识到, 这个事务为了给我一个'前后一致、不会变来变去'的视图, 已经在我第一次查询时, 把整个世界为我'拍了张照'、冻结了下来; 此后我看到的, 始终是那张照片, 而非鲜活的现场;我对着一张静止的照片, 苦苦等待照片里的人动起来——而他早已在照片之外, 走远了。这让我领悟到一个关于"视图、时刻与一致性代价"的深刻认知:任何"观察/读取", 都发生在某个特定的时刻、透过某种特定的机制, 它给我们的从来不是'赤裸的、实时的真相', 而是'经过这套机制处理后、属于某个时刻的视图';尤其当一个系统为了给我们'稳定、一致、可重复'的体验, 而刻意为我们冻结了一个视图时——这份'稳定'恰恰意味着'与外界实时变化的隔绝'; '一致'和'最新', 常常是一对必须取舍的矛盾, 鱼与熊掌不可兼得;所以每当我'读取'一个值、并要据此做判断时, 都要追问: 我读到的这个, 是'当下最新的真相', 还是'某个时刻为了某种一致性而冻结给我的视图'?我现在的需求, 要的究竟是哪一种?。这给了我一种看待"一切'读取、观测、获取状态'之事"时的清醒:每当我依据"读到的值"去做决策时,要追问"这个值, 反映的是此刻的最新真相, 还是某个过去时刻、为了某种目的被定格的视图?如果我需要的是最新, 我用的这套读取方式, 真的能给我最新吗?"——需要一致稳定的视图, 就接受它与实时的隔绝; 需要当下最新的真相, 就别用一个会冻结视图的机制去读;"看清自己读到的是哪个时刻的视图、让读取方式匹配对'一致'还是'最新'的真实需求",是用对事务、也是做对一切'基于读取的判断'的关键。认清事务里读的是快照而非实时、RR 用读不到最新换读取一致、要最新就别用长事务快照读——这,是我用一次事务卡死、读不到提交的事故,换来的、关于数据库、也关于如何看待"我看到的现在是哪个现在"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次在事务里反复查一个值、却怎么也读不到别人的更新时,先想想"我这是不是被自己事务的快照锁住了?我要的是一致还是最新?",并在要最新时果断换上短事务或当前读,那我对着那个"别人早改了我却永远读旧值"的卡死折腾的大半天,就值了。
—— 别看了 · 2026