ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MySQL 二级索引有 MVCC 快照吗?

MySQL 二级索引有 MVCC 快照吗? 面试考点分析考察对 InnoDB MVCC 底层实现的掌握程度是否清楚可见性判断依赖哪些隐藏列。考察聚簇索引与二级索引的存储结构差异尤其是二级索引叶子节点到底存了什么。考察二级索引查询时“回表”与 MVCC 可见性判断之间的执行链路关系。考察覆盖索引能否绕过 MVCC 判断以及读已提交和可重复读下 Read View 的生成时机。考察对 undo log 版本链、purge 机制及其对长事务写放大影响的理解深度。一、标准回答先直接给结论MySQL 的二级索引本身没有 MVCC 快照也不存储 MVCC 可见性判断所需的版本链信息。InnoDB 实现 MVCC 所依赖的隐藏列DB_TRX_ID事务 ID和DB_ROLL_PTR回滚指针只存在于聚簇索引的叶子节点中二级索引叶子节点只保存索引键 主键值因此二级索引查询必须先通过索引定位主键再回表到聚簇索引进行快照读和可见性判断。它的作用主要体现在两个方面一是二级索引负责加速查询通过索引过滤大量无关记录减少扫描范围二是 MVCC 负责在高并发场景下实现不加锁的一致性快照读让读操作不阻塞写操作写操作也不阻塞读操作。它的核心特点可以概括为聚簇索引承载版本信息二级索引提供路径导航可见性判断永远发生在聚簇索引上二级索引只是“查询入口”。二、核心原理2.1 InnoDB MVCC 的底层支柱MySQL 官方文档对 InnoDB 多版本并发控制的描述中核心对象是undo log和隐藏列。简单来说InnoDB 并不是在每次更新时复制整行数据而是通过行记录上的两个隐藏字段 undo 日志版本链来维护多版本。聚簇索引的每条记录除了业务列以外还额外包含DB_TRX_ID6 字节记录最近一次修改该行的事务 ID用于和 Read View 比较判断可见性。DB_ROLL_PTR7 字节指向该行旧版本的 undo log 记录旧版本再接旧版本形成版本链。DB_ROW_ID6 字节如果表没有显式主键且没有非空唯一索引InnoDB 才会自动生成这个隐藏主键。当一条记录被更新时InnoDB 会把旧值写入 undo log同时新行记录的DB_ROLL_PTR指向这条 undo 记录。这样一来旧数据并没有被直接覆盖而是形成了一条从新版本到旧版本的链。快照读时如果当前记录的事务 ID 对快照不可见就沿着DB_ROLL_PTR向前回溯直到找到一个对当前快照可见的版本。因此理解 MVCC 的关键在于版本链和可见性判断所需的事务信息都绑定在聚簇索引的行记录上而不是绑定在二级索引上。2.2 聚簇索引与二级索引的存储差异InnoDB 表有且只有一个聚簇索引。如果表定义中声明了主键聚簇索引就按主键组织否则 InnoDB 会选择第一个非空唯一索引再否则生成隐藏的DB_ROW_ID。聚簇索引叶子节点直接保存完整行数据 隐藏列因此它本身就是表数据。二级索引的结构则完全不同。二级索引叶子节点只保存当前索引的键值比如idx_user_id保存user_id。对应的主键值用于回表定位聚簇索引记录。二级索引记录不包含DB_TRX_ID和DB_ROLL_PTR。也就是说当扫描二级索引时InnoDB 只能知道“满足索引条件的记录对应哪个主键”但无法直接判断这条记录对当前事务的快照是否可见。下面用表格对比两者在 MVCC 相关维度上的差异对比维度聚簇索引二级索引叶子节点内容完整行数据索引键 主键值是否存储 DB_TRX_ID是否是否存储 DB_ROLL_PTR是否是否维护 undo 版本链是否MVCC 可见性判断直接判断必须回表判断查询数据是否回表不需要通常需要值得注意的是二级索引在更新时也不是完全和 MVCC 无关。比如UPDATE修改了二级索引键InnoDB 会先在旧索引上做标记删除再插入新的索引记录。不过这种“删除 插入”只服务于索引内容的变化二级索引仍然不承载快照可见性判断。2.3 二级索引查询的可见性判断流程理解执行流程以后这个问题会变得非常清晰。假设表t_user有主键id二级索引idx_name(name)执行如下快照读SELECT id, name, age FROM t_user WHERE name tom;InnoDB 的处理过程如下从idx_name二级索引中查找name tom的索引记录取得对应主键值例如id 10。用主键id 10回表在聚簇索引中找到完整行记录及其DB_TRX_ID和DB_ROLL_PTR。把该行记录的事务 ID 与当前事务的 Read View 比较。如果可见直接返回聚簇索引上的行数据。如果不可见沿着聚簇索引上的DB_ROLL_PTR版本链回溯找到可见版本后返回当年版本对应的数据。这个流程说明二级索引承担的是“找到主键”的工作真正的 MVCC 判断在回表后的聚簇索引上完成。如果没有回表InnoDB 就无从读取DB_TRX_ID和DB_ROLL_PTR自然也无法判断快照可见性。再看 Read View 的生成时机它会直接影响业务看到的数据READ COMMITTED读已提交每次快照读都会生成一个新的 Read View因此事务内可能读到其他事务已经提交的新版本。REPEATABLE READ可重复读只在事务内第一次快照读时生成 Read View后续快照读复用同一个 Read View因此能保证事务内读取结果一致。Read View 判断记录可见性的核心规则可以总结为事务 ID 小于 Read View 低水位或者事务 ID 虽在范围内但不在活跃事务列表中则该记录可见否则需要沿版本链继续回溯。三、应用场景3.1 日常开发场景高频查询字段优先建二级索引但要理解回表成本。比如订单表经常按user_id查询给user_id建二级索引可以快速过滤数据。但如果SELECT中还要返回amount、status等其他列就需要回表到聚簇索引回表过程中才进行 MVCC 可见性判断。查询列越多回表次数越多随机 IO 成本越高。覆盖索引能减少数据列回表但不能绕过可见性判断。有的开发者会误以为“覆盖索引不用回表所以也不会做 MVCC 判断”。实际上即使查询的所有列都在二级索引中要拿到满足当前快照的准确结果InnoDB 仍然需要确认记录是否对当前事务可见。官方实现中可见性检查依然发生在聚簇索引上所以覆盖索引优化的是取列开销而不是消除 MVCC 判断。分页查询要注意二级索引 回表的放大问题。深分页如LIMIT 100000, 20时二级索引可能先扫描大量索引页再逐条回表判断可见性和取数。此时如果索引设计不当会放大 IO 和 CPU 消耗。3.2 企业真实场景高并发读多写少的交易系统。例如电商订单中心读接口大量使用快照读写接口通过UPDATE修改订单状态。由于二级索引本身没有快照读请求仍要回表到聚簇索引判断可见性。在这种场景下除合理建立二级索引外通常还会配合连接池、缓存和读写分离减少数据库层回表压力。长事务导致的 undo log 膨胀和 purging 延迟。实际开发中如果某个事务长时间不提交Read View 会一直持有旧版本信息。其他事务在聚簇索引上判断可见性时需要沿版本链回溯更多历史版本导致 undo log 无法及时被 purge 线程清理进而拖慢查询和占用磁盘。这种问题本质上是 MVCC 版本链管理问题和二级索引本身没有快照这件事相互放大。批量更新注意二级索引维护成本。企业场景中经常有批量任务更新大表。每次更新不仅修改聚簇索引还可能维护多个二级索引。二级索引虽然不存 MVCC 信息但索引键变化时会产生“标记删除 新插入”的写放大因此在做大批量更新前需要评估二级索引数量和对写入吞吐的影响。四、使用方式4.1 准备工作下面通过一个 Java 示例演示“二级索引查询 MVCC 快照读”的完整链路。执行前请准备MySQL 8.x使用 InnoDB 存储引擎。JDBC 驱动mysql-connector-java或mysql-connector-j。一个测试库例如mvcc_demo。测试表结构如下id是聚簇索引idx_user_id是二级索引CREATE TABLE user_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, user_id BIGINT NOT NULL COMMENT 用户ID, order_no VARCHAR(64) NOT NULL COMMENT 订单号, amount DECIMAL(10,2) NOT NULL COMMENT 金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0未支付1已支付, KEY idx_user_id (user_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户订单表;4.2 核心代码示例下面代码演示三个关键点通过二级索引执行快照读、另一个事务提交更新、原事务再次通过二级索引读取时仍读到快照中的旧版本。import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; public class SecondaryIndexMVCCDemo { private static final String URL jdbc:mysql://localhost:3306/mvcc_demo?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue; private static final String USER root; private static final String PASSWORD 123456; public static void main(String[] args) { try { initTable(); runMVCCDemo(); } catch (SQLException e) { e.printStackTrace(); } } private static void initTable() throws SQLException { try (Connection conn getConnection(); Statement stmt conn.createStatement()) { stmt.execute(CREATE TABLE IF NOT EXISTS user_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, order_no VARCHAR(64) NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, KEY idx_user_id (user_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4); stmt.execute(TRUNCATE TABLE user_order); stmt.execute(INSERT INTO user_order (user_id, order_no, amount, status) VALUES (1001, ORD20240001, 199.00, 0)); stmt.execute(INSERT INTO user_order (user_id, order_no, amount, status) VALUES (1001, ORD20240002, 299.00, 0)); } } private static void runMVCCDemo() throws SQLException { Connection txA getConnection(); Connection txB getConnection(); try { // 事务A可重复读先开启事务并进行第一次快照读固定Read View txA.setAutoCommit(false); txA.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ); String sql SELECT id, user_id, order_no, amount, status FROM user_order WHERE user_id ?; readOrders(txA, sql, 事务A第一次快照读); // 事务B读已提交更新同一批二级索引命中的记录 txB.setAutoCommit(false); txB.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); try (PreparedStatement ps txB.prepareStatement( UPDATE user_order SET status 1 WHERE user_id ?)) { ps.setLong(1, 1001L); int updated ps.executeUpdate(); System.out.println(事务B更新行数: updated); } txB.commit(); // 事务A再次通过二级索引快照读仍然读到事务开启时的旧版本 readOrders(txA, sql, 事务A第二次快照读); // 查看执行计划二级索引idx_user_id 回表 printExplainPlan(txA, sql); txA.commit(); } finally { txA.close(); txB.close(); } } private static void readOrders(Connection conn, String sql, String tag) throws SQLException { try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, 1001L); try (ResultSet rs ps.executeQuery()) { System.out.println( tag ); while (rs.next()) { System.out.printf( id%d, order_no%s, amount%s, status%d%n, rs.getLong(id), rs.getString(order_no), rs.getString(amount), rs.getInt(status)); } } } } private static void printExplainPlan(Connection conn, String sql) throws SQLException { try (PreparedStatement ps conn.prepareStatement(EXPLAIN sql)) { ps.setLong(1, 1001L); try (ResultSet rs ps.executeQuery()) { System.out.println( 执行计划 ); while (rs.next()) { System.out.printf( table%s, type%s, key%s, Extra%s%n, rs.getString(table), rs.getString(type), rs.getString(key), rs.getString(Extra)); } } } } private static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }4.3 执行流程分析运行上述代码后核心输出可以对应到前面讲的执行链路事务 A 第一次快照读通过二级索引idx_user_id找到所有user_id 1001的索引记录读取其中的主键值然后回表到聚簇索引。在读已提交或可重复读的不同语义下事务 A 在第一次快照读时生成 Read View。事务 B 更新并提交事务 B 修改status在聚簇索引中产生新版本并把旧版本写入 undo log。由于二级索引idx_status的键值发生了变化InnoDB 会维护对应二级索引记录。事务 A 第二次快照读仍然使用idx_user_id定位主键并回表。此时聚簇索引上的最新版本由事务 B 产生但事务 A 的 Read View 认为该版本不可见于是沿着DB_ROLL_PTR回溯到事务 A 可见的旧版本最终返回旧数据。EXPLAIN 信息会看到key为idx_user_id说明使用了二级索引Extra中通常会出现Using index condition等回表相关提示证明二级索引只是导航入口最终还要回表完成 MVCC 判断和取数。4.4 注意事项在使用中需要特别注意以下问题不要误把覆盖索引当成“无 MVCC 判断”。覆盖索引可以减少回表取业务列的 IO但记录是否对当前快照可见仍然需要在聚簇索引中判断。查询优化时要把“减少回表”和“仍需可见性判断”区分开。可重复读下 Read View 只生成一次。如果业务依赖实时数据要避免使用可重复读长事务反复做快照读否则通过二级索引读到的始终是事务开始时的旧版本容易造成业务误判。FOR UPDATE 是当前读不是快照读。如果代码中需要锁定通过二级索引定位的记录必须回表到聚簇索引并加锁锁仍然是加在聚簇索引记录上这也和“MVCC 版本信息在聚簇索引”是一致的。长事务会拖慢可见性判断。事务 A 长时间不提交会阻止 purge 清理旧版本其他事务回表后可能要走更长的 undo 版本链导致二级索引查询整体变慢。五、扩展延伸5.1 技术对比二级索引与聚簇索引在 MVCC 中的角色从存储模型看聚簇索引是“数据 版本信息”的统一载体而二级索引只是“键值到主键的映射表”。聚簇索引叶子节点的隐藏列让 InnoDB 可以在不回表的情况下完成可见性判断二级索引因为没有这两个隐藏列天然无法独立完成 MVCC 判断。这也是为什么 InnoDB 无论如何最终都要回到聚簇索引进行快照读。从性能上看二级索引回表会带来额外的 B 树查找但它的好处是可以在索引层过滤大量记录。两者是导航效率和版本判断职责分离的关系而不是谁替代谁的关系。5.2 优缺点分析这种“二级索引不存 MVCC 快照”的设计有明显优点节省磁盘空间如果每个二级索引都冗余存储DB_TRX_ID和DB_ROLL_PTR并且各自维护版本链空间消耗和写放大都会急剧上升。简化一致性维护所有行级版本信息统一以聚簇索引为锚点避免多处维护版本导致的一致性问题。更新链路清晰可见性判断只有一个权威入口查询优化器可以在二级索引导航后统一回到聚簇索引处理。缺点也同样存在二级索引查询必须回表即使只查索引列可见性判断仍依赖聚簇索引会增加一次主键查找。undo 版本链集中在聚簇索引上长事务和高并发更新时版本链增长和回表压力会叠加。二级索引更新存在写放大索引键变化时需要进行标记删除和新插入批量写入场景下要谨慎评估索引数量。5.3 实际开发注意事项能用主键查询就优先用主键主键查询天然在聚簇索引上完成避免二级索引回表的额外开销。二级索引尽量精简不要为了“万一用得上”而建立大量冗余索引否则会同时增加写放大和 MVCC 回表路径上的维护成本。事务粒度尽量短及时提交避免长事务阻塞 undo purge影响所有依赖聚簇索引版本链判断的查询。高并发读场景不要过度依赖二级索引优化必要时配合缓存和读写分离降低数据库层频繁回表的压力。写多读少场景优先关注索引写放大必要时通过批量任务降低实时写入频率再评估二级索引的收益。六、面试追问追问 1覆盖索引能避免回表是不是也能避免 MVCC 可见性判断回答思路先明确两个概念覆盖索引解决的是“取业务列不需要回表”MVCC 解决的是“这条记录对当前快照是否可见”。然后说明 InnoDB 的可见性判断依赖聚簇索引上的DB_TRX_ID和DB_ROLL_PTR二级索引中没有这些信息所以仍需回表判断。标准答案不能。覆盖索引只优化了“避免二次回表取列”这一步但无法替代 MVCC 可见性判断。因为二级索引没有存储事务 ID 和回滚指针只有回到聚簇索引才能判断一个版本是否对当前 Read View 可见。追问 2为什么 InnoDB 不在二级索引中也存一份事务 ID 和回滚指针回答思路从空间、写放大和一致性三个角度展开。不要只说“设计就是这样”要说明冗余存储的代价。标准答案如果每个二级索引都冗余存储事务 ID 和回滚指针会显著增加磁盘占用。同时任何一次行更新都可能需要维护多个二级索引上的版本链导致写放大非常严重。更关键的是多套版本链难以保证一致性最终还是要以某个权威副本为准所以在聚簇索引上统一管理版本链是更合理的取舍。追问 3可重复读下事务通过二级索引读到旧版本后事务提交前会一直阻塞 purge 吗回答思路先说明 Read View 生命周期再说明 purge 的触发条件最后落到长事务影响。标准答案会。可重复读隔离级别下事务的 Read View 在第一次快照读时生成事务结束前一直有效。只要旧版本仍可能被这个 Read View 访问purge 线程就不能清理相关 undo log。所以长事务会导致 undo log 膨胀影响后续查询的版本链回溯效率甚至拖慢整个表的写入和查询性能。追问 4二级索引上的DELETE或UPDATE是如何处理旧版本和可见性的回答思路分开讲“二级索引记录变更”和“聚簇索引版本链”再说明快照读如何通过主键回到聚簇索引找可见版本。标准答案二级索引没有可见性版本链。删除时旧索引记录会被标记删除插入时写入新索引记录。快照读通过二级索引找到主键后回表到聚簇索引根据 Read View 判断聚簇索引行记录是否可见。如果不可见就沿着聚簇索引上的DB_ROLL_PTR找到历史可见版本从而保证事务读到的数据始终符合快照一致性。
返回列表