Day 114:从 N+1 查询到批量聚合——列表页性能优化的正确顺序
Day 114:从 N+1 查询到批量聚合——列表页性能优化的正确顺序 上周给栏目页加「每篇文章摘要 + 标签云」之后,本地测试一切正常,上线后发现分类列表页在高峰期翻到第二页要转两秒。今天没有写新功能,专门把这个列表页的性能问题从头到尾捋了一遍,顺便把「性能优化到底先做什么、后做什么」这件事想明白了一些。 症状…
- 入库时间
- 下载量
- 0
- 人审标记
- 已人工审核
Day 114:从 N+1 查询到批量聚合——列表页性能优化的正确顺序
上周给栏目页加「每篇文章摘要 + 标签云」之后,本地测试一切正常,上线后发现分类列表页在高峰期翻到第二页要转两秒。今天没有写新功能,专门把这个列表页的性能问题从头到尾捋了一遍,顺便把「性能优化到底先做什么、后做什么」这件事想明白了一些。
症状定位:先量再改,不要凭感觉
第一步不是改代码,而是把请求时间拆开看。在 API 入口和数据库连接层各加了一个计时点,请求带 request_id 串联,结论很清晰:两千多行日志里,列表接口耗时约一千八百毫秒,其中数据库侧占了一千六百毫秒——典型的数据库瓶颈,而不是网络或模板渲染。
接着用 pg_stat_statements 按总耗时排序,二十分钟就抓到了凶手:标签查询平均执行三百二十次每请求。也就是说,列表页返回二十篇文章,就为每篇文章单独发一次标签查询,合计近四百条 SQL,接近排队上限。这是教科书级的 N+1 查询。每次往返看着只有几毫秒,乘上次数再乘上并发,就成了用户感知到的两秒。
为什么 N+1 总在列表页出现
ORM 和懒加载让这件事变得太自然了:代码里写 article.tags,框架就帮你每篇查一次。单篇详情页没问题,列表页二十篇就是二十次往返。每次往返在同城数据库大概是三到五毫秒,看着不大,但它们是串行的,乘上二十就是七八十毫秒;再叠上分页和并发请求,连接池被短查询填满,排队等待进一步放大延迟。除此之外,每次查询的解析、执行计划生成本身也是固定开销,批量把这部分开销摊薄,收益往往比给单条 SQL 加索引更明显。
修复顺序:两步走
第一步:批量 IN 查询
先不改数据结构,只做最朴素的改写:列表查询拿到二十个文章 id 之后,用 WHERE article_id = ANY($1) 一次性把标签全部取回来,在应用层按 article_id 分组拼装成 map。这一步几乎没有侵入性:业务代码仍然假定「每篇文章带自己的标签」,只是取数方式变了。上线后列表接口从一千八百毫秒降到五百毫秒左右——N+1 消失了。
第二步:聚合下推到数据库
但大部分耗时仍然在 SQL 侧。于是把聚合直接下推:一条 SQL 用 LEFT JOIN 加 array_agg,把二十篇文章的标签一次性聚合成 article_id, tags[] 两列返回。SQL 长这样:
SELECT a.id, array_remove(array_agg(t.name ORDER BY t.name), NULL) AS tags
FROM articles_v4 a
LEFT JOIN article_tags at ON at.article_id = a.id
LEFT JOIN tags t ON t.id = at.tag_id
WHERE a.category_id = $1 AND a.locale = $2 AND a.status = 'published'
GROUP BY a.id
ORDER BY a.published_at DESC
LIMIT 20;
一条语句替代了过去「先取列表、再逐篇取标签」两轮负载。数据库真正的敌人是往返次数,并发连接数是放大器——这条经验已经被反复验证过了。首先考虑一次加载:二十篇文章的标签共同取回,列表页的成本不再随页面文章数线性增长;其次考虑 SQL 层聚合:分页越深,把过滤、关联、聚合都压进同一条语句,收益越明显。
溢出的收获
优化越靠前,收益越便宜。列表接口全量切到批量风格后,接口耗时下降到九十毫秒。这次经验再次强调,性能优化不要全凭经验直接跳进索引和缓存。定位、修掉最大的收益,顺序远比工具重要。
检查清单
- 先用 request_id 串联分段计时,确认瓶颈在应用、数据库还是网络
- pg_stat_statements 按总耗时排序,不猜、先量
- 列表接口默认用批量预加载(batch loader)或 JOIN,避免 N+1
- 任何「每篇 × 子表」的读取都要在列表场景单独看执行次数
- 优先在同一轮查询里做聚合,再考虑索引,最后才考虑缓存
- 改动后对比压测记录归档
优化完成后,高峰期列表接口中位数耗时稳定在九十毫秒以内,慢查询告警也没有再触发。这只修复了最耗时的一段,但顺序对了,后面的事才有的谈。