改了行配置死锁没了账却乱了:MySQL 隔离级别选型血泪史

以下分析基于 MySQL 8.0.32场景:一个高并发订单系统,改了一行 transaction-isolation=READ-COMMITTED,死锁消失了对不上账了 路径:死锁风暴 → RC 切换 → 对账异常 → 隔离级别本质 → 选型决策

上篇讲了长事务 UNDO 膨胀——ReadView 不释放时 UNDO 日志堆积,回滚段涨到撑爆表空间。这次说一个更反常识的事:你改了配置解决了死锁,但引入了另一个问题——账对不上了。

-- 同一个事务内
BEGIN;
SELECT COUNT(*) FROM orders WHERE date = '2026-07-20';  -- 返回 12,847 行
-- 30 秒后,同一个事务,同一个 SQL
SELECT COUNT(*) FROM orders WHERE date = '2026-07-20';  -- 返回 12,803 行(?!)

SELECT SUM(amount) FROM orders WHERE date = '2026-07-20';  -- 返回 683,492.15
-- 再查一次
SELECT SUM(amount) FROM orders WHERE date = '2026-07-20';  -- 返回 659,644.55(??)

——同一个事务,同一个 WHERE 条件,COUNT(*) 差了 44 行,SUM(amount) 差了 ¥23,847.60。

不是数据库 bug,不是并发冲突——根因是两周前 DBA 改了一行配置transaction-isolation=READ-COMMITTED。那行配置是为了解决另一个问题——每天 200+ 次死锁。


现象 — 从死锁风暴到对账不平

第一幕:每天 200 多次死锁

订单批量处理系统,8 个 worker 线程并发处理分表后的订单。MySQL 8.0.32,默认 REPEATABLE READ 隔离级别。

每个 worker 的事务模式:

BEGIN;
SELECT balance FROM account WHERE id = ? FOR UPDATE;
UPDATE orders SET status = 'processing' WHERE order_id = ? AND status = 'pending';
INSERT INTO order_log(order_id, action, operator) VALUES (?, ?, ?);
COMMIT;

一天之内 SHOW ENGINE INNODB STATUS 报 200+ 次死锁。运维群每天一条"订单卡住"的投诉。

死锁日志 — SHOW ENGINE INNODB STATUS gap lock 死锁

第二幕:改了一行 my.cnf

systemctl restart mysql,死锁从 200+ 降到 0。运维群安静了。所有人都以为问题解决了——除了月底对账的人。

my.cnf 配置变更 — transaction-isolation 改成 READ-COMMITTED

第三幕:月初对账,差了 23000

统计报表事务:

@Transactional
public Report generateDailyReport(Date date) {
    long count1 = orderRepo.countByDate(date);             // ① 12,847 行
    // 中间有 30 秒的其他查询和计算
    long count2 = orderRepo.countByDate(date);             // ② 12,803 行
    BigDecimal sum1 = orderRepo.sumAmountByDate(date);     // ③ 683,492.15
    // 又一些计算
    BigDecimal sum2 = orderRepo.sumAmountByDate(date);     // ④ 659,644.55
    return new Report(count1, count2, sum1, sum2);
}

四次读取,三组不同数字。对账差异:¥23,847.60

"数据量不大,这个月才 1 万 3 千条订单——但同一个事务里,同一个查询,数字能变三次。这是在 MySQL 里,不是在分布式系统里。"

RC 下同一事务不可重复读 — 两次查询结果不同


解析 — 四层追问,一个核心洞察

第一层:RR 的间隙锁从哪来?

InnoDB 在 REPEATABLE READ 下使用 Next-Key Lock 来防止幻读。Next-Key Lock 是两层的组合:

  • Record Lock(记录锁):锁住索引上的某一行
  • Gap Lock(间隙锁):锁住两个索引值之间的间隙——不是锁数据,是锁"位置"
-- 事务 A
BEGIN;
SELECT * FROM orders WHERE amount BETWEEN 100 AND 200 FOR UPDATE;

这条 SQL 看上去只锁 amount=100 和 amount=200 的行。但在 RR 下,它额外锁住了 (100,200) 之间的每一个间隙:

锁定范围(amount 索引):
... 80 → 100[行锁] → (100, 150)间隙锁 → 150[行锁] → (150, 200)间隙锁 → 200[行锁] → ...

Worker 1 锁了 (100, 150) 间隙,Worker 2 要插入 amount=120 的订单——被堵住。两个 worker 各自锁了不同间隙但又互相等待 → 死锁。

这就是每天 200+ 次死锁的根源。不是代码写得不好,是 RR 的间隙锁在并发写入场景下天然容易死锁。

Next-Key Lock 锁定范围 — amount 索引上的 Record Lock + Gap Lock

第二层:RC 为什么没有间隙锁?

MySQL 的设计决策:RC 不需要防幻读。

READ-COMMITTED 的语义是"读已提交的数据"。在这种语义下,幻读(同一查询在不同时间返回不同行集)是预期行为——不是问题,不需要解决。

所以 RC 彻底取消了间隙锁(外键检查(foreign key constraint)和级联(cascading)操作除外,这两种场景不管什么隔离级别都需要 gap lock 保证引用完整性)。

改配置后死锁消失的根本原因就在这里——RC 模式下没有 gap lock,所有插入操作只锁自己那一行,不会和别人的间隙锁打架。

RR vs RC 锁机制对比 — gap lock 是 RR 死锁的根源

第三层:RC 的 ReadView 每语句重建

RC 解决了死锁,但引入了另一个问题——不可重复读(non-repeatable read)

MVCC(Multi-Version Concurrency Control)是 InnoDB 实现事务隔离的核心机制。它的工作单位叫 ReadView(读视图),记录了"事务启动时哪些事务是活跃的"——快照读时只看到 ReadView 允许看到的版本。

RR 和 RC 的关键区别在于 ReadView 的存活期:

特性 RR RC
ReadView 创建时机 事务第一个 SELECT 每条 SQL
ReadView 复用 整个事务复用 不复用
间隙锁 有 (Next-Key Lock)
不可重复读 不会发生 会发生
幻读(快照读) 不会
死锁概率 高(gap lock 互锁)

RR vs RC ReadView 创建时机 — 事务级 vs 语句级

回到对账场景——countByDate() 在 RC 下每次执行都创建一个新的 ReadView。Worker 2 在两次查询之间插入了一条订单并提交——新的 ReadView 能看到它。所以第一次查 12,847 行,第二次查 12,803 行(有订单被删),SUM 差了 23,847.60。

不是 RC 有 bug——是代码假设了"事务内一致"的行为,这恰好是 RR 的语义,而运行环境是 RC。

第四层:核心洞察——为什么 MySQL 默认 RR?(这篇文章必须打透的点)

很多人以为 MySQL 默认 RR 是因为它"更安全"。完全不是。这是历史遗留。

看你的复制模式:

SHOW VARIABLES LIKE 'binlog_format';
-- 5.7 默认:STATEMENT
-- 8.0 默认:ROW

问题的关键在于 binlog_format=STATEMENT。

STATEMENT 复制下,主库执行的 SQL 原文会被记录到 binlog,从库逐条 replay。想象这样一个场景:

-- 主库(RC 隔离级别,无 gap lock)
DELETE FROM orders WHERE status = 'expired' LIMIT 1000;

主库执行时,因为没有 gap lock,InnoDB 按索引顺序扫描,删除了前 1000 条 status=expired 的记录。这条 SQL 写入 binlog。

从库 replay 同一条 SQL。但从库的数据分布可能不同——或者同样的数据但扫描路径不同——导致删除了"不同的"1000 条记录。主从不一致。

RR 的 gap lock 解决了这个问题。 它通过锁住扫描范围,保证了主库和从库在 replay 相同的 SQL 时,扫描和锁定的行顺序一致。

但 binlog_format=ROW 普及后,binlog 记录的是"哪一行被改了"而不是"改了哪条 SQL"——从库不再需要 replay SQL,只需要应用行变更。gap lock 的保护作用消失了。

所以 MySQL 8.0 虽然已经把 binlog_format 默认改成 ROW,但出于兼容性从未改过默认隔离级别。官方文档里有一段话:

"The default isolation level for InnoDB is REPEATABLE READ. It was chosen for compatibility with earlier versions of MySQL, and to support the statement-based replication."

翻译一下:默认 RR 是为了兼容旧版本和 STATEMENT 复制——不是因为它更好。

这就是全篇最应该记住的一句话:

"MySQL 默认 RR 不是因为它更安全——是 2003 年的复制协议需要它,而 MySQL 从来没改过默认值。"

STATEMENT vs ROW 复制 — gap lock 在两种模式下的作用差异


路径 — 🔍 半夜接到告警后的真实排障

假设你半夜接到告警:"对账不平"或者"死锁增多"。怎么快速判断是不是隔离级别的问题?

Step 1:确认当前连接的隔离级别(比你想的复杂)

-- 别查全局变量
SHOW GLOBAL VARIABLES LIKE 'transaction_isolation';
-- 这只能看到 my.cnf 的默认值,不代表当前连接

-- 查会话级
SELECT @@transaction_isolation;

关键陷阱:连接池的长连接在启动后不再读 my.cnf。如果你改了 transaction-isolation 后没重启应用,长连接走的是旧配置。你确认线上运行的是 RC——但某个慢查询事务可能还是 RR。

Step 2:死锁日志 → 搜 gap lock 关键字

SHOW ENGINE INNODB STATUS\G

LATEST DETECTED DEADLOCK 段搜索:

关键字 含义 结论
lock_mode X locks gap before rec 间隙锁,等待前面的记录 隔离级别是 RR
LOCK_GAP 间隙锁标记 隔离级别是 RR
lock_mode X locks rec but not gap 仅有记录锁,无间隙锁 RC 或 RR 都可能
死锁日志完全不出现 gap 只有行锁竞争 RC 隔离级别

如果死锁日志里有 gap lock——隔离级别就是 RR。这是最快的诊断方法。

排查步骤表 — 确认隔离级别 + 查死锁日志 + 验证不可重复读

Step 3:验证 RC 下的不可重复读

开一个 MySQL 会话,模拟对账事务:

-- session A(RC 隔离级别)
BEGIN;
SELECT COUNT(*) FROM orders WHERE date = '2026-07-20';
-- 此时在 session B 插入一条记录并提交
SELECT COUNT(*) FROM orders WHERE date = '2026-07-20';
-- ↑ 数字变了!
ROLLBACK;

如果同一个事务内两次 SELECT 结果不同——隔离级别就是 RC(或者代码里混用了 FOR UPDATE)。

三个信号速查

故障 信号 判断
死锁频繁 死锁日志含 locks gap before rec RR 间隙锁导致
同一事务 COUNT 不一致 RC 下两次查询结果不同 RC 语义预期行为
既有死锁又对账不平 gap lock + 不可重复读 混合事务——不同连接隔离级别不同

你的代码不可能同时在 RR 和 RC 下都正确。选一个,然后验证代码的行为与之匹配。


重构 — 隔离级别选型 + 无损切换方案

选型决策树

binlog_format 是什么?
  ├─ ROW → 可以安全地选 RC
  │   ├─ 业务需要事务内一致性快照(报表/对账)?
  │   │   ├─ YES → 保留 RR,但必须控制事务粒度和执行时间
  │   │   └─ NO  → 用 RC,死锁更少并发更高
  │   └─ 代码中有 FOR UPDATE 后跟普通 SELECT 的混合模式?
  │       ├─ YES → 建议用 RR 或统一读模式(全用 FOR UPDATE)
  │       └─ NO  → RC 安全
  └─ STATEMENT 或 MIXED → 必须保留 RR(gap lock 是必需品)

安全切换方案(不改 my.cnf,连接级别灰度)

关键提醒:改了 my.cnf 不代表现有连接会变更。 重启应用或重建连接池后新连接才生效。

灰度切换步骤

  1. 先改一台从库的连接级别,观察 24 小时,确认无死锁回归、无数据不一致
  2. 逐个灰度主库连接,每次改 10% 流量的连接池
  3. 验证命令sql SELECT @@transaction_isolation; -- 预期:READ-COMMITTED
  4. 所有连接都切完后才改 my.cnf(作为新连接的默认值)
  5. 最后改对账/报表的查询——如果保留 RR,确保它们的 @Transactional 单独设置了 isolation

配置修改 before/after — my.cnf + Spring 连接级别 + @Transactional 选型决策树 — binlog_format → 业务需求 → 代码模式


标记 — 🗝 在项目里搜索隔离级别陷阱

命令 1:搜所有显式设置隔离级别的地方

grep -rn 'Isolation\|isolation' src/main/java/ --include='*.java'

重点关注: - @Transactional(isolation = Isolation.DEFAULT) —— 表示"跟随系统默认" - Isolation.READ_COMMITTEDIsolation.REPEATABLE_READ —— 显式指定

命令 2:搜连接池配置文件

grep -rn 'transaction-isolation\|transaction_isolation' src/main/resources/

命令 3:搜同一事务内混合读模式(最高风险)

grep -rn 'FOR UPDATE' src/main/java/ | grep -B 10 '@Transactional'

人工审查:同一方法体内同时有 SELECT 和 SELECT...FOR UPDATE —— 这是 RR 下数据横跳的根源。

命令 4:确认你的复制模式

mysql -e "SHOW VARIABLES LIKE 'binlog_format';"
结果 含义 行动
ROW 行复制,gap lock 无额外作用 可以安全评估切换 RC
STATEMENT SQL 复制,gap lock 是必需品 不要改隔离级别
MIXED 混合模式 大部分走 STATEMENT 逻辑,建议保持 RR

总结

核心洞察

"同一个数据库,同一个事务,同一个 SQL——金额可以不一样。不是数据库坏了,是你没告诉它你要什么隔离级别。"

隔离级别选择的本质不是在"安全"和"性能"之间选——是在三种成本结构之间选:

选型 付出的代价 获得的好处
RR gap lock 导致的死锁风险 事务内一致性快照
RC 不可重复读 + 幻读 无 gap lock,更高并发
不改(默认 RR) 两样都要承担 不需要做选择(但也不解决问题)

从 naive 到正确的三阶段:

阶段 你会说 然后发现
❌ naive "隔离级别用默认的" 每天 200+ 死锁
⚠️ 修正 "RC 并发高,改成 RC" 对账不平,差 2 万 3
✅ 正确 "选 RR 还是 RC——先看 binlog_format,再看业务是否需要 ReadView 一致性,最后评估 gap lock 对写入模式的影响" 配置跟着业务走

"MySQL 默认 RR 不是因为它更安全——是 2003 年的复制协议需要它,而 MySQL 从来没改过默认值。"

下篇聊 连接数爆满——连接泄漏三种典型场景。这次说另一个"配置改好了但什么都没解决"的故事——连接池参数看着都对,连接数还是涨到 MySQL 拒绝连接。

📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」