单条查询都不慢,慢的是一千二百次往返。
一、8 秒的列表页与日志里 1,200 条同样的查询
周二下午,一个团队账号的项目列表打不开。他先刷自己的账号,12 个项目,0.6 秒;再登客户的账号,100 个项目,8.0 秒。同一段代码,差别只在数据量。
他的第一反应是数据库 CPU 打满了。看了一眼监控:CPU 32%,连接数 5/100,磁盘 IO 正常。资源都不紧张,于是他把怀疑从「数据库慢」改成「这个请求到底做了什么」。
慢查询日志里没有一条超过 500 ms 的语句,最长的一条 11 ms。他改成按请求统计:一个列表请求打了 1,200 条查询,平均每条 6.7 ms,加起来正好 8.0 秒。每一条都合格,合起来不合格。
以下情境为教学假设,人物、引语和数字不代表经核实的真实案例。
| 层级 | 每个项目次数 | 100 个项目合计 | 该层耗时 |
|---|---|---|---|
| 字段、权限、最近活动 | 3 | 300 | 1.6 s |
| 任务、子任务、评论计数 | 3 | 300 | 2.4 s |
| 成员、负责人、里程碑 | 3 | 300 | 2.0 s |
| 附件、未读、标签 | 3 | 300 | 2.0 s |
| 合计 | 12 | 1,200 | 8.0 s |
数据库不会为「次数」报警。 监控盯的是单条耗时与 p95,1,200 条 6.7 ms 的查询在每一项指标上都合格;只有把一次请求的查询数加起来,问题才会显形。
他还发现第二个代价:列表请求把应用进程占住 8 秒,同一账号的其它请求只能排在后面。用户感知到的是「整个产品卡住」,而不只是「这一页慢」。
复现方式也要固定:同一个客户账号、同一个列表页、冷缓存、不并发。他第一次在自己账号上测,12 个项目只打 144 条查询,0.6 秒,几乎看不出问题。
二、1,200 = 100 × 12 的 N+1 结构
代码结构很普通:先取 100 个项目的 ID,再对每个项目循环取 12 类附属数据;日志里那 1,200 条就是后半段。一次查询带出 N 行,然后每行再查若干次,这就是 N+1。
12 类里有 6 类只需要一个数字、时间或布尔值(字段、权限、最近活动、任务数、子任务数、评论数),可以在主查询里用一次 LATERAL 取回;另外 6 类围绕成员与附件,分成两次批量聚合。问题不在 SQL 写得差,而在循环的位置。
一个具体的坏写法是:取到项目列表后,在模板里对每个项目调用 project.members.count()。模板循环几次,就查几次;页面越丰富,N 越大。
N+1 的代价不在单条,而在往返。 每条查询都要解析、计划、执行、回包;数据量小的查询里,固定开销占了大头。100 个项目把这份固定开销乘了 1,200 次。
把 12 类逐条列出来会发现,它们的形状高度重复:都是「按 project_id 取一小撮数据」。重复的形状意味着可以合并,而不是意味着要为每一类单独优化。
合并的边界是「能靠 project_id 一次批量取回」。6 类计数与单值进主查询,6 类跨表关系留在两条聚合里,用一次 JOIN 或一次 IN 查询取回,再在应用层按 project_id 分组。
三、三个索引与 3 次查询
改法分两层:先把 12 类合并成 3 次查询,再给这 3 条查询配上索引。只做前者,次数降了但每条仍可能扫全表;只做后者,1,200 次会快一点,仍然是 1,200 次。
| 查询 | 改前次数 | 改后次数 | 改后耗时 | 依赖索引 |
|---|---|---|---|---|
| 主查询(含字段、权限、活动、计数) | 600 | 1 | 0.12 s | projects(account_id, updated_at desc)、tasks(project_id, status) |
| 聚合一:成员、负责人、里程碑 | 300 | 1 | 0.11 s | project_members(project_id, user_id) |
| 聚合二:附件、未读、标签 | 300 | 1 | 0.12 s | 既有主键与外键索引 |
| 合计 | 1,200 | 3 | 0.35 s | 新增 3 个索引 |
三个新索引各自对应一条访问路径:projects 按账号与更新时间取 100 行;project_members 按 project_id 批量取;tasks 按 project_id 与 status 分组计数。列顺序按「等值在前、排序在后」排。
以主查询里的计数为例,tasks(project_id, status) 让分组在索引上完成,不必回表读整行;100 个项目的计数在一次扫描里出结果。
索引不是越多越好。三个索引让写入多了一点维护成本,但项目、任务与成员表的写入频率远低于列表页读取;在一个人维护的系统里,这笔交换划算。
EXPLAIN 要看扫描行数与回表次数。 改完后三条都是索引扫描:主查询 100 行,两条聚合各 100 组,没有一条再按项目逐次回表。
改前他也跑过 EXPLAIN,看到的是 Index Scan,于是认为索引没问题。问题在于每条都快,而条数太多;执行计划解释不了「同一形状的语句被调用 1,200 次」。
建索引只花了几分钟,写入延迟没有可感知的变化。如果这张表是每秒数百次写入,同样的三个索引就要重新评估,不能照搬这个结论。
0.35 秒的构成是 0.12 + 0.11 + 0.12。三条查询仍然串行;若要再快,可以并行或加缓存,但缓存是第五篇的事。本文停在「3 次、0.35 秒」。
四、每天 16.8 分钟 → 44 秒
每天 420 次页面浏览,列表页占 30%,即 126 次。改前 126 × 8.0 = 1,008 秒 = 16.8 分钟;改后 126 × 0.35 = 44.1 秒。
一天省 963.9 秒,约 16.1 分钟。这个数字比「快了 23 倍」更有用,因为它能对上人的等待:每次从 8 秒降到 0.35 秒,用户不再切到别的标签页去等。
「快了 23 倍」来自 8.0 ÷ 0.35 ≈ 22.9;按百分比说,耗时降到原来的 4.4%(0.35 ÷ 8.0)。两个说法都对,但都不如每天省下 16.1 分钟直观。
7 小时投入按每天 16.1 分钟回收,约 26 天回本。7 小时里大约 2 小时读日志与 EXPLAIN,3 小时改查询与合并聚合,2 小时建索引并复测。
按月看,30 天省 963.9 × 30 = 28,917 秒 ≈ 8.0 小时,已经超过 7 小时的投入;第一个月结束时,这一项就是净收益。
复算:
改前查询数 = 100 个项目 × 12 次/项目 = 1,200 次
改后查询数 = 1 条主查询 + 2 条聚合 = 3 次
单次平均耗时 = 8.0 s ÷ 1,200 ≈ 6.67 ms
改后耗时 = 0.12 + 0.11 + 0.12 = 0.35 s
每天列表页加载 = 420 次浏览 × 30% = 126 次
改前每天 = 126 × 8.0 = 1,008 s = 16.8 min
改后每天 = 126 × 0.35 = 44.1 s ≈ 44 s
每天省 = 1,008 − 44.1 = 963.9 s ≈ 16.1 min
7 h = 420 min;回本天数 = 420 ÷ 16.1 ≈ 26.1 天
合计:1,200 → 3 次、8.0 → 0.35 s、每天 1,008 → 44 s,花 7 h。
先把次数降下来,再把每条变快。 顺序反了,就会在 1,200 条查询上逐条调索引,收益被次数吃掉。
五、行动清单与复算验收
查询优化最容易被做成「看到慢就加索引」。按下面的顺序,先确认次数,再确认单条。
- 在一次列表请求上打点,统计总查询数与总耗时。
- 按请求而非按语句看日志,找出「单条不慢但次数多」的接口。
- 画出 N+1 结构:主查询几行、每行再查几类。
- 把计数与存在性判断合并进主查询,减少往返。
- 为三条保留查询各写一次 EXPLAIN,记录扫描行数。
- 按「等值在前、排序在后」建索引,确认没有全表扫描。
- 用 100 个项目的账号复测,确认 3 次查询与 0.35 秒。
- 把「列表页查询 ≤ 5 次」写进发布检查。
验收标准:你能说出一次列表请求打了几条查询、每条走哪个索引;说不清,说明你看到的还只是单条慢查询日志。
使用边界与实操验收
本文只处理一次请求内的查询次数、聚合方式与索引选择,不讨论 Postgres 参数调优与硬件规格。
缓存不归本文:命中率、三层 TTL 与写后失效是第五篇的内容;本文的 0.35 秒是未命中缓存时的数字。
索引有写入成本。写多读少的表要单独判断,不能照搬「等值在前、排序在后」这一条。
计数可以接受轻微滞后时,物化计数比实时聚合更合适;但那需要缓存与后台任务,属于后两篇的范围。
3 次是这条路径的结果,不是所有列表页的标准。预算写的是「列表页查询 ≤ 5 次」,留了两次余量给后续功能。
不要把 ORM 的「预加载」当成万能解。它可能生成一条巨大的 JOIN,把 1,200 次往返换成一次传输过量数据。
100 个项目是客户账号的规模,不是全站平均;小账号改前也只有 0.6 秒,所以这个问题半年没被发现。
查询数必须按请求统计。单条日志永远看不出 N+1,只有把一次请求内的语句数加起来,8.0 秒才有解释。
如果业务要求计数实时且强一致,合并聚合会变复杂;先确认「差几秒」是否真的影响用户,再决定要不要付出这份复杂度。
什么时候停:查询数进入预算(≤ 5 次)、每条都走索引、EXPLAIN 没有全表扫描,就停手;继续压 0.35 秒的收益远小于把时间花在缓存或首屏上。
关键参考与核验范围
- PostgreSQL: Using EXPLAIN :用于核对执行计划、扫描方式与代价的读法。
- PostgreSQL: Indexes :用于核对索引类型与多列索引的列顺序。
- Use The Index, Luke :用于核对索引设计与分页查询的一般做法。
- PostgreSQL Wiki: Performance Optimization :用于核对查询优化的排查顺序。
以上链接于 2026-10-26 实测可公开访问;未核验版本、功能范围与政策更新。核验日期:2026-10-26。
范围上只讨论列表页的 N+1、聚合改写与索引,不讨论连接池、缓存策略与数据库扩容。
系列导航
- 上一篇:首屏:412 KB 的去处与一次 1.8 秒的字体阻塞
- 下一篇:缓存:三层、86% 命中与一次 11 分钟的旧数据
- 回到目录:一个人的性能与账单:4.2 秒的首屏、一千二百次查询与每月 210 美元
- 相关阅读:容量与成本:先找瓶颈,再谈扩容
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。