一、核心概念

索引跳跃扫描(Index Skip Scan) 是MySQL 8.0.13版本引入的优化技术,允许在某些特定条件下跳过联合索引的最左列,直接使用后续列进行查询。

打破常规

-- 索引:INDEX(gender, age, city)
-- 传统:必须先用gender(最左前缀原则)
-- 跳跃扫描:可以跳过gender,直接用age
SELECT * FROM users WHERE age=25;

关键点

  • MySQL 8.0.13+ 特性
  • 仅在特定条件下生效
  • 本质是一种启发式优化

二、工作原理

1. 传统方式 vs 跳跃扫描

场景设定

-- 表结构
CREATE TABLE users (
    id INT,
    gender ENUM('M', 'F'),  -- 只有2个值
    age INT,
    city VARCHAR(50),
    INDEX idx_gac(gender, age, city)
);

-- 查询
SELECT * FROM users WHERE age=25 AND city='Beijing';

传统执行方式(MySQL 5.7及以下)

问题:跳过了最左列gender,索引无法使用
结果:全表扫描
type: ALL

跳跃扫描方式(MySQL 8.0.13+)

优化器改写查询:

原查询:
  WHERE age=25 AND city='Beijing'

改写为:
  WHERE gender='M' AND age=25 AND city='Beijing'
  UNION ALL
  WHERE gender='F' AND age=25 AND city='Beijing'

执行:
  - 第1次:扫描 (M, 25, Beijing) 的索引范围
  - 第2次:扫描 (F, 25, Beijing) 的索引范围
  - 合并结果

2. B+树遍历过程

索引结构示意

B+树叶子节点(简化):
(F, 20, Beijing) → (F, 20, Shanghai) → (F, 25, Beijing) → (F, 25, Shanghai)
(F, 30, Beijing) → (F, 30, Shanghai)
(M, 20, Beijing) → (M, 20, Shanghai) → (M, 25, Beijing) → (M, 25, Shanghai)
(M, 30, Beijing) → (M, 30, Shanghai)

跳跃扫描步骤

步骤1:确定最左列的所有可能值
  → gender: {F, M}

步骤2:为每个值构造子查询
  → 子查询1: gender=F AND age=25 AND city=Beijing
  → 子查询2: gender=M AND age=25 AND city=Beijing

步骤3:依次扫描
  → 扫描 (F, 25, Beijing) 范围
  → "跳跃" 到 (M, 25, Beijing) 范围

步骤4:合并结果

3. 成本估算

跳跃扫描成本 = 
  + 获取最左列不同值的成本
  + N次索引范围扫描成本(N=最左列不同值数量)
  + 回表成本

全表扫描成本 = 
  + 扫描所有数据页的成本
  + CPU过滤成本

当:跳跃扫描成本 < 全表扫描成本 时触发

三、触发条件

1. 必要条件

✅ 必须满足:
  - MySQL 8.0.13 或更高版本
  - 查询跳过了联合索引的最左列
  - 最左列的不同值数量很少(通常 ≤ 几十个)
  - 后续列的过滤条件明确
  - 优化器开关启用(默认启用)

❌ 不会触发:
  - 最左列不同值太多(如用户ID,几百万个)
  - 全表扫描成本更低(小表)
  - 后续列也是范围查询
  - optimizer_switch 中 skip_scan=off

2. 典型触发场景

场景1:枚举类型最左列

-- 索引:INDEX(status, create_time, user_id)
-- status: 'pending', 'active', 'cancelled' (3个值)

SELECT * FROM orders 
WHERE create_time >= '2025-11-01' AND user_id=12345;

-- 可能触发跳跃扫描
-- 优化器改写为3个子查询(每个status一个)

场景2:布尔类型最左列

-- 索引:INDEX(is_deleted, update_time)
-- is_deleted: 0/1 (2个值)

SELECT * FROM products WHERE update_time > '2025-11-01';

-- 改写为:
-- WHERE is_deleted=0 AND update_time > '2025-11-01'
-- UNION ALL
-- WHERE is_deleted=1 AND update_time > '2025-11-01'

场景3:小范围分类字段

-- 索引:INDEX(category_id, price, name)
-- category_id: 1-20 (20个类别)

SELECT * FROM products 
WHERE price BETWEEN 100 AND 200 AND name LIKE 'Phone%';

-- 可能触发跳跃扫描(20次范围扫描)

3. 不触发的场景

场景1:最左列值太多

-- 索引:INDEX(user_id, order_time)
-- user_id: 100万个不同用户

SELECT * FROM orders WHERE order_time > '2025-11-01';

-- 不会触发:需要扫描100万次,成本太高
-- 结果:全表扫描或使用其他索引

场景2:小表

-- 表只有1000行数据
SELECT * FROM small_table WHERE age=25;

-- 不会触发:全表扫描更快

四、EXPLAIN 输出

1. 识别跳跃扫描

EXPLAIN SELECT * FROM users WHERE age=25;

-- 输出:
type: range
key: idx_gac
Extra: Using where; Using index for skip scan
       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
       这是关键标识

2. 详细分析

EXPLAIN FORMAT=TREE 
SELECT * FROM users WHERE age=25 AND city='Beijing';

-- 输出(简化):
-> Filter: (age=25 AND city='Beijing')
    -> Index skip scan on idx_gac
        -> Skip scan values: gender in ('M', 'F')
        -> Index range scan: (gender=?, age=25, city='Beijing')

3. 性能对比

-- 测试准备
CREATE TABLE test (
    gender ENUM('M', 'F'),
    age INT,
    city VARCHAR(50),
    data VARCHAR(100),
    INDEX idx_gac(gender, age, city)
);

-- 插入100万行数据

-- 测试1:MySQL 5.7(或禁用skip scan)
SET optimizer_switch='skip_scan=off';
SELECT * FROM test WHERE age=25;
-- type: ALL, 执行时间: 0.5秒

-- 测试2:MySQL 8.0.13+(启用skip scan)
SET optimizer_switch='skip_scan=on';
SELECT * FROM test WHERE age=25;
-- type: range, Extra: Using index for skip scan
-- 执行时间: 0.05秒(快10倍)

五、性能考量

1. 优势与劣势

优势

✅ 避免全表扫描
✅ 充分利用现有联合索引
✅ 无需创建额外索引
✅ 对低区分度最左列效果显著

劣势

❌ 性能不如直接满足最左前缀的索引
❌ 最左列值越多,性能越差
❌ 多次索引扫描有额外开销
❌ 仅MySQL 8.0.13+支持

2. 性能对比

方案 索引使用 扫描次数 性能 适用场景
直接索引 INDEX(age) 1次 最优 最左列不重要
联合索引 INDEX(gender,age)WHERE gender=x AND age=y 1次 最优 需要gender过滤
跳跃扫描 INDEX(gender,age)WHERE age=y N次(N=gender值数量) 较好 gender值少
全表扫描 无索引 全表 最差 无可用索引

3. 成本阈值

假设:
- 最左列有 N 个不同值
- 表有 M 行数据
- 每次索引扫描成本为 C

跳跃扫描成本 ≈ N × C
全表扫描成本 ≈ M × (磁盘IO成本)

通常当 N < 20-50 时,跳跃扫描有优势

六、优化建议

1. 设计索引时考虑列顺序

-- 不好:高区分度列放前面
INDEX(user_id, gender, age)
-- user_id区分度高,但如果经常只查gender和age,索引利用率低

-- 好:低区分度常用列放前面(如果支持跳跃扫描)
INDEX(gender, age, user_id)
-- gender值少,跳跃扫描可跳过
-- 同时支持 gender+age 的高频查询

2. 何时依赖跳跃扫描

✅ 适合依赖:
  - MySQL 8.0.13+ 环境
  - 最左列是状态/类型等枚举字段(值少)
  - 后续列查询频繁
  - 不想维护太多索引

❌ 不适合依赖:
  - 需要兼容旧版本MySQL
  - 最左列值很多(几百上千个)
  - 对性能要求极高的核心查询
  - 不确定优化器行为

3. 监控和验证

-- 1. 查看是否启用
SHOW VARIABLES LIKE 'optimizer_switch';
-- 确认: skip_scan=on

-- 2. 验证实际使用
EXPLAIN SELECT * FROM table WHERE ...;
-- 检查: Extra 列是否有 "Using index for skip scan"

-- 3. 性能对比
-- 禁用跳跃扫描测试
SET optimizer_switch='skip_scan=off';
SELECT ...;  -- 记录执行时间

-- 启用跳跃扫描测试
SET optimizer_switch='skip_scan=on';
SELECT ...;  -- 记录执行时间

-- 4. 监控慢查询
-- 如果跳跃扫描导致性能问题,考虑创建专用索引

七、实战案例

案例1:电商订单系统

-- 场景:订单表有1000万行
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    status ENUM('pending','paid','shipped','completed','cancelled'),
    create_time DATETIME,
    user_id BIGINT,
    INDEX idx_status_time_user(status, create_time, user_id)
);

-- 需求:按时间范围查询某用户订单(不关心状态)
SELECT * FROM orders 
WHERE create_time >= '2025-11-01' AND user_id=12345;

-- MySQL 5.7:全表扫描(0.8秒)
-- MySQL 8.0.13+:跳跃扫描(0.06秒)
--   内部改写为5个子查询(每个status一个)

案例2:社交媒体帖子

-- 场景:帖子表
CREATE TABLE posts (
    id BIGINT,
    is_deleted TINYINT,  -- 0/1
    publish_time DATETIME,
    author_id BIGINT,
    INDEX idx_del_time_author(is_deleted, publish_time, author_id)
);

-- 需求:按时间倒序获取某作者帖子(包括已删除)
SELECT * FROM posts 
WHERE publish_time >= '2025-01-01' AND author_id=999
ORDER BY publish_time DESC
LIMIT 20;

-- 跳跃扫描:
--   子查询1: is_deleted=0 AND ...
--   子查询2: is_deleted=1 AND ...
--   合并并排序

八、常见问题

Q1:能强制使用跳跃扫描吗?

回答:不能直接强制,但可以:

-- 确保启用优化器开关
SET optimizer_switch='skip_scan=on';

-- 如果优化器仍不使用,可能是:
-- 1. 成本估算认为不划算
-- 2. 最左列值太多
-- 3. 统计信息不准确 → ANALYZE TABLE

Q2:跳跃扫描与索引下推的关系?

回答

  • 索引跳跃扫描:解决跳过最左列的问题
  • 索引下推:将WHERE条件下推到存储引擎层过滤

两者可以同时工作:

EXPLAIN SELECT * FROM users WHERE age=25 AND city LIKE 'Bei%';
-- Extra: Using index for skip scan; Using index condition
--        ^^^^^^^^^^^^^^^^^^^^      ^^^^^^^^^^^^^^^^^^^^^^^
--        跳跃扫描                   索引下推

Q3:如何禁用跳跃扫描?

-- 全局禁用(不推荐)
SET GLOBAL optimizer_switch='skip_scan=off';

-- 会话级别禁用
SET SESSION optimizer_switch='skip_scan=off';

-- 查询级别禁用(MySQL 8.0.20+)
SELECT /*+ NO_SKIP_SCAN(table) */ * FROM table WHERE ...;

九、面试总结

索引跳跃扫描(Index Skip Scan) 是MySQL 8.0.13+引入的优化技术,允许跳过联合索引最左列。

工作原理

  • 获取最左列的所有不同值
  • 为每个值构造子查询
  • 依次执行索引范围扫描
  • 合并结果

触发条件

  • MySQL 8.0.13+
  • 最左列不同值数量少(通常<20-50个)
  • 后续列有明确过滤条件
  • 优化器评估成本划算

适用场景

  • 最左列是状态、类型、布尔等低区分度字段
  • 经常跳过最左列查询后续列
  • 不想创建过多索引

性能特点

  • 比全表扫描快
  • 比直接满足最左前缀的索引慢
  • 扫描次数 = 最左列不同值数量

优化建议

  • 索引设计时可考虑将低区分度字段放前面
  • 使用EXPLAIN验证是否触发
  • 高频核心查询仍建议创建专用索引
  • 定期ANALYZE更新统计信息

这是MySQL 8.0的重要优化特性,体现了对最左前缀原则的补充和完善。