MySQL 面试复盘:把事务、索引和深分页中的模糊点讲清楚
这次复盘暴露的问题,主要不是没听过知识点,而是把不同层次的概念混在了一起。例如,把“能从索引拿到字段”等同于“能参与索引定位”,把“SQL 执行失败”等同于“整个事务自动回滚”。
本文按照“错误回答 → 问题分析 → 正确理解 → 面试表达”整理。错误回答是对本轮回答的概括,不逐字保留语音中的重复和停顿。数据库部分以 MySQL InnoDB 为前提;Spring 示例适用于 Java 8、Spring Boot 2.x 常见配置。
一、脏读、不可重复读、幻读:不能只说“两次结果不一样”
错误回答
不可重复读是同一个事务执行相同查询,结果不一样;幻读也是相同查询的结果集不一样。
问题在哪里
“两次结果不一样”太笼统,没有说清楚读到了什么变化,以及变化是否已经提交。
幻读不一定只由 INSERT 引起。删除、修改筛选字段也可能使记录退出或进入查询范围;结果集发生变化,行数也不一定必然变化。
面试表达:
脏读关注读到未提交数据;不可重复读关注同一条记录的值发生变化;幻读关注同一查询条件下符合条件的行集合发生变化。
二、隔离级别与 Read View:RU、RC、RR 不能混着记
错误回答
RU 只创建一次快照;RC 每次读取都创建快照;RR 使用第一次创建的快照。
正确理解
这里必须加上“快照读”三个字。不能说“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 的常见场景中:
“当前读读最新已提交数据”是常见概括,但不要理解成能读取其他事务未提交的修改:遇到冲突锁可能需要等待;同时也能看到本事务自己的修改。
例如,A 在 RR 中先做普通 SELECT,随后 B 修改并提交:
A 再做普通 SELECT:通常仍看到原快照版本。
A 改做 SELECT ... FOR UPDATE:可能看到 B 提交后的新值,并加锁。
因此,同一事务混用快照读和当前读,可能看到不同的数据状态。
面试表达:
快照读根据 Read View 读取可见版本;当前读读取当前可处理的数据并加锁,遇到冲突可能等待。RR 的快照读可重复,不代表所有读取方式都看到同一个版本。
四、RR 与幻读:不能简单背“有”或“没有”
容易误解的说法
RR 的当前读会受并发插入影响,所以还是会幻读。
这句话缺少前提,容易让人误以为 InnoDB RR 的锁定范围查询无法阻止插入。
更准确的理解是:
连续快照读通常复用同一 Read View,因此不会看到后来其他事务新提交的记录。
锁定范围查询通常通过间隙锁、临键锁保护相关索引区间,阻止影响该范围的并发插入,实际加锁范围受索引和执行计划影响。
混用快照读、当前读与写操作时,仍可能观察到行集合变化,不能把“RR”理解为所有语句永远看到完全相同的集合。
锁名先记到这一层即可:记录锁锁索引记录,间隙锁锁记录之间的区间,临键锁是记录锁与其前方间隙的组合。
面试表达:
InnoDB 的 RR 对快照读通过 MVCC 保持一致视图,对锁定范围读通常通过间隙锁、临键锁防止幻影插入;分析幻读时要明确读取方式和执行顺序。
五、范围定位、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 = 5a、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 页的原因是:没有目标页之前的排序边界,无法仅从页码算出游标。
延迟关联是任意跳页的一种优化方式,不是唯一选择;也可以预存页边界等,但会带来维护成本。
七、SQL 报错,不等于整个事务自动回滚
错误回答
第二条 UPDATE 失败,一般整个事务自动回滚;没有完全回滚,可能是设置了 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。
十、面试前重点复述这八句话
脏读看未提交,不可重复读看同一行的值,幻读看符合条件的行集合。
RC 每次快照读新建 Read View;RR 通常第一次快照读创建,后续复用。
Read View 是可见性规则,不是整表副本;当前读不能套用快照读规则。
范围定位缩小索引扫描区间,ICP 在回表前过滤,覆盖索引看字段是否齐全。
字段在联合索引里,不代表一定能继续参与范围定位。
延迟关联减少提前取完整行的成本;游标分页避免大 OFFSET 的跳过成本。
SQL 报错、当前语句回滚、整个事务回滚,是三个不同的概念。
Spring 根据事务边界与规则决定提交或回滚,数据库负责执行;吞异常可能破坏业务预期的整体回滚。
