KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03 · include 到底发几条 SQL — keel 龙骨

这一章回答:一次带关联的查询,到底往数据库发了几条语句——以及"发一条"和"发得对"为什么是两件事。

这一章回答:一次带关联的查询,到底往数据库发了几条语句——以及"发一条"和"发得对"为什么是两件事。

现场:一个列表页,51 条 SQL

要显示"最新的 50 篇已发布文章,每篇带作者名"。最直觉的写法是这样:

const posts = await prisma.posts.findMany({
  where: { status: "published" },
  orderBy: { id: "asc" },
  take: 50,
  select: { id: true, title: true, author_id: true },
});

for (const p of posts) {
  const u = await prisma.users.findUnique({
    where: { id: p.author_id },
    select: { display_name: true },
  });
  render(p.title, u?.display_name);
}

逻辑没毛病:先取文章,再逐篇取作者。跑起来也不慢——因为我们这个实验表只有 5000 行、全在缓存里。

把客户端的查询事件数一遍:

SQL 条数 = 51   Prisma 侧耗时合计 = 26.1 ms

50 篇文章,51 条 SQL。 这就是 N+1:一个"取 N 条主记录"的查询,拖出了 N 条子查询。它的真正危险不是那 26 毫秒,而是这个数字和列表页大小成正比。今天列表是 50 条,明天产品说要 500 条,明天这个接口就是 501 条 SQL、每次请求建立 501 次往返。数据库还好,网络往返先扛不住。

换一种写法,include(在本课程的例子里,因为 schema 里字段名是 users,所以写出来是 users: { select: ... }):

const posts = await prisma.posts.findMany({
  where: { status: "published" },
  orderBy: { id: "asc" },
  take: 50,
  select: { id: true, title: true, users: { select: { display_name: true } } },
});
SQL 条数 = 1   Prisma 侧耗时合计 = 1.0 ms

一条。 于是最常见的结论就出来了:"用 include 就没有 N+1"。这个结论方向对、理由错,而错误的理由会让你在真正需要判断的地方失手。接下来两节把理由补上。

一、它长什么样:三条路的 SQL 原文

先把三条路的完整 SQL 摊开。这是本实验室跑出来的原文,只有换行被压平过。

路径 A:循环里逐条查(51 条)

-- 第 1 条:取主表
SELECT "t0"."id", "t0"."title", "t0"."author_id"
FROM "public"."posts" AS "t0"
WHERE "t0"."status" = CAST($1::text AS "public"."post_status")
ORDER BY "t0"."id" ASC LIMIT $2 OFFSET $3
-- params: ["published","50","0"]

-- 第 2~51 条:一模一样的一条语句,重复 50 次,每次换一个 id
SELECT "t0"."id", "t0"."display_name"
FROM "public"."users" AS "t0"
WHERE ("t0"."id" = $1 AND 1=1) LIMIT $2
-- params: [3,"1"]  [4,"1"]  [5,"1"]  … 共 50 条,逐条执行

注意第 2 条起那 50 次调用的形状完全相同、只有参数不同。数据库有能力把它们合并,但它不知道这 50 次调用属于同一个业务动作——它们对你是一次列表渲染,对数据库是 50 次互不相干的请求。

路径 B:include,query 策略(2 条)

-- 第 1 条:还是取主表,但多带一列外键
SELECT "public"."posts"."id", "public"."posts"."title", "public"."posts"."author_id"
FROM "public"."posts"
WHERE "public"."posts"."status" = CAST($1::text AS "public"."post_status")
ORDER BY "public"."posts"."id" ASC LIMIT $2 OFFSET $3

-- 第 2 条:把主表那一趟收集到的外键值,一次性批量取回
SELECT "public"."users"."id", "public"."users"."display_name"
FROM "public"."users"
WHERE "public"."users"."id" IN ($1,$2,$3,...,$50) OFFSET $51

这才是 include 的真实机制:不是"省掉了子查询",而是"把 N 次子查询合并成 1 次批量查询"。

代价被挪了个位置,没消失。看第二条 SQL 的 IN ($1,...,$50)——占位符个数等于主表返回的行数。50 条就是 50 个,5000 条就是 5000 个。这条语句的文本长度随 N 线性增长,规划器要为它做的工作也随 N 线性增长。另外注意它没有 LIMIT:批量取回的是全部匹配行,不是"每篇作者最多一个"。

路径 C:include + join 策略(1 条)

SELECT "t0"."id", "t0"."title", "t0"."author_id",
       "posts_users"."__prisma_data__" AS "users"
FROM "public"."posts" AS "t0"
LEFT JOIN LATERAL (
  SELECT JSONB_BUILD_OBJECT('display_name', "t1"."display_name") AS "__prisma_data__"
  FROM "public"."users" AS "t1"
  WHERE "t0"."author_id" = "t1"."id"
  LIMIT $1
) AS "posts_users" ON true
WHERE "t0"."status" = CAST($2::text AS "public"."post_status")
ORDER BY "t0"."id" ASC LIMIT $3

这里有一件很多人没料到的事:它没有把关联表的列摊到结果集里。 关联的那一行被 JSONB_BUILD_OBJECT 打包成一个 JSON 值,放在一个叫 __prisma_data__ 的列里回传,客户端再解析出来。

这就是为什么它一次往返就够了:主表和关联表之间不需要"多出来几行",关联数据被塞进了一个单元格。LIMIT $1 是 1——因为 posts → users 是"多对一",一篇只有一个作者,所以子查询最多取一行。

二、join 策略不是"更好",是"另一种代价"

到这里的对照还只是"1 条 vs 2 条"。真正需要判断的场景是关联是一对多的时候——比如"文章 + 每篇的评论"。把 users 换成 comments,同一条 findMany,N 从 100 变到 3000:

N(篇) query 策略 join 策略
100 3.5 ms 2.9 ms
1000 11.6 ms 14.7 ms
3000 18.9 ms 34.6 ms

到 3000 篇时 join 策略慢了一倍。为什么?EXPLAIN 说得最清楚。这是同一次查询(3000 篇 → 实际 2500 篇 → 10000 条评论)在两个策略下的真实计划:

=== join 策略 ===
Limit
  ->  Nested Loop Left Join
        ->  Index Scan using posts_pkey on posts t0
              Filter: (status = ('published'::cstring)::post_status)
              Rows Removed by Filter: 2500
              Buffers: shared hit=1098
        ->  Aggregate                       ← loops=2500
              ->  Bitmap Heap Scan on comments t1
                    Heap Blocks: exact=10000
                    Buffers: shared hit=15000
                    ->  Bitmap Index Scan on comments_post_id_idx
                          Index Cond: (post_id = t0.id)
                          Buffers: shared hit=5000
Buffers: shared hit=16098
Execution Time: 29.157 ms

=== query 策略(第二条 SQL 的等价形式)===
Seq Scan on comments
  Filter: (post_id = ANY ('{2,3,4,8,9,...}'::integer[]))
  Rows Removed by Filter: 10000
  Buffers: shared hit=186
Planning Time: 11.944 ms
Execution Time: 3.202 ms

两个关键数字:

还有一件事很反直觉:那个 2500 个值的 IN 列表并没有走索引。规划器把它改写成 post_id = ANY('{...}'),然后发现这批值覆盖了 comments 表 50% 的行,于是选了顺序扫描——一次扫完 10000 行只用 186 个页,比逐行定位快得多。所以这一局的胜负不是"性能高低",而是:"一次扫描 1 万行"和"2500 次各扫 4 行",哪个便宜。

顺便看 Planning Time:query 策略 11.944 ms,比它的执行时间 3.202 ms 还长。规划一个 2500 个元素的 IN 列表本身的成本不可忽略。这也是"占位符随 N 线性增长"要付的账。

三、策略在哪里选,以及一个会翻车的默认值

relationLoadStrategy 这个参数控制走哪条路:"query" 或 "join"。

关键的一点是——它在本实验里需要开预览特性才能用:

generator client {
  provider        = "prisma-client"
  output          = "../generated/prisma"
  previewFeatures = ["relationJoins"]
}

不开的时候,写 relationLoadStrategy: "join" 会直接报 Unknown argument relationLoadStrategy,因为生成的类型里根本没有这个字段。原因是它没进正式 API。

而更需要注意的是开了之后默认值会变:

# 未开 relationJoins
include 默认 → 2 条 SQL(query 策略)

# 开了 relationJoins
include 默认 → 1 条 SQL(join 策略)

同一个 include,因为改了一行 generator 配置,编译出来的东西完全不同。这意味着一行配置的改动会让全站的关联查询集体换一种执行方式——包括那些你没动过的接口。下面的判断表里的取舍,都要放在"这个默认值可能是哪一个"的前提下去读。

怎么选:

情况 倾向 理由
关联是多对一(文章 → 作者) 都可以,join 更省一次往返 不会放大行数,LIMIT 1 子查询很轻
关联是一对多,且主表行数不大(几十到几百) 都可以 量小的时候两边差距在毫秒级,别过度优化
关联是一对多,主表行数上千 倾向 query 策略 逐行 LATERAL 的循环成本随主表行数线性增长
需要限制子表的行数(每篇文章只取 2 条评论) 看清两种策略各怎么实现 这是最容易被"一条 SQL"误导的场景
数据库与应用之间的延迟很高 倾向 join 少一次往返的价值随 RTT 上升

唯一可靠的判断方式是量。 上面那张表里的数字全部来自一次真跑,而且换一张表、换一个数据分布就会变——Seq Scan 还是 Index Scan 取决于那批 IN 值覆盖了多少行,这个比例一变,结论就变。

四、_count 也是 LATERAL

同一个机制还有一个更隐蔽的出口。你只想显示"每篇文章有几条评论",不取评论内容:

await prisma.posts.findMany({
  take: 3,
  select: { id: true, _count: { select: { comments: true } } },
});

编译出来是:

SELECT "t0"."id",
       JSONB_BUILD_OBJECT('comments', COALESCE("t1"."_aggr_count_comments", 0)) AS "_count"
FROM "public"."posts" AS "t0"
LEFT JOIN LATERAL (
  SELECT COUNT(*) AS "_aggr_count_comments"
  FROM "public"."comments" AS "t2"
  WHERE "t0"."id" = "t2"."post_id"
) AS "t1" ON true
ORDER BY "t0"."id" ASC LIMIT $1

又是 LEFT JOIN LATERAL——每篇文章一次 COUNT(*)。这个写法看起来"只取个数字"很便宜,实际上在主表行数大时和 join 策略是同一个问题。"只取计数"不等于"不用扫子表"。

本章脉络

flowchart TD
    A["要显示:N 篇主记录 + 每篇的关联数据"] --> B{"怎么写?"}
    B -- "循环里逐条查" --> C["N+1:N+1 条 SQL<br/>50 篇 → 51 条"]
    B -- "include" --> D{"relationLoadStrategy<br/>(默认值受预览特性影响)"}
    D -- query --> E["2 条 SQL:主表 + IN 批量<br/>占位符随 N 线性增长<br/>无 LIMIT"]
    D -- join --> F["1 条 SQL:LEFT JOIN LATERAL<br/>关联行聚成 JSONB 列"]
    F --> G{"关联是 to-one<br/>还是 to-many?"}
    G -- to-one --> H["子查询 LIMIT 1<br/>不放大行数"]
    G -- to-many --> I["Aggregate loops=N<br/>每行执行一次子计划"]
    I --> J["16098 页 / 29.157 ms"]
    E --> K["186 页 / 3.202 ms<br/>但计划耗时 11.944 ms"]
    F -. "同一个机制" .-> L["_count 也是逐行 LATERAL<br/>只取计数不等于不扫子表"]
    style C fill:#ffebee,color:#b71c1c
    style J fill:#fff3e0,color:#e65100
    style K fill:#e8f5e9,color:#1b5e20
    style L fill:#fff3e0,color:#e65100

生产边界

动手:可观察结果

产出 判断标准
一次 N+1 的 SQL 计数 能拿出"主表 50 行 → 51 条 SQL"的证据,并说清第 2~51 条为什么形状相同
同一查询在两种策略下的 SQL 原文 能指出 query 策略的 IN 占位符个数、join 策略的 LIMIT $1 各是怎么来的
一张 N 增长的分歧表 能指出两条线在哪个量级交叉,并解释交叉点由什么决定
同一次查询的两份 EXPLAIN 能找到 loops=2500 和 = ANY('{...}') 这两处,并说出它们各自的代价来源
一次 _count 的 SQL 能指出它也是 LATERAL,并说明"只取计数"为什么没省掉扫子表

完成标志:给定一个带关联的列表页需求,你能先写出它会有几条 SQL、关联多的时候会走哪条路、大概碰多少个页的量级(只看比值),再用查询日志和 EXPLAIN 核对。

故障注入

注入方式 观察
把循环里的 findUnique 换成 fetch 手写并发 SQL 条数变不变;连接池够不够用
把 include 的 take 从 50 提到 5000 query 策略的 IN 占位符个数;计划耗时是否超过执行时间
在 to-many 关联上加 take: 2 两种策略下各生成什么 SQL;结果集是否一致
去掉 previewFeatures = ["relationJoins"] 重新生成 原来能跑的 relationLoadStrategy: "join" 报什么错;默认策略是否翻回去
打开 relationJoins,但显式传 relationLoadStrategy: "query" 是否还能走回 2 条 SQL 的路径
把 select 里关联的字段从 display_name 扩到该表全部字段 join 策略回传的 JSON 有多大;多字段对两种策略的影响是否同向
给 comments.post_id 删掉索引再跑 query 策略那条 Seq Scan 变成什么;join 策略的 loops=N 那次索引扫描变成什么

自测题

  1. include 没有 N+1,这个结论对在哪里、错在哪里?"2 条 SQL"里第二条的代价是什么?
  2. query 策略的第二条 SQL 里,IN ($1,...,$50) 的 50 是从哪来的?为什么它没有 LIMIT?
  3. join 策略为什么可以只发一条 SQL?__prisma_data__ 这一列里的东西是怎么拼出来的?
  4. 同一个 findMany,为什么在主表 100 行时 join 略快、3000 行时慢一倍?loops=2500 读出来的是什么?
  5. 2500 个值的 IN 列表为什么没走索引?"没走索引"在这个场景里是坏事吗?
  6. 为什么 query 策略 3.202 ms 的执行时间旁边,挂着 11.944 ms 的计划时间?这个数字在什么情况下会变成主要成本?
  7. _count: { select: { comments: true } } 看起来只是取一个数字。它为什么还是 LATERAL?
  8. 改一行 generator 配置(加 previewFeatures)会让全站关联查询换策略。这件事对"上线前要验什么"提出了什么要求?

现在能解释什么

下一步:04 章 · 写路径:从 upsert 到嵌套写 —— 读的那一侧看完了,接下来是写:一次 upsert 是真的一条语句吗,一次带子表的 create 又发了什么。

进入 keel 阅读