












一个用得好好的列表页或数据看板,数据一多、时间周期一拉长,就开始转圈、甚至超时。第一反应往往是"加个缓存挡一下",但很多时候根因压根不在缓存,而在查询本身 —— 它扫了太多行。
这篇把治慢查询最该懂的几件事讲清楚:怎么定位慢在哪、索引为什么快又何时失效、查询本身还有哪些坑,以及聚合类看板的终极解法——预聚合。配套一个零依赖 demo,
node demo.js就能看到"扫描行数"怎么从五万降到个位数。
治慢查询的第一步不是改 SQL,是先搞清楚它慢在哪。凭感觉改,十有八九改错地方。
数据库给了两件趁手的工具。
第一件:EXPLAIN,看执行计划。 在一条 SQL 前面加 EXPLAIN,数据库会告诉你它打算怎么执行这条查询——走不走索引、扫多少行、用什么方式 join。最该盯的两个信息:
一句话点破:慢查询的本质,绝大多数时候就是"扫的行太多"。 后面所有招式,本质都是在想办法少扫行。
第二件:慢查询日志(Slow Query Log)。 数据库可以把执行时间超过某个阈值(比如 1 秒)的查询自动记下来。它解决的是"我根本不知道哪条慢"的问题——线上几百条 SQL,靠人盯不现实,让数据库自己把慢的挑出来,再逐条 EXPLAIN 分析。
顺序很重要:先用慢查询日志找出慢的 SQL,再用
EXPLAIN看它为什么慢,最后才动手优化。跳过定位直接改,是在赌。
定位完发现是全表扫,最常见的解法就是加索引。但索引不是"加了就快"的银弹,得懂它为什么快、怎么建对、什么时候会悄悄失效。
没有索引,数据库查一个值只能全表扫描:从第一行摸到最后一行,挨个比对,n 行就是 O(n)。表越大越慢,而且是线性变慢。
索引(主流是 B+ 树)相当于给数据建了一套有序目录。查一个值,从树根往下找,每往下一层就排除掉一大半数据,n 行只需要查大约 log(n) 次。直观对比:一百万行,全表扫要摸一百万次,B+ 树大约 20 次就定位到了。这就是 O(n) → O(log n) 的差距,数据量越大,差距越悬殊。
demo 场景一:五万行的订单表,按 userId 查,全表扫要摸 50000 行,建了索引后只摸命中的 50 行。
容易误解的一点:索引不是"一张表",而是一份单独存储、额外占磁盘空间的有序数据结构(关系型库主流是 B+ 树)。可以理解成书后面的索引页——正文(表数据)之外,另做一份"关键词 → 位置"的有序目录;你建几个索引,就多几份不同排序的目录。
这里还藏着一个区分,正好解释什么叫"回表":
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 行。代价是不能随机跳页(只能上一页 / 下一页),但对"无限下拉"的信息流场景完全够用。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 / 列式数仓里跑 |
(a, b, c)。a / a+b / a+b+c);单独按中间或末尾字段查用不上。LIMIT offset, n 中 offset 很大时,数据库要读出并丢弃前 offset 行,扫描量随页码线性增长。WHERE id > :lastId 续上,避免跳过大量行;代价是不能随机跳页。GROUP BY 算好,存进一张小的汇总表,看板直接查汇总表。换来快,代价是数据有延迟、要维护任务。配套 demo:backend-notes/01-data/query-demo ——
node demo.js一跑,用"扫描行数 + 耗时"看清三件事:场景一点查从全表扫到走索引(50000 → 50 行),场景二聚合从实时重算到查预聚合汇总表(50000 → 1 行),场景三深分页从 offset 到游标(40020 → 20 行)。本文属《研发都要懂的事》· 数据存储专题。上一篇:缓存:为什么快、三大坑、和数据库怎么保持一致。完整代码与系列在 GitHub · backend-notes。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。