KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

05 · 值穿过边界时变成了什么 — keel 龙骨

这一章回答:一个值从数据库出发,经过驱动、ORM、再到 JSON 响应,中间被换了几次身份——以及哪一次转换会把整条接口打挂。

这一章回答:一个值从数据库出发,经过驱动、ORM、再到 JSON 响应,中间被换了几次身份——以及哪一次转换会把整条接口打挂。

现场:本机好好的,上线就 500

一个接口返回评论列表。本机跑得通,测试环境跑得通。上线后第一个真实请求就 500,日志里只有一行:

TypeError: Do not know how to serialize a BigInt

代码看起来毫无问题:

const comments = await prisma.comments.findMany({
  where: { post_id },
  select: { id: true, body: true, created_at: true },
});
res.json(comments);          // ← 这里炸

id 是 BigInt,而 JSON.stringify 拒绝序列化 BigInt。 为什么本机能跑?因为本地的评论表是手工造的两三条,id 小到某个环节把它当成了普通数字;也可能因为本地那条路根本没走到 res.json。总之,这类 bug 的特征是"和数据无关、和值域有关"——它不在你的逻辑里,它在类型系统的接缝上。

这一章就把这些接缝一条条摸过去。所有类型名都是实测打印出来的,不是文档抄的。

一、它长什么样:一条真实记录穿过四层

先摆形态。这是一条真实记录在客户端里的样子——同一行数据,在不同层看到不同的身份:

数据库列                       JS 侧读到的值(实测)
─────────────────────────────────────────────────────────
id            integer      →  Number              3
rating        numeric(3,1) →  内部类实例          Decimal2   toString() = "3.9"
created_at    timestamptz  →  Date                2026-10-06T13:35:17.300Z
status        post_status  →  String              "published"
view_count    integer      →  Number              111
id            bigint       →  BigInt              1n        ← 就是它把接口打挂
user_id       integer NULL →  Number 或 null      2

四层的转换链是这样的:

PostgreSQL 类型
   ↓ ① 驱动(pg)   把 wire protocol 的文本/二进制解成 JS 原生类型
JS 原生类型(number / string / Date / bigint)
   ↓ ② 驱动适配器     把原生类型按 schema 的类型映射再翻一遍
Prisma 的值(可能换成自己的包装类,例如 Decimal)
   ↓ ③ 你的代码
JSON 响应
   ↓ ④ JSON.stringify  ← 这一层不认识 BigInt

问题几乎总是出在第 ④ 层。 前三层都在"把值往正确方向搬",第四层的工作是"把值变成文本",而它有一份有限的类型清单,BigInt 不在里面。

二、Decimal 有 toJSON,BigInt 没有

这是整件事的分水岭,两个值类型都"不是 JSON 的原生类型",但命运完全不同。

Decimal(对应数据库的 numeric / decimal) 的实例上有一个 toJSON 方法:

const raw = await prisma.posts.findFirst({ where: { rating: { not: null } }, select: { rating: true } });
JSON.stringify(raw);   // → {"rating":"3.9"}

注意输出:它变成了字符串 "3.9",不是数字 3.9。

这不是随手的选择,它是必要的。number 是双精度浮点,能精确表示的有效数字有限;numeric(3,1) 这种"精确小数"一旦转成 number 就可能失真。金额字段上这类失真是不能接受的。所以序列化器宁可给你一个字符串——保真优先于方便。

代价是:你的前端拿到的 "3.9" 是字符串,+ 号拼接会变成字符串拼接,需要显式 Number() 或专门的十进制库。这个转换发生得很安静,因为 JSON.stringify 不会报错。

还有个细节值得留意:这个类的构造器名字不是 Decimal。实测打印出来是 Decimal2——它是被压缩过的内部类名。所以不要用 constructor.name === 'Decimal' 做判断,要判断就判断方法(有没有 toNumber)或者用 ORM 导出的类型守卫。依赖压缩后的名字会在升级时静默失效。

BigInt(对应数据库的 bigint) 就没有这个待遇:

JSON.stringify({ id: 1n });
// TypeError: Do not know how to serialize a BigInt

它是抛异常,不是静默转换。原因很实在:bigint 可以表示超过 2⁵³ 的整数,而 JSON 的数字语法上没法无损表达它。序列化器有两个选择——悄悄转成可能失真的 number,或者直接拒绝。它选了拒绝,因为它没法知道你接不接受失真。

这是本章最重要的一条判断:同一个"数据库类型不是 JS 原生 JSON 类型"的问题,Decimal 选了静默保真(转字符串),BigInt 选了显式报错。这个不对称性决定了两类 bug 的形态完全不同——Decimal 的问题在联调时才暴露(值变了形),BigInt 的问题在运行时立刻炸。

修法都是显式转:

res.json(rows, (_k, v) => (typeof v === "bigint" ? v.toString() : v));

但更值得做的是从 schema 层面避免它:主键用 bigint 有多必要?如果单表不会超过 21 亿行,integer 就够了,也就永远不会有这个问题。"要不要用 bigint 做 id"是一个应该在建表时回答的问题,不是等到接口炸了才发现的问题。

三、枚举变成字符串,DateTime 变成 Date

枚举(post_status):读回来就是普通字符串 "published",构造器是 String。数据库那一层的枚举约束在 JS 侧完全不存在——你可以把这个值赋成 "随便什么" 而不报错,只有写回数据库时才会被拒。

这带来一个值得利用的机会:写查询条件时,枚举值在生成的客户端类型里是字面量联合类型("draft" | "review" | "published" | "archived"),所以拼错枚举值会在类型检查阶段被抓到,不用等运行时。这是用 ORM 比手写 SQL 确实强的一处。

DateTime(timestamptz):读回来是 Date 对象。JSON.stringify 会调用它的 toJSON,输出 ISO 8601 的 UTC 字符串:"2026-10-06T13:35:17.300Z"。两个后果:

可空字段(user_id integer NULL)读回来是 = null,不是 undefined。这个差别在 JSON.stringify 里很关键:null 会被输出成 null,undefined 的键会被整个丢掉。所以序列化之后的响应里,"值确实是空"和"这个键不存在"是两种不同的形状。

四、原生 SQL 的逃生口,和它拒绝的东西

ORM 表达不了的查询(聚合、窗口函数、UNION、复杂 CTE)总要落到原生 SQL。Prisma 给了两套入口:

// 有参数 → 用模板标签,${v} 走参数通道
const agg = await prisma.$queryRaw`
  SELECT status::text AS status, count(*) AS cnt, avg(view_count)::numeric(10,2) AS avg_views
  FROM posts GROUP BY status ORDER BY cnt DESC
`;
// → [{ status: "published", cnt: 2500n, avg_views: Decimal2("4928.02") }, ...]

// 需要动态拼 SQL 结构时 → 位置参数形式
const u = await prisma.$queryRawUnsafe(
  "SELECT count(*) AS c FROM posts WHERE status = $1", "draft"
);

注入安全是结构性的,和字符串转义无关:

await prisma.$queryRaw`SELECT count(*) AS c FROM posts WHERE title = ${"' OR 1=1 --"}`;
// → count = 0

那个 ' OR 1=1 -- 被当成一个普通字符串去和 title 比,一行都不匹配。如果它是被拼进 SQL 文本的,这条会变成 WHERE title = '' OR 1=1 --',返回全表。判据永远是那个问题:这个值是"值"还是"语法"?拼字符串是在把数据当语法用。

然后是一个让人意外但很有价值的边界。这条查询会失败:

await prisma.$queryRaw`SELECT pg_sleep(0.001)`;
PrismaClientKnownRequestError:
  code: 'P2010'
  cause: { kind: 'UnsupportedNativeDataType', type: 'void' }

原因是 pg_sleep 的返回类型是 PostgreSQL 的 void,而驱动适配器不认识这个类型,无法把它反序列化成任何 JS 值。同类问题还会出现在几何类型、tsvector、自定义复合类型上。

这一条比它看起来重要。它说明:原生 SQL 的返回类型也必须落在"ORM 认识的那一集合"里。 逃生口不是万能门——你要么在 SQL 里 ::text 显式转换掉不认识的类型,要么承认这里过不去、改用别的通道(比如直接拿底层的 pg 连接)。

本章脉络

flowchart TD
    A["数据库列"] --> B["驱动解成 JS 原生类型<br/>number / string / Date / bigint"]
    B --> C["适配器按 schema 映射<br/>可能换成包装类"]
    C --> D["你的代码"]
    D --> E["JSON.stringify"]
    E --> F{"这个类型<br/>认识吗?"}
    F -- "number / string / boolean / null" --> G["正常输出"]
    F -- "有 toJSON(Date、Decimal)" --> H["静默转换<br/>Date → ISO 串<br/>Decimal → 字符串 '3.9'"]
    F -- "BigInt" --> I["抛错<br/>Do not know how to serialize a BigInt"]
    D --> J{"要落到原生 SQL?"}
    J -- "$queryRaw 模板标签" --> K["${v} 走参数通道<br/>注入隔离"]
    J -- "$queryRawUnsafe" --> L["位置参数 $1<br/>结构可拼、值仍走参数"]
    K --> M{"返回类型<br/>适配器认识吗?"}
    M -- "否,如 void / 几何 / tsvector" --> N["P2010<br/>UnsupportedNativeDataType"]
    style G fill:#e8f5e9,color:#1b5e20
    style H fill:#fff3e0,color:#e65100
    style I fill:#ffebee,color:#b71c1c
    style N fill:#fff3e0,color:#e65100

生产边界

动手:可观察结果

产出 判断标准
一张"数据库类型 → JS 值"的实测表 至少覆盖 integer、bigint、numeric、timestamptz、枚举、可空列六类,写出构造器名与 JSON.stringify 的结果
一次故意触发的 BigInt 序列化失败 能抄出完整报错,并给出两种修法(改类型 / 显式转换)及各自代价
一次 Decimal 的静默转换 能拿出 {"rating":"3.9"},并说明为什么它必须是字符串
一次注入尝试的对照 模板标签与字符串拼接的结果差,且能说清差在"值/语法"这个区分上
一次 P2010 能指出是哪个返回类型不认识,并用 ::text 绕过

完成标志:拿到一份 schema,你能先扫一遍指出哪几列会在序列化时换形状(所有 bigint、所有 numeric、所有 timestamptz),并写出各自的正确序列化方式——而不是等接口报错再一列一列试。

故障注入

注入方式 观察
给 BigInt.prototype 加一个 toJSON 报错是否消失;这个补丁影响了哪些别的东西
把 Decimal 字段直接拿去 + 1 结果是数字还是字符串拼接
把 timestamptz 字段的微秒部分取出来比较 穿过 JS 之后还剩几位
把可空字段用 undefined 赋值写回 生成什么 SQL(是 SET col = NULL 还是整列不出现)
给 $queryRaw 传一个数组参数 参数怎么绑定;和展开成多个占位符有什么区别
用 $queryRaw 查一个 tsvector 或几何列 报什么错;加 ::text 后是否通过
把枚举字段用任意字符串写入 到哪一层才报错(客户端类型检查 / 数据库约束)
在 res.json 里传一个含循环引用的对象 报错是哪一方给的(提示:这不是类型问题,是结构问题)

自测题

  1. 为什么 Decimal 有 toJSON 而 BigInt 没有?两种处理方式各自保护了什么?
  2. JSON.stringify({r: decimal("3.9")}) 得到 {"r":"3.9"}。为什么不能直接给 3.9?
  3. 一条 bigint 主键的接口在本地能跑、上线就炸。说出至少两种可能的原因,并给出建表阶段就能避免它的做法。
  4. 枚举在数据库里有约束,在 JS 侧没有。这给"外部输入"留下什么缺口,应该在哪一层补?
  5. timestamptz 穿过 JS 之后会丢什么?哪些业务场景会因此出错?
  6. null 和 undefined 在 JSON.stringify 之后的形状不同。这个差别会导致什么样的接口契约问题?
  7. $queryRaw 里 ${v} 为什么是安全的?如果换成把 v 拼进字符串,同一段代码会变成什么?
  8. SELECT pg_sleep(0.001) 为什么会报 UnsupportedNativeDataType?这说明了"原生 SQL 逃生口"的什么边界?
  9. 为什么"给 BigInt.prototype 打个补丁"是一个应该避免的做法?

现在能解释什么

下一步:06 章 · 迁移:从空库、从现成库、从被人改过的库 —— 值能正确地进出了,接下来是最后一个大问题:当 schema 要变的时候,怎么让数据库跟上,以及跟不上的时候会发生什么。

进入 keel 阅读