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
两个关键数字:
- join 策略碰了 16098 个缓冲页,query 策略碰了 186 个。差 86 倍。
- join 策略的那个
Aggregate节点写着loops=2500——它是"外层每行执行一次"的相关子查询。2500 篇 × (一次索引扫描 + 一次堆扫描 + 一次 JSON 聚合)。这就是"一条 SQL"的账单:它把 N 次往返换成了一次往返里的 N 次循环。
还有一件事很反直觉:那个 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
生产边界
- 本章所有数字来自一张 5000 行的实验表,且数据基本全在缓存。 缓冲页的比值(16098 vs 186)比毫秒数更可迁移;毫秒数会随硬件、缓存命中率、网络 RTT 全变。
IN列表有它的上限。 本实验里 2500 个元素的列表还能被规划器处理得不错,但这个成本是超线性的(计划耗时 11.944 ms 已经超过执行时间)。主表返回上万行时,"一次批量取回"就变成了另一种问题——正确的做法是先限制主表返回的行数(分页),而不是指望关联取数机制替你兜住。- N+1 的判据是"SQL 条数随数据量增长",不是"某次查询慢"。 本实验里那个 51 条 SQL 的接口只花了 26 毫秒,在测试环境完全看不出问题。这类问题必须在代码评审阶段靠结构识别,不能等它慢下来才发现。
relationLoadStrategy与预览特性耦合。 升级或改动 generator 配置时,默认策略可能跟着变。上线前要重新跑一遍关联查询的 SQL 计数,这是一条会被忽略的回归点。- join 策略在
to-many上会放大传输量。 主表 2500 行 × 每行 4 条子记录,聚合后的 JSON 是一个大对象。当子记录本身很宽(含text大字段)时,这个影响会从 CPU 转到网络。 - "每篇只取 2 条评论"这类需求,两种策略的实现差异要看清楚再写。 这是本章没有展开的一格:子表的
take在两种策略下的语义与代价都不一样。遇到这类需求,先写出两种写法,比 SQL 条数和总耗时,再决定。
动手:可观察结果
| 产出 | 判断标准 |
|---|---|
| 一次 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 那次索引扫描变成什么 |
自测题
include没有 N+1,这个结论对在哪里、错在哪里?"2 条 SQL"里第二条的代价是什么?- query 策略的第二条 SQL 里,
IN ($1,...,$50)的 50 是从哪来的?为什么它没有LIMIT? - join 策略为什么可以只发一条 SQL?
__prisma_data__这一列里的东西是怎么拼出来的? - 同一个
findMany,为什么在主表 100 行时 join 略快、3000 行时慢一倍?loops=2500读出来的是什么? - 2500 个值的
IN列表为什么没走索引?"没走索引"在这个场景里是坏事吗? - 为什么 query 策略 3.202 ms 的执行时间旁边,挂着 11.944 ms 的计划时间?这个数字在什么情况下会变成主要成本?
_count: { select: { comments: true } }看起来只是取一个数字。它为什么还是LATERAL?- 改一行 generator 配置(加
previewFeatures)会让全站关联查询换策略。这件事对"上线前要验什么"提出了什么要求?
现在能解释什么
- 为什么
include能消掉 N+1——它把 N 次子查询合并成 1 次批量查询,而不是取消了子查询; - 为什么"一条 SQL"不等于"一次工作":
LEFT JOIN LATERAL把 N 次往返换成了 1 次往返里的 N 次循环; - 为什么关联是"多对一"还是"一对多"会彻底改变结论,以及"多对一"为什么是最安全的场景;
- 为什么批量取回的
IN列表会随数据量增长,以及这个增长会在哪一刻变成主要成本(计划时间); - 为什么"只取计数"和"取内容"在数据库看来差别没那么大;
- 为什么一个默认值可能是两个不同值——以及为什么这件事必须在回归清单里单独列一条。
下一步:04 章 · 写路径:从 upsert 到嵌套写 —— 读的那一侧看完了,接下来是写:一次 upsert 是真的一条语句吗,一次带子表的 create 又发了什么。