把隔离级别从 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. 自测
- 同一事务里两次相同
SELECT读到不同值,先怀疑隔离级别还是读模式? - MySQL 的
REPEATABLE-READ挡住幻读,代价是什么?PostgreSQL 用的是什么手段? - 为什么说
SERIALIZABLE的失败模式是并发塌陷,而不是单纯变慢? - 哪些业务不变量用唯一索引比用高隔离级别更划算?
- 读写分离场景下,隔离级别能提供什么保证,缺的那部分该由谁补?
- 调低隔离级别之前,应该先做哪三件零风险的事?