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

四点值得记:

  1. 必须先插主表:子表的 post_id 要等主表自增 id 出来才知道。这叫依赖顺序,不是实现偷懒。
  2. 子表内部会合并:2 条评论是 1 条多值 INSERT,不是 2 条。这一层是省了的。
  3. 第 4 条是"回读":select 里要求返回关联数据,而关联数据在第 2、3 条里刚插进去——ORM 可以选择"在内存里拼出来",但它选择再查一次数据库。这条的代价容易被忽略:每个带 select 关联字段的嵌套写,末尾都挂着一次 LATERAL 查询。 不需要回读的时候(比如你只关心 id),写 select: { id: true } 就能省掉它。
  4. 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 行还没分块。不是"每批一万行"这种固定数字。

两件事值得记:

还有一个副作用值得留意:行数上去之后,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。 所以:

本章脉络

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 的 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 里拉了哪几列;大字段是否也在里面

自测题

  1. "先查再写"为什么会有窗口?upsert 关掉这个窗口靠的是什么机制,而不是什么写法技巧?
  2. ON CONFLICT ("slug") DO UPDATE SET ... WHERE ("slug" = $9 AND 1=1) 里那个 WHERE 是干什么的?去掉会怎样?
  3. update 不写 select 时返回全部列。这在宽表上意味着什么?正确的写法是什么?
  4. 一次"创建主表 + 2 条子记录"的嵌套写为什么是 5 条语句?逐条说出各自存在的理由,并指出哪一条在特定条件下可以省掉。
  5. createMany 的分块公式是什么?为什么 4 列的表 8191 行不分块、8192 行要分?
  6. 为什么"日志里出现 COMMIT"不足以证明事务的范围?要补什么证据?
  7. 在事务里 return 一个失败标志和 throw 一个异常,结果有什么不同?这个差别会导致什么样的线上 bug?
  8. increment: 1 和"先查出来 +1 再写回"在并发下有什么不同?后者的学术名字叫什么?

现在能解释什么

下一步:05 章 · 值穿过边界时变成了什么 —— 数据进出了,接下来看它在"数据库类型 → JS 类型 → JSON 响应"这条路上变成了什么,以及哪一步会把接口打挂。

进入 keel 阅读