杀死 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 次查询会迅速吃光数据库连接池;应用层在循环里不断等待网络 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 做了什么?
每个 WITH 子句就像流水线上的一个工位,数据从上一步流到下一步。JSON_AGG 把多行 EAV 数据聚合成一个 JSON 数组,一个 row_id 对应一个 JSON。整个查询只有一次数据库交互,所有数据打包返回。
CTE 如何消除往返
CTE 相当于在数据库内部搭一条流水线。没有 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 秒以上延迟;优化后是 1 次查询、25ms 延迟。跨区域部署下,5 秒变成 80ms,提升 98%。
为什么这个方案有效?
网络往返是最大的敌人
CPU 和内存的速度以纳秒计,网络往返以毫秒计,差了 6 个数量级。减少网络往返,往往比优化 CPU 计算更有效。
数据库是数据处理专家
PostgreSQL 的查询优化器经过几十年打磨,知道如何高效执行 JOIN、如何利用索引、如何并行处理。把数据聚合的工作交给它,比在应用层循环处理快得多。
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 不是答案。
第一种是服务共置时的极小结果集。如果你只获取 3-5 条记录,而且应用和数据库跑在同一台机器上(开发环境或小型部署常见),在数据库中构建 JSON 的开销可能超过 N+1 的代价。
// 对于小型、共置的工作负载,简单查询可能更快
if recordCount < 5 && isLocalhost {
// 简单的 N+1 在这里没问题
}第二种是复杂的逐记录转换。如果每条记录都需要无法用 SQL 表达的业务逻辑(调用外部 API、复杂校验、ML 推理),你无论如何都要循环,把聚合推到数据库没有帮助。
// 例如:每条记录需要调用外部 API
for _, record := range records {
record.EnrichedData = callExternalAPI(record.ID) // SQL 无法做到这点
}第三种是内存受限的数据库服务器。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 处理复杂分析。批量导出、跨数万条记录的复杂聚合、OLAP 类查询,这些确实会给 JSON_AGG 带来压力,但它们本来就不该用 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 湖仓,以及脏读是怎么被挡住的。
系列导航
- [第一篇] 为什么 EAV 是 AI 时代最被低估的数据模型
- [第二篇] 杀死 N+1:一次 SQL 优化如何让延迟从 1 秒降到 25 毫秒 ← 当前
- [第三篇] 零脏读的 Serverless 湖仓:我们如何用 DuckDB 解决一致性难题(完结)
本文基于 Forma 项目的工程实践。Forma 是一个为 AI 时代设计的灵活数据存储引擎。