改了行配置死锁没了账却乱了: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+ 次死锁。运维群每天一条"订单卡住"的投诉。

第二幕:改了一行 my.cnf
systemctl restart mysql,死锁从 200+ 降到 0。运维群安静了。所有人都以为问题解决了——除了月底对账的人。

第三幕:月初对账,差了 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 里,不是在分布式系统里。"

解析 — 四层追问,一个核心洞察
第一层: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 的间隙锁在并发写入场景下天然容易死锁。

第二层:RC 为什么没有间隙锁?
MySQL 的设计决策:RC 不需要防幻读。
READ-COMMITTED 的语义是"读已提交的数据"。在这种语义下,幻读(同一查询在不同时间返回不同行集)是预期行为——不是问题,不需要解决。
所以 RC 彻底取消了间隙锁(外键检查(foreign key constraint)和级联(cascading)操作除外,这两种场景不管什么隔离级别都需要 gap lock 保证引用完整性)。
改配置后死锁消失的根本原因就在这里——RC 模式下没有 gap lock,所有插入操作只锁自己那一行,不会和别人的间隙锁打架。

第三层: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 互锁) | 低 |

回到对账场景——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 从来没改过默认值。"

路径 — 🔍 半夜接到告警后的真实排障
假设你半夜接到告警:"对账不平"或者"死锁增多"。怎么快速判断是不是隔离级别的问题?
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 不代表现有连接会变更。 重启应用或重建连接池后新连接才生效。
灰度切换步骤
- 先改一台从库的连接级别,观察 24 小时,确认无死锁回归、无数据不一致
- 逐个灰度主库连接,每次改 10% 流量的连接池
- 验证命令:
sql SELECT @@transaction_isolation; -- 预期:READ-COMMITTED - 所有连接都切完后才改 my.cnf(作为新连接的默认值)
- 最后改对账/报表的查询——如果保留 RR,确保它们的 @Transactional 单独设置了 isolation

标记 — 🗝 在项目里搜索隔离级别陷阱
命令 1:搜所有显式设置隔离级别的地方
grep -rn 'Isolation\|isolation' src/main/java/ --include='*.java'
重点关注:
- @Transactional(isolation = Isolation.DEFAULT) —— 表示"跟随系统默认"
- Isolation.READ_COMMITTED 或 Isolation.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拆代码的曹操」