7AA7 Blog

事务与隔离级别:脏读、不可重复读与幻读

VectorQ UID:96
2026-10-19 18:20
33 0 1

ACID 四个字母里,A、C、D 在工程上相对好验证:原子性靠日志回滚,持久性靠刷盘策略,一致性是业务与约束共同保证的结果。真正让人头疼的是 I——隔离性。它的定义本身就带着妥协:完全隔离等价于串行执行,性能不可接受;降低隔离又会引入各种读写异常。工程上的隔离级别,本质是在正确性和并发度之间选一个自己能接受的点。

一、三种读异常的定义

讨论隔离级别之前,先把三个现象区分清楚,它们经常被混用:

  • 脏读(Dirty Read):读到了别的事务尚未提交的数据。对方回滚后,你读到的东西就凭空消失了。
  • 不可重复读(Non-repeatable Read):同一个事务内,两次读同一行得到不同结果,中间被别的事务的提交改掉了。
  • 幻读(Phantom Read):同一个事务内,两次执行同一范围查询,返回的行数不同,中间被别的事务插入了新行并提交。注意区分:不可重复读针对”某一行变了”,幻读针对”多出或少掉了行”。

四个隔离级别与这些现象的对应关系如下:

隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED 可能 可能 可能
READ COMMITTED 不会 可能 可能
REPEATABLE READ 不会 不会 可能(标准 SQL 定义)
SERIALIZABLE 不会 不会 不会

这张表是 ANSI SQL 的标准定义。需要注意的是,很多实际数据库(包括 InnoDB 默认的 REPEATABLE READ)通过多版本与间隙锁的组合,把幻读也挡住了大部分,比标准定义更严格。理论表格和实际实现之间总有缝隙,排查问题时要以实现为准。

二、MVCC:快照读与当前读

InnoDB 的高并发读能力主要来自 MVCC(多版本并发控制)。每行记录除了业务字段,还隐含了两个系统列:最近修改事务 ID 和回滚指针。回滚指针把该行的历史版本串成一个链,旧版本存放在 undo 日志里。

事务读取时,InnoDB 根据一个叫 Read View 的快照来判断哪些版本对自己可见。Read View 在事务第一次读的时候生成(REPEATABLE READ 下),记录了当时活跃的事务 ID 列表;每次读一行时,沿着版本链找到第一个对自己可见的版本返回。这样读操作不需要加锁,也不阻塞写。

这里有一个关键的区分:

  • 快照读(Snapshot Read):普通的 SELECT,读的是历史版本,不加锁。
  • 当前读(Current Read):SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、以及所有 INSERT/UPDATE/DELETE,必须读最新版本并加锁。

MVCC 解决的是”读不阻塞写、写不阻塞读”,但它只对快照读生效。当事务里混用了当前读时,看到的是最新数据,和快照读的结果可能不一致,这是很多”我明明加了事务怎么还读到旧数据”困惑的来源。

一个工程上常见的坑是长事务导致 undo 链膨胀:Read View 生成得越早,能被清理的旧版本就越少,undo 日志持续堆积,查询要沿着更长的版本链回溯。这既占空间也拖慢读取,监控里看到 history list length 持续走高,往往就是长事务的信号。

三、间隙锁与幻读

REPEATABLE READ 下,快照读本身不会产生幻读(Read View 保证了两次读的可见性一致),幻读的场景通常出现在当前读里:事务 A 先 SELECT ... FOR UPDATE 查了范围,事务 B 插入新行并提交,A 再查一次就多出一行。

InnoDB 的对策是间隙锁(Gap Lock)和临键锁(Next-Key Lock)。普通行锁只锁住已存在的行,挡不住新行插入;间隙锁锁住的是索引记录之间的”间隙”,阻止其他事务往这个区间插入。临键锁是行锁加间隙锁的组合,锁住左开右闭的区间。

副作用是并发插入的冲突变多。比如按 user_id 做范围更新时,临键锁会锁住一段区间,其他事务往这个 user_id 范围内插入新订单就会被阻塞甚至死锁。等值查询且命中唯一索引时,InnoDB 会退化为只锁单行(间隙锁可以省掉),这是把业务查询尽量写成唯一键等值查询的一个实际动机。

INSERT 用的是插入意向锁,和间隙锁之间存在互斥关系:间隙锁挡住插入,插入意向锁等待间隙释放。批量插入遇到长时间不提交的事务时被卡住,追下去多半是这个机制。

四、长事务的代价

长事务不只是”持锁久”这么简单,它的危害是累积性的:

  1. 锁持有时间变长,死锁概率和锁等待超时都随之上升;
  2. undo 版本无法回收,磁盘占用和查询回溯成本增加;
  3. 主从复制受影响,大事务在从库上回放耗时,可能造成复制延迟;
  4. 回滚代价大,事务跑了很久之后失败,回滚本身可能比执行还慢,期间锁依然不释放。

工程上的惯例是把事务边界压到最小:只把必须原子的操作放进事务,RPC 调用、文件上传、消息发送这类不确定时长的操作全部挪到事务之外。一条事务里混入外部调用,等于把外部系统的响应时间变成数据库的锁持有时间。

五、默认级别与业务适配

MySQL InnoDB 默认 REPEATABLE READ,很多其他数据库默认 READ COMMITTED。这个差异在跨数据库迁移时容易踩坑:同一段 SQL 在不同默认级别下行为不同,尤其是依赖”事务内能读到其他事务已提交数据”的逻辑。

怎么选,看业务能容忍什么:

  • 读多写少、对一致性要求高(账务、库存):REPEATABLE READ 或更高,关键路径上用当前读显式加锁。
  • 读多写少、容忍短暂旧数据(报表、列表页):READ COMMITTED 就够,锁粒度更小,死锁更少。
  • 批量对账、后台任务:READ COMMITTED 通常更合适,长时间扫描不会因为 Read View 早生成而拖累版本清理。
  • SERIALIZABLE:并发代价明显,一般只在审计、结算这类对正确性极度敏感的短场景里用。

一个务实的做法是:默认 REPEATABLE READ,只在明确知道可以放宽的地方(比如只读报表)把连接会话级别临时设为 READ COMMITTED。隔离级别是会话可调的,不必全局一刀切。

六、死锁的识别与规避

死锁在高并发写场景下几乎不可避免,重点是让它快速失败、快速重试,而不是完全消灭。

识别手段有几种:

-- 查看最近一次死锁详情
SHOW ENGINE INNODB STATUSG
-- 查看当前锁等待
SELECT * FROM performance_schema.data_lock_waits;

SHOW ENGINE INNODB STATUS 里的 LATEST DETECTED DEADLOCK 段落会给出两个事务各自持有什么锁、在等什么锁、被回滚的是哪一个。这是排查死锁的第一手材料,建议在出问题时第一时间抓取保存(输出会被下一次死锁覆盖)。

规避的手段更多是工程习惯:

  • 约定加锁顺序。多个事务都要更新同一组资源时,统一按主键升序处理,交叉等待就会消失。
  • 缩短事务,前面已经说过,这是最有效的一招。
  • 用低基数索引做范围更新时小心间隙锁,尽量收窄范围条件。
  • 对死锁做重试。死锁发生后 InnoDB 会回滚代价较小的那个事务,业务层捕获错误码 1213 做有限次数退避重试即可,不要让重试变成雪崩。

隔离性的问题很少能靠改一个配置参数解决。真正起作用的是三件事的组合:对 MVCC 机制的理解、对事务边界的克制,以及对锁行为的预判。把这些变成代码评审时的检查项,比事后调参数有效得多。

831Views
33Likes
0Comments
0Participants
8 minread time

Comments (0)

No comments yet — be the first.