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 次查询会迅速吃光数据库连接
  • CPU 空转:应用层在循环中不断等待网络 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 做了什么?

  1. CTE 链式处理:每个 WITH 子句就像流水线上的一个工位,数据从上一步流到下一步
  2. JSON_AGG 魔法:把多行 EAV 数据聚合成一个 JSON 数组,一个 row_id 对应一个 JSON
  3. 一次往返:整个查询只有一次数据库交互,所有数据打包返回

CTE 如何消除往返

CTE(Common Table Expression,公共表表达式)就像在数据库内部搭建一条流水线。没有 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 秒以上延迟
  • 优化后: 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.5ms10~5ms~8msN+1
同数据中心2ms10~20ms~10msCTE
同数据中心2ms100~200ms~25msCTE
跨区域50ms10~500ms~60msCTE
跨区域50ms100~5000ms~80msCTE

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

何时在应用层保留聚合

CTE + JSON_AGG 并非万能:

1. 服务共置时的极小结果集

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

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

2. 复杂的逐记录转换

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

go
// 例如:每条记录需要调用外部 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)单条记录,数据量极小
批量导出 / 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 湖仓架构,以及最关键的——如何解决大家对"Lakehouse 读脏数据"的信任危机。

系列导航

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