|

Aimee

Write the Code. Change the World.

查询优化:慢查询怎么治 —— 索引 + 预聚合

· 分享镜

一个用得好好的列表页或数据看板,数据一多、时间周期一拉长,就开始转圈、甚至超时。第一反应往往是"加个缓存挡一下",但很多时候根因压根不在缓存,而在查询本身 —— 它扫了太多行。

这篇把治慢查询最该懂的几件事讲清楚:怎么定位慢在哪、索引为什么快又何时失效、查询本身还有哪些坑,以及聚合类看板的终极解法——预聚合。配套一个零依赖 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

登录后参与评论。

还没有评论,来抢沙发吧。

回到顶部