3029 字
约 10 分钟
19
S11-A11 · 事务隔离级别的选择:从读已提交讲起
2024-12-31
2026-11-09

把隔离级别从 READ-COMMITTED 改成 REPEATABLE-READ 之后,"读到不同行"的问题没了,代价是锁等待翻了几倍,凌晨还多出一种没见过的死锁。

不是配置错了,而是隔离级别的价格本来就这么付:每挡掉一类异常,要么加锁、要么加版本校验、要么让更多事务重试。别问"越高越安全吗",要问:必须挡住哪类异常、成本落在谁身上、跨服务时还剩多少。


1. 先分清三件事

同一个"读到脏数据"的现场可能来自三层,改隔离级别只对一层有效:

层次 表现 隔离级别管用吗
单库单事务 同一事务内两次读到不同值 管用
单库多事务 并发写覆盖、丢更新 部分管用,常需锁或版本号
跨库/跨服务 已提交的本地事务被下游看到不同快照 不管用

判据:能在一个连接、一个事务里复现的才归隔离级别管;要两个服务配合复现的是一致性问题。

2. 四种级别的实际语义

背"挡什么"而不是定义:

级别 脏读 不可重复读 幻读 实现代价
READ-UNCOMMITTED 可能 可能 可能 几乎无
READ-COMMITTED 挡住 可能 可能 每条语句一份快照
REPEATABLE-READ 挡住 挡住 InnoDB 靠间隙锁挡,标准不要求 事务级快照 + 范围锁
SERIALIZABLE 挡住 挡住 挡住 读也加锁或冲突回滚

MySQL 默认 REPEATABLE-READ,PostgreSQL 默认 READ-COMMITTED。两个库默认值相反本身就是证据:这个选择依赖实现成本(按 MySQL 8.x、PG 15/16 默认值核对)。

3. 三个现象的准确含义

现象 准确定义 真实危害
脏读 读到未提交、可能回滚的值 基于空气决策,回滚后对不上
不可重复读 同事务两次读同一行值变了 先查后改的判断失效
幻读 同范围两次查询行数变了 汇总与明细不一致

差别不在定义,在代价:InnoDB 的 REPEATABLE-READ 下快照读走 MVCC、当前读走 next-key lock,"挡住幻读"是拿锁换的;PostgreSQL 的 REPEATABLE-READ 即快照隔离,写冲突直接报 could not serialize access,靠重试而非阻塞,SERIALIZABLE 用 SSI 检测危险结构,同样以回滚告终。

一句话:MySQL 用等待换正确性,PG 用重试换正确性——前者锁等待升高,后者 40001 升高。

4. READ-COMMITTED + MVCC 为什么是多数业务的默认

因为多数不变量不需要事务级快照:单行更新、按主键读、幂等写入在 READ-COMMITTED 下已够用且最便宜。

维度 READ-COMMITTED REPEATABLE-READ
快照时机 每条语句 事务内第一条快照读
锁范围 锁匹配到的行 额外加间隙锁
长事务影响 小,快照不长期持有 undo 不能清理,版本堆积
死锁概率 低 明显更高(范围锁交叉)
典型适用 短事务、主键访问 同事务多次读同批

判据是事务里有没有"读—判断—再读":有才需要事务级快照。

5. 快照读与当前读:同一事务里两次查询不同

最容易误判成"隔离级别失效"的现象:同一事务、同一条 SQL 结果不同。

START TRANSACTION;                            -- REPEATABLE-READ
SELECT balance FROM account WHERE id = 1;              -- 快照读:事务开始时的版本
SELECT balance FROM account WHERE id = 1 FOR UPDATE;   -- 当前读:最新已提交版本
COMMIT;

规则:普通 SELECT 走快照,FOR UPDATE/FOR SHARE/UPDATE/DELETE 走当前读。所以"先查余额再扣款"若第一步是普通查询,判断依据可能已过期——这不是隔离级别问题,是读模式选错。

6. 间隙锁:死锁与并发下降的来源

REPEATABLE-READ 下范围条件还锁行之间的"间隙",插入落进间隙即被阻塞;两个事务逆序锁相邻间隙就是死锁:

(1) UPDATE t SET v=1 WHERE k BETWEEN 10 AND 20
(1) HOLDS lock_mode X locks gap before rec 15 ; WAITING FOR rec 18
(2) UPDATE t SET v=2 WHERE k BETWEEN 15 AND 25
(2) HOLDS lock_mode X locks rec 18 ; WAITING FOR gap before rec 15
*** WE ROLL BACK TRANSACTION (1)
手段 效果 代价
范围条件改等值 消除间隙锁 要业务能精确到键
加合适索引 缩小锁定范围 索引成本
固定访问顺序 消除交叉等待 代码约束
降为 READ-COMMITTED 减少间隙锁 失去事务级快照
缩短事务 减少持锁时间 需拆分逻辑

前三条免费,先做;降级别是最后手段——改的是语义,不是写法。

7. SERIALIZABLE 的真实成本:不是慢一点

成本项 表现 量级
读也加锁 纯读事务互相等待 读并发下降
冲突即失败 PG 报 40001,MySQL 报死锁 应用要能重试
重试放大 每次重试都是完整事务 高冲突时恶化
吞吐上限 并发受不变量粒度限制 热点行成为串行点

关键判断:失败模式是"并发塌陷"不是"变慢"。并发超过冲突阈值后吞吐会突然掉到接近串行,重试风暴再放大它。

8. 隔离级别与业务不变量的匹配

不变量类型 需要的保证 更便宜的替代
余额不为负 读—判断—写原子 条件更新 WHERE balance >= x
用户名唯一 插入不能并发同名 唯一索引(首选)
库存不超卖 扣减不能穿透 条件更新或行锁 + 短事务
单据只入账一次 幂等 唯一键 + 幂等表
汇总等于明细之和 跨行一致 同事务写、或对账补偿
序号连续无空洞 严格串行 很难只靠隔离级别

唯一约束能解决的不变量,别用隔离级别解决:前者声明式、成本固定、错误立刻可见;后者依赖每条写路径都走对,漏一条就失效。

9. 乐观锁、悲观锁与隔离级别的关系

隔离级别是数据库的默认保护,锁与版本号是应用显式声明的保护,二者叠加而非替代。

-- 乐观锁:不额外加锁,靠版本号让并发写失败
UPDATE orders SET status='PAID', version=version+1 WHERE id=42 AND version=7;
-- 影响行数 0 表示被抢先,应用决定重试还是报错
SELECT * FROM orders WHERE id = 42 FOR UPDATE;   -- 悲观锁:先占住,事务要短

冲突高用悲观锁排队,冲突低用乐观锁重试,改不动的存量系统只能靠高级别。READ-COMMITTED + 乐观锁常比 REPEATABLE-READ 裸跑更快,因为失败被显式化了。

10. 跨库、跨服务与复制:隔离级别的失效面

场景 隔离级别的作用 应对
同事务写两个库 无效,是两个独立事务 本地消息表、TCC、对账
服务间调用 无效,没有共享事务 幂等 + 补偿
从库读 不保证,延迟内读到旧值 关键读走主库或等位点
缓存并存 / 跨库 join 无效 失效策略先行,聚合放应用层

判据:一致性保证只在"一个库、一个连接、一个事务"的边界内成立。越界就得换应用层显式机制,隔离级别只是心理安慰(延迟看 SHOW REPLICA STATUS 的位点)。

11. 运行时怎么验证

别靠读文档确认隔离行为,并发跑一遍:

-- 会话 A:声明级别后两次读同一范围
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION; SELECT COUNT(*) FROM t WHERE k > 10;
-- 会话 B:另一连接插入并提交
INSERT INTO t (k) VALUES (12); COMMIT;
-- 回 A 再读:值变了(RC)还是旧值(RR),这就是证据

SELECT * FROM performance_schema.data_lock_waits\G       -- 谁在等谁
SELECT * FROM information_schema.innodb_trx ORDER BY trx_started LIMIT 10;

data_lock_waits 给阻塞关系,innodb_trx 给事务年龄(长事务是 undo 堆积的根源)。两条纪律:每次显式 SET SESSION,不依赖全局配置;验证后清理数据。

12. 调低顺序与回滚方式

顺序 动作 观察指标 回滚条件
1 优化语句与索引,消掉范围锁 锁等待时长、扫描行数 无风险
2 缩短事务,外部调用移出事务 事务时长、undo 大小 无风险
3 热点写加乐观锁或条件更新 影响行数 0 的比例 重试率过高就回退
4 单库降到 READ-COMMITTED 死锁次数、40001 比例 出现不可重复读相关 bug
5 特定事务用 SERIALIZABLE 该事务吞吐与重试率 并发塌陷立即回退

先会话级灰度再全局,否则回滚只能靠重启:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- 会话级灰度
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;            -- 事务级(PG 与部分 MySQL 版本)
SELECT @@transaction_isolation;                          -- 确认实际生效值

13. 排错表

现象 最可能原因 处理
改 RR 后锁等待飙升 范围条件触发间隙锁 加索引、改等值,必要时降回 RC
出现没见过的死锁 两事务逆序锁同一组间隙 统一访问顺序,看死锁日志
同一事务两次查询不同 第二次是当前读 统一读模式,或改用 FOR UPDATE
40001 增多 PG 的 SSI 检测到危险结构 应用层重试 + 缩短事务
主库写后从库读不到 复制延迟 关键读走主库
低级别下丢更新 读—改—写非原子 条件更新或版本号

14. 边界:隔离级别不是关键杠杆

场景 结论 理由
单线程写入 不必调级别 无并发冲突
纯读服务 不必调级别 只需快照一致性
已用唯一约束保证不变量 不必升 SERIALIZABLE 约束已覆盖冲突面
数据可由对账修复 优先对账而非加锁 成本更低
冲突极低的长尾路径 乐观锁即可 高隔离级别不划算

必须认真选的信号:同一行高频并发写、不变量无法用唯一约束表达、或出现过"账对不上"的事故。


15. 自测

  1. 同一事务里两次相同 SELECT 读到不同值,先怀疑隔离级别还是读模式?
  2. MySQL 的 REPEATABLE-READ 挡住幻读,代价是什么?PostgreSQL 用的是什么手段?
  3. 为什么说 SERIALIZABLE 的失败模式是并发塌陷,而不是单纯变慢?
  4. 哪些业务不变量用唯一索引比用高隔离级别更划算?
  5. 读写分离场景下,隔离级别能提供什么保证,缺的那部分该由谁补?
  6. 调低隔离级别之前,应该先做哪三件零风险的事?
S11-A11 · 事务隔离级别的选择:从读已提交讲起
https://www.clxhxhhr.top/posts/6207/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。