单表数据量大走覆盖索引仍然慢怎么优化
覆盖索引只能说明查询字段都在二级索引里,不需要回表。它能减少一次“二级索引到聚簇索引”的随机访问,但它不能保证 SQL 一定快。
一句话理解:
覆盖索引解决的是“少回表”,不是“少扫描”。如果扫描范围很大、排序很重、分页很深、返回很多、索引太宽或数据冷热不合理,走覆盖索引仍然会慢。
学习目标
| 目标 | 需要掌握什么 |
|---|---|
| 知道现象 | Extra = Using index 但 SQL 仍然慢 |
| 知道原因 | 扫描行数大、深分页、低选择性、排序、索引太宽、Buffer Pool 未命中 |
| 知道排查 | 会看 EXPLAIN、EXPLAIN ANALYZE、慢日志、rows_examined |
| 知道优化 | 减少扫描、改联合索引、游标分页、归档冷热、预聚合、分库分表 |
| 知道边界 | 索引不是万能的,单表容量和业务访问模式也要治理 |
为什么覆盖索引还会慢
覆盖索引的执行链路大致是:
flowchart TD
A["SQL 查询"] --> B["走二级索引 B+Tree"]
B --> C["定位起点"]
C --> D["扫描索引叶子页"]
D --> E["在索引里完成字段读取"]
E --> F["返回结果"]它省掉的是回表:
flowchart TD
A["普通二级索引查询"] --> B["扫描二级索引"]
B --> C["拿到主键 id"]
C --> D["回聚簇索引读取整行"]
D --> E["返回"]
F["覆盖索引查询"] --> G["扫描二级索引"]
G --> H["索引中已有字段"]
H --> I["直接返回"]但是如果二级索引本身要扫描几百万行,少回表也不够。
常见慢因:
| 慢因 | 说明 | 典型表现 |
|---|---|---|
| 扫描行数太大 | 覆盖索引全扫或大范围扫描 | type = index/range,rows 很大 |
| 条件选择性低 | status = 1 命中 90% 数据 | 走索引也要扫大量叶子页 |
| 深分页 | limit 1000000, 20 要跳过大量记录 | rows_examined 很大 |
| 排序不被索引支持 | 过滤和排序顺序不匹配 | Using filesort |
| 分组统计重 | group by、count distinct 数据量大 | Using temporary |
| 返回数据太多 | 即使不回表,网络和客户端处理也重 | SQL 快一点但接口仍慢 |
| 覆盖索引太宽 | 为了覆盖塞很多列,索引页变大 | Buffer Pool 命中下降 |
| 冷数据太多 | 大表历史数据和热数据混在一起 | 热查询扫到大量冷页 |
| 统计信息不准 | 优化器估算错误 | 选错索引或扫描量预估偏差 |
先用证据定位
不要一看到 Using index 就认为索引没问题。Using index 只说明覆盖索引,不说明扫描少。
先看执行计划:
explain
select id, order_no, created_at
from orders
where status = 1
order by created_at desc
limit 100000, 20;重点看:
| 字段 | 关注点 |
|---|---|
type | index 可能是扫描整个索引,range 也可能范围很大 |
key | 是否用了你预期的联合索引 |
rows | 预估扫描行数是否过大 |
filtered | 过滤比例是否很低 |
Extra | Using index、Using filesort、Using temporary |
MySQL 8 可以用 EXPLAIN ANALYZE 看真实执行情况:
explain analyze
select id, order_no, created_at
from orders
where status = 1
order by created_at desc
limit 100000, 20;生产环境要谨慎使用 EXPLAIN ANALYZE,因为它会实际执行 SQL。可以在测试库或低峰期验证。
慢日志也要看:
Rows_examined: 1000020
Rows_sent: 20
Query_time: 2.8这说明虽然只返回 20 行,但扫描了 100 万行。覆盖索引只能让这 100 万行扫描相对轻一点,不能把 100 万变成 20。
场景一:低选择性字段导致扫描大
问题 SQL:
select id, order_no, created_at
from orders
where status = 1
order by created_at desc
limit 20;索引:
create index idx_status_created_order
on orders(status, created_at, order_no);如果 status = 1 占全表 90%,这个索引虽然覆盖,但仍然可能扫描大量索引页。
优化方向:
- 增加更有选择性的条件,例如租户、用户、机构、时间范围。
- 把查询限定在业务真正需要的时间窗口。
- 对后台全局列表使用冷热归档或搜索系统。
更合理的 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;对应索引:
create index idx_tenant_status_created_id
on orders(tenant_id, status, created_at, id);为什么有效:
| 条件 | 作用 |
|---|---|
tenant_id | 先缩小到租户数据 |
status | 在租户内继续过滤 |
created_at | 支持时间范围和排序 |
id | 做稳定排序和游标分页 |
场景二:深分页导致扫描和丢弃大量行
深分页是“大表覆盖索引仍慢”的最高频原因。
问题 SQL:
select id, order_no, created_at
from orders
where tenant_id = 1001
order by created_at desc
limit 100000, 20;即使有覆盖索引:
create index idx_tenant_created_order
on orders(tenant_id, created_at, order_no);MySQL 仍然要先按索引顺序找到前 100020 条,然后丢掉前 100000 条,返回后 20 条。
flowchart TD
A["limit 100000,20"] --> B["按索引扫描 100020 条"]
B --> C["丢弃前 100000 条"]
C --> D["返回 20 条"]优化成游标分页:
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;对应索引:
create index idx_tenant_created_id_order
on orders(tenant_id, created_at, id, order_no);游标分页的核心是:前端或调用方带上上一页最后一条记录的排序值,让 MySQL 从这个位置继续往后扫,而不是从第一页重新数。
如果业务必须支持跳到第 5000 页,要重新评估需求:
| 需求 | 更合适方案 |
|---|---|
| 用户列表下一页 | 游标分页 |
| 后台跳页查看 | 限制最大页数或加筛选条件 |
| 导出大量数据 | 异步导出任务,分批扫描 |
| 任意复杂检索 | ES、ClickHouse 或离线宽表 |
场景三:排序字段和索引顺序不匹配
问题 SQL:
select id, order_no, created_at
from orders
where tenant_id = 1001
and status = 1
order by amount desc
limit 20;索引:
create index idx_tenant_status_created
on orders(tenant_id, status, created_at);这个索引可以帮助过滤 tenant_id 和 status,但不能直接按 amount desc 返回。MySQL 可能需要额外排序。
优化方向:
- 如果业务高频按金额排序,设计匹配排序的索引。
- 如果只是偶发后台查询,不一定要为它新增索引。
- 如果排序维度很多,不可能给每种排序都建索引,应考虑搜索或分析系统。
高频金额榜索引:
create index idx_tenant_status_amount_id
on orders(tenant_id, status, amount, id);稳定排序 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。项目里要按实际版本验证。
场景四:覆盖索引太宽
为了覆盖索引,有人会把很多字段塞进联合索引:
create index idx_big_cover
on orders(
tenant_id,
status,
created_at,
order_no,
receiver_name,
receiver_phone,
address,
remark
);这样可能短期减少回表,但会带来新问题:
- 索引页变大,一页能放的记录变少。
- B+Tree 叶子页更多,扫描更多页。
- Buffer Pool 被大索引占用。
- 写入、更新、删除都要维护更大的索引。
address、remark这类长字段会明显拖累索引。
更合理的做法:
| 页面 | 字段策略 |
|---|---|
| 列表页 | 只覆盖 id、状态、时间、少量展示字段 |
| 详情页 | 根据主键查整行或查详情表 |
| 大字段 | 拆到详情表,避免列表查询读取 |
示例:
create index idx_order_list
on orders(tenant_id, status, created_at, id, order_no, amount);列表只查:
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;详情页再查:
select *
from order_detail
where order_id = 900000;场景五:统计类查询不适合只靠覆盖索引
问题 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 |
汇总表示例:
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;查询看板:
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 个月,但历史数据还在同一张表里,会出现:
- 索引越来越大。
- Buffer Pool 混入冷页。
- DDL、备份、统计信息维护成本升高。
- 查询虽然走索引,但范围和树结构都越来越重。
治理方式:
| 方式 | 适合场景 | 注意 |
|---|---|---|
| 冷热归档 | 历史订单、日志、流水 | 归档查询入口要单独设计 |
| 分区表 | 按时间范围管理数据 | 分区键要进入查询条件 |
| 按业务拆表 | 租户、机构、订单类型等隔离 | 避免过度拆分 |
| 分库分表 | 单机容量、写入、索引都到瓶颈 | 引入路由、事务、聚合复杂度 |
| ES/数仓 | 搜索、报表、分析 | 数据同步和一致性要治理 |
冷热归档示例:
-- 热表保留最近 6 个月
orders
-- 冷表保存历史数据
orders_archive热查询只访问热表:
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;历史查询走单独入口:
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%,原来的索引选择就可能不合适。
可以更新统计信息:
analyze table orders;MySQL 8 可以考虑直方图,让优化器更了解非索引列或数据分布:
analyze table orders
update histogram on status;不要轻易长期使用 force index:
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:
select id, order_no, amount, created_at
from orders
where status = 1
order by created_at desc
limit 500000, 20;已有覆盖索引:
create index idx_status_created_cover
on orders(status, created_at, id, order_no, amount);问题:
status = 1命中比例很高。- 深分页要扫描并丢弃 50 万行。
- 查询没有租户、用户、时间范围,业务边界太宽。
优化后:
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;索引:
create index idx_tenant_status_created_id_cover
on orders(tenant_id, status, created_at, id, order_no, amount);优化点:
| 优化 | 作用 |
|---|---|
增加 tenant_id | 缩小业务范围 |
| 使用游标分页 | 避免 offset 丢弃大量行 |
加 id 做二级排序 | 保证排序稳定 |
| 覆盖列表字段 | 减少回表 |
| 控制列表字段 | 避免索引过宽 |
排查流程
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 filesort、Using temporary |
| 查询条件 | 是否缺少租户、时间、业务范围 |
| 分页方式 | 是否 limit offset,size 深分页 |
| 索引宽度 | 是否为了覆盖塞入太多字段 |
| 数据分布 | 是否低选择性字段命中比例过高 |
| 数据冷热 | 是否历史数据拖累热查询 |
| 事务锁 | 是否存在锁等待或长事务 |
大表治理决策树
大表慢查询不要一上来就喊“分库分表”。分库分表是成本很高的治理手段,会引入路由、跨库查询、跨库事务、全局 ID、扩容迁移、运维监控等复杂度。更合理的方式是按证据逐层判断。
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 字段随便拼起来,而是围绕访问路径设计。
常见顺序:
等值过滤字段 -> 范围/排序字段 -> 稳定排序主键 -> 少量覆盖字段例如订单列表:
where tenant_id = ?
and status = ?
and created_at < ?
order by created_at desc, id desc
limit 20索引可以是:
create index idx_tenant_status_created_id_list
on orders(tenant_id, status, created_at, id, order_no, amount);但如果查询是:
where status = 1
order by created_at desc
limit 20;在大表上即使建 (status, created_at) 也可能不理想,因为 status = 1 可能命中大部分数据,业务范围太宽。
第四层:什么时候做归档或分区
如果业务高频只查热数据,历史冷数据却长期留在同一张表里,索引、统计信息、备份、DDL 都会越来越重。
| 方案 | 适合条件 | 不适合 |
|---|---|---|
| 冷热归档 | 历史数据低频查询,可单独入口 | 高频跨冷热联合查询 |
| 分区表 | 查询天然带分区键,例如时间 | 查询经常不带分区键 |
| 历史库 | 历史查询可以接受独立库 | 需要强事务跨热冷数据 |
分区不是万能优化。只有查询条件能命中分区裁剪时,分区才有明显意义。如果 SQL 不带分区键,MySQL 可能仍然要扫多个分区。
第五层:什么时候分库分表
只有出现下面这些信号时,才认真评估分库分表:
| 信号 | 说明 |
|---|---|
| 单表数据量持续增长,归档后仍过大 | 索引和 DDL 难维护 |
| 单机写入 TPS 到瓶颈 | 加索引反而拖慢写入 |
| 单机容量、备份、恢复压力不可接受 | 运维窗口过长 |
| 核心查询能找到稳定分片键 | 例如 tenant_id、user_id、order_id |
| 团队能承担治理复杂度 | 路由、扩容、监控、事务、聚合 |
如果没有稳定分片键,分库分表可能把一个慢 SQL 变成多个库上的慢 SQL,再加上结果合并,复杂度更高。
第六层:什么时候换 ES 或 OLAP
不是所有查询都应该由 MySQL 承担。
| 需求 | 更适合的系统 | 原因 |
|---|---|---|
| 全文搜索、模糊检索、高亮、搜索聚合 | Elasticsearch | 倒排索引和搜索相关性 |
| 多维报表、大范围 group by | ClickHouse / Doris / StarRocks | 列式存储和分析执行 |
| 强事务、点查、状态机更新 | MySQL | 事务和约束能力 |
| 离线统计、跨域分析 | 数仓 | 成本和扩展性 |
判断标准很简单:如果需求本质是“搜索视图”或“分析看板”,不要强行让 MySQL 通过一堆覆盖索引硬扛;如果需求是“交易事实”,也不要把 ES 当主库。
优化优先级
建议按这个顺序做:
- 确认业务是否真的需要查这么大范围。
- 增加必要业务条件,例如租户、机构、用户、时间范围。
- 把深分页改成游标分页。
- 重新设计联合索引:等值条件、范围和排序、稳定主键、必要覆盖字段。
- 裁剪返回字段,避免为了覆盖索引放入大字段。
- 对统计类查询改成汇总表、异步任务或分析型数据库。
- 对历史冷数据做归档、分区或拆表。
- 如果单机容量、写入、索引维护都到瓶颈,再考虑分库分表。
- 更新统计信息,必要时验证优化器是否选错索引。
- 最后通过慢日志、
EXPLAIN、压测和线上监控验证。
常见误区
| 误区 | 为什么错 |
|---|---|
Using index 就一定快 | 它只代表覆盖索引,可能仍然扫描整个索引 |
| 覆盖字段越多越好 | 索引会变宽,写入和缓存成本上升 |
| 强制索引就能解决 | 数据分布变化后可能更慢 |
| 深分页只要覆盖索引就行 | offset 仍然要扫描和丢弃大量记录 |
| 单表无限增长没关系 | 索引、缓存、备份、DDL、统计信息都会变重 |
| 所有慢 SQL 都在 MySQL 内解决 | 搜索、报表、分析类需求可能应该交给 ES 或数仓 |
关联知识点
| 知识点 | 继续学习什么 |
|---|---|
| MySQL 索引 | B+Tree、联合索引、覆盖索引、索引失效 |
| MySQL EXPLAIN | 用执行计划判断扫描、排序、覆盖索引 |
| 数据库优化 | 慢 SQL 综合优化流程 |
| InnoDB 存储结构 | 页、聚簇索引、二级索引和 Buffer Pool |
本章小结
单表数据量大时,覆盖索引只能减少回表,不能自动减少扫描。真正要优化,要先看扫描行数、返回行数、排序、分页、索引宽度、数据冷热和业务访问范围。高频列表优先用“业务条件缩小范围 + 合理联合索引 + 游标分页 + 少量覆盖字段”;统计分析类需求用汇总表、离线任务或分析型数据库;历史数据要归档或拆分。优化的目标不是让 Extra 出现 Using index,而是让 Rows_examined、响应时间和资源消耗真正下降。
