Skip to content

单表数据量大走覆盖索引仍然慢怎么优化

覆盖索引只能说明查询字段都在二级索引里,不需要回表。它能减少一次“二级索引到聚簇索引”的随机访问,但它不能保证 SQL 一定快。

一句话理解:

覆盖索引解决的是“少回表”,不是“少扫描”。如果扫描范围很大、排序很重、分页很深、返回很多、索引太宽或数据冷热不合理,走覆盖索引仍然会慢。

学习目标

目标需要掌握什么
知道现象Extra = Using index 但 SQL 仍然慢
知道原因扫描行数大、深分页、低选择性、排序、索引太宽、Buffer Pool 未命中
知道排查会看 EXPLAINEXPLAIN ANALYZE、慢日志、rows_examined
知道优化减少扫描、改联合索引、游标分页、归档冷热、预聚合、分库分表
知道边界索引不是万能的,单表容量和业务访问模式也要治理

为什么覆盖索引还会慢

覆盖索引的执行链路大致是:

mermaid
flowchart TD
    A["SQL 查询"] --> B["走二级索引 B+Tree"]
    B --> C["定位起点"]
    C --> D["扫描索引叶子页"]
    D --> E["在索引里完成字段读取"]
    E --> F["返回结果"]

它省掉的是回表:

mermaid
flowchart TD
    A["普通二级索引查询"] --> B["扫描二级索引"]
    B --> C["拿到主键 id"]
    C --> D["回聚簇索引读取整行"]
    D --> E["返回"]
    F["覆盖索引查询"] --> G["扫描二级索引"]
    G --> H["索引中已有字段"]
    H --> I["直接返回"]

但是如果二级索引本身要扫描几百万行,少回表也不够。

常见慢因:

慢因说明典型表现
扫描行数太大覆盖索引全扫或大范围扫描type = index/rangerows 很大
条件选择性低status = 1 命中 90% 数据走索引也要扫大量叶子页
深分页limit 1000000, 20 要跳过大量记录rows_examined 很大
排序不被索引支持过滤和排序顺序不匹配Using filesort
分组统计重group bycount distinct 数据量大Using temporary
返回数据太多即使不回表,网络和客户端处理也重SQL 快一点但接口仍慢
覆盖索引太宽为了覆盖塞很多列,索引页变大Buffer Pool 命中下降
冷数据太多大表历史数据和热数据混在一起热查询扫到大量冷页
统计信息不准优化器估算错误选错索引或扫描量预估偏差

先用证据定位

不要一看到 Using index 就认为索引没问题。Using index 只说明覆盖索引,不说明扫描少。

先看执行计划:

sql
explain
select id, order_no, created_at
from orders
where status = 1
order by created_at desc
limit 100000, 20;

重点看:

字段关注点
typeindex 可能是扫描整个索引,range 也可能范围很大
key是否用了你预期的联合索引
rows预估扫描行数是否过大
filtered过滤比例是否很低
ExtraUsing indexUsing filesortUsing temporary

MySQL 8 可以用 EXPLAIN ANALYZE 看真实执行情况:

sql
explain analyze
select id, order_no, created_at
from orders
where status = 1
order by created_at desc
limit 100000, 20;

生产环境要谨慎使用 EXPLAIN ANALYZE,因为它会实际执行 SQL。可以在测试库或低峰期验证。

慢日志也要看:

text
Rows_examined: 1000020
Rows_sent: 20
Query_time: 2.8

这说明虽然只返回 20 行,但扫描了 100 万行。覆盖索引只能让这 100 万行扫描相对轻一点,不能把 100 万变成 20。

场景一:低选择性字段导致扫描大

问题 SQL:

sql
select id, order_no, created_at
from orders
where status = 1
order by created_at desc
limit 20;

索引:

sql
create index idx_status_created_order
on orders(status, created_at, order_no);

如果 status = 1 占全表 90%,这个索引虽然覆盖,但仍然可能扫描大量索引页。

优化方向:

  1. 增加更有选择性的条件,例如租户、用户、机构、时间范围。
  2. 把查询限定在业务真正需要的时间窗口。
  3. 对后台全局列表使用冷热归档或搜索系统。

更合理的 SQL:

sql
select id, order_no, created_at
from orders
where tenant_id = 1001
  and status = 1
  and created_at >= '2026-07-01 00:00:00'
order by created_at desc
limit 20;

对应索引:

sql
create index idx_tenant_status_created_id
on orders(tenant_id, status, created_at, id);

为什么有效:

条件作用
tenant_id先缩小到租户数据
status在租户内继续过滤
created_at支持时间范围和排序
id做稳定排序和游标分页

场景二:深分页导致扫描和丢弃大量行

深分页是“大表覆盖索引仍慢”的最高频原因。

问题 SQL:

sql
select id, order_no, created_at
from orders
where tenant_id = 1001
order by created_at desc
limit 100000, 20;

即使有覆盖索引:

sql
create index idx_tenant_created_order
on orders(tenant_id, created_at, order_no);

MySQL 仍然要先按索引顺序找到前 100020 条,然后丢掉前 100000 条,返回后 20 条。

mermaid
flowchart TD
    A["limit 100000,20"] --> B["按索引扫描 100020 条"]
    B --> C["丢弃前 100000 条"]
    C --> D["返回 20 条"]

优化成游标分页:

sql
select id, order_no, created_at
from orders
where tenant_id = 1001
  and (created_at, id) < ('2026-07-04 10:00:00', 900000)
order by created_at desc, id desc
limit 20;

对应索引:

sql
create index idx_tenant_created_id_order
on orders(tenant_id, created_at, id, order_no);

游标分页的核心是:前端或调用方带上上一页最后一条记录的排序值,让 MySQL 从这个位置继续往后扫,而不是从第一页重新数。

如果业务必须支持跳到第 5000 页,要重新评估需求:

需求更合适方案
用户列表下一页游标分页
后台跳页查看限制最大页数或加筛选条件
导出大量数据异步导出任务,分批扫描
任意复杂检索ES、ClickHouse 或离线宽表

场景三:排序字段和索引顺序不匹配

问题 SQL:

sql
select id, order_no, created_at
from orders
where tenant_id = 1001
  and status = 1
order by amount desc
limit 20;

索引:

sql
create index idx_tenant_status_created
on orders(tenant_id, status, created_at);

这个索引可以帮助过滤 tenant_idstatus,但不能直接按 amount desc 返回。MySQL 可能需要额外排序。

优化方向:

  1. 如果业务高频按金额排序,设计匹配排序的索引。
  2. 如果只是偶发后台查询,不一定要为它新增索引。
  3. 如果排序维度很多,不可能给每种排序都建索引,应考虑搜索或分析系统。

高频金额榜索引:

sql
create index idx_tenant_status_amount_id
on orders(tenant_id, status, amount, id);

稳定排序 SQL:

sql
select id, order_no, amount
from orders
where tenant_id = 1001
  and status = 1
order by amount desc, id desc
limit 20;

注意:不同 MySQL 版本对降序索引支持不同。MySQL 8 支持真正的降序索引,MySQL 5.7 主要依赖反向扫描,遇到混合升降序时更容易 filesort。项目里要按实际版本验证。

场景四:覆盖索引太宽

为了覆盖索引,有人会把很多字段塞进联合索引:

sql
create index idx_big_cover
on orders(
  tenant_id,
  status,
  created_at,
  order_no,
  receiver_name,
  receiver_phone,
  address,
  remark
);

这样可能短期减少回表,但会带来新问题:

  1. 索引页变大,一页能放的记录变少。
  2. B+Tree 叶子页更多,扫描更多页。
  3. Buffer Pool 被大索引占用。
  4. 写入、更新、删除都要维护更大的索引。
  5. addressremark 这类长字段会明显拖累索引。

更合理的做法:

页面字段策略
列表页只覆盖 id、状态、时间、少量展示字段
详情页根据主键查整行或查详情表
大字段拆到详情表,避免列表查询读取

示例:

sql
create index idx_order_list
on orders(tenant_id, status, created_at, id, order_no, amount);

列表只查:

sql
select id, order_no, amount, created_at
from orders
where tenant_id = 1001
  and status = 1
order by created_at desc, id desc
limit 20;

详情页再查:

sql
select *
from order_detail
where order_id = 900000;

场景五:统计类查询不适合只靠覆盖索引

问题 SQL:

sql
select tenant_id, count(*)
from orders
where created_at >= '2026-07-01 00:00:00'
group by tenant_id
order by count(*) desc;

即使有覆盖索引 (created_at, tenant_id),MySQL 仍然需要扫描范围内大量数据、分组、排序。覆盖索引只能减少读取列的成本,不能消除聚合计算。

商业项目常用方案:

场景推荐
实时小范围统计合理索引 + 限定时间范围
首页看板定时汇总表
大范围报表离线任务或数仓
多维分析ClickHouse、Doris、StarRocks
搜索聚合Elasticsearch

汇总表示例:

sql
create table order_daily_summary (
  stat_date date not null,
  tenant_id bigint not null,
  order_count bigint not null,
  amount_sum decimal(18, 2) not null,
  primary key(stat_date, tenant_id)
) engine = InnoDB default charset = utf8mb4;

查询看板:

sql
select tenant_id, sum(order_count) as order_count
from order_daily_summary
where stat_date >= '2026-07-01'
  and stat_date < '2026-07-04'
group by tenant_id
order by order_count desc
limit 20;

场景六:历史冷数据拖慢热查询

如果单表有几千万甚至上亿行,很多业务只查最近 3 个月,但历史数据还在同一张表里,会出现:

  1. 索引越来越大。
  2. Buffer Pool 混入冷页。
  3. DDL、备份、统计信息维护成本升高。
  4. 查询虽然走索引,但范围和树结构都越来越重。

治理方式:

方式适合场景注意
冷热归档历史订单、日志、流水归档查询入口要单独设计
分区表按时间范围管理数据分区键要进入查询条件
按业务拆表租户、机构、订单类型等隔离避免过度拆分
分库分表单机容量、写入、索引都到瓶颈引入路由、事务、聚合复杂度
ES/数仓搜索、报表、分析数据同步和一致性要治理

冷热归档示例:

sql
-- 热表保留最近 6 个月
orders

-- 冷表保存历史数据
orders_archive

热查询只访问热表:

sql
select id, order_no, created_at
from orders
where tenant_id = 1001
  and created_at >= '2026-07-01 00:00:00'
order by created_at desc
limit 20;

历史查询走单独入口:

sql
select id, order_no, created_at
from orders_archive
where tenant_id = 1001
  and created_at >= '2024-01-01 00:00:00'
  and created_at < '2024-02-01 00:00:00'
order by created_at desc
limit 20;

场景七:统计信息和优化器选择不准

如果数据分布变化很大,优化器可能估算错误。例如某个状态从 1% 变成 90%,原来的索引选择就可能不合适。

可以更新统计信息:

sql
analyze table orders;

MySQL 8 可以考虑直方图,让优化器更了解非索引列或数据分布:

sql
analyze table orders
update histogram on status;

不要轻易长期使用 force index

sql
select id, order_no
from orders force index(idx_status_created)
where status = 1
order by created_at desc
limit 20;

force index 可以作为临时验证手段,但数据分布变化后可能反而更慢。更可靠的方式是修正 SQL、索引顺序、统计信息和业务查询范围。

商业 Demo:订单列表优化

假设订单表有 8000 万行。

原 SQL:

sql
select id, order_no, amount, created_at
from orders
where status = 1
order by created_at desc
limit 500000, 20;

已有覆盖索引:

sql
create index idx_status_created_cover
on orders(status, created_at, id, order_no, amount);

问题:

  1. status = 1 命中比例很高。
  2. 深分页要扫描并丢弃 50 万行。
  3. 查询没有租户、用户、时间范围,业务边界太宽。

优化后:

sql
select id, order_no, amount, created_at
from orders
where tenant_id = 1001
  and status = 1
  and (created_at, id) < ('2026-07-04 10:00:00', 90000000)
order by created_at desc, id desc
limit 20;

索引:

sql
create index idx_tenant_status_created_id_cover
on orders(tenant_id, status, created_at, id, order_no, amount);

优化点:

优化作用
增加 tenant_id缩小业务范围
使用游标分页避免 offset 丢弃大量行
id 做二级排序保证排序稳定
覆盖列表字段减少回表
控制列表字段避免索引过宽

排查流程

mermaid
flowchart TD
    A["覆盖索引仍然慢"] --> B["看 rows 和 Rows_examined"]
    B --> C{"扫描是否很大"}
    C -- "是" --> D["缩小条件 / 改联合索引 / 游标分页"]
    C -- "否" --> E["看 filesort 和 temporary"]
    E --> F{"是否额外排序分组"}
    F -- "是" --> G["调整索引顺序或改为汇总"]
    F -- "否" --> H["看返回量和网络"]
    H --> I{"返回是否过多"}
    I -- "是" --> J["分页 / 字段裁剪 / 异步导出"]
    I -- "否" --> K["看锁等待 / IO / Buffer Pool / 统计信息"]

排查清单:

检查项判断
Rows_examined是否远大于 Rows_sent
type是否是 index 全索引扫描
rows是否预估扫描很大
Extra是否有 Using filesortUsing temporary
查询条件是否缺少租户、时间、业务范围
分页方式是否 limit offset,size 深分页
索引宽度是否为了覆盖塞入太多字段
数据分布是否低选择性字段命中比例过高
数据冷热是否历史数据拖累热查询
事务锁是否存在锁等待或长事务

大表治理决策树

大表慢查询不要一上来就喊“分库分表”。分库分表是成本很高的治理手段,会引入路由、跨库查询、跨库事务、全局 ID、扩容迁移、运维监控等复杂度。更合理的方式是按证据逐层判断。

mermaid
flowchart TD
    A["大表查询慢"] --> B["确认业务范围是否过大"]
    B --> C{"能否加租户、用户、机构、时间条件"}
    C -- "能" --> D["先缩小查询范围"]
    C -- "不能" --> E["看是否深分页"]
    D --> F["重新设计联合索引"]
    E --> G{"是否必须跳任意页"}
    G -- "否" --> H["改游标分页"]
    G -- "是" --> I["限制页数或异步导出"]
    F --> J{"仍然扫描大量历史数据"}
    H --> J
    I --> J
    J -- "是" --> K["冷热归档或分区"]
    J -- "否" --> L["压测和上线观察"]
    K --> M{"单机容量或写入仍到瓶颈"}
    M -- "是" --> N["评估分库分表"]
    M -- "否" --> O["保留 MySQL 热表方案"]
    N --> P{"需求是否是搜索或多维分析"}
    P -- "是" --> Q["ES / ClickHouse / Doris"]
    P -- "否" --> R["按分片键设计分库分表"]

第一层:能不能缩小业务范围

很多慢 SQL 的根因不是 MySQL 不行,而是业务查询边界太宽。

宽查询更好的查询边界
查所有租户订单tenant_id
查所有历史订单限定最近 3 个月
查所有机构数据org_id
后台无条件列表必须输入时间范围或关键条件
导出全量明细异步任务分批导出

如果一个查询不加任何业务范围,要在 8000 万行里找 20 行,覆盖索引也只是让“扫 8000 万索引项”比“扫 8000 万整行”轻一些,本质上仍然不合理。

第二层:能不能改分页模型

深分页是可以通过产品交互和接口设计解决的问题。

分页方式适合场景代价
limit offset,size小页数、后台低频offset 越大越慢
游标分页信息流、订单列表、消息列表不能任意跳页
异步导出大范围明细下载用户需要等待任务完成
搜索引擎分页搜索结果浏览仍要限制深分页

商业系统里,用户真正需要的通常是“继续看下一页”,不是“跳到第 50000 页”。如果产品坚持任意深跳页,要把性能成本讲清楚,并通过筛选条件、最大页数、导出任务降低风险。

第三层:能不能通过索引和 SQL 解决

只有当查询范围已经合理,才应该设计联合索引。联合索引不是把所有 where 字段随便拼起来,而是围绕访问路径设计。

常见顺序:

text
等值过滤字段 -> 范围/排序字段 -> 稳定排序主键 -> 少量覆盖字段

例如订单列表:

sql
where tenant_id = ?
  and status = ?
  and created_at < ?
order by created_at desc, id desc
limit 20

索引可以是:

sql
create index idx_tenant_status_created_id_list
on orders(tenant_id, status, created_at, id, order_no, amount);

但如果查询是:

sql
where status = 1
order by created_at desc
limit 20;

在大表上即使建 (status, created_at) 也可能不理想,因为 status = 1 可能命中大部分数据,业务范围太宽。

第四层:什么时候做归档或分区

如果业务高频只查热数据,历史冷数据却长期留在同一张表里,索引、统计信息、备份、DDL 都会越来越重。

方案适合条件不适合
冷热归档历史数据低频查询,可单独入口高频跨冷热联合查询
分区表查询天然带分区键,例如时间查询经常不带分区键
历史库历史查询可以接受独立库需要强事务跨热冷数据

分区不是万能优化。只有查询条件能命中分区裁剪时,分区才有明显意义。如果 SQL 不带分区键,MySQL 可能仍然要扫多个分区。

第五层:什么时候分库分表

只有出现下面这些信号时,才认真评估分库分表:

信号说明
单表数据量持续增长,归档后仍过大索引和 DDL 难维护
单机写入 TPS 到瓶颈加索引反而拖慢写入
单机容量、备份、恢复压力不可接受运维窗口过长
核心查询能找到稳定分片键例如 tenant_iduser_idorder_id
团队能承担治理复杂度路由、扩容、监控、事务、聚合

如果没有稳定分片键,分库分表可能把一个慢 SQL 变成多个库上的慢 SQL,再加上结果合并,复杂度更高。

第六层:什么时候换 ES 或 OLAP

不是所有查询都应该由 MySQL 承担。

需求更适合的系统原因
全文搜索、模糊检索、高亮、搜索聚合Elasticsearch倒排索引和搜索相关性
多维报表、大范围 group byClickHouse / Doris / StarRocks列式存储和分析执行
强事务、点查、状态机更新MySQL事务和约束能力
离线统计、跨域分析数仓成本和扩展性

判断标准很简单:如果需求本质是“搜索视图”或“分析看板”,不要强行让 MySQL 通过一堆覆盖索引硬扛;如果需求是“交易事实”,也不要把 ES 当主库。

优化优先级

建议按这个顺序做:

  1. 确认业务是否真的需要查这么大范围。
  2. 增加必要业务条件,例如租户、机构、用户、时间范围。
  3. 把深分页改成游标分页。
  4. 重新设计联合索引:等值条件、范围和排序、稳定主键、必要覆盖字段。
  5. 裁剪返回字段,避免为了覆盖索引放入大字段。
  6. 对统计类查询改成汇总表、异步任务或分析型数据库。
  7. 对历史冷数据做归档、分区或拆表。
  8. 如果单机容量、写入、索引维护都到瓶颈,再考虑分库分表。
  9. 更新统计信息,必要时验证优化器是否选错索引。
  10. 最后通过慢日志、EXPLAIN、压测和线上监控验证。

常见误区

误区为什么错
Using index 就一定快它只代表覆盖索引,可能仍然扫描整个索引
覆盖字段越多越好索引会变宽,写入和缓存成本上升
强制索引就能解决数据分布变化后可能更慢
深分页只要覆盖索引就行offset 仍然要扫描和丢弃大量记录
单表无限增长没关系索引、缓存、备份、DDL、统计信息都会变重
所有慢 SQL 都在 MySQL 内解决搜索、报表、分析类需求可能应该交给 ES 或数仓

关联知识点

知识点继续学习什么
MySQL 索引B+Tree、联合索引、覆盖索引、索引失效
MySQL EXPLAIN用执行计划判断扫描、排序、覆盖索引
数据库优化慢 SQL 综合优化流程
InnoDB 存储结构页、聚簇索引、二级索引和 Buffer Pool

本章小结

单表数据量大时,覆盖索引只能减少回表,不能自动减少扫描。真正要优化,要先看扫描行数、返回行数、排序、分页、索引宽度、数据冷热和业务访问范围。高频列表优先用“业务条件缩小范围 + 合理联合索引 + 游标分页 + 少量覆盖字段”;统计分析类需求用汇总表、离线任务或分析型数据库;历史数据要归档或拆分。优化的目标不是让 Extra 出现 Using index,而是让 Rows_examined、响应时间和资源消耗真正下降。