我在一个事务里反复查同一行数据,想等它被别的流程改成功就继续,结果别人明明早就改了、也提交了,我这边却怎么查都还是旧值、活活卡死在那里,排查半天发现是可重复读隔离级别下的快照读在作怪的深度复盘

我有段业务逻辑:开一个事务、做了些前置操作,然后轮询数据库里的某一行,等另一个流程把它的状态改成已完成就继续。我想当然认为别人改了并提交后我再查一次自然就能查到最新值。可上线后这段逻辑诡异卡死:我明明能用另一个连接直接查到状态早就是已完成、事务也提交成功了,可我这个事务里的轮询无论查多少次读到的永远是那个旧的处理中,像被冻住,死活等不到、最后超时。我一度怀疑缓存、主从延迟、别人没提交,逐一排除都对不上,直到翻出隔离级别和读取方式才恍然:问题出在我自己事务身上——MySQL InnoDB 默认可重复读(RR)隔离级别下,事务里的普通 SELECT 是快照读,读的是事务第一次查询那一刻建立的一致性快照、整个事务沿用它,期间别人提交的新值看不到。这不是 bug,是 RR 用读不到最新换取事务内读取一致(可重复)的有意设计。我却在一个本该等待外部变化的场景用了一个把外部变化挡在门外的机制。正解是别在长事务里轮询,用独立短事务/新连接每次建立新快照,或用当前读 SELECT FOR UPDATE 读最新版本,或用 READ COMMITTED 隔离级别。这篇复盘从故障现场讲到隔离级别与快照读机理、为何读不到提交、怎么诊断,再到独立短事务轮询、当前读、RC 的完整正解,以及主从延迟、缓存旧值、对象缓存、长事务耗时等同类坑,和读到的是某时刻的视图而非实时真相、一致与最新是相互排斥的取舍、要看清自己读的是哪一刻的认知。

我在一个事务里反复查同一行数据,想等它被别的流程改成功就继续,结果别人明明早就改了、也提交了,我这边却怎么查都还是旧值、活活卡死在那里,排查半天发现是可重复读隔离级别下的快照读在作怪的深度复盘

这是一次让我对"我看到的'现在',到底是哪一刻的'现在'"有了刻骨认知的事故。我有段业务逻辑:开一个事务,做了些前置操作,然后轮询数据库里的某一行——等待另一个流程(另一个事务/服务)把它的状态改成"已完成",一旦查到"已完成"就继续往下走。我想当然地认为:别人改了并提交后,我这边再查一次,自然就能查到最新值了。

可上线后,这段逻辑诡异地卡死了:我明明能确认那个流程早就把状态改成了"已完成"、而且事务也提交成功了(我用另一个连接直接查,清清楚楚就是"已完成"),可我这个事务里的轮询,无论查多少次,读到的状态永远是那个旧的"处理中",像被冻住了一样,死活等不到"已完成",最后超时。我一度怀疑是缓存、是主从延迟、是别人根本没提交,逐一排除后全都对不上号。直到我把隔离级别和读取方式翻出来,才恍然大悟:问题出在我这个事务自己身上——在可重复读(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 隔离级别。

我立下的几条规矩

这场"事务里读不到别人提交、活活卡死"的事故,换来了我用数据库事务时,刻进骨子里的几条铁律:

  1. MySQL InnoDB 默认 RR,事务里普通 SELECT 是快照读——读的是事务开始时的快照,不是此刻最新。
  2. RR 保证"事务内多次读同一数据一致",代价就是读不到事务开始后别人的提交,这是设计不是 bug。
  3. "轮询等待外部状态变化"绝不能放在长事务里用快照读,要用独立短事务/新连接,每次建立新快照。
  4. 事务内确实要读最新值,用当前读(SELECT...FOR UPDATE,读最新版本且加锁)。
  5. 要每条语句都读到最新已提交,可用 READ COMMITTED 隔离级别(代价是不可重复读)。
  6. 读到旧值别只怪缓存/主从延迟,先想想是不是自己事务的快照锁死了。
  7. 读之前先想清楚:我要的是"一个不变的一致视图",还是"当下的最新真相"——它们是相互排斥的取舍。

附:我现在写"等待外部状态变化"的正确轮询骨架

这是我现在写任何"等待外部把某状态改好再继续"逻辑时,固定遵循的轮询骨架——把"轮询查询"和"真正干活的事务"严格分开,每次查询都用独立短事务,绝不在一个长事务里一边等一边查:

// 错误示范: 在一个长事务里轮询, 快照锁死, 永远读不到外部提交
// 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我在 Java 里用 == 比较两个字符串是否相等,测试时一直好好的,可一接上用户真实输入就时灵时不灵、明明看着一模一样却判为不等,排查半天发现 == 比的根本不是内容、而字符串常量池给我演了一出好戏的深度复盘

2026-6-3 4:17:13

技术教程

我的服务调用下游一切正常,可一到高峰期就大量报连接失败、报错说没有可用端口了,我的机器明明负载不高、内存也充足,排查半天发现是几万个处于 TIME_WAIT 状态的连接把本地端口耗光了的深度复盘

2026-6-3 4:27:30

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索