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"。两个后果:
- 时区信息在这里被"归一化"了。 数据库里存的是带时区的时刻,JS 侧是
Date(内部是 UTC 毫秒数),JSON 里是带Z的 UTC 文本。任何"显示成本地时间"的逻辑都必须在前端或序列化层显式做,不能指望它自动带着时区。 - 精度可能被截断。 数据库的
timestamptz有微秒精度,而Date只有毫秒。如果你的业务依赖微秒级排序或去重,这个精度在穿过 JS 的那一刻就丢了。 这类字段要保精度,只能在数据库侧比较,或者存成字符串。
可空字段(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
生产边界
Decimal变字符串是保真选择,不是 bug。 但你的前端代码必须知道这件事——一处total + 1会因为"3.9" + 1 === "3.91"而静默出错。在需要算术的地方显式转,或者用十进制库。BigInt报错是保护,不是刁难。 修它的时候不要图省事全库JSON.parse(JSON.stringify(...))或者给BigInt.prototype.toJSON打补丁——后者是全局污染,会让"哪里丢了精度"永远查不出来。Date只有毫秒精度。 依赖微秒的业务(高频事件排序、幂等去重键)不能在 JS 侧做比较。- 枚举的约束只在数据库侧。 JS 侧拿到的是
string,类型检查只在"你写查询条件"时帮忙,"从外部来的输入"这一侧没有防护,入库才报错。校验要在入口做。 $queryRaw的返回类型也要能过适配器。void、几何、tsvector、自定义类型都可能触发P2010 / UnsupportedNativeDataType。遇到它先试::text转换,而不是怀疑写法。$queryRawUnsafe是"结构可拼"的入口。 它的位置参数形式仍然参数化值,但拼接的部分就是 SQL。任何来自外部的字符串进了拼接位置就等同于手写注入漏洞。
动手:可观察结果
| 产出 | 判断标准 |
|---|---|
| 一张"数据库类型 → 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 里传一个含循环引用的对象 |
报错是哪一方给的(提示:这不是类型问题,是结构问题) |
自测题
- 为什么
Decimal有toJSON而BigInt没有?两种处理方式各自保护了什么? JSON.stringify({r: decimal("3.9")})得到{"r":"3.9"}。为什么不能直接给3.9?- 一条
bigint主键的接口在本地能跑、上线就炸。说出至少两种可能的原因,并给出建表阶段就能避免它的做法。 - 枚举在数据库里有约束,在 JS 侧没有。这给"外部输入"留下什么缺口,应该在哪一层补?
timestamptz穿过 JS 之后会丢什么?哪些业务场景会因此出错?null和undefined在JSON.stringify之后的形状不同。这个差别会导致什么样的接口契约问题?$queryRaw里${v}为什么是安全的?如果换成把v拼进字符串,同一段代码会变成什么?SELECT pg_sleep(0.001)为什么会报UnsupportedNativeDataType?这说明了"原生 SQL 逃生口"的什么边界?- 为什么"给
BigInt.prototype打个补丁"是一个应该避免的做法?
现在能解释什么
- 为什么"本机好好的、上线就 500"是这类问题的典型形态——它和值域有关,和逻辑无关;
- 为什么同一个"类型不在 JSON 原生集合里"的问题,会分裂成"静默变形"(Decimal)和"直接报错"(BigInt)两种完全不同的故障;
- 为什么钱不能用
number、时间不能在 JS 侧比到微秒、可空字段的null和undefined不能混用; - 为什么"参数化"和"注入安全"是同一件事,以及判据是"值还是语法";
- 为什么原生 SQL 也有边界——返回的类型同样要过适配器那一关;
- 为什么"用
bigint做主键"是一个值得在建表阶段就想清楚的问题。
下一步:06 章 · 迁移:从空库、从现成库、从被人改过的库 —— 值能正确地进出了,接下来是最后一个大问题:当 schema 要变的时候,怎么让数据库跟上,以及跟不上的时候会发生什么。