Skip to content

杀死 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 模式,把属性存成行而不是列。

聪明的选择。但当你写查询代码时,噩梦开始了:

go
// 步骤 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 次10ms110ms
50 条51 次10ms510ms
100 条101 次10ms1010ms

一秒钟。用户点了"查询"按钮,就得等这一秒。更糟的还在后面:高并发时,每个请求 101 次查询会迅速吃光数据库连接池;应用层在循环里不断等待网络 I/O;如果数据库在另一个区域,单次往返可能是 50ms,100 条记录就是 5 秒。

这就是 N+1 查询,一个在 ORM 世界里臭名昭著的性能杀手。

英雄登场:让数据库做它擅长的事

问题的根源在于,我们把"组装数据"的活交给了应用层。应用层在循环中一条一条地问数据库要详情,数据库也只能一条一条地回答。

但数据库本来就是干这个的。它有索引、有连接优化器、有向量化执行引擎,天生就是用来批量处理数据的。所以把循环搬到数据库里,一次查询返回所有数据。

CTE + JSON_AGG

PostgreSQL 的 CTE(Common Table Expression)和 JSON_AGG 函数是这个方案的核心。

先看完整 SQL,再解释原理:

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:

go
// 一次查询,所有数据
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~15ms86%
50 条51 次1 次510ms~20ms96%
100 条101 次1 次1010ms~25ms97%

在跨区域部署(单次往返 50ms)的场景下:

记录数优化前延迟优化后延迟降幅
100 条5050ms~80ms98%

从 5 秒到 80 毫秒。用户体验从"这网站是不是挂了"变成"秒开"。

小结:优化前 100 条记录是 101 次查询、1 秒以上延迟;优化后是 1 次查询、25ms 延迟。跨区域部署下,5 秒变成 80ms,提升 98%。

为什么这个方案有效?

网络往返是最大的敌人

CPU 和内存的速度以纳秒计,网络往返以毫秒计,差了 6 个数量级。减少网络往返,往往比优化 CPU 计算更有效。

数据库是数据处理专家

PostgreSQL 的查询优化器经过几十年打磨,知道如何高效执行 JOIN、如何利用索引、如何并行处理。把数据聚合的工作交给它,比在应用层循环处理快得多。

JSON_AGG 被低估了

很多人只把 PostgreSQL 当关系型数据库用,忘了它从 9.4 版本开始就是一个不错的 JSON 数据库。JSON_AGGJSON_BUILD_OBJECT 可以在数据库层面完成复杂的数据重组,避免在应用层做低效的循环拼接。

CPU 与网络的权衡:何时切换策略

CTE + JSON_AGG 的本质是用 CPU 换更少的网络往返。这笔交易什么时候不划算,值得说清楚。

盈亏平衡分析

数据库内聚合获胜的条件:

网络延迟 × 记录数 > CPU聚合时间 + 1次往返

代入真实数字:

场景网络往返延迟记录数N+1 延迟CTE 延迟赢家
同机器 (localhost)0.5ms10~5ms~8msN+1
同数据中心2ms10~20ms~10msCTE
同数据中心2ms100~200ms~25msCTE
跨区域50ms10~500ms~60msCTE
跨区域50ms100~5000ms~80msCTE

当数据库和应用在同一数据中心时,临界点大约在 5-10 条记录;跨区域场景下,即使只有 2-3 条记录也值得用 CTE。

何时在应用层保留聚合

有三种情况 CTE + JSON_AGG 不是答案。

第一种是服务共置时的极小结果集。如果你只获取 3-5 条记录,而且应用和数据库跑在同一台机器上(开发环境或小型部署常见),在数据库中构建 JSON 的开销可能超过 N+1 的代价。

go
// 对于小型、共置的工作负载,简单查询可能更快
if recordCount < 5 && isLocalhost {
    // 简单的 N+1 在这里没问题
}

第二种是复杂的逐记录转换。如果每条记录都需要无法用 SQL 表达的业务逻辑(调用外部 API、复杂校验、ML 推理),你无论如何都要循环,把聚合推到数据库没有帮助。

go
// 例如:每条记录需要调用外部 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)单条记录,数据量极小
批量导出 / OLAPDuckDB + Parquet列式存储,专为此设计

"内存爆炸"这种场景,也就是在 PostgreSQL 中对数万条记录聚合数百个属性,在这样分层的系统里根本不会发生。每一层各司其职。

混合策略

在实践中,Forma 使用混合策略:

查询类型记录数策略原因
分页列表20-100CTE + JSON_AGG数据库内聚合的甜蜜区
单记录详情1直接查询无需聚合
批量导出1000+流式游标避免内存压力
实时仪表盘不定取决于延迟预算先测量,再决定

决策流程图

                    ┌─────────────────┐
                    │ 你要获取多少    │
                    │ 条记录?        │
                    └────────┬────────┘

              ┌──────────────┼──────────────┐
              │              │              │
           1-5 条         5-500 条       500+ 条
              │              │              │
              ▼              ▼              ▼
    ┌─────────────────┐ ┌─────────────┐ ┌─────────────────┐
    │ 数据库和应用    │ │ 使用 CTE +  │ │ 使用流式游标    │
    │ 是否共置?      │ │ JSON_AGG    │ │ 或分页          │
    └────────┬────────┘ │ (默认)    │ └─────────────────┘
             │          └─────────────┘
        是   │  否
             │   │
             ▼   ▼
    ┌─────────┐ ┌─────────┐
    │ N+1 可以│ │ 用 CTE  │
    └─────────┘ └─────────┘

测量你的具体工作负载

经验法则只是起点,要测量:

sql
-- 添加 EXPLAIN ANALYZE 查看实际执行时间
EXPLAIN ANALYZE
WITH filtered_ids AS (...)
SELECT ...;

在真实网络条件下和你的 N+1 实现做对比。上面的数字只是参考,实际结果会随几个因素变化:实际网络延迟(用 ping 或分布式追踪测)、数据库连接池配置、你的 EAV schema 复杂度,以及索引覆盖率。

适用场景与限制

这个方案适合属性存成行、需要聚合成记录的 EAV 模式,适合一次查询多条记录的批量场景(分页列表、批量导出等),也适合数据库与应用不在同一机房、网络延迟敏感的部署。

限制也要知道。你需要 PostgreSQL 9.4+,才有 JSON_AGGJSON_BUILD_OBJECT。CTE 嵌套过深会影响可读性,建议封装为视图或存储过程。JSON_AGG 会在内存中构建 JSON 数组,超大结果集需要注意内存限制。

下一步:当数据量超过单机怎么办?

这篇文章解决了 N+1 查询问题,上一篇介绍了热表和 JSON Schema 的设计。还有一个问题没有回答:当历史数据积累到亿级,PostgreSQL 单机扛不住怎么办?

第三篇接着讲冷数据:DuckDB + CDC + Parquet 的 Serverless 湖仓,以及脏读是怎么被挡住的。

系列导航

本文基于 Forma 项目的工程实践。Forma 是一个为 AI 时代设计的灵活数据存储引擎。