适用场景

业务表按租户、状态和创建时间查询列表。数据量从几十万增长到千万级后,接口 P95 延迟突然升高;应用监控中数据库耗时占比明显增加,但 CPU 和磁盘利用率并不一定很高。

本文以常见的订单列表为例,说明如何确认“建了索引却没有用好”的联合索引问题,并在不影响线上写入的前提下完成优化。

现象描述

接口执行的 SQL 如下:

SELECT id, order_no, amount, created_at
FROM orders
WHERE tenant_id = 42
  AND status = 'PAID'
  AND created_at >= '2026-07-01 00:00:00'
  AND created_at <  '2026-08-01 00:00:00'
ORDER BY created_at DESC
LIMIT 50;

表上原有索引为 KEY idx_created_status_tenant (created_at, status, tenant_id)。慢日志显示这条语句频繁出现,Rows_examined 远大于实际返回的 50 行。

先确认:是不是索引顺序问题

先用 EXPLAIN ANALYZE 查看真实执行情况(MySQL 8.0):

EXPLAIN ANALYZE
SELECT id, order_no, amount, created_at
FROM orders
WHERE tenant_id = 42
  AND status = 'PAID'
  AND created_at >= '2026-07-01 00:00:00'
  AND created_at <  '2026-08-01 00:00:00'
ORDER BY created_at DESC
LIMIT 50;

重点看三项:

  • actual time:索引扫描与回表各自花了多久;
  • rows:实际扫描行数,远大于 50 通常说明过滤没有尽早发生;
  • Index range scanTable scan:确认优化器选择了哪个访问路径。

在 MySQL 5.7 或需要快速比对时,也可执行:

EXPLAIN FORMAT=TRADITIONAL
SELECT id, order_no, amount, created_at
FROM orders
WHERE tenant_id = 42 AND status = 'PAID'
  AND created_at >= '2026-07-01 00:00:00'
  AND created_at < '2026-08-01 00:00:00'
ORDER BY created_at DESC LIMIT 50;

key 表示实际使用的索引,key_len 表示使用了索引的前缀长度,rows 是估算扫描行数,Extra 中出现 Using filesort 代表排序未能直接利用索引顺序。不要只因 possible_keys 有目标索引就判断优化成功,它只是候选索引。

原因:最左前缀与范围条件

联合 B+Tree 索引按列的先后顺序排序。对于 (created_at, status, tenant_id),查询首先遇到的是 created_at 的范围条件;范围扫描会覆盖一整段时间区间,后面的 statustenant_id 难以把扫描范围继续收窄。结果是数据库可能扫描这个月大量订单,再过滤出目标租户和状态。

本场景中 tenant_idstatus 都是等值条件,created_at 是范围和排序列。因此更合适的索引顺序是:等值过滤列在前,范围/排序列在后。

修复方案:建立匹配访问路径的索引

先在从库或预发环境核对数据分布与执行计划,然后创建新索引:

ALTER TABLE orders
  ADD INDEX idx_tenant_status_created (tenant_id, status, created_at),
  ALGORITHM=INPLACE,
  LOCK=NONE;

说明:

  • tenant_id, status 在前,能迅速定位到一个租户的一种状态;
  • created_at 在后,既支持时间范围,也满足 ORDER BY created_at DESC 的反向扫描;
  • LOCK=NONE 是期望的在线 DDL 方式,不是绝对保证。执行前应确认 MySQL 版本、表结构和存储引擎支持情况;
  • 大表加索引仍会消耗 IO、CPU 和磁盘空间,应选择业务低峰并设置变更观察窗口。

创建后再次执行原 SQL 的 EXPLAIN ANALYZE。理想结果是 key 变为 idx_tenant_status_created,扫描行数接近满足时间条件的少量记录,且 Extra 不再出现额外排序。

如果列表页只需要索引中的字段,可进一步把查询改成覆盖索引,减少回表。例如只返回 idcreated_at 时:

SELECT id, created_at
FROM orders
WHERE tenant_id = 42 AND status = 'PAID'
  AND created_at >= '2026-07-01 00:00:00'
  AND created_at < '2026-08-01 00:00:00'
ORDER BY created_at DESC
LIMIT 50;

InnoDB 的二级索引叶子节点包含主键值,因此主键为 id 时该查询通常可由上述索引直接返回;执行计划中的 Extra: Using index 是覆盖索引的信号。

定位示例:从慢日志找到候选 SQL

开启并分析慢日志时,不要只看单次耗时,还要关注调用次数和总耗时:

pt-query-digest /var/log/mysql/mysql-slow.log --limit 20

输出中的 Count 是调用次数,Query_time sum 是累计耗时,Rows_examined 用来发现“返回很少、扫描很多”的语句。优先处理累计耗时高且扫描放大的 SQL,通常比盯住一次偶发超时更能改善整体延迟。

若没有 Percona Toolkit,可从 MySQL 慢日志中抽取原始语句,并用业务真实参数在只读副本执行 EXPLAIN ANALYZE。不要在生产主库对高频 SQL 反复做全量压测。

上线后的验证与旧索引处理

发布索引后至少观察一个业务高峰,确认:

  1. 接口 P95/P99、数据库查询耗时和 Rows_examined 均下降;
  2. 写入延迟、复制延迟和磁盘剩余空间没有异常;
  3. 新索引被稳定使用,而不是只在测试参数下命中。

确认无其他查询依赖旧索引后,再评估删除 idx_created_status_tenant。删除前可查询索引基数和业务 SQL;不要仅因为它与新索引“看起来相似”就立即删除。索引同时影响读性能、写放大和存储成本,保留或删除都应有监控数据支撑。

预防措施

  • 为高频 SQL 建立“等值列 → 范围列 → 排序列”的索引评审清单;
  • 发布前保存 EXPLAIN 结果,避免依赖主观判断;
  • 用真实租户和真实时间范围验证,避免测试数据分布失真;
  • 定期从慢日志按总耗时排序复盘,而不是只处理告警时的单条 SQL;
  • 在大表 DDL 中预留回滚方案和磁盘余量,必要时使用在线变更工具分批实施。

总结

联合索引不是列越多越好,关键在于列顺序是否匹配查询的过滤、范围和排序方式。对本例,将索引从 (created_at, status, tenant_id) 调整为 (tenant_id, status, created_at),能让 MySQL 先缩小等值条件范围,再按时间顺序取出少量结果。通过慢日志、EXPLAIN ANALYZE 和上线监控形成闭环,才能把一次慢查询优化沉淀为可重复的排障方法。