MySQLNotes

第 10 章:索引设计实战

zjc 于 2026-01-10 发布

这是《MySQL 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 索引设计不是把所有 WHERE 字段都建一遍,而是从查询路径、数据分布、写入成本和业务演进出发,选择一组能覆盖核心访问模式的索引。

10.1 索引设计原则

核心原则:

  1. 先明确高频 SQL,再设计索引;
  2. 联合索引优先于多个单列索引;
  3. 等值条件放前面,范围条件放后面;
  4. 尽量让排序和分组复用索引顺序;
  5. 高频轻查询考虑覆盖索引;
  6. 唯一性交给唯一索引,不靠代码约定;
  7. 控制索引总数;
  8. 为索引建立负责人和验证记录;
  9. 上线前后对比执行计划;
  10. 定期清理无效索引。

10.2 选择性评估

查看表规模:

SELECT TABLE_ROWS, DATA_LENGTH, INDEX_LENGTH
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'shop'
  AND TABLE_NAME = 'orders';

查看单列选择性:

SELECT
  COUNT(*) AS total_rows,
  COUNT(DISTINCT user_id) AS distinct_user,
  COUNT(DISTINCT status) AS distinct_status,
  COUNT(DISTINCT merchant_id) AS distinct_merchant,
  COUNT(DISTINCT user_id) / COUNT(*) AS user_selectivity,
  COUNT(DISTINCT status) / COUNT(*) AS status_selectivity
FROM orders;

查看组合选择性:

SELECT
  COUNT(*) AS total_rows,
  COUNT(DISTINCT user_id, status) AS distinct_user_status
FROM orders;

选择性的常见判断:

选择性 说明
接近 1 大部分值唯一,适合索引
中等 可能适合与其他列组合
很低 单独索引未必有效
严重倾斜 平均值误导,需要按热门值分析

状态列基数低,但 (status, created_at) 对处理任务可能有效,因为任务只需扫描少量待处理状态。

10.3 联合索引顺序

假设订单表有三个高频查询:

-- Q1
SELECT *
FROM orders
WHERE user_id = 1001
ORDER BY created_at DESC
LIMIT 20;

-- Q2
SELECT *
FROM orders
WHERE user_id = 1001
  AND status IN ('CREATED', 'PAID')
ORDER BY created_at DESC;

-- Q3
SELECT *
FROM orders
WHERE status = 'CREATED'
  AND created_at >= '2026-08-01'
ORDER BY created_at;

推荐索引:

KEY idx_user_created (user_id, created_at),
KEY idx_status_created (status, created_at)

不建议一开始就设计:

KEY idx_user_status_created (user_id, status, created_at)

原因是 Q1 不能使用该索引完成 ORDER BY created_at,因为中间隔了 status

联合索引设计口诀:

固定过滤 -> 可选过滤 -> 范围过滤 -> 排序字段

真实业务要结合查询频率和结果集大小调整,不能机械套用。

10.4 覆盖索引设计

订单列表接口只展示:

order_no、user_id、status、total_amount、created_at

创建索引:

ALTER TABLE orders
  ADD INDEX idx_user_created_cover (
    user_id,
    created_at,
    order_no,
    status,
    total_amount
  );

查询:

SELECT
  id,
  order_no,
  status,
  total_amount,
  created_at
FROM orders
WHERE user_id = 1001
ORDER BY created_at DESC
LIMIT 20;

验证:

EXPLAIN
SELECT id, order_no, status, total_amount, created_at
FROM orders
WHERE user_id = 1001
ORDER BY created_at DESC;

如果 Extra 出现 Using index,说明无需回表。

覆盖索引代价:

  1. 索引变宽;
  2. 写入成本增加;
  3. 磁盘和内存占用增加;
  4. 索引列更新更频繁时收益下降。

适合核心高频接口,不适合盲目给所有查询加宽。

10.5 前缀索引

长字符串可以只索引前缀:

ALTER TABLE users
  ADD INDEX idx_nickname_prefix (nickname(20));

评估前缀选择性:

SELECT
  COUNT(DISTINCT LEFT(nickname, 10)) / COUNT(DISTINCT nickname) AS p10,
  COUNT(DISTINCT LEFT(nickname, 20)) / COUNT(DISTINCT nickname) AS p20,
  COUNT(DISTINCT LEFT(nickname, 30)) / COUNT(DISTINCT nickname) AS p30
FROM users;

限制:

  1. 不能用于 ORDER BY 的完整排序优化;
  2. 不能作为覆盖索引;
  3. 唯一前缀可能误判,唯一约束应使用完整列或业务散列;
  4. 前缀过短会导致大量回表。

长文本搜索应考虑全文索引、外部搜索系统或单独散列列。

10.6 唯一索引

唯一索引负责数据库层一致性:

ALTER TABLE users
  ADD UNIQUE KEY uk_mobile (mobile);

插入冲突:

INSERT INTO users (username, nickname, mobile)
VALUES ('dave', 'Dave', '13800000001');

报错 Duplicate entry 是数据库在保护业务约束。

MySQL 允许唯一索引中多个 NULL 值。业务上如果不允许多个空手机号,应把列定义为 NOT NULL,或使用默认值加唯一约束。

逻辑删除示例:

ALTER TABLE users
  DROP INDEX uk_username,
  ADD UNIQUE KEY uk_username_deleted (username, deleted);

该方案只能区分“未删”和“一次删除”,多次删除同值仍会冲突。更稳妥的是使用删除版本号:

ALTER TABLE users
  ADD COLUMN delete_version BIGINT UNSIGNED NOT NULL DEFAULT 0,
  DROP INDEX uk_username_deleted,
  ADD UNIQUE KEY uk_username_delver (username, delete_version);

未删除行 delete_version=0,删除时更新为自增值。

10.7 函数索引与表达式索引

MySQL 8.0.13 支持函数索引:

ALTER TABLE users
  ADD INDEX idx_upper_username ((UPPER(username)));

查询必须使用相同表达式:

SELECT *
FROM users
WHERE UPPER(username) = 'ALICE';

也可以为 JSON 字段建立表达式索引:

ALTER TABLE orders
  ADD INDEX idx_extra_channel ((CAST(extra->>'$.channel' AS CHAR(32))));

原则:

  1. 优先改写 SQL 为普通索引可用的形式;
  2. 函数索引必须与应用表达式完全一致;
  3. 隐式转换和字符集差异会导致失效;
  4. 复杂表达式增加维护成本;
  5. 用注释记录用途。

10.8 不可见索引

MySQL 8.0 支持不可见索引:

ALTER TABLE orders
  ALTER INDEX idx_status_created INVISIBLE;

优化器会忽略不可见索引,但写入仍维护它。适合删除索引前的灰度验证:

1. 将索引设为 INVISIBLE;
2. 观察慢日志和业务指标;
3. 确认无影响后 DROP;
4. 如出现劣化,立即 VISIBLE 恢复。

恢复:

ALTER TABLE orders
  ALTER INDEX idx_status_created VISIBLE;

注意:

  1. 不可见索引仍占用空间和写入成本;
  2. 主键不能设为不可见;
  3. 需要监控确认没有强制使用该索引的 SQL;
  4. 灰度周期要覆盖业务高峰和周期任务。

10.9 冗余与重复索引

以下索引存在冗余:

KEY idx_user (user_id),
KEY idx_user_created (user_id, created_at)

通常 idx_user 可以删除,因为 idx_user_created 可支持按 user_id 前缀查询。

以下不是简单冗余:

KEY idx_user_created (user_id, created_at),
KEY idx_user_status (user_id, status)

两者支持不同排序和过滤路径,需要结合执行计划判断。

查找重复和未使用索引:

SELECT
  OBJECT_SCHEMA,
  OBJECT_NAME,
  INDEX_NAME,
  COUNT_READ,
  COUNT_WRITE
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE OBJECT_SCHEMA = 'shop'
  AND INDEX_NAME IS NOT NULL
ORDER BY COUNT_READ ASC, COUNT_WRITE DESC;

判断索引是否可删除:

  1. 观察一个完整业务周期;
  2. 覆盖日常、周报、月报、活动、批处理;
  3. 搜索代码和配置中的 FORCE INDEX;
  4. 在预发或影子库验证;
  5. 先设为 INVISIBLE;
  6. 删除后保留恢复脚本。

10.10 在线创建索引

MySQL 8.0 InnoDB 支持 Online DDL 创建索引:

ALTER TABLE orders
  ADD INDEX idx_merchant_created (merchant_id, created_at),
  ALGORITHM=INPLACE,
  LOCK=NONE;

Online DDL 不等于零影响:

  1. 需要扫描原表;
  2. 需要临时磁盘空间;
  3. 记录增量变更;
  4. 占用 IO 和 CPU;
  5. 末尾可能短暂持有元数据锁;
  6. 长事务会阻塞 DDL;
  7. 主从延迟可能上升。

上线前检查长事务:

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id
FROM information_schema.INNODB_TRX
ORDER BY trx_started;

避免在长事务、大流量和备份任务重叠时执行。

10.11 索引设计案例

案例一:订单查询

原始 SQL:

SELECT *
FROM orders
WHERE merchant_id = 10
  AND status IN ('PAID', 'SHIPPED')
ORDER BY created_at DESC
LIMIT 20;

问题:

  1. merchant_id 有索引但缺少时间排序;
  2. SELECT * 无法覆盖;
  3. 回表后可能再排序。

索引:

ALTER TABLE orders
  ADD INDEX idx_merchant_created (merchant_id, created_at);

如果 IN 值较多,ORDER BY created_at 是否能完全避免排序取决于优化器执行方式,需用执行计划验证。

案例二:后台任务扫描

SELECT *
FROM orders
WHERE status = 'TIMEOUT_PENDING'
  AND created_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE)
LIMIT 100;

索引:

ALTER TABLE orders
  ADD INDEX idx_status_created (status, created_at);

该索引虽然状态基数低,但待处理状态占比很小,扫描量极低,是典型有效设计。

案例三:多租户表

SELECT *
FROM tenant_documents
WHERE tenant_id = 100
  AND folder_id = 20
  AND deleted = 0
ORDER BY updated_at DESC
LIMIT 50;

索引:

ALTER TABLE tenant_documents
  ADD INDEX idx_tenant_folder_updated (
    tenant_id,
    folder_id,
    deleted,
    updated_at
  );

如果 deleted 只有 0/1 且必传,可以放在排序前。若多数查询不传 folder_id,应保留或设计另一条索引。

案例四:活动库存扣减

UPDATE activity_stock
SET stock = stock - 1
WHERE activity_id = 1001
  AND sku_id = 2002
  AND stock > 0;

索引:

ALTER TABLE activity_stock
  ADD UNIQUE KEY uk_activity_sku (activity_id, sku_id);

唯一索引精确定位行,避免扫描大量库存行并降低锁范围。

10.12 索引规范模板

命名:

类型 前缀
普通索引 idx_
唯一索引 uk_
全文索引 ft_
表达式索引 idx_表达式含义_

上线评审表:

项目 内容
SQL 原始 SQL 和参数分布
表规模 当前行数、月增量
问题 扫描行、耗时、临时表、排序
新索引 完整 DDL
预期 新执行计划和扫描行变化
写入影响 预估 DML 延迟变化
验证 功能、性能、复制、监控
回滚 删除或不可见索引 DDL

本章小结

索引设计要从真实查询出发。等值条件前置、范围条件后置、排序字段尽量复用索引顺序;高频接口可评估覆盖索引,唯一性必须由唯一索引保证。索引会增加写入、存储、内存和 DDL 成本,因此每个索引都应有明确用途、验证数据和下线机制。

思考题

  1. 为什么 (a,b) 索引通常可以让 (a) 查询使用,反之不行?
  2. 覆盖索引适合什么场景?有哪些代价?
  3. 低基数索引在什么情况下仍然有效?
  4. 不可见索引如何用于安全删除索引?
  5. 为你们系统的一条核心慢 SQL 设计索引,并写出上线验证清单。