KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

07 · 管不到的那部分 — keel 龙骨

这一章回答:ORM 替你做完的那些事之外,还剩哪几件永远得你自己管——连接、深翻、方言,以及它装进来却用不上的东西。

这一章回答:ORM 替你做完的那些事之外,还剩哪几件永远得你自己管——连接、深翻、方言,以及它装进来却用不上的东西。

现场:两个凌晨的告警

第一条告警,列表页的 P99 从 40 毫秒涨到 1.8 秒,只发生在"翻到后面几页"的时候。

第二条告警,整站 500,日志里反复出现:

timeout exceeded when trying to connect

两条告警看起来毫无关系——一条是慢查询,一条是连接不上。它们其实是同一件事的两面:ORM 让你不用手写 SQL,但它没有让你不用管连接、不用管页偏移。 这一章把这两样东西量出来,再补上几件同类的事,作为整门课的收口。

一、连接池:默认只有 10 条,而且等待是有上限的

Prisma 7 之后连接由驱动(pg)管,池的大小默认是 pg 自己的默认值。实测:40 个并发查询压上去,从数据库侧数出真正被占用的连接数——

配置 40 并发时数据库侧观察到的连接峰值
不设 max 10
max: 5 5
max: 1 1

第二、三行说明 max 是生效的(配置从 new PrismaPg({ connectionString, max: 5 }) 传进去)。第一行是重点:你没配过它,它也不是"要多少给多少",就是 10。

10 条连接本身不是问题——大多数应用用不着更多。问题在于池满了之后发生什么。 把池占满(每条连接跑一个 4 秒的 pg_sleep),再从外面取一条连接:

配置 取连接的耗时 结果
max: 1, connectionTimeoutMillis: 800 807 ms 抛 timeout exceeded when trying to connect
max: 3, connectionTimeoutMillis: 1500 1509 ms 同上

等待时间恰好等于你设的超时值。这说明它的行为是"排队等,等到超时才放弃",不是"立刻失败"。

这个行为解释了很多真实故障的形态:接口不是立刻 500,而是先集体慢下来(请求排在队里),然后在一个固定延迟点上成批超时。 那个"服务一开始会变慢、然后突然全挂"的曲线特征,就是池耗尽。

几个可操作的判断:

二、深翻分页:代价随页码线性增长

回到第一条告警。列表页支持翻页,用 skip / take:

await prisma.posts.findMany({ orderBy: { id: "asc" }, skip: 3000, take: 20 });

编译出来是 ... ORDER BY "id" ASC LIMIT $2 OFFSET $3。看起来很正常。真正的问题是数据库为了跳过前 3000 行必须真的读过它们。

用一张 2 万行的表,对比"偏移分页"和"游标分页"(cursor: { id: 3001 })在翻到第 15000 行时的真实计划:

=== 偏移分页:LIMIT 20 OFFSET 15000 ===
Limit
  ->  Index Only Scan using comments_pkey on comments
        Heap Fetches: 0
        rows=15020                    ← 实际读到的行数
        Buffers: shared hit=45
Execution Time: 1.948 ms

=== 游标分页:WHERE id >= (SELECT id WHERE id = 15001) LIMIT 20 ===
Limit
  Buffers: shared hit=12
  InitPlan 1 (returns $0)
    ->  Index Only Scan using comments_pkey on comments
          Index Cond: (id = 15001)
          rows=1
  ->  Index Only Scan using comments_pkey on comments
        Index Cond: (id >= $0)
        rows=20                       ← 实际读到的行数
        Buffers: shared hit=12
Execution Time: 0.041 ms

15020 行 vs 20 行。 偏移分页读了 15020 行,只为丢掉前面 15000 行。缓冲页 45 比 12,执行时间差 47 倍——但倍数不是重点,重点是那两行 rows=:一个是"和页码成正比",一个是常数。

这就是这条告警的真实原因:偏移分页的代价不是恒定的,它随页码增长。第 1 页和第 300 页的成本差了 300 倍。而"第 300 页"这件事对用户是常态(爬虫、导出、翻旧账),对你却往往从来没测过。

游标分页的写法有个额外好处:它的锚点是一个值(id >= 15001),不是位置("跳过 15000 个")。值可以直接走索引定位,位置必须先数过去。判据就这一句。

代价是游标分页换不来"跳到第 N 页"——它的语义天然是"接着往下"。所以选哪个取决于产品要什么:

需求 选哪个
无限滚动、"加载更多" 游标
导出全量、后台批处理遍历 游标
必须有"第 3 页 / 共 20 页" 偏移(但要给页码设上限,或把深页降级)
数据量小、页码不深(列表默认只翻前几页) 偏移,别过度设计

三、方言:有些写法只在一种数据库上成立

Prisma 的查询 API 是跨库的,但它生成的 SQL 不是。distinct 就是一个例子:

await prisma.posts.findMany({ distinct: ["status"], select: { status: true } });
SELECT DISTINCT ON ("t0"."status") "t0"."id", "t0"."status"::text
FROM "public"."posts" AS "t0"

DISTINCT ON 是 PostgreSQL 专有语法(其他库没有)。跨库 API 生成方言 SQL 是正常的——它总得落到某个具体数据库上。要紧的是知道哪些地方落了方言,因为那些地方在换库时行为会变(判据是:换库之后 migrate diff 出来的 DDL 变没变、以及这些查询在新的库上还能不能跑)。

另一个方向是表达能力:Prisma 的 API 覆盖了哪些形状。实测下来覆盖面比想象中宽:

你想写的 Prisma API 能给吗 编译成
等值 / 范围 / IN / NOT 能 =, >, IN, NOT
模糊匹配 能 LIKE ('%' || $1 || '%'),insensitive 时是 ILIKE
关联存在性判断 能 EXISTS(...) / NOT EXISTS(...),是半连接不是 JOIN
聚合 count / avg / max 能 一条 SELECT COUNT(*), AVG(...), MAX(...) FROM (子查询) AS "sub"
GROUP BY 能 一条 SQL + GROUP BY
关联计数 _count 能 LEFT JOIN LATERAL (SELECT COUNT(*)),逐行执行
去重 能 DISTINCT ON(方言)
窗口函数(ROW_NUMBER / RANK / 累计和) 不能 —
UNION / UNION ALL 不能 —
CTE / WITH 不能 —
HAVING 不能(GROUP BY 后的过滤要用原生) —
EXPLAIN 不能 —

下面这一排就是要落 $queryRaw 的地方。这不是缺陷清单,是分界线:ORM 管"常见的读写形状",管不了"分析型查询"。碰到窗口函数、UNION、递归 CTE 这类需求,直接写原生 SQL 比自己想办法绕要正确得多。

顺带说一条:EXPLAIN 也得走原生 SQL。ORM 不会告诉你查询计划。所以前面六章里所有的计划证据,都是绕到 psql 或者 $queryRaw 那边拿的——这不是章节安排,这就是真实的工作方式:ORM 给你调用方式,计划要自己去看。

四、它装进来但用不上的东西

最后一个问题不在代码里,在磁盘上。装完之后:

大小
node_modules 总计 313 MB
运行时:@prisma/client 72 MB
运行时:@prisma/adapter-pg / pg 80 KB / 142 KB
开发期:prisma / @prisma/engines / @prisma/studio-core / @prisma/dev 41M / 22M / 43M / 19M
生成的客户端(13 个 TS 文件) 410 KB

那 72 MB 里装的是什么,值得看一眼:

query_compiler_fast_bg.postgresql.wasm-base64.mjs     4.4 MB
query_compiler_small_bg.postgresql.wasm-base64.mjs    2.3 MB
query_compiler_fast_bg.mysql.wasm-base64.mjs          4.4 MB
query_compiler_fast_bg.sqlite.wasm-base64.mjs         4.3 MB
query_compiler_fast_bg.sqlserver.wasm-base64.mjs      4.5 MB
query_compiler_fast_bg.cockroachdb.wasm-base64.mjs    4.5 MB
...

5 种数据库方言 × 2 种形态(fast / small)× 2 种模块制式(.js / .mjs)× 2 个版本(打包版 / wasm-base64 内联版)= 40 个文件,加起来就是那 72 MB。

你只连 PostgreSQL,装完却带着另外四种数据库的查询编译器。这不是 bug,是"查询编译器做在客户端"这件事的必然结果——它能编译出哪种方言,就得到手哪种方言的编译器。旧版本用平台相关的二进制引擎,同样要下载,但那时是"下载一次、平台限定"。

三条实际后果:

本章脉络

flowchart TD
    A["ORM 管完了读写与迁移"] --> B["还剩什么得自己管?"]
    B --> C["连接池:默认 10 条"]
    C --> C1["池满 → 排队 → 到达超时值才抛<br/>807 ms / 1509 ms"]
    C --> C2["多实例 × 每实例上限<br/>容易一起打满数据库"]
    B --> D["深翻分页:OFFSET 的账单"]
    D --> D1["读了 15020 行丢掉 15000 行<br/>45 页 vs 12 页"]
    D --> D2{"产品要什么?"}
    D2 -- "无限滚动 / 全量遍历" --> D3["游标:代价恒定(读 20 行)"]
    D2 -- "必须跳到第 N 页" --> D4["偏移 + 给页码设上限"]
    B --> E["方言与表达能力"]
    E --> E1["DISTINCT ON:方言,换库会变"]
    E --> E2["窗口函数 / UNION / CTE / HAVING / EXPLAIN<br/>→ 落 $queryRaw"]
    B --> F["装机体积:72 MB 运行时"]
    F --> F1["5 方言 × 2 形态 × 2 制式 × 2 版本<br/>= 40 个 WASM 编译器文件"]
    style C1 fill:#fff3e0,color:#e65100
    style C2 fill:#fff3e0,color:#e65100
    style D1 fill:#ffebee,color:#b71c1c
    style D3 fill:#e8f5e9,color:#1b5e20
    style F1 fill:#fff3e0,color:#e65100

生产边界

动手:可观察结果

产出 判断标准
一次池容量实测 能用"数据库侧观察到的连接数峰值"证明默认是 10,并证明 max 生效
一次池耗尽 能证明等待时间等于配置的超时值,并抄出完整报错
一组深翻对照 能拿出两条 EXPLAIN 的 rows= 那一行(15020 vs 20),并解释它为什么不随页码变化
一张 API 表达力清单 至少写出三样必须落原生 SQL 的形状
一份体积拆解 能指出 72 MB 里是什么,以及其中多少对你无用
一次 EXPLAIN 的获取路径 能说清为什么它必须绕到原生通道

完成标志:给定一个上线前的检查清单,你能补上四条本项目特有的项——池的两个参数、连接超时与上游的先后关系、最深的那个页偏移在最大数据量下的代价、以及构建产物体积。这四条都不在你的业务代码里,但都决定它能不能扛住真实流量。

故障注入

注入方式 观察
把 max 设成 1,并发跑 20 个请求 请求是排队还是失败;总耗时如何变化
把 connectionTimeoutMillis 设成 0 池耗尽时请求会怎样(会不会永远挂着)
把连接超时设成大于"上游网关超时"的值,然后在池耗尽时压测 上游报什么错、你这一层报什么错,两边对得上吗
把 skip 提到 15000 再 EXPLAIN rows= 变成多少;换成 cursor 之后变成多少
把 findMany 改成"取全量再在 JS 里切片" 传回来的行数与内存占用;对比在数据库侧分页
用 $queryRaw 写一个窗口函数查询 能跑通吗?API 能等价表达吗?
用 $queryRaw 写 UNION 同上
关掉开发依赖做一次生产构建 构建产物变小多少;@prisma/studio-core 还在不在里面
只保留 postgresql 的那一个 WASM 文件 功能还能用吗(验证"另外四种是纯负担")

自测题

  1. 池默认只有 10 条连接,这是问题吗?真正的问题是什么?
  2. 池耗尽时请求是"立刻失败"还是"排队等待"?你怎么证明?
  3. 为什么连接获取超时必须小于上游网关的超时?反过来会出什么事?
  4. 8 个实例部署,每个都用默认池配置。数据库那边应该准备多少连接?这个算法为什么容易出错?
  5. 偏移分页 OFFSET 15000 读了 15020 行。这 15020 是从哪来的?换成游标分页为什么变成 20?
  6. 游标分页的代价优势来自哪个字——"位置"还是"值"?
  7. 为什么 DISTINCT ON 值得单独记住?它在什么场景下会咬人?
  8. 窗口函数和 UNION 都不在 API 覆盖里。这是缺陷还是设计取舍?碰到时该怎么做?
  9. EXPLAIN 也得绕到原生通道。这件事对"我怎么确认一次查询是好的"提出了什么要求?
  10. 你只用 PostgreSQL,为什么装进来 5 种方言的编译器?这件事在哪两个环节会变成成本?

现在能解释什么

这门课从头到尾在回答同一个问题:你写的那个对象,到底让数据库做了什么。

如果这门课只能留下一句话,是这句:ORM 改变的只是"你怎么写",没有改变"数据库怎么执行"。

所以判据永远是同一套——查询日志里发了什么、EXPLAIN 里读了什么、台账里记了什么。版本会变(这门课里的每一条 CLI 变更、每一个默认值,都是被某一版改过或将会被改的),工具会换,但这套取证方法不依赖任何一个版本。

回到本板块(courses/frontend/):接下来的三门课(模块与构建、组件与样式、渲染与部署)都默认你已经掌握了这里的方法——因为那些话题的每一个结论,最后都要落到"它实际发出了什么、实际渲染了什么"上面。

进入 keel 阅读