先明确本讲的讨论范围

这一讲讨论 ACID 中的隔离性,以 InnoDB 为对象。事务能力由存储引擎提供,并不是所有引擎都支持事务,例如 MyISAM 就不支持。本讲关注的是普通 SELECT 这类一致性读能看到哪个版本的数据;UPDATE、加锁读等“当前读”遵循另一套规则,要到行锁和 MVCC 细节的后续讲次再展开。

三种并发现象,对应四个隔离级别

多个事务并发执行时,可能出现脏读、不可重复读和幻读。隔离级别就是在“看到多少别人的修改”与并发效率之间做取舍:隔离越严,通常等待与冲突越多。

  • 读未提交:别人尚未提交的修改,我也可能读到,因此会出现脏读。
  • 读提交:只能读到别人已经提交的修改;同一事务里两次读取同一行,结果可能不同。
  • 可重复读:事务在一致性读中看到的数据保持一致,别人中途提交的修改不会改变我已经看到的结果。
  • 串行化:通过加锁让冲突的读写排队执行,读与读之间可以并发,读写、写写冲突时后来者等待。

用同一条时间线比较四个级别

设表 T 只有一行,c = 1。事务 A 与 B 先各自读到 1;接着 B 把 c 改为 2 但暂不提交,此时 A 读一次(V1);B 提交后 A 再读一次(V2);A 自己提交后又读一次(V3)。

  • 读未提交:V1 已能看到 B 未提交的 2,所以 V1、V2、V3 都是 2。
  • 读提交:V1 看不到未提交修改,仍是 1;B 提交后 V2 变为 2;V3 也是 2。
  • 可重复读:V1、V2 都是 1,因为 A 在事务内保持同一个视图;A 提交后的 V3 属于新的读取,才看到 2。
  • 串行化:A 读过这一行,B 的修改会被锁挡住,直到 A 提交;因此 V1、V2 是 1,V3 是 2。

读未提交

  1. A 与 B 先各自读到 1A 读到 1读到 1
  2. B 把 c 改为 2,暂不提交A 读到 1改为 2,未提交
  3. 此时 A 读一次(V1)A 读到 2改为 2,未提交
  4. B 提交A 读到 2已提交
  5. B 提交后,A 再读一次(V2)A 读到 2已提交
  6. A 自己提交后又读一次(V3)A 读到 2已提交

读提交

  1. A 与 B 先各自读到 1A 读到 1读到 1
  2. B 把 c 改为 2,暂不提交A 读到 1改为 2,未提交
  3. 此时 A 读一次(V1)A 读到 1改为 2,未提交
  4. B 提交A 读到 1已提交
  5. B 提交后,A 再读一次(V2)A 读到 2已提交
  6. A 自己提交后又读一次(V3)A 读到 2已提交

可重复读

  1. A 与 B 先各自读到 1A 读到 1读到 1
  2. B 把 c 改为 2,暂不提交A 读到 1改为 2,未提交
  3. 此时 A 读一次(V1)A 读到 1改为 2,未提交
  4. B 提交A 读到 1已提交
  5. B 提交后,A 再读一次(V2)A 读到 1已提交
  6. A 自己提交后又读一次(V3)A 读到 2已提交

串行化

  1. A 与 B 先各自读到 1A 读到 1读到 1
  2. B 把 c 改为 2,暂不提交A 读到 1被锁挡住直到 A 提交
  3. 此时 A 读一次(V1)A 读到 1被锁挡住直到 A 提交
  4. B 提交A 读到 1原文未写明是否在此提交原文没有写明 B 的提交发生在哪一步。只说 B 的修改会被锁挡住,直到 A 提交,因此 V1、V2 是 1,V3 是 2。
  5. B 提交后,A 再读一次(V2)A 读到 1被锁挡住直到 A 提交
  6. A 自己提交后又读一次(V3)A 读到 2原文未写明 B 何时提交原文没有写明 B 的提交发生在哪一步。只说 B 的修改会被锁挡住,直到 A 提交,因此 V1、V2 是 1,V3 是 2。

视图是什么时候建立的

这里的“视图”指 InnoDB 的一致性读视图(read view),不是 CREATE VIEW 创建的视图。它决定一致性读能看见哪些版本。

  • 读提交:每条一致性读语句开始时建立新的视图,所以语句之间可以看到新提交的数据。
  • 可重复读:整个事务复用同一个视图。更准确地说,BEGIN 本身并不立即建立视图,而是在第一次一致性读时建立;需要立刻建立时可用 START TRANSACTION WITH CONSISTENT SNAPSHOT。
  • 读未提交:直接读取记录最新值,不依赖视图。
  • 串行化:主要靠加锁避免冲突。InnoDB 在该级别下,事务中的普通 SELECT 会被当作加共享锁的读取。

查看与设置隔离级别,先核对版本

  • 启动参数写作 transaction-isolation,例如设置为 READ-COMMITTED;也可以在全局或会话层面设置。
  • 查询变量时,MySQL 5.7.20 起使用 transaction_isolation,更早版本使用 tx_isolation;8.0.3 已移除后者。遇到“变量不存在”,先确认版本。
  • InnoDB 默认是可重复读。从默认读提交的数据库(例如 Oracle)迁移时,应当评估业务是否依赖读提交语义,而不是机械地照搬配置。
  • InnoDB 在读提交级别下不能使用 STATEMENT 格式的 binlog,需要行格式(MIXED 会自动改用行事件);调整隔离级别时,也要一起检查复制与 binlog 配置。

可重复读适合什么场景

典型场景是对账:用上月余额、当前余额和本月明细核对是否一致。如果核对过程中有新交易提交,读提交下前后几次查询可能落在不同时点,结果对不上。可重复读让整个核对事务读取同一个时点的数据,逻辑更容易成立。代价是这个事务不能拖得太久,原因见后文。

MVCC:同一行,可以同时存在多个版本

InnoDB 修改记录时,会同时生成用于回滚的 undo 信息。沿着这些信息,可以从最新值一步步推回更早的版本。假设一行依次从 1 改为 2、3、4:三个不同时刻建立的视图,分别可能看到 1、2、4。越早的视图,需要回溯的版本越多。这就是多版本并发控制(MVCC)的基本思路。

  • 一致性读不需要给读取的行加锁;另一个事务此时把 4 改成 5,不会与这些只读视图互相阻塞。
  • “能看到哪个版本”由视图决定,“旧版本从哪里来”由 undo 提供,两者配合才构成可重复读。
  • 这里是概念模型;版本可见性的具体判断规则,要到后续讲次再拆开。

从最新值 4 沿着 undo 回到 3、2、1。

  1. 一个视图看到 1,回溯经过 4、3、2、1。
  2. 一个视图看到 2,回溯经过 4、3、2。
  3. 一个视图看到 4。

长事务为什么危险

旧版本何时可以清理?当系统中已经没有任何视图还可能需要它们时,后台 purge 才能清理。长事务持有很老的视图,它提交之前,相关的旧版本都得保留。

  • undo 空间持续增长,版本链变长,读取旧版本的成本也随之升高。
  • MySQL 5.5 及更早版本的 undo 存放在系统表空间 ibdata 中,即使事务结束、空间可复用,文件也不会自动变小。较新版本支持独立 undo 表空间与截断,具体能力需要按部署版本核对。
  • 长事务还可能长时间持有锁,阻塞其他事务。锁的问题在后续讲次展开。

事务怎么开始,决定了它会持续多久

很多长事务不是有意为之,而是启动方式被误用。

  • 显式事务:BEGIN 或 START TRANSACTION 开始,COMMIT 提交,ROLLBACK 回滚。
  • SET autocommit = 0:关闭当前会话的自动提交,连一条 SELECT 也会开启事务,并一直持续到 COMMIT、ROLLBACK 或断开连接。
  • 有些客户端框架连接后默认关闭自动提交;再配合连接池的长连接,就容易形成意外的长事务。
  • 更稳妥的做法是保持 autocommit = 1,用显式语句开启事务。在意多一次交互时,可用 COMMIT WORK AND CHAIN:提交当前事务的同时立即开启下一个,事务边界仍然清楚。

如何找到正在运行的长事务

information_schema.innodb_trx 记录 InnoDB 当前活跃事务。可以按 trx_started 与当前时间的差值筛选,例如找出持续超过 60 秒的事务:SELECT * FROM information_schema.innodb_trx WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60。阈值要结合业务确定;这里整理的是排查思路,没有在生产库执行。

复习:怎样防止长事务

  • 应用侧:确认框架没有默认关闭 autocommit;事务只包住必须原子执行的数据库操作,不把远程调用、文件处理等慢操作放进事务。
  • 应用侧:只读查询不需要显式事务时,不要用 BEGIN 包起来;可以为只读 SELECT 设置执行时长上限(例如会话级 max_execution_time),但要避免误伤备份等本就耗时的任务。
  • 数据库侧:持续监控 innodb_trx 中的事务时长和 undo 积压,超过阈值时告警,由人判断是否终止。
  • 数据库侧:结合版本规划 undo 表空间与截断策略,并在测试环境用慢 SQL、未提交会话等场景提前演练。

本讲留下的检查顺序

  • 先确认当前隔离级别和 MySQL 版本,再解释“为什么看不见”。
  • 区分一致性读与当前读:看不见新数据,不代表更新时也会基于旧值。
  • 遇到 undo 增长、查询变慢或锁等待,先查有没有长期未提交的事务。