查询优化:慢查询怎么治 —— 索引 + 预聚合
一个用得好好的列表页或数据看板,数据一多、时间周期一拉长,就开始转圈、甚至超时。第一反应往往是"加个缓存挡一下",但很多时候根因压根不在缓存,而在查询本身 —— 它扫了太多行。
这篇把治慢查询最该懂的几件事讲清楚:怎么定位慢在哪、索引为什么快又何时失效、查询本身还有哪些坑,以及聚合类看板的终极解法——预聚合。配套一个零依赖 demo,
node demo.js就能看到"扫描行数"怎么从五万降到个位数。
一、先定位:慢在哪
治慢查询的第一步不是改 SQL,是先搞清楚它慢在哪。凭感觉改,十有八九改错地方。
数据库给了两件趁手的工具。
第一件:EXPLAIN,看执行计划。 在一条 SQL 前面加 EXPLAIN,数据库会告诉你它打算怎么执行这条查询——走不走索引、扫多少行、用什么方式 join。最该盯的两个信息:
- 扫描类型:是全表扫描(把整张表一行行读一遍)还是走索引(沿索引直接定位)。全表扫描是慢查询最常见的元凶。
- 预估扫描行数:这条查询大概要"摸"多少行。一个返回 20 条的列表,如果预估扫描几十万行,那就是明显的信号——绝大部分行被读出来又丢掉了。
一句话点破:慢查询的本质,绝大多数时候就是"扫的行太多"。 后面所有招式,本质都是在想办法少扫行。
第二件:慢查询日志(Slow Query Log)。 数据库可以把执行时间超过某个阈值(比如 1 秒)的查询自动记下来。它解决的是"我根本不知道哪条慢"的问题——线上几百条 SQL,靠人盯不现实,让数据库自己把慢的挑出来,再逐条 EXPLAIN 分析。
顺序很重要:先用慢查询日志找出慢的 SQL,再用
EXPLAIN看它为什么慢,最后才动手优化。跳过定位直接改,是在赌。
二、索引:为什么快,怎么建,何时失效
定位完发现是全表扫,最常见的解法就是加索引。但索引不是"加了就快"的银弹,得懂它为什么快、怎么建对、什么时候会悄悄失效。
为什么快:把 O(n) 变成 O(log n)
没有索引,数据库查一个值只能全表扫描:从第一行摸到最后一行,挨个比对,n 行就是 O(n)。表越大越慢,而且是线性变慢。
索引(主流是 B+ 树)相当于给数据建了一套有序目录。查一个值,从树根往下找,每往下一层就排除掉一大半数据,n 行只需要查大约 log(n) 次。直观对比:一百万行,全表扫要摸一百万次,B+ 树大约 20 次就定位到了。这就是 O(n) → O(log n) 的差距,数据量越大,差距越悬殊。
demo 场景一:五万行的订单表,按 userId 查,全表扫要摸 50000 行,建了索引后只摸命中的 50 行。
索引以什么形态存在
容易误解的一点:索引不是"一张表",而是一份单独存储、额外占磁盘空间的有序数据结构(关系型库主流是 B+ 树)。可以理解成书后面的索引页——正文(表数据)之外,另做一份"关键词 → 位置"的有序目录;你建几个索引,就多几份不同排序的目录。
这里还藏着一个区分,正好解释什么叫"回表":
- 聚簇索引(主键):数据行本身就存在主键 B+ 树的叶子节点上——主键索引和数据是一体的,所以按主键查最快。
- 二级索引(普通索引):是另一棵单独的 B+ 树,叶子里只存"索引字段值 + 主键 id"。按它查到 id 后,还得拿 id 回主表捞完整行——这一步就是回表。
怎么建:单列、联合、覆盖
- 单列索引:给一个字段建索引,适合"经常按这个字段单独查"的场景(比如按
user_id查订单)。 - 联合索引(复合索引):给多个字段按顺序建一个索引,比如
(user_id, created_at)。适合"经常一起出现在查询条件里"的字段组合。 - 覆盖索引:如果一条查询要的字段,全都在索引里,数据库直接从索引就能拿到结果,不用回表——省掉一次回主表的开销。比如索引是
(user_id, amount),而你只SELECT amount WHERE user_id = ?,就能被这个索引完全覆盖。
何时失效:加了索引也可能用不上
这是最容易踩的坑——索引明明建了,EXPLAIN 一看却还是全表扫。常见原因:
| 失效情形 | 例子 | 为什么 |
|---|---|---|
| 对字段用函数 / 运算 | WHERE YEAR(created_at) = 2026 | 索引存的是原始值,套了函数后数据库没法用有序目录定位 |
| 隐式类型转换 | 字段是字符串,却 WHERE phone = 138...(传了数字) | 数据库要先把每行转类型再比,等于对字段做了函数 |
| 前缀模糊匹配 | WHERE name LIKE '%x'(以 % 开头) | 索引按前缀有序,开头不定就没法定位;'x%' 则可以走 |
OR 连接非索引列 | WHERE a = 1 OR b = 2,b 没索引 | 有一个条件用不上索引,整条往往退化成全表扫 |
| 联合索引没满足最左前缀 | 索引 (a, b, c),却只按 b 查 | 见下 |
最左前缀原则值得单独讲。联合索引 (a, b, c) 是先按 a 排,a 相同再按 b 排,b 相同再按 c 排——像电话簿先按姓、再按名排。所以它能高效支持"从最左开始连续的前缀":按 a、按 a+b、按 a+b+c 都能走索引;但单独按 b 或 c 查,用不上(电话簿里只知道名、不知道姓,没法快速翻)。建联合索引时,字段顺序怎么排,得照着真实查询条件来。
一句话点破:索引不是"建了就生效",写 SQL 的方式决定它能不能被用上。改完记得用
EXPLAIN确认真的走了索引。
不同数据库的索引
上面以关系型数据库(MySQL / PostgreSQL)为例,但"按某字段快速定位"是通用需求——几乎所有数据库都有某种索引,只是形态差别很大:
| 数据库 | 索引形态 |
|---|---|
| 关系型(MySQL / PG / Oracle) | 最丰富,B+ 树为主;PG 还有 Hash、GIN / GiST(JSON、全文、地理) |
| 文档型(MongoDB) | 默认 _id 索引,可建二级 / 复合 / 文本 / 地理 / TTL 索引 |
| 键值(Redis) | key 本身就是哈希索引(O(1));要按别的字段查,得用 ZSet / Set 手动建索引 |
| 搜索引擎(Elasticsearch) | 核心就是倒排索引,为全文检索而生 |
| 列式 / OLAP(ClickHouse) | 稀疏主键索引,点查很弱——它为"大范围扫描 + 列式压缩"而生,不靠精确索引 |
一个规律:越偏在线点查(关系型、文档),索引越精细、越依赖它;越偏大范围分析(列式 / OLAP),越不靠精确索引,而靠分区 + 列式全扫——这也呼应第五节"分析负载交给 OLAP"。
三、加了索引还是慢:查询本身的坑
有时候索引建对了、也走上了,查询还是慢。这往往是查询写法本身有问题,几个高频坑:
深分页。 LIMIT 100000, 20 想取第十万条往后的 20 条,看着只要 20 条,但数据库为了"跳过前面十万行",得老老实实把这十万行都读出来再丢掉,实际扫了 100020 行。翻页越往后越慢。
- 对策——游标 / 书签法:别用
offset,改成记住上一页最后一条的 id,下一页直接WHERE id > :lastId ORDER BY id LIMIT 20。因为主键 id 本身有序,数据库沿索引直接跳到书签处,只读要返回的 20 行。代价是不能随机跳页(只能上一页 / 下一页),但对"无限下拉"的信息流场景完全够用。 - demo 场景三:取第四万条往后 20 条,offset 扫 40020 行,游标只扫 20 行。
SELECT *。 把所有字段都查出来,一是传输和内存开销大,二是容易让覆盖索引失效——本来索引能覆盖的查询,因为多要了几个不在索引里的字段,被迫回表。只取真正要用的列。
join 过多 / join 没走索引。 多表 join 时,如果关联字段上没索引,数据库可能对其中一张表做全表扫,一旦行数相乘,代价爆炸。确保 join 的关联字段、以及 join 后的过滤条件都有索引;join 的表也不是越多越好,必要时拆成几次查询。
子查询。 某些写法下子查询会被反复执行(对外层每一行都跑一遍),性能很差。很多时候可以改写成 join,或者拆成两步查询,效果好得多。
这一节的坑有个共同点:索引没问题,是查询多扫 / 多取了数据。所以加完索引别急着收工,把这几样也对照检查一遍。
四、聚合类的终极解法:预聚合
有一类慢查询,加索引也救不了——看板、报表里的聚合统计。
典型场景:运营看板要展示"每天的订单总额""每月的活跃用户数"。对应的 SQL 是对海量明细表做 GROUP BY:
SELECT day, SUM(amount) FROM orders GROUP BY day;
问题在于,聚合天生要扫一大片数据:要算"每天总额",就得把当天每一笔订单都读出来加一遍。明细表几千万行,看板每刷新一次就重扫一次,再多索引也快不起来——因为瓶颈不是"定位",是"要算的数据本来就多"。
解法是把"实时算"换成"提前算好":预聚合。 用一个定时任务(比如每天凌晨跑一次),提前把"每天的汇总"算好,存进一张专门的汇总表:
明细表 orders(几千万行) 汇总表 daily_summary(每天一行)
id, user_id, amount, day ──► day, total_amount
...几千万行... 定时 2026-06-05, 1234567
聚合 2026-06-06, 2345678
看板不再查明细表,改查汇总表:一个月的数据就 30 行,行数从千万级骤降到几十行,秒出。
demo 场景二:五万行订单,实时聚合每次扫 50000 行;预聚合后查汇总表只扫 1 行。
这里的取舍要讲清楚。 预聚合用"快"换来的代价是:
- 数据有延迟:汇总表是定时任务算的,今天的数据可能要等明天凌晨的任务跑完才齐。看板看到的是"截止昨天"的数,对大多数报表场景完全可接受,但要让使用方心里有数。
- 要维护汇总任务:多了一个定时任务和一张表要管。任务失败了、补数了、统计口径改了,都得跟着维护。
- 不够灵活:汇总表是按固定维度(比如按天)预先算好的。临时想换个维度看(比如突然要"按小时"),汇总表里没有,还得回去查明细或重新聚合。
一句话点破:实时聚合是"每次都现算",预聚合是"提前算好存着"——用一点数据延迟和维护成本,换看板从超时到秒开。
五、数据量真的很大:再往上的招式
如果明细表本身就大到几亿行,上面的招式开始吃力,得动用更偏架构的手段:
- 分区表:把一张大表按规则(最常见是按时间,比如按月)切成多个物理分区。查"最近一个月"时,数据库只扫相关的那个分区,跳过其余所有分区。本质还是"少扫行",但粒度是分区级的。
- 冷数据归档:绝大多数查询只碰最近的数据。把很久以前的冷数据从主表挪到归档表(甚至更便宜的存储),让主表保持"小而热",日常查询就快。
- 读写分离:写主库、读从库,把大量读请求分摊到多个从库上,缓解单库压力。注意主从同步有延迟,刚写完马上读可能读到旧数据,对一致性敏感的读要走主库。
再往上,如果是分析型查询(大范围扫描、多维聚合、跑报表),传统的事务型数据库(行式存储)本来就不擅长。这类负载更适合 OLAP / 列式存储的数据仓库——它为"扫海量行、只算某几列"而生。
一句话:事务型库(行式)扛在线业务的点查和小范围操作,分析型库(列式 / OLAP)扛报表和大范围聚合。分析需求重到一定程度,正解是把数据同步到数仓里跑,而不是在业务库上硬扛。
六、一张表:症状 → 原因 → 对策
把全文对到真实排查场景,一张表带走:
| 症状 | 可能原因 | 对策 |
|---|---|---|
| 单条查询全表扫、慢 | 没建索引 / 索引失效 | 建合适的索引;EXPLAIN 确认走了索引;查失效情形(函数、隐式转换、前缀模糊) |
| 查询返回少但扫描行数巨大 | SELECT *、深分页、回表多 | 只取必要列(争取覆盖索引);深分页改游标 / 书签法 |
| 翻页越往后越慢 | LIMIT offset 要跳过大量行 | 游标分页:WHERE id > :lastId ORDER BY id LIMIT n |
| 多表 join 慢 | 关联字段没索引 / join 太多 | 给关联字段建索引;减少 join,必要时拆成多次查询 |
| 看板 / 报表聚合慢 | 每次实时 GROUP BY 扫海量明细 | 预聚合:定时任务算好存汇总表,看板查汇总表 |
| 数据量爆炸、整体变慢 | 单表过大 | 分区(按时间)、冷数据归档、读写分离 |
| 大范围分析 / 多维聚合慢 | 行式库不擅长分析负载 | 同步到 OLAP / 列式数仓里跑 |
名词解释
- EXPLAIN:在 SQL 前加它,数据库会返回这条查询的执行计划(走不走索引、扫多少行、怎么 join),不真正执行。排查慢查询的第一工具。
- 慢查询日志(Slow Query Log):数据库把执行时间超过阈值的查询自动记录下来,用于发现"到底哪条慢"。
- 全表扫描(Full Table Scan):不走索引,从头到尾把整张表一行行读一遍,O(n)。慢查询最常见的元凶。
- B+ 树索引:数据库主流的索引结构,把数据组织成有序的多层树,查找是 O(log n);可以理解成一套帮你快速定位的"有序目录"。它是单独存储、额外占空间的结构,不是一张表。
- 聚簇索引 / 二级索引:聚簇索引(主键)的叶子节点直接存数据行,主键索引与数据一体;二级索引是另一棵树,叶子只存"索引字段 + 主键 id",按它查到 id 后要回表取完整行。
- 联合索引(复合索引):对多个字段按顺序建的一个索引,如
(a, b, c)。 - 最左前缀:联合索引只能高效支持"从最左字段开始、连续的前缀"组合(
a/a+b/a+b+c);单独按中间或末尾字段查用不上。 - 覆盖索引:一条查询要的字段全在索引里,数据库直接从索引返回结果,无需回表。
- 回表:二级索引里通常只有索引字段和主键 id;要的字段不在索引里时,得用主键再回主表捞完整行,这一步就是回表。
- 深分页:
LIMIT offset, n中 offset 很大时,数据库要读出并丢弃前 offset 行,扫描量随页码线性增长。 - 游标 / 书签分页:用上一页最后一条的有序键(如主键 id)作为书签,下一页
WHERE id > :lastId续上,避免跳过大量行;代价是不能随机跳页。 - 预聚合 / 汇总表:用定时任务把明细数据按固定维度(如按天)提前
GROUP BY算好,存进一张小的汇总表,看板直接查汇总表。换来快,代价是数据有延迟、要维护任务。 - 分区表:把一张大表按规则(常按时间)切成多个物理分区,查询只扫相关分区。
- 读写分离:写主库、读从库,分摊读压力;主从同步有延迟,需注意刚写完即读的一致性。
- OLAP / 列式存储:面向分析型负载(大范围扫描、多维聚合)的数据库 / 数仓,按列存储,适合"扫海量行只算某几列"的报表场景;与之相对的是面向在线事务的行式数据库。
配套 demo:backend-notes/01-data/query-demo ——
node demo.js一跑,用"扫描行数 + 耗时"看清三件事:场景一点查从全表扫到走索引(50000 → 50 行),场景二聚合从实时重算到查预聚合汇总表(50000 → 1 行),场景三深分页从 offset 到游标(40020 → 20 行)。本文属《研发都要懂的事》· 数据存储专题。上一篇:缓存:为什么快、三大坑、和数据库怎么保持一致。完整代码与系列在 GitHub · backend-notes。
评论(0)
登录后参与评论。
还没有评论,来抢沙发吧。

