惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

L
LangChain Blog
V
V2EX
爱范儿
爱范儿
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Martin Fowler
Martin Fowler
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Apple Machine Learning Research
Apple Machine Learning Research
WordPress大学
WordPress大学
有赞技术团队
有赞技术团队
宝玉的分享
宝玉的分享
Last Week in AI
Last Week in AI
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
罗磊的独立博客
小众软件
小众软件
Vercel News
Vercel News
博客园 - 司徒正美
阮一峰的网络日志
阮一峰的网络日志
V
Visual Studio Blog
J
Java Code Geeks
P
Proofpoint News Feed
MongoDB | Blog
MongoDB | Blog
B
Blog
美团技术团队
量子位

Aimee's Blog

秒杀怎么防机器人:验证码之外的几道防线 防薅羊毛怎么做:号码风控、设备指纹与分层策略 接口限流怎么选:固定窗口、滑动窗口、令牌桶 批量打包下载怎么设计:流式 ZIP 与异步任务 图片处理怎么做:异步处理与 CDN 实时参数 大文件上传怎么设计:分片、断点续传、秒传 报表可重跑的设计:快照、水位线与幂等 UV 统计为什么有误差:精确计数与近似计数怎么选 大数据量导出怎么做:流式写入与异步任务 排行榜的两类需求,难度差了一个数量级 内容审核怎么选:先审后发,还是先发后审 实时热榜怎么设计:Redis ZSet 与热度分值 互关状态怎么保持同步:双向关系的维护与缓存失效 并发点赞的计数设计:原子操作与最终对账 删了父评论,子评论怎么处理——先想清楚用户看到的是什么 定时消息任务触发了两次 消息量大,写扩散还是读扩散——接到这个需求先问清楚规模 同一条消息推送了三次 活动结束了,用户还在收短信 接到注销需求,先问两个问题 微服务与服务拆分:何时拆、怎么拆 异步与事件驱动架构:把协作从「打电话」改成「发消息」 高可用设计:怎么让系统尽量不宕机 可扩展性设计:怎么让系统加机器就能扛更多 缓存架构:多级缓存怎么搭 高并发三板斧:限流、熔断、降级 架构设计到底在设计什么 —— 从单体到微服务的演进 服务成本账:一个服务一个月烧多少钱 可观测性:线上出问题怎么查 API 设计:好接口长什么样
查询优化:慢查询怎么治 —— 索引 + 预聚合
Aimee · 2026-06-08 · via Aimee's Blog

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

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