0
0
0

MySQL 面试复盘:把事务、索引和深分页中的模糊点讲清楚

2026-10-29
2026-10-29
文章摘要
|

这次复盘暴露的问题,主要不是没听过知识点,而是把不同层次的概念混在了一起。例如,把“能从索引拿到字段”等同于“能参与索引定位”,把“SQL 执行失败”等同于“整个事务自动回滚”。

本文按照“错误回答 → 问题分析 → 正确理解 → 面试表达”整理。错误回答是对本轮回答的概括,不逐字保留语音中的重复和停顿。数据库部分以 MySQL InnoDB 为前提;Spring 示例适用于 Java 8、Spring Boot 2.x 常见配置。

一、脏读、不可重复读、幻读:不能只说“两次结果不一样”

错误回答

不可重复读是同一个事务执行相同查询,结果不一样;幻读也是相同查询的结果集不一样。

问题在哪里

“两次结果不一样”太笼统,没有说清楚读到了什么变化,以及变化是否已经提交。

现象

关键条件

典型例子

脏读

读到其他事务尚未提交的数据

B 把余额改为 200,未提交,A 就读到了 200;随后 B 回滚

不可重复读

同一事务两次读取同一条记录,中间其他事务提交修改,导致值变化

A 第一次读余额为 100,B 修改并提交后,A 再读变成 200

幻读

同一事务按相同条件查询,中间其他事务提交变更,导致符合条件的行集合变化

A 查询余额大于 100 的账户,B 插入符合条件的账户并提交,A 再查多出记录

幻读不一定只由 INSERT 引起。删除、修改筛选字段也可能使记录退出或进入查询范围;结果集发生变化,行数也不一定必然变化。

面试表达:

脏读关注读到未提交数据;不可重复读关注同一条记录的值发生变化;幻读关注同一查询条件下符合条件的行集合发生变化。

二、隔离级别与 Read View:RU、RC、RR 不能混着记

错误回答

RU 只创建一次快照;RC 每次读取都创建快照;RR 使用第一次创建的快照。

正确理解

隔离级别

名称

需要记住的行为

RU

读未提交

允许读到其他事务未提交的修改,不能套用“只创建一次 Read View”的说法

RC

读已提交

每次普通快照读创建新的 Read View,因此能看到两次查询之间其他事务已提交的修改

RR

可重复读

通常在事务第一次快照读时建立 Read View,后续快照读复用

Serializable

串行化

隔离最严格;InnoDB 根据事务和 autocommit 等条件,通过锁定读等机制限制并发

这里必须加上“快照读”三个字。不能说“RC 每次读取都新建 Read View”,因为当前读不是按照快照读的 Read View 来决定可见性。

Read View 可以理解为版本可见性判断规则,不是把整张表复制一份。InnoDB 结合记录上的事务信息与 undo 版本链,找到当前事务可见的版本。

RR 建立快照的时间通常不是 BEGIN 的时间。 普通 BEGIN 后,第一次一致性读才建立快照;START TRANSACTION WITH CONSISTENT SNAPSHOT 是需要单独区分的情况。

此外,事务可以看到自己已经做出的修改。因此,“RR 永远只能读到最开始的数据”也不准确。

面试表达:

RC 每次快照读使用新的 Read View,RR 通常在第一次快照读时创建并复用;RU 允许脏读,不能理解为只创建一次快照。

三、快照读和当前读:加锁的意义不只是“读得更新”

模糊回答

普通 SELECT 是快照读,SELECT UPDATE 是当前读,当前读能读最新的数据。

正确理解

标准语法是:

SELECT * FROM account WHERE id = 1 FOR UPDATE;

在 RC、RR 的常见场景中:

类型

常见操作

核心行为

快照读

普通 SELECT

按 Read View 判断版本可见性,通常不加记录锁

当前读

SELECT ... FOR UPDATE、锁定共享读、UPDATE、DELETE

按当前数据进行读取或处理,并使用相应的锁

“当前读读最新已提交数据”是常见概括,但不要理解成能读取其他事务未提交的修改:遇到冲突锁可能需要等待;同时也能看到本事务自己的修改。

例如,A 在 RR 中先做普通 SELECT,随后 B 修改并提交:

  • A 再做普通 SELECT:通常仍看到原快照版本。

  • A 改做 SELECT ... FOR UPDATE:可能看到 B 提交后的新值,并加锁。

因此,同一事务混用快照读和当前读,可能看到不同的数据状态。

面试表达:

快照读根据 Read View 读取可见版本;当前读读取当前可处理的数据并加锁,遇到冲突可能等待。RR 的快照读可重复,不代表所有读取方式都看到同一个版本。

四、RR 与幻读:不能简单背“有”或“没有”

容易误解的说法

RR 的当前读会受并发插入影响,所以还是会幻读。

这句话缺少前提,容易让人误以为 InnoDB RR 的锁定范围查询无法阻止插入。

更准确的理解是:

  1. 连续快照读通常复用同一 Read View,因此不会看到后来其他事务新提交的记录。

  2. 锁定范围查询通常通过间隙锁、临键锁保护相关索引区间,阻止影响该范围的并发插入,实际加锁范围受索引和执行计划影响。

  3. 混用快照读、当前读与写操作时,仍可能观察到行集合变化,不能把“RR”理解为所有语句永远看到完全相同的集合。

锁名先记到这一层即可:记录锁锁索引记录,间隙锁锁记录之间的区间,临键锁是记录锁与其前方间隙的组合。

面试表达:

InnoDB 的 RR 对快照读通过 MVCC 保持一致视图,对锁定范围读通常通过间隙锁、临键锁防止幻影插入;分析幻读时要明确读取方式和执行顺序。

五、范围定位、ICP、覆盖索引:这是本轮最需要分开的三个概念

错误回答

前面的字段能精确定位,就可以使用覆盖索引;不能精确定位,就只能使用 ICP。

问题在哪里

这三个概念回答的是三个不同的问题:

概念

回答的问题

主要收益

范围定位

从索引哪里开始扫,到哪里结束?

缩小扫描区间

ICP:索引条件下推

回表前能否先用索引字段过滤?

减少无效回表

覆盖索引

查询需要的列能否从这个索引拿齐?

通常避免为了补取字段而回表

1. 为什么 c 在索引里,不一定能继续定位?

假设联合索引为 (a, b, c):

SELECT name
FROM product
WHERE a = 1 AND b LIKE 'abc%' AND c = 5;

索引按 a、b、c 的顺序排序。a 固定后,b 仍可能对应多个不同值;只有在相同 a、b 内部,c 才继续有序。跨不同 b 值,c 不是独立的全局排序序列。

所以,在这个常规范围扫描例子中,c=5 通常不能像连续等值前缀那样进一步定位扫描区间;它仍可能用于索引上的过滤。

若改为:

WHERE a = 1 AND b = 'abc' AND c = 5

a、b 都固定后,就可以继续使用 c 定位。

注意: 不要把经验规则扩大为“遇到任何范围条件,后面的列都绝对不能影响边界”。具体范围提取还受运算符、排序规则和优化器影响,需要结合 EXPLAIN 判断。

2. ICP 为什么减少回表,而不是直接缩小扫描范围?

仍然查询 name,且 name 不在索引中:

SELECT name
FROM product
WHERE a = 1 AND b LIKE 'abc%' AND c = 5;

如果采用 ICP,扫描相关索引记录时,可以先判断 c=5,只对通过条件的候选记录回表获取 name。

它主要减少的是取完整行的次数,不意味着相关索引区间内不符合 c=5 的记录都不需要经过扫描。

3. 覆盖索引与 c 能否定位无关

SELECT a, b, c
FROM product
WHERE a = 1 AND b LIKE 'abc%' AND c = 5;

查询所需字段都在 (a,b,c) 中,即使 c 不能继续缩小定位区间,也具备覆盖索引条件。

这里的理由是“所需字段齐全”,不是“ICP 过滤后找到了 ID”。覆盖索引并不依赖 ICP;InnoDB ICP 主要用于需要访问完整行的二级索引访问。

InnoDB 二级索引还包含主键值,因此返回主键也可能被覆盖。若还要返回不在索引中的 name,通常就要回表。严格来说,MVCC 可见性检查等情形仍可能触发聚簇索引访问,面试中的“不回表”主要指无需补取缺失字段。

面试表达:

范围定位决定扫描区间,ICP 在回表前过滤候选记录,覆盖索引取决于查询所需字段是否都在索引里。三者不能相互替代。

六、深分页:延迟关联和游标分页优化的成本不同

错误回答一

延迟关联需要扫描全部索引,普通 LIMIT 则会全表扫描、回表十万次。

正确理解

SELECT * FROM orders ORDER BY id LIMIT 100000, 20;

含义是:跳过前 100000 条,最多返回 20 条。实际成本取决于索引、过滤、排序与执行计划。

特别注意:InnoDB 主键索引就是聚簇索引,叶子节点保存完整行。按主键扫描不能直接描述成“每条记录都需要二级索引回表”。

延迟关联更典型的收益场景是:按二级索引排序,但需要返回索引以外的字段。

假设存在索引 (created_at, id),且 id 是主键:

SELECT o.*
FROM orders AS o
JOIN (
    SELECT id, created_at
    FROM orders
    ORDER BY created_at, id
    LIMIT 100000, 20
) AS p ON p.id = o.id
ORDER BY p.created_at, p.id;

子查询用较窄的覆盖索引扫描并跳过前面的记录,只把本页 20 个 ID 交给外层取完整行。

它不一定扫描全部索引,但仍需要经过偏移量对应的索引记录。简单无额外过滤场景中,可以理解为大致扫描 OFFSET + LIMIT 条;复杂查询可能更多。外层必须显式 ORDER BY,不能依赖子查询的顺序自动保留。

面试表达:

延迟关联先通过覆盖索引找出本页主键,再关联获取完整行,减少提前取完整行的成本,但无法消除大 OFFSET 的扫描成本。

错误回答二

游标分页先计算偏移量,再通过范围索引和 LIMIT 偏移量查询。

游标不是 OFFSET,而是上一页最后一条记录的排序键值。

按主键升序,上一页最后 ID 为 100000:

SELECT *
FROM orders
WHERE id > 100000
ORDER BY id
LIMIT 20;

这里没有大 OFFSET,LIMIT 20 只是限制返回数量。ID 不连续也不影响查询。

它利用索引从边界继续取数据,避免每次从开头扫描并丢弃大量记录。其核心收益不是笼统的“减少回表”。

若按 created_at 排序,必须加入唯一的 id 作为稳定排序的第二个字段,并把两者一起作为游标,否则相同时间的记录可能漏取。

错误回答三

游标固定,所以不能跳页;想跳页只能用延迟关联。

游标会随着翻页变化。不能直接跳到第 5000 页的原因是:没有目标页之前的排序边界,无法仅从页码算出游标。

延迟关联是任意跳页的一种优化方式,不是唯一选择;也可以预存页边界等,但会带来维护成本。

方式

主要收益

仍然存在的限制

OFFSET 分页

容易按页码跳转

深页需要跳过大量记录

OFFSET + 延迟关联

减少提前获取完整行的成本

仍要扫描前面的索引记录

游标分页

从排序边界继续扫描

通常适合连续翻页,需要已知游标

七、SQL 报错,不等于整个事务自动回滚

错误回答

第二条 UPDATE 失败,一般整个事务自动回滚;没有完全回滚,可能是设置了 SAVEPOINT。

正确理解

数据库可能回滚的是“当前语句”,也可能是“整个事务”,必须区分错误类型。

情况

InnoDB 常见处理

普通 INSERT/UPDATE 遇到唯一键冲突

通常回滚当前语句,事务此前成功的修改仍处于未提交状态

被选为死锁牺牲者

回滚整个事务

锁等待超时

默认通常回滚当前语句;受 innodb_rollback_on_timeout 等配置影响

主动执行 ROLLBACK

撤销整个事务尚未提交的修改

主动执行 ROLLBACK TO SAVEPOINT

撤销到指定保存点,不结束整个事务

以下假设两个账户都存在,第二条语句发生主键冲突:

START TRANSACTION;

UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET id = 1 WHERE id = 2; -- 主键冲突

ROLLBACK;

第二条报错时,第一条并没有因此自动提交,也不一定已经被回滚;最后执行整个事务的 ROLLBACK,第一条才随之撤销。

如果此时执行 COMMIT,第一条通常仍会提交。这里讨论的是显式事务,不应与 autocommit 下每条语句独立提交混淆。

面试表达:

SQL 失败不等于整个事务回滚。唯一键冲突通常只撤销当前语句,死锁可能撤销整个事务;业务需要整体失败时,应由事务管理逻辑明确触发回滚。

八、SAVEPOINT 是回滚位置,不是自动回滚开关

事务并不是“开始 → 提交 → 设置保存点 → 回滚保存点”的固定流程。COMMIT 和完整 ROLLBACK 是事务的结束方式,SAVEPOINT 则是事务内的可选操作。

START TRANSACTION;

UPDATE account SET balance = balance - 100 WHERE id = 1;
SAVEPOINT before_second;

UPDATE account SET balance = balance + 100 WHERE id = 2;
ROLLBACK TO SAVEPOINT before_second;

COMMIT;

最终第一条更新提交,第二条更新被撤销。只有创建保存点、没有执行回滚到保存点,并不会自动产生这项效果。

九、MySQL 事务和 Spring 事务:不是两套互不相干的回滚

模糊认识

MySQL 有事务,Spring 又有另一种事务。

对于常见的单数据源 JDBC 本地事务,Spring 最终管理的就是数据库连接上的事务:安排事务边界、让相关数据库操作使用受管理的连接,并根据方法结果及规则决定提交或回滚。真正的数据提交、锁与撤销由数据库完成。

可以记成:MySQL 负责执行事务,Spring 负责组织业务方法的事务边界和回滚规则。

为什么在 Spring 里常常表现为“出错就全部回滚”?

假设代理正常生效,使用同一个受管理数据源,方法由其他 Bean 经代理调用:

@Transactional
public void update() {
    jdbcTemplate.update(
        "UPDATE account SET balance = balance - 100 WHERE id = 1"
    );
    jdbcTemplate.update(
        "UPDATE users SET username = ? WHERE id = ?",
        "already_exists", 2
    );
}

假设 username 有唯一索引,且 already_exists 属于其他用户:第二条发生冲突,经 Spring JDBC 转换为运行时异常并向外抛出。数据库通常只撤销第二条语句,但 Spring 事务拦截器看到异常后,会按默认规则回滚整个事务,第一条也被撤销。

吞掉异常,Spring 可能正常提交

@Transactional
public void update() {
    jdbcTemplate.update(
        "UPDATE account SET balance = balance - 100 WHERE id = 1"
    );
    try {
        jdbcTemplate.update(
            "UPDATE users SET username = ? WHERE id = ?",
            "already_exists", 2
        );
    } catch (DataAccessException e) {
        log.error("更新失败", e);
    }
}

在这个直接使用 JdbcTemplate 的例子中,如果未被其他机制标记为 rollback-only,方法正常返回,第一条可能提交。

如果是内层代理事务方法先抛异常并把共享事务标记为 rollback-only,外层再捕获,情况又不同:外层提交时可能遇到 UnexpectedRollbackException。因此不能机械地背“只要 catch 就一定提交”。

默认回滚规则与推荐写法

Java 8 常见的 Spring Boot 2.x 默认规则:

  • RuntimeException、Error:触发回滚。

  • 普通受检异常:默认不触发回滚。

  • 配置 rollbackFor、noRollbackFor:按显式规则判断。

当业务要求受检异常也回滚,可以明确配置:

@Transactional(rollbackFor = Exception.class)
public void update() throws Exception {
    // 执行业务 SQL,失败时让异常继续向外抛出
}

不过,rollbackFor 不会让“已经吞掉且未标记回滚的异常”自动生效。通常应让 Service 层异常向外传播,在事务方法外统一转换响应;不要为了返回失败结果,在事务内部仅记录日志就返回。

还要确认事务确实生效:默认代理模式下,同类内部直接调用通常不会经过事务代理;跨线程操作也不会自动加入原线程的事务。

@Transactional、JdbcTemplate、DataAccessException 属于 Spring,不是 Java 8 自带 API。

十、面试前重点复述这八句话

  1. 脏读看未提交,不可重复读看同一行的值,幻读看符合条件的行集合。

  2. RC 每次快照读新建 Read View;RR 通常第一次快照读创建,后续复用。

  3. Read View 是可见性规则,不是整表副本;当前读不能套用快照读规则。

  4. 范围定位缩小索引扫描区间,ICP 在回表前过滤,覆盖索引看字段是否齐全。

  5. 字段在联合索引里,不代表一定能继续参与范围定位。

  6. 延迟关联减少提前取完整行的成本;游标分页避免大 OFFSET 的跳过成本。

  7. SQL 报错、当前语句回滚、整个事务回滚,是三个不同的概念。

  8. Spring 根据事务边界与规则决定提交或回滚,数据库负责执行;吞异常可能破坏业务预期的整体回滚。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论