杀死 N+1:一次 SQL 优化如何让延迟从 1 秒降到 25 毫秒
Forma 工程博客 · 系列第二篇
TL;DR
我们将数据库查询次数从 101 次减少到 1 次,延迟从 1000ms 降至 25ms——降幅达 97%。秘诀不是什么黑魔法,而是一个被严重低估的 PostgreSQL 特性:CTE + JSON_AGG。
如果你正在用 EAV(Entity-Attribute-Value)模式存储灵活结构的数据,并且被 N+1 查询折磨得死去活来,这篇文章就是为你写的。
这不是老旧技术。 CTE + JSON_AGG 模式与现代数据栈无缝配合。Forma 使用 DuckDB 查询冷数据,并导出到 S3 上的 Parquet。无论你是直接查询 PostgreSQL,还是在冷热存储之间运行联邦查询,同样的查询优化原则都适用。如果你在构建湖仓架构,这些技术依然相关。
反派登场:N+1 查询的噩梦
先来看一个真实场景。
你的 SaaS 应用有一个"联系人"功能,不同客户的联系人字段完全不同:客户 A 需要 12 个字段,客户 B 需要 30 个字段,客户 C 每周都在改。为了避免每次加字段都要改表结构(ALTER TABLE),你选择了 EAV 模式——把属性存成行而不是列。
聪明的选择。但当你写查询代码时,噩梦开始了:
// 步骤 1: 查询 EAV 表,获取符合条件的 row_id (1 次查询)
rowIDs := db.Query("SELECT DISTINCT row_id FROM eav_table WHERE ...")
// 步骤 2: 对每个 row_id 循环查询主表 (N 次查询!)
for _, rowID := range rowIDs {
record := db.Query("SELECT * FROM entity_main WHERE row_id = ?", rowID)
results = append(results, record)
}这段代码看起来很直观,但它有一个致命问题:查询 100 条记录需要 101 次数据库往返。
让我们算笔账:
| 记录数 | 查询次数 | 单次往返延迟 | 总延迟 |
|---|---|---|---|
| 10 条 | 11 次 | 10ms | 110ms |
| 50 条 | 51 次 | 10ms | 510ms |
| 100 条 | 101 次 | 10ms | 1010ms |
一秒钟!用户点击"查询"按钮后要等一秒钟才能看到结果。更糟糕的是:
- 连接池耗尽:高并发时,101 次查询会迅速吃光数据库连接
- CPU 空转:应用层在循环中不断等待网络 I/O
- 跨区域部署?翻倍!:如果数据库在另一个区域,单次往返可能是 50ms,100 条记录就是 5 秒
这就是 N+1 查询——一个在 ORM 世界里臭名昭著的性能杀手。
英雄登场:让数据库做它擅长的事
问题的根源是什么?
我们把"组装数据"的活交给了应用层。应用层在循环中一条一条地问数据库:"给我这条记录的详情",数据库只能一条一条地回答。
但数据库本来就是干这个的啊!它有索引、有连接优化器、有向量化执行引擎——它天生就是用来批量处理数据的。
解决方案:把循环搬到数据库里,一次查询返回所有数据。
CTE + JSON_AGG:一次查询搞定一切
PostgreSQL 的 CTE(Common Table Expression)和 JSON_AGG 函数是这个方案的核心。
先看完整 SQL,再解释原理:
WITH
-- 步骤 1: 找出符合条件的记录 ID
filtered_ids AS (
SELECT DISTINCT row_id
FROM eav_table
WHERE schema_id = $1 AND /* 过滤条件 */
),
-- 步骤 2: 排序 + 分页
paginated AS (
SELECT row_id
FROM filtered_ids
ORDER BY /* 排序条件 */
LIMIT $page_size OFFSET $offset
),
-- 步骤 3: 获取主表数据
main_data AS (
SELECT m.*
FROM paginated p
JOIN entity_main m ON m.row_id = p.row_id
),
-- 步骤 4: 聚合 EAV 属性为 JSON
eav_json AS (
SELECT
p.row_id,
JSON_AGG(
JSON_BUILD_OBJECT(
'attr_id', e.attr_id,
'value', COALESCE(e.value_text, e.value_numeric::text)
)
) AS attributes
FROM paginated p
JOIN eav_table e ON e.row_id = p.row_id
GROUP BY p.row_id
)
-- 最终结果: 主表 + EAV 属性,一次返回
SELECT m.*, COALESCE(e.attributes, '[]') AS attributes_json
FROM main_data m
LEFT JOIN eav_json e ON e.row_id = m.row_id;这段 SQL 做了什么?
- CTE 链式处理:每个
WITH子句就像流水线上的一个工位,数据从上一步流到下一步 - JSON_AGG 魔法:把多行 EAV 数据聚合成一个 JSON 数组,一个
row_id对应一个 JSON - 一次往返:整个查询只有一次数据库交互,所有数据打包返回
CTE 如何消除往返
CTE(Common Table Expression,公共表表达式)就像在数据库内部搭建一条流水线。没有 CTE 时,你的应用和数据库在打乒乓球:
应用: "给我 ID 列表" → DB 响应(往返 1)
应用: "给我 ID 1 的详情" → DB 响应(往返 2)
应用: "给我 ID 2 的详情" → DB 响应(往返 3)
...重复 101 次...使用 CTE,你只发送一条指令:
应用: "数据库,你来找出 ID,获取详情,
聚合成 JSON,然后一次性把所有东西给我。"数据库的查询规划器会把整个流水线作为单个执行单元优化。它可以:
- 在所有步骤中高效使用索引
- 并行化独立操作
- 避免步骤之间的序列化/反序列化开销
这就是计算下推——让数据库做它本来就擅长的事。
应用层只需要解析 JSON:
// 一次查询,所有数据
rows := db.Query(cteSQL, schemaID, pageSize, offset)
for rows.Next() {
var record Record
var attributesJSON string
rows.Scan(&record, &attributesJSON)
json.Unmarshal(attributesJSON, &record.Attributes)
results = append(results, record)
}性能对比:数字不会说谎
优化前后的对比:
| 记录数 | 优化前查询次数 | 优化后查询次数 | 优化前延迟 | 优化后延迟 | 降幅 |
|---|---|---|---|---|---|
| 10 条 | 11 次 | 1 次 | 110ms | ~15ms | 86% |
| 50 条 | 51 次 | 1 次 | 510ms | ~20ms | 96% |
| 100 条 | 101 次 | 1 次 | 1010ms | ~25ms | 97% |
在跨区域部署(单次往返 50ms)的场景下:
| 记录数 | 优化前延迟 | 优化后延迟 | 降幅 |
|---|---|---|---|
| 100 条 | 5050ms | ~80ms | 98% |
从 5 秒到 80 毫秒。用户体验从"这网站是不是挂了"变成"秒开"。
划重点:
- 优化前: 100 条记录 = 101 次查询 = 1 秒以上延迟
- 优化后: 100 条记录 = 1 次查询 = 25ms 延迟
- 跨区域部署: 5 秒 → 80ms(提升 98%)
为什么这个方案有效?
1. 网络往返是最大的敌人
在现代系统中,CPU 和内存的速度以纳秒计,而网络往返以毫秒计——差了 6 个数量级。减少网络往返次数,往往比优化 CPU 计算更有效。
2. 数据库是数据处理专家
PostgreSQL 的查询优化器经过几十年的打磨,它知道如何高效地执行 JOIN、如何利用索引、如何并行处理。把数据聚合的工作交给它,比在应用层循环处理快得多。
3. JSON_AGG 是被低估的神器
很多人只把 PostgreSQL 当关系型数据库用,忘了它从 9.4 版本开始就是一个优秀的 JSON 数据库。JSON_AGG + JSON_BUILD_OBJECT 可以在数据库层面完成复杂的数据重组,避免在应用层做低效的循环拼接。
CPU 与网络的权衡:何时切换策略
CTE + JSON_AGG 的本质是用 CPU 换取更少的网络往返。但天下没有免费的午餐——理解这个权衡何时有意义非常重要。
盈亏平衡分析
每种优化都有一个临界点。以下是思考框架:
数据库内聚合获胜的条件:
网络延迟 × 记录数 > CPU聚合时间 + 1次往返让我们代入真实数字:
| 场景 | 网络往返延迟 | 记录数 | N+1 延迟 | CTE 延迟 | 赢家 |
|---|---|---|---|---|---|
| 同机器 (localhost) | 0.5ms | 10 | ~5ms | ~8ms | N+1 |
| 同数据中心 | 2ms | 10 | ~20ms | ~10ms | CTE |
| 同数据中心 | 2ms | 100 | ~200ms | ~25ms | CTE |
| 跨区域 | 50ms | 10 | ~500ms | ~60ms | CTE |
| 跨区域 | 50ms | 100 | ~5000ms | ~80ms | CTE |
关键洞察: 当数据库和应用在同一数据中心时,临界点大约在 5-10 条记录;但在跨区域场景中,即使只有 2-3 条记录也值得使用 CTE。
何时在应用层保留聚合
CTE + JSON_AGG 并非万能:
1. 服务共置时的极小结果集
如果你只获取 3-5 条记录,而且应用和数据库运行在同一台机器上(开发环境或小型部署常见),在数据库中构建 JSON 的开销可能超过 N+1 的代价。
// 对于小型、共置的工作负载,简单查询可能更快
if recordCount < 5 && isLocalhost {
// 简单的 N+1 在这里没问题
}2. 复杂的逐记录转换
如果每条记录都需要无法用 SQL 表达的业务逻辑(调用外部 API、复杂校验、ML 推理),你无论如何都要循环。把聚合推到数据库没有帮助。
// 例如:每条记录需要调用外部 API
for _, record := range records {
record.EnrichedData = callExternalAPI(record.ID) // SQL 无法做到这点
}3. 内存受限的数据库服务器
JSON_AGG 在返回之前会在内存中构建完整的 JSON 数组。对于非常大的结果集(1 万行以上,带复杂嵌套数据),这可能给数据库服务器造成内存压力。这种情况下,逐行流式返回结果可能更安全。
但在实践中,这很少成为问题。 Forma 的架构提供了三层保护:
第一层:热表消除了列表查询的聚合需求
| 查询类型 | 需要的字段 | 数据来源 | 需要 JSON_AGG? |
|---|---|---|---|
| 分页列表 | 5-10 个热字段 | 仅 entity_main | 不需要 |
| 详情视图 | 所有字段,1 条记录 | entity_main + EAV | 需要,但很小 |
大多数查询(80%+)是列表视图,只需要热字段——根本不需要 JSON_AGG。
第二层:真实记录的字段数有限
即使聚合 EAV 属性,典型记录最多也就 30-50 个字段。一个包含姓名、电话、邮箱、公司、地址和 20 个自定义字段的 CRM 联系人?每条记录约 2-5KB 的 JSON。对于 100 条记录的批量查询,也就 200-500KB——任何数据库都能轻松处理。
第三层:DuckDB 处理复杂分析
对于那些确实会给 JSON_AGG 带来压力的场景——批量导出、跨数万条记录的复杂聚合、OLAP 类查询——你本来就不应该用 PostgreSQL。这正是 DuckDB + Parquet 层的用武之地(见第三篇)。
| 工作负载 | 解决方案 | 原因 |
|---|---|---|
| 列表视图 | 热表 (PostgreSQL) | 有 B-tree 索引,无需聚合 |
| 详情视图 | CTE + JSON_AGG (PostgreSQL) | 单条记录,数据量极小 |
| 批量导出 / OLAP | DuckDB + Parquet | 列式存储,专为此设计 |
"内存爆炸"场景——在 PostgreSQL 中对数万条记录聚合数百个属性——在架构合理的系统中根本不会发生。每一层各司其职。
混合策略
在实践中,Forma 使用混合策略:
| 查询类型 | 记录数 | 策略 | 原因 |
|---|---|---|---|
| 分页列表 | 20-100 | CTE + JSON_AGG | 数据库内聚合的甜蜜区 |
| 单记录详情 | 1 | 直接查询 | 无需聚合 |
| 批量导出 | 1000+ | 流式游标 | 避免内存压力 |
| 实时仪表盘 | 不定 | 取决于延迟预算 | 先测量,再决定 |
决策流程图
┌─────────────────┐
│ 你要获取多少 │
│ 条记录? │
└────────┬────────┘
│
┌──────────────┼──────────────┐
│ │ │
1-5 条 5-500 条 500+ 条
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────┐ ┌─────────────────┐
│ 数据库和应用 │ │ 使用 CTE + │ │ 使用流式游标 │
│ 是否共置? │ │ JSON_AGG │ │ 或分页 │
└────────┬────────┘ │ (默认) │ └─────────────────┘
│ └─────────────┘
是 │ 否
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ N+1 可以│ │ 用 CTE │
└─────────┘ └─────────┘测量你的具体工作负载
不要相信经验法则。测量:
-- 添加 EXPLAIN ANALYZE 查看实际执行时间
EXPLAIN ANALYZE
WITH filtered_ids AS (...)
SELECT ...;在真实网络条件下与你的 N+1 实现进行对比。上面的数字是指导性的——你的实际情况会因以下因素而异:
- 实际网络延迟(使用
ping或分布式追踪) - 数据库连接池配置
- 你的 EAV schema 复杂度
- 索引覆盖率
适用场景与限制
适合这个方案的场景
- EAV 模式:属性存储为行,需要聚合成记录
- 批量查询:一次查询多条记录(分页列表、批量导出等)
- 网络延迟敏感:数据库与应用不在同一机房
需要注意的限制
- PostgreSQL 9.4+:需要
JSON_AGG和JSON_BUILD_OBJECT函数 - 复杂查询的可维护性:CTE 嵌套过深会影响可读性,建议封装为视图或存储过程
- 内存使用:
JSON_AGG会在内存中构建 JSON 数组,超大结果集需要注意内存限制
下一步:当数据量超过单机怎么办?
这篇文章解决了 N+1 查询问题,上一篇文章介绍了热表和 JSON Schema 的设计。但还有一个问题没有回答:
当历史数据积累到亿级,PostgreSQL 单机扛不住怎么办?
第三篇将介绍我们如何用 DuckDB + CDC + Parquet 构建 Serverless 湖仓架构,以及最关键的——如何解决大家对"Lakehouse 读脏数据"的信任危机。
系列导航
- [第一篇] 为什么 EAV 是 AI 时代最被低估的数据模型
- [第二篇] 杀死 N+1:一次 SQL 优化如何让延迟从 1 秒降到 25 毫秒 ← 当前
- [第三篇] 零脏读的 Serverless 湖仓:我们如何用 DuckDB 解决一致性难题(完结)
本文基于 Forma 项目的工程实践。Forma 是一个为 AI 时代设计的灵活数据存储引擎。