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,而是先集体慢下来(请求排在队里),然后在一个固定延迟点上成批超时。 那个"服务一开始会变慢、然后突然全挂"的曲线特征,就是池耗尽。
几个可操作的判断:
- 生产里应该显式配置
max和connectionTimeoutMillis。 默认值不会替你考虑你的数据库能承受多少连接,也不会替你决定"排队多久算太久"。 - 超时值要和上游的超时对齐。 如果网关 3 秒超时,而你的连接获取超时设了 10 秒,那么请求会在网关已经放弃之后还挂在池里等着。这一层的超时必须小于上游的,否则它永远不会生效。
- 多实例部署时,池大小是"每实例"的。 8 个实例 × 默认 10 = 80 条连接。数据库的
max_connections是全局的,每个实例都按自己的默认值算,很容易一起把数据库连接数打满。 - Prisma 7 换了这套默认值(用
pg的默认):连接超时从旧版驱动的 5 秒变成 0(等于不超时),空闲超时从 300 秒变成 10 秒。"不超时"是一个会在池耗尽时把请求永久挂住的默认值,值得上线前单独确认一次。
二、深翻分页:代价随页码线性增长
回到第一条告警。列表页支持翻页,用 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,是"查询编译器做在客户端"这件事的必然结果——它能编译出哪种方言,就得到手哪种方言的编译器。旧版本用平台相关的二进制引擎,同样要下载,但那时是"下载一次、平台限定"。
三条实际后果:
- 构建产物会变大,除非打包器能按条件导出把它裁掉。
wasm-base64那种内联形式对压缩特别不友好(base64 本身就是膨胀的)。上线前看一眼构建产物的体积报告,别只看node_modules的安装体积。 - 镜像体积会变大。 容器镜像里如果带着
node_modules(而不是打包后的产物),增量就是几十到上百 MB。 @prisma/studio-core43 MB 是开发期的。 生产构建要确保它没被打进去——这类"只在开发时有用"的包是最常见的镜像臃肿来源。
本章脉络
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
生产边界
- 池的两个参数必须显式配(
max与连接超时),而且连接超时要小于上游网关的超时,否则它永远不生效。多实例部署时按"实例数 × 每实例上限"核对数据库的max_connections。 connectionTimeoutMillis: 0(新版默认)等于不超时。 池耗尽时请求会永久挂住而不是快速失败——快速失败通常比挂住好,因为挂住会一路占满上游的线程和连接。- 深翻分页的问题不会在小数据量下暴露。 实验表只有 2 万行,第 15000 页的偏移还只花了 1.948 毫秒。它真正的代价随表增长——同一个
OFFSET 15000在表变成 2000 万行时,成本涨的是读取的行数,不是常数倍。上线前按"最大可能的数据量"估一次,不要用测试库的量估。 DISTINCT ON是方言。 任何"换数据库"的评估里,这类查询都要单独列出来重测。- 窗口函数、
UNION、CTE、HAVING、EXPLAIN都不在 API 覆盖范围内。 这不是"缺功能",是分界线。碰到它们直接写原生 SQL,硬用 API 拼出来的等价写法(比如循环取数再在内存里算)通常更慢也更难维护。 $queryRaw的返回类型也要过适配器那一关(第 05 章的void就是例子)。原生 SQL 不是后门,它同样受约束。- 装机体积要在意"构建产物"而不是"安装体积"。 40 个 WASM 编译器文件里只有 1 个对你有用,剩下的取决于打包器的裁剪能力。看构建报告的体积,不看
node_modules的大小。
动手:可观察结果
| 产出 | 判断标准 |
|---|---|
| 一次池容量实测 | 能用"数据库侧观察到的连接数峰值"证明默认是 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 文件 | 功能还能用吗(验证"另外四种是纯负担") |
自测题
- 池默认只有 10 条连接,这是问题吗?真正的问题是什么?
- 池耗尽时请求是"立刻失败"还是"排队等待"?你怎么证明?
- 为什么连接获取超时必须小于上游网关的超时?反过来会出什么事?
- 8 个实例部署,每个都用默认池配置。数据库那边应该准备多少连接?这个算法为什么容易出错?
- 偏移分页
OFFSET 15000读了 15020 行。这 15020 是从哪来的?换成游标分页为什么变成 20? - 游标分页的代价优势来自哪个字——"位置"还是"值"?
- 为什么
DISTINCT ON值得单独记住?它在什么场景下会咬人? - 窗口函数和
UNION都不在 API 覆盖里。这是缺陷还是设计取舍?碰到时该怎么做? EXPLAIN也得绕到原生通道。这件事对"我怎么确认一次查询是好的"提出了什么要求?- 你只用 PostgreSQL,为什么装进来 5 种方言的编译器?这件事在哪两个环节会变成成本?
现在能解释什么
这门课从头到尾在回答同一个问题:你写的那个对象,到底让数据库做了什么。
- 第 01 章给了这个问题的方法:打开查询日志看编译产物,再用
EXPLAIN看执行。你知道了多出来的那些字(限定名、CAST、占位符)各自解决什么问题,也知道了同一个CAST放在参数侧和放在列侧会差 122 倍缓冲页。 - 第 02 章说清了"能被编译的那份东西"从哪来:逆向只描述结构、不描述意图,约束和注释会告警后丢弃,视图和触发器连告警都没有。两个方向只能选一个当真相。
- 第 03 章推翻了"用
include就没有 N+1"这个流行结论——它做的是把 N 次子查询合并成 1 次批量查询,而另一条路(join 策略)把 N 次往返换成了 1 次往返里的 2500 次循环。"一条 SQL"和"一次工作"不是同一件事。 - 第 04 章把写路径拆开:
upsert的原子性来自ON CONFLICT这条语句,嵌套写是"多条语句 + 自动事务 + 一次为了返回给你看的回读",而事务的开始在日志里根本看不见。 - 第 05 章追了一个值的旅程:
Decimal静默变成字符串、BigInt直接让接口 500、时间精度在穿过 JS 时丢掉、原生 SQL 的返回类型也可能被适配器拒绝。问题几乎总在最外层,而不在你的逻辑里。 - 第 06 章把"改结构"这件事分成三种处境,并指出最危险的一处:被人改过的库会让工具建议你清空数据,而
migrate status在那之前还会告诉你"一切正常"。 - 第 07 章收口在 ORM 的边界上:连接池、深翻分页、方言与表达能力、装机体积。这四样都不在你的业务代码里,但都决定它能不能扛住真实流量。
如果这门课只能留下一句话,是这句:ORM 改变的只是"你怎么写",没有改变"数据库怎么执行"。
所以判据永远是同一套——查询日志里发了什么、EXPLAIN 里读了什么、台账里记了什么。版本会变(这门课里的每一条 CLI 变更、每一个默认值,都是被某一版改过或将会被改的),工具会换,但这套取证方法不依赖任何一个版本。
回到本板块(courses/frontend/):接下来的三门课(模块与构建、组件与样式、渲染与部署)都默认你已经掌握了这里的方法——因为那些话题的每一个结论,最后都要落到"它实际发出了什么、实际渲染了什么"上面。