KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

01 · 一次 findMany 编译成了什么 — keel 龙骨

这一章回答:你在 JS 里写的那十几行对象,到数据库那边变成了什么——以及它替你多写了哪些字。

这一章回答:你在 JS 里写的那十几行对象,到数据库那边变成了什么——以及它替你多写了哪些字。

现场:一行代码,两屏 SQL

一个列表接口,要的是"最新的 20 篇已发布文章的标题和阅读量":

const posts = await prisma.posts.findMany({
  where: { status: "published" },
  orderBy: { created_at: "desc" },
  take: 20,
  select: { id: true, title: true, view_count: true },
});

打开客户端日志(log: [{ emit: "event", level: "query" }]),数据库实际收到的是这样:

SELECT "public"."posts"."id", "public"."posts"."title", "public"."posts"."view_count"
FROM "public"."posts"
WHERE "public"."posts"."status" = CAST($1::text AS "public"."post_status")
ORDER BY "public"."posts"."created_at" DESC
LIMIT $2 OFFSET $3
params: ["published","20","0"]

对照着看一遍:select 里的三个字段、where 的条件、orderBy 的方向、take 都还在。但多出来四样东西——完全限定名、一个你没写过的 CAST、三个占位符、以及你从没要求过的 LIMIT $2 OFFSET $3 里的 OFFSET。

这四样东西不是噪声。它们是这一章的全部内容:你对 SQL 的直觉是从手写 SQL 里长出来的,而 ORM 生成的 SQL 遵守另一套书写规则。 不把这套规则拆开,你没法判断一条缓慢的 ORM 查询到底慢在哪——因为你在日志里看到的那行字,和你脑子里预演的那行字,根本不是同一行。

一、它长什么样:多出来的四样东西

先把完整形态摆出来,逐样拆。下面这段输出是照着上面那条 SQL 用 PREPARE / EXPLAIN EXECUTE 复现的(这样参数走的是真参数,不是把值拼进去),表是 posts(5000 行,其中一个索引是 (status, created_at DESC)):

PREPARE p1(text, bigint, bigint) AS
  SELECT "public"."posts"."id", "public"."posts"."title", "public"."posts"."view_count"
  FROM "public"."posts"
  WHERE "public"."posts"."status" = CAST($1::text AS "public"."post_status")
  ORDER BY "public"."posts"."created_at" DESC
  LIMIT $2 OFFSET $3;

EXPLAIN (ANALYZE, BUFFERS) EXECUTE p1('published', 20, 0);
Limit  (actual time=0.004..0.009 rows=20 loops=1)
  Buffers: shared hit=6
  ->  Index Scan using posts_status_created_idx on posts
        Index Cond: (status = ('published'::cstring)::post_status)
        Buffers: shared hit=6
Execution Time: 0.016 ms

索引用上了。 这一条先钉住,因为它是初学者最容易误判的地方,下一节单独说。

完全限定名:"public"."posts"."id"

Prisma 生成的每一列、每一张表都带 schema 前缀和双引号。原因不是"它啰嗦",而是它必须保证语义无歧义:

代价是可读性。你从 pg_stat_statements 或者慢查询日志里看到的是满屏引号,扫一眼认不出来是哪个接口发的——这个代价后面第 06 章还会碰到一次。

参数占位符:$1 $2 $3

值不拼进 SQL 文本,而是作为参数单独发送。这一条有两个后果,一好一坏:

好的一面是注入安全。 这是结构性的,不是"驱动帮你转义了特殊字符":

// $queryRaw 的模板标签形式:值走参数通道
const safe = await prisma.$queryRaw`SELECT count(*) AS c FROM posts WHERE title = ${"' OR 1=1 --"}`;
// → count = 0

那个 ' OR 1=1 -- 被当作一个普通的字符串值去和 title 比较,一行都不匹配,所以 count 是 0。如果它是被拼进 SQL 文本的,这条语句会变成 WHERE title = '' OR 1=1 --',返回全表。判据很简单:这个值是"值"还是"语法"?拼字符串是在把你的数据当语法用。

坏的一面是计划复用带来的不确定性。 参数化之后,PostgreSQL 会尝试用一份通用计划服务所有参数值。当某个参数的分布特别偏斜时,通用计划可能远不如为具体值定制的计划。同一个接口,第一个用户和第一万个用户可能走不同的计划——这既可能变快,也可能变慢,而且它和你的代码没关系。

LIMIT / OFFSET 也是参数

注意 LIMIT $2 OFFSET $3,params 是 ["published","20","0"]。分页大小是作为参数传的,不是字面量 20。

Prisma 7 总是这么发。后果是:分页参数进不了计划缓存的选择。正常手写 SQL 时 LIMIT 20 是个常量,规划器知道"只要 20 行",会更倾向于选索引扫描;而当它是参数时,规划器只能按估算的行数做决定。幸运的是在这个例子里两者一致(都走了索引),但这是巧合,不是保证——判据永远是 EXPLAIN 出来的那一行,不是"我这么写一般都用得上索引"。

顺带注意那个凭空出现的 OFFSET $3:你只写了 take: 20,没写 skip,它依然发 OFFSET 0。同理,所有 WHERE 里都会带一句 AND 1=1。这些是编译器的固定残渣,看着碍眼,无害。

CAST($1::text AS "public"."post_status"):为什么这行字没毁掉索引

status 是 PostgreSQL 的枚举类型 post_status,而 $1 是从驱动传进来的文本。类型对不上,所以要在参数上做一次转换。

这里是这门课第一个真正的分水岭。同一种"转换"写法,位置不同,结果差两个数量级:

写法 计划 缓冲页 执行
status = 'published' Index Scan using posts_status_created_idx 6 0.043 ms
status = CAST($1::text AS post_status)(Prisma 编译出来的) Index Scan using posts_status_created_idx 6 0.016 ms
status::text = 'published'(把转换包在列上) Seq Scan + Sort 735 2.828 ms

前两行是同一件事。第三行是灾难。

原因是一句可以直接用的话:索引里存的是列的值本身。要让索引能被用来定位,比较的左边必须就是那个列本身。 把列包进任何函数、任何转换、任何表达式,索引就失去了和"要查的那个值"的对应关系——规划器只能把每一行的 status 取出来转成文本再比较,那就只能全表扫。

而 CAST($1::text AS post_status) 转换的是右边:$1 已经是一个具体值,转换它只是把一个值变成另一个值,左边仍然是那个裸列。所以定位能力完好。

第三行还多了一个 Sort。索引 (status, created_at DESC) 除了定位,还顺带提供了顺序——status 相等的那一段里,created_at 天然是降序的,所以 ORDER BY created_at DESC 免排序。一旦 status 那一边失去了索引,这个顺序也没了,得老老实实排一遍。这就是"索引同时管定位和管顺序"的具象场面:一个写法失误丢掉的是两样东西。

二、谁在做这件事:Prisma 7 的编译管线

上面那些字是编译出来的,所以你得知道编译器在哪。装完 Prisma 7 之后有两件事和几年前完全不同:

第一,运行时没有 Rust 查询引擎了。 在旧版本里,客户端会下载一个和平台绑定的查询引擎二进制(query_engine-windows.dll.node 之类),你的 JS 调用会穿过它去访问数据库。Prisma 7 里这个东西不存在了:

$ find node_modules -iname "*query_engine*"
$            ← 无输出

$ ls node_modules/@prisma/engines/
schema-engine-windows.exe        ← 只剩这一个,20 MB,给 CLI 和迁移用

取代它的是一个 WASM 查询编译器加一个驱动适配器:编译(把你的调用变成 SQL)在 WASM 里做,执行(把 SQL 发给数据库)交给你自己装的驱动。这是为什么 Prisma 7 强制要求你传 adapter:

import { PrismaPg } from "@prisma/adapter-pg";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });

不传 adapter 就直接报错。这不是配置啰嗦,是架构变了——连接不再由 Prisma 自己管,而是由 pg 这个驱动管(第 07 章会看到这个改动的全部后果)。

第二,生成的客户端是 TypeScript 源码,不是编译好的库。 执行 prisma generate 之后:

generated/prisma/
├── client.ts
├── enums.ts
├── models.ts
├── models/{comments,posts,post_tags,tags,users}.ts
└── internal/{class.ts,prismaNamespace.ts,...}
                     共 13 个文件,410 KB

旧版本把产物塞进 node_modules/.prisma/client 里,是编译好的 JS 加类型声明。Prisma 7 直接把 TS 源码输出到你指定的目录,由你自己的构建链路去编译它。所以:

三、你在日志里看到的,和 DBA 看到的不是同一条

同一件事有三份记录,三份都不一样:

你看的地方 内容 用途
Prisma 的 query 事件 完整 SQL + params 数组(值已展开) 定位"哪个调用发了什么"
pg_stat_statements 相同形状的语句被归一化成一条,参数变 $1,带累计耗时与调用次数 定位"哪个形状最贵"
慢查询日志 超过阈值的完整语句 定位"哪次特别慢"

pg_stat_statements 的归一化是它最有价值也最容易误读的地方:它把 params 抹掉了。所以你在它里面看到"这条 SQL 调用了 80 万次、累计 40 分钟",能定位到是哪个形状,但定位不到是哪个用户、哪个参数触发的。反过来,Prisma 日志里能看到具体值,但它不告诉你累计代价。

两边都要用。这一章只说清一件事:"这条 SQL 慢"和"这个调用慢"是两个不同的问题,前者去统计视图里找,后者在客户端日志里找。

本章脉络

flowchart TD
    A["你写的对象<br/>findMany + where/orderBy/take/select"] --> B["WASM 查询编译器<br/>(Prisma 7:没有 Rust 引擎)"]
    B --> C["生成的 SQL 文本"]
    C --> D["① 完全限定名<br/>schema 前缀 + 双引号"]
    C --> E["② 参数占位符<br/>$1 $2 $3 —— 值走独立通道"]
    C --> F["③ 枚举 CAST<br/>转换在参数一侧"]
    C --> G["④ LIMIT / OFFSET 也是参数"]
    C --> H["⑤ 固定的 AND 1=1 残渣"]
    F --> I{"转换包在<br/>哪一侧?"}
    I -- 参数侧 --> J["索引完好<br/>Index Cond: status = ...<br/>6 页 / 0.016 ms"]
    I -- 列侧 --> K["索引失效<br/>Seq Scan + Sort<br/>735 页 / 2.828 ms"]
    E --> L["注入安全<br/>但计划可能被复用"]
    D --> M["排查时可读性下降<br/>日志与统计视图对不上名"]
    B --> N["生成的客户端 = 13 个 TS 文件<br/>类型从 schema 推导"]
    style J fill:#e8f5e9,color:#1b5e20
    style K fill:#ffebee,color:#b71c1c
    style L fill:#fff3e0,color:#e65100
    style M fill:#fff3e0,color:#e65100

生产边界

动手:可观察结果

产出 判断标准
一条 findMany 的查询日志 能逐字指出哪部分是"你写的"、哪部分是"它加的",四样多出来的东西一样不落
同一条 SQL 的两种 EXPLAIN 能拿出 Index Cond 那一行,说清参数侧转换与列侧转换的区别
一次 pg_stat_statements 查询 能指出归一化后参数消失在哪,并说出这个特性让你查不到什么
一次注入尝试 能对比 $queryRaw 模板标签与字符串拼接的结果差,并说明差在"值/语法"的区分上
一次 EXPLAIN 看 ORDER BY 能指出免排序与要 Sort 的分界,并把它和索引的列序对上

完成标志:随便给你一条 ORM 查询,你能先写出它大概会变成什么形状(限定名、占位符、枚举怎么办、能不能吃到索引),再打开日志和 EXPLAIN 核对——而不是反过来先看日志再去解释。

故障注入

注入方式 观察
在 where 里加一个 contains 模糊匹配 SQL 里出现了什么;(提示:LIKE ('%' || $1 || '%'),前导 % 意味着定位能力归零)
把 mode: "insensitive" 打开 谓词从 LIKE 变成什么;它在非 ASCII 上的默认行为是什么
把 select 从三个字段扩到全部字段 返回体积与 Buffers(索引还能不能覆盖)
把 take: 20 改成 skip: 3000, take: 20 OFFSET 变成多少;第 07 章会量化它的代价
用不支持的位置放 $queryRawUnsafe 它和模板标签形式在参数传递上的差别
在生成的 models/posts.ts 里改一个字段类型 prisma generate 之后会被覆盖成什么——验证 schema 才是唯一真相

自测题

  1. "public"."posts"."id" 里的 public 和双引号各自解决什么问题?不写会怎样?
  2. status = CAST($1::text AS post_status) 里的转换如果在左边,为什么索引就用不上了?用你自己的话说清"索引里存的是什么"。
  3. 同一个 status::text = 'published',计划里除了 Seq Scan 还多了一个 Sort。这个 Sort 是从哪来的?
  4. LIMIT $2 和 LIMIT 20 对规划器意味着什么差别?为什么这会让"这样写应该走索引"变成一句不可信的话?
  5. pg_stat_statements 把参数抹掉了,这个特性让哪一类问题好查、哪一类问题查不了?
  6. 为什么 Prisma 7 必须传 driver adapter?不传会失败在哪一层?
  7. 生成的客户端是 13 个 TS 文件而不是编译好的库,这对你的构建链路提出了什么新要求?

现在能解释什么

下一步:02 章 · schema 是一份两向的契约 —— 现在你知道调用会变成 SQL,接下来要看这份"能被编译"的东西是谁定义的:它既可以是从数据库里读出来的,也可以是从手里写出来推到数据库去的,而这两个方向的行为差异很大。

进入 keel 阅读