KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
04 · 写路径:从 upsert 到嵌套写 — keel 龙骨
这一章回答:一次写入操作到底发了几条语句——「先查再写」和「一条 upsert」在并发下的差别是什么,「一条 create 带子表」又为什么会变成五条。
这一章回答:一次写入操作到底发了几条语句——「先查再写」和「一条 upsert」在并发下的差别是什么,「一条 create 带子表」又为什么会变成五条。
现场:偶发的唯一键冲突
一个订阅接口,用户点"关注",如果记录不存在就创建,存在就更新。第一版这样写:
const existing = await prisma.subscriptions.findUnique({
where: { user_id_channel: { user_id, channel } },
});
if (existing) {
await prisma.subscriptions.update({ where: { id: existing.id }, data: { enabled: true } });
} else {
await prisma.subscriptions.create({ data: { user_id, channel, enabled: true } });
}
开发环境跑通了,测试环境跑通了。上线一周后开始偶发报错:
Unique constraint failed on the fields: (`user_id`,`channel`)
两次请求几乎同时到达,两次 findUnique 都返回 null,两次都走 create 分支——第二条撞在唯一约束上。这不是"代码写错了",这是"先查再写"这个模式本身就有的窗口。 从查询到写入之间的那几毫秒里,世界可以变。
upsert 是标准答案:
await prisma.subscriptions.upsert({
where: { user_id_channel: { user_id, channel } },
create: { user_id, channel, enabled: true },
update: { enabled: true },
});
但"用 upsert 就好了"这句话本身不够——你得知道它是怎么实现的,因为它是原子的这件事不是免费的,而且不是所有情况下都成立。
一、它长什么样:一条真正的 ON CONFLICT
把查询日志打开,upsert 发出来的是这样(本实验室真实输出):
INSERT INTO "public"."posts" ("author_id","title","slug","body","status","view_count","created_at")
VALUES ($1,$2,$3,$4,CAST($5::text AS "public"."post_status"),$6,$7)
ON CONFLICT ("slug") DO UPDATE SET "title" = $8
WHERE ("public"."posts"."slug" = $9 AND 1=1)
RETURNING "public"."posts"."id", "public"."posts"."title"
params: [1,"新建","post-1","x","draft","0","2026-10-06T08:41:24.982Z","改过标题","post-1"]
一条语句,没有 SELECT。 这是关键:那个"先查一下存不存在"的窗口被数据库自己关上了。INSERT ... ON CONFLICT 是 PostgreSQL 单条语句内的原子操作——冲突检测和更新发生在同一次执行里,中间没有你能插进去的位置。
再仔细看两个细节:
WHERE ("public"."posts"."slug" = $9 AND 1=1):这个 WHERE 加在 DO UPDATE 后面,是 ON CONFLICT DO UPDATE ... WHERE 语法的一部分,用来限制"哪些冲突行才更新"。这里它等于恒真,是编译器生成的保护性条件。注意 $9 和 $3 传的是同一个值 "post-1"——同一个 slug 传了两遍,一遍给 INSERT 的列,一遍给 DO UPDATE 的 WHERE。
RETURNING:返回的是 id 和 title,因为调用时写了 select: { id: true, title: true }。不写 select 就会 RETURNING 全部列——这在宽表上是一笔真实的传输成本,第 07 章还会再遇到它。
upsert 还有个能力值得试一下,它也是编译进 SQL 的:
update: { view_count: { increment: 1 } }
ON CONFLICT ("slug") DO UPDATE SET "view_count" = ("public"."posts"."view_count" + $8) ...
increment 被编译成"读列 + 加值"写在 SET 里,不是"先查出来再加一"。这是数据库端的原子自增,两个并发请求同时 increment 不会丢更新。如果业务上需要这种保序累加,用 increment 而不是"读出来 + 1 再写回去"。
二、写操作的形状清单
同一批写入方法,编译出来的东西差别很大。全部来自实测:
| 调用 | 发出的 SQL | 返回 |
|---|---|---|
create |
INSERT ... RETURNING <指定的列> |
创建出的行 |
upsert(有唯一键) |
INSERT ... ON CONFLICT (...) DO UPDATE SET ... WHERE ... RETURNING ... |
创建或更新后的行 |
update(唯一 where) |
UPDATE ... WHERE ... RETURNING <全部列>(未指定 select 时) |
更新后的行 |
updateMany |
UPDATE ... WHERE ...,没有 RETURNING |
受影响行数 |
deleteMany |
DELETE ... WHERE ... |
受影响行数 |
createMany |
一条多值 INSERT(超过阈值自动分成多条,见下一节) |
写入行数 |
两个容易踩的点:
update 不加 select 会回全部列。 如果你的表里有一个 text 大字段,改个布尔标志也会把它整列拉回来:
UPDATE "public"."posts" SET "view_count" = $1 WHERE ("public"."posts"."id" = $2 AND 1=1)
RETURNING "public"."posts"."id", "public"."posts"."author_id", "public"."posts"."title",
"public"."posts"."slug", "public"."posts"."body", ← 大字段也回来了
"public"."posts"."status"::text, "public"."posts"."view_count",
"public"."posts"."rating", "public"."posts"."published_at", "public"."posts"."created_at"
注意 "status"::text:枚举在 RETURNING 里被转成文本回传(第 05 章会讲这个转换在客户端那头的后果)。
updateMany 不返回行。 它只给你 count。所以"批量改完之后要拿到改过的那些行"这件事,updateMany 做不到,要么改成循环 update(放弃批量),要么用原生 SQL 加 RETURNING。
三、一条 create 带子表,其实是五条
这是最容易误判的一处。下面这次调用创建一篇文章,同时带 2 条评论和 2 条标签关联:
await prisma.posts.create({
data: {
author_id: 2, title: "带评论的新文章", slug: "...", body: "正文",
comments: { create: [{ user_id: 1, body: "评论 A" }, { user_id: 2, body: "评论 B" }] },
post_tags: { create: [{ tag_id: 1 }, { tag_id: 2 }] },
},
select: { id: true, comments: { select: { id: true } }, post_tags: { select: { tag_id: true } } },
});
你以为是一条 INSERT(或者三条),实际上是五条:
-- [1] 先插主表,拿到自增 id
INSERT INTO "public"."posts" ("author_id","title","slug","body","status","view_count","created_at")
VALUES ($1,$2,$3,$4,CAST($5::text AS "public"."post_status"),$6,$7)
RETURNING "public"."posts"."id"
-- [2] 子表:两条评论合成一条多值 INSERT(用的还是刚才拿到的 id = 5003)
INSERT INTO "public"."comments" ("post_id","user_id","body","created_at")
VALUES ($1,$2,$3,$4), ($5,$6,$7,$8)
-- [3] 关联表:同样合成一条
INSERT INTO "public"."post_tags" ("post_id","tag_id") VALUES ($1,$2), ($3,$4)
-- [4] 回读:因为你写了 select 要 comments / post_tags,它再查一次
SELECT "t0"."id", "posts_comments"."__prisma_data__" AS "comments",
"posts_post_tags"."__prisma_data__" AS "post_tags"
FROM "public"."posts" AS "t0"
LEFT JOIN LATERAL (SELECT COALESCE(JSONB_AGG(...), '[]') FROM ... ) AS "posts_comments" ON true
LEFT JOIN LATERAL (SELECT COALESCE(JSONB_AGG(...), '[]') FROM ... ) AS "posts_post_tags" ON true
WHERE "t0"."id" = $1 LIMIT $2
-- [5] 结束
COMMIT
四点值得记:
- 必须先插主表:子表的
post_id要等主表自增 id 出来才知道。这叫依赖顺序,不是实现偷懒。 - 子表内部会合并:2 条评论是 1 条多值
INSERT,不是 2 条。这一层是省了的。 - 第 4 条是"回读":
select里要求返回关联数据,而关联数据在第 2、3 条里刚插进去——ORM 可以选择"在内存里拼出来",但它选择再查一次数据库。这条的代价容易被忽略:每个带select关联字段的嵌套写,末尾都挂着一次 LATERAL 查询。 不需要回读的时候(比如你只关心id),写select: { id: true }就能省掉它。 COMMIT出现了:嵌套写自动包在一个事务里。这解决了一个真实问题——如果第 3 条失败,前两条会一起回滚吗?会的。这是嵌套写少有的"白送"的好处。
但日志里看不到 BEGIN。 上面那份记录里只有 COMMIT,开头没有对应的 BEGIN。这不是它没开事务,而是 BEGIN 没有以查询事件的形式暴露出来。后果很实际:你没法靠查询日志判断一段代码到底在不在事务里。 想看事务边界,得去数据库侧(pg_stat_activity 的 xact_start)或者读代码。
四、createMany 的分块:32767 这个数
批量写入时,一条 INSERT 能带多少个值是有上限的。PostgreSQL 自己给的上限是 65535 个参数,但 Prisma 用的是更保守的一个数:
| 表 | 列数 | 行数 | INSERT 语句数 | 每块参数数 |
|---|---|---|---|---|
| comments | 4 | 8191 | 1 | 32764 |
| comments | 4 | 8192 | 2 | 16384 |
| comments | 4 | 30000 | 4 | 30000 |
| post_tags | 2 | 16383 | 1 | 32766 |
| post_tags | 2 | 16384 | 2 | 16384 |
| tags | 1 | 30000 | 1 | 30000 |
规律很干净:每块参数总数不超过 32767,也就是
每块行数 = floor(32767 / 表的列数)
4 列的表每块 8191 行,2 列的表每块 16383 行,1 列的表一次能塞 30000 行还没分块。不是"每批一万行"这种固定数字。
两件事值得记:
- 你不必手动分块,它会自己分。但你要知道一次
createMany可能变成多条语句,所以"这条导入是一次数据库操作"是错的(对事务而言尤其重要——见下一节)。 - 32767 是 2¹⁵−1,比 PostgreSQL 的 65535 小一半。 这个保守值是不是永久如此、会不会变,取决于它实现里的常量,不要当成数据库的天花板。真正的判据是查询日志里的
INSERT语句条数。
还有一个副作用值得留意:行数上去之后,pg 驱动会打出这样一条弃用警告:
DeprecationWarning: Calling client.query() when the client is already executing a query is
deprecated and will be removed in pg@9.0. Use async/await or an external async flow control
mechanism instead.
它说明分块后的多条 INSERT 是并发压在同一条连接上的。现在是警告,pg@9.0 之后会变成错误。批量导入脚本是这类问题的集中地。
五、事务:两种写法,和一份不完整的证据
Prisma 给两种事务形态。
数组式,适合已知的几条独立操作:
await prisma.$transaction([
prisma.users.update({ where: { id: 1 }, data: { bio: "a" } }),
prisma.users.update({ where: { id: 2 }, data: { bio: "b" } }),
]);
交互式,适合"后一步依赖前一步的结果":
await prisma.$transaction(async (tx) => {
const u = await tx.users.findUnique({ where: { id: 1 }, select: { bio: true } });
await tx.users.update({ where: { id: 1 }, data: { bio: (u?.bio ?? "") + "+1" } });
});
交互式事务里 throw 会真的回滚——实测里抛出异常后,那个字段的值没变。这是"用事务"和"不抛异常"之间的差别:回滚的触发条件是异常,不是"逻辑上不想继续了"。 提前 return 一个表示失败的值,事务会提交。
证据的部分要小心。数组式事务的查询日志长这样:
[q] UPDATE "public"."users" SET "bio" = $1 WHERE (...)
[q] UPDATE "public"."users" SET "bio" = $1 WHERE (...)
[q] COMMIT
交互式事务:
[q] SELECT "t0"."id", "t0"."bio" FROM "public"."users" AS "t0" WHERE (...)
[q] UPDATE "public"."users" SET "bio" = $1 WHERE (...)
[q] COMMIT
回滚那次:
[q] UPDATE "public"."users" SET "bio" = $1 WHERE (...)
[q] ROLLBACK
只有 COMMIT / ROLLBACK,没有 BEGIN。 所以:
- 拿查询日志证明"这段代码在事务里"是不完整的——你能看到结尾,看不到开头。要证明事务存在,得去数据库侧查
xact_start,或者用pg_locks观察锁的持有范围。 - 更要紧的是:日志里出现
COMMIT不代表"只有被日志记录的语句在事务里"。第 3 节那五条语句是同一个事务,但日志上你只能从最后那个COMMIT推断。 - 分块后的
createMany在多不在一个事务里? 这个问题日志回答不了。要实测就得在写入过程中从另一个会话查pg_stat_activity看有几个xact_start相同的后端——这是一个值得自己动手验证的问题,本课程不给结论,因为它取决于版本实现。
本章脉络
flowchart TD
A["一次写入"] --> B{"是哪种写?"}
B -- "先查再写(findUnique + create/update)" --> C["有窗口:两次并发都查到 null<br/>→ 唯一键冲突"]
B -- "upsert" --> D["一条 INSERT ... ON CONFLICT DO UPDATE<br/>窗口被数据库关上"]
B -- "create + 嵌套子表" --> E["多条语句 + 自动事务"]
E --> E1["① INSERT 主表 RETURNING id"]
E1 --> E2["② 子表多值 INSERT(按依赖顺序)"]
E2 --> E3["③ 关联表多值 INSERT"]
E3 --> E4["④ 回读 LATERAL(因为 select 要了关联字段)"]
E4 --> E5["⑤ COMMIT"]
B -- "createMany" --> F["每块参数 ≤ 32767<br/>行数 / 列数决定分几条"]
D --> G["increment 编译进 SET<br/>数据库端原子自增"]
E --> H["日志里只有 COMMIT<br/>看不到 BEGIN"]
style C fill:#ffebee,color:#b71c1c
style D fill:#e8f5e9,color:#1b5e20
style E4 fill:#fff3e0,color:#e65100
style H fill:#fff3e0,color:#e65100
生产边界
upsert的原子性来自数据库,不是来自 ORM。 它编译成ON CONFLICT,这在 PostgreSQL 里是原子的。但换一个数据库、换一种冲突形态(比如冲突发生在非唯一键上),它可能退化成"先查再写"。判据永远是查询日志里有没有ON CONFLICT。upsert的where必须是唯一的。 传一个能匹配多行的条件,编译出来的语句在语义上就不成立。真实使用里唯一键要用复合唯一(user_id_channel)这种形式,而不是靠"反正只有一行"。- 嵌套写里的回读是隐性成本。 每个要求返回关联字段的嵌套写都会多一次 LATERAL 查询,而它出现在写事务内部——意味着这次查询会延长事务持有的时间。写事务越短越好,这条可以直接用于优化。
createMany会分块,所以"一次调用 = 一次数据库操作"是错的。 如果这 N 条必须全成功或全失败,光靠createMany不构成保证,要放进显式的$transaction。pg@9.0会收紧并发查询。 现在看到的是弃用警告,将来是错误。批量写入路径是第一批受影响的地方。- 日志不是事务的证据。
BEGIN不出现是常态。要在生产里确认事务范围,靠数据库侧的活动视图,不靠客户端日志。 - 写事务里不要做网络调用。 事务一开就持有连接和锁;等待第三方接口的 3 秒里,那条连接谁也拿不走。这和 ORM 无关,但嵌套写 + 回读 + 业务逻辑放在一个
$transaction里是常见写法,值得单独检查一遍。
动手:可观察结果
| 产出 | 判断标准 |
|---|---|
一次 upsert 的 SQL |
能指出 ON CONFLICT 在哪个位置、DO UPDATE 后面的 WHERE 是什么、RETURNING 返回了几列 |
一次 increment 的 SQL |
能指出它是写在 SET 里的数据库端加法,而不是"先查再写" |
| 一次嵌套写的完整 SQL 序列 | 能数出几条、指出哪条是回读、并说出回读能不能省掉(怎么省) |
| 一张分块对照表 | 能给出"每块参数 ≤ 32767"这个公式,并用两种列数的表验证 |
| 一次事务的日志 | 能指出日志里缺了什么,并给出补上证据的方法 |
| 一次回滚 | 能证明数据真的回退了,并说出触发回滚的条件是什么 |
完成标志:给定一段写入代码,你能先写出它会发出的语句序列(几条、什么形状、哪条是回读、事务边界在哪),再打开日志核对。对"这次写入会不会有并发窗口"这类问题,你能在写代码之前给出答案。
故障注入
| 注入方式 | 观察 |
|---|---|
把 upsert 换成 findUnique + create |
SQL 变成几条;并发下是否会出现唯一键冲突 |
把 upsert 的 where 改成非唯一条件 |
编译出来什么;能不能跑通 |
把嵌套写的 select 从"含关联字段"改成 { id: true } |
第 4 条回读还在不在;总语句数从几条变几条 |
让嵌套写的第 3 条语句失败(比如给一个不存在的 tag_id) |
第 1、2 条是否回滚 |
createMany 传 8191 / 8192 行 |
INSERT 语句数是否从 1 跳到 2 |
在交互式事务里 return { ok: false } 而不是 throw |
事务提交了还是回滚了 |
在事务里 await 一个 3 秒的假网络请求,同时从另一个会话看 pg_stat_activity |
这条连接的状态与 xact_start |
把 update 的 select 去掉,改一行布尔字段 |
RETURNING 里拉了哪几列;大字段是否也在里面 |
自测题
- "先查再写"为什么会有窗口?
upsert关掉这个窗口靠的是什么机制,而不是什么写法技巧? ON CONFLICT ("slug") DO UPDATE SET ... WHERE ("slug" = $9 AND 1=1)里那个WHERE是干什么的?去掉会怎样?update不写select时返回全部列。这在宽表上意味着什么?正确的写法是什么?- 一次"创建主表 + 2 条子记录"的嵌套写为什么是 5 条语句?逐条说出各自存在的理由,并指出哪一条在特定条件下可以省掉。
createMany的分块公式是什么?为什么 4 列的表 8191 行不分块、8192 行要分?- 为什么"日志里出现
COMMIT"不足以证明事务的范围?要补什么证据? - 在事务里
return一个失败标志和throw一个异常,结果有什么不同?这个差别会导致什么样的线上 bug? increment: 1和"先查出来+1再写回"在并发下有什么不同?后者的学术名字叫什么?
现在能解释什么
- 为什么"先查再写"会有并发窗口,而
upsert没有——原子性来自ON CONFLICT这条语句,不来自 ORM; - 为什么一次带子表的
create是五条语句,以及其中哪一条纯属"为了返回给你看"而存在; - 为什么批量写入不需要你手动分块,但"一次调用等于一次数据库操作"依然是错的;
- 为什么
updateMany拿不到改过的行,而update默认会把整行拉回来; - 为什么客户端日志不能用来证明事务边界——它只记录了结尾,没记录开头;
- 为什么在事务里
return和throw是两件完全不同的事。
下一步:05 章 · 值穿过边界时变成了什么 —— 数据进出了,接下来看它在"数据库类型 → JS 类型 → JSON 响应"这条路上变成了什么,以及哪一步会把接口打挂。