细雨还是棱镜?选择之前请先查看连接类型与记录的SQL语句。
在 Drizzle 和 Prisma 中构建相同的用户表和发票表,比较连接结果的类型及生成的 SQL 语句,找出那些将总计值转换为字符串的驱动程序映射问题。
关于ORM的讨论往往围绕下载量统计和会议口号展开,但在实际生产环境中真正重要的问题却简单得多:当将用户与他们的发票关联时,total 的类型是什么?能否查看生成该值的SQL语句?本指南在Drizzle和Prisma中分别创建了相同的两个表,分别在两者中执行一次插入操作和一次关联操作,然后比较推断出的TypeScript类型、记录的查询语句、迁移输出结果以及在Node原生TypeScript环境下的运行表现。通过这个简短且可重复的实验,你可以针对自己的代码库回答ORM相关问题,而非依赖他人的测试结果。如需一个能同时考虑原始SQL的更全面的决策框架,请参阅如何在原始SQL、Prisma和Drizzle之间选择数据库层。
各工具的优化目标
这两个库提供了不同的功能承诺。Drizzle 提供的查询代码看起来就像用 TypeScript 编写的 SQL,无需独立的查询引擎进程,其设计也更适合边缘运行环境。Prisma 则采用以模式优先的工作流程,配有专用的 schema.prisma 文件以及自动生成的客户端;其最近的版本更新一直在将查询引擎从 Rust 转向 TypeScript。在撰写本文时,这一引擎迁移仍在进展中,因此请查看最新的 Prisma 发布说明,以了解您所使用的版本对应哪种引擎。
受欢迎程度也有两面性:Prisma在安装量上仍居领先地位,而Drizzle则在发展势头方面更受关注。但这些都无法说明你选择哪个工具的合理性。下面所用的两个标准是经过精心挑选且实用的:一是total是否以数字形式出现,二是日志中的SQL语句是否是你能在故障处理时放心直接粘贴到psql中的。
设想一个小型发票应用,其/invoices页面需要显示总金额。堆栈中的某个组件必须为这个总金额指定类型,比较也就从这里开始。
同样的两张表,两次使用
在同一个PostgreSQL实例上创建两个独立的项目文件夹,并为每个文件夹指定不同的模式名称。如果在两个ORM之间共享表,就会导致重复写入数据,这些数据看似性能相关的数据,实则为错误。
在 Drizzle 中,该模式以普通 TypeScript 的形式存在于 src/schema.ts 文件中。注意,列名使用下划线命名法(user_id)明确声明,而属性名则使用驼峰命名法(userId);此外,外键是指向 users.id 的函数引用:
import { integer, pgTable, uuid, varchar } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: varchar("email", { length: 255 }).notNull().unique(),
});
export const invoices = pgTable("invoices", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").notNull().references(() => users.id),
total: integer("total").notNull(),
});
在 Prisma 中,相同的模型被保存在 prisma/schema.prisma 文件中。关联关系需要在双方都进行声明:User 拥有一个 invoices 数组,而 Invoice 则包含标量值 userId 以及用于建立关联的 @relation 标签:
model User {
id String @id @default(uuid())
email String @unique
invoices Invoice[]
}
model Invoice {
id String @id @default(uuid())
userId String
total Int
user User @relation(fields: [userId], references: [id])
}
现在来看关键的查询:根据电子邮件获取用户的发票信息。Drizzle 通过带有 where 子句的显式内连接来实现这一功能,而 Prisma 则是先获取用户信息,再要求“包含其发票”:
// drizzle
const rows = await db
.select()
.from(invoices)
.innerJoin(users, eq(invoices.userId, users.id))
.where(eq(users.email, email));
// prisma
const user = await prisma.user.findUnique({
where: { email },
include: { invoices: true },
});
结果类型反映了这两种思维模式。Drizzle 返回的行结构与连接查询的结果一致,每行都包含 users 键和 invoices 键。而 Prisma 返回的是 User & { invoices: Invoice[] } 这种嵌套对象结构。两种方式都是正确的,只不过 Drizzle 的结构更接近 SQL 语句,而 Prisma 的结构则更符合即将渲染的页面需求。
开启查询日志后,两者的差异依然存在。Drizzle 的输出是开发人员可以直接阅读的连接查询结果,而 Prisma 的输出虽然完全可用,但它生成的 SQL 语句并不适合手动编辑。
对于如此小的架构来说,迁移过程毫无问题。drizzle-kit generate生成的SQL文件可以提交;prisma migrate则生成了自身的迁移历史记录,同样可以提交。这两种工具处理两个表时都没有遇到困难,而这种规模的架构无法体现更复杂的迁移场景,因此不应据此得出结论。
在本地复现实验环境
将每个工具链安装在其独立的文件夹中。Drizzle需要ORM、驱动程序(此处为postgres)以及用于迁移的drizzle-kit;Prisma则需要CLI和客户端,再通过prisma init来生成架构文件:
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit
pnpm add prisma @prisma/client
pnpm exec prisma init
在每个文件夹中插入一名用户和两张发票,执行一次关联操作,然后打印第一张发票的total值及其运行时的数据类型。注意不同的访问路径:Drizzle的关联结果行使用rows[0].invoices.total,而Prisma的嵌套对象结构则使用user.invoices[0].total。
console.log(rows[0]?.invoices.total, typeof rows[0]?.invoices.total);
console.log(user?.invoices[0]?.total, typeof user?.invoices[0]?.total);
如果一个工具显示为字符串类型,而另一个显示为数字类型,其原因几乎总是数据库驱动程序的类型映射问题,而非ORM的设计理念。PostgreSQL驱动程序通常会将bigint和numeric类型的列转换为字符串,以避免在JavaScript数字中丢失精度,而普通的integer类型列则会以数字形式返回。因此,在发票上,字符串类型的总和会像"1200" + 50那样悄悄变成"120050"。在选择相关库之前,请先记录下typeof的检测结果。
使用原生TypeScript运行查询文件
接下来,检查代码是否能在Node内置的类型剥离功能下直接运行,该功能无需额外构建即可通过删除类型注解来执行.ts文件:
node src/query.ts
仅由函数和类型注解构成的 Drizzle 模块可以正常运行。生成在 node_modules 中的 Prisma 客户端,通过一个简单的封装函数调用时也能正常工作。问题出现在那些以旧风格导入 Prisma 生成枚举的文件上。TypeScript 的 enum 声明不仅仅是类型,它们会被编译为运行时对象,而 Node 的仅剥离模式无法删除这些对象,因此会导致程序执行失败。这不是 Prisma 的缺陷,而是生成型运行时代码的固有特性。如果你们使用的 Prisma 版本采用了基于 TypeScript 的新引擎和生成器,请先查看 prisma generate 实际生成的代码,再判断该问题是否仍然存在,并确定所测试的版本号。
开启查询日志记录
凭猜测编写SQL是导致问题拖延解决的原因。这两个库都能记录每一条查询:Drizzle通过logger选项实现,而Prisma则通过客户端端的log数组来实现:
const db = drizzle(client, { logger: true });
const prisma = new PrismaClient({ log: ["query"] });
将记录下来的两条SQL语句与两个typeof total的结果放在一起。这四行数据就是本实验所需的全部数据。
每种工具的成本
这些优缺点体现在五个方面。
类型。Prisma的include功能能生成/invoices页面所需的精确数据结构;Drizzle的连接功能则能在调试总金额为何翻倍时提供所需的数据结构。这两种方式在不同情况下各有优势,因此建议为每个数据库选择一种工具,而非同时对同一张表使用两种工具。
SQL 可读性。当汇总结果出现错误时,由于查询语句清晰易读,Drizzle 的日志能更快地解决问题。而当新成员需要添加字段时,Prisma 的架构文件则更为便捷。这两种情况各有所优。
生成步骤。Prisma 要求每次修改架构后都执行 prisma generate;Drizzle 则要求 schema.ts 保持准确。在持续集成环境中,人们很容易忘记执行生成步骤,而客户端版本落后于架构文件会导致令人困惑的故障。若跳过生成步骤,应让持续集成流程直接失败。
边缘运行时环境。Drizzle 对边缘环境的支持确实是个优势,但只有在你将应用部署到边缘运行时环境中时它才有意义。在 VPS 上与 PostgreSQL 并存的 Node 进程无法从中受益,因此不要让这一优势成为决定服务器端应用的选择依据。
捆绑边界问题。两种 ORM 都不应出现在客户端组件中。如果将其中任意一种导入到 "use client" 模块中,比如用于交互式表格筛选的功能,就会导致客户端边界设置得过高,从而使数据库驱动程序被发送到浏览器。文章 介绍了如何正确设置 use client 边界,其中说明了解决该问题的方法。
分项账单
进一步拆分成本:
- 时间开销。Drizzle 的性能损耗源于连接操作的结果获取方式:根据查询语句的不同,可能是
rows[0].invoices.total或rows[0].total。而 Prisma 的性能损耗则在于每次修改架构后都需要重新生成代码。
选择方案及应避免的做法
当您希望在代码审查中看到 SQL 语句,且团队已经习惯使用连接操作时,请选择 Drizzle。将架构保存在 schema.ts 中,并确保团队中有人能够熟练阅读 innerJoin 代码。
如果团队的工作习惯是基于 schema.prisma 和 include,则请选择 Prisma。需为 CI 流水线中的生成步骤预留时间,若该步骤未执行则让流水线失败。
无论选择哪种方案,都应避免以下做法:
- 同时使用两种 ORM 对相同的生产表进行“比较”。这样会导致数据被重复记录,还可能让某人花费一整天时间来核对发票与银行记录。
- 根据每周的下载量来做选择。应依据在压力下能否快速理解某种的连接结果类型来做出决定。
本应是驱动程序问题的字符串总数问题
一个真实的故障案例说明了为何进行typeof检查如此重要。某个团队在两种工具中都对相同的两个表进行了建模,将一名用户与两张发票关联起来,并记录了total的类型:两种情况下均为number。一周后,又引入了另一种驱动程序,它将数值列映射为string类型,结果报表开始进行字符串拼接而非数值相加,导致显示的数值翻了一倍。
一个看似可行的解决办法是在每次调用处都用 Number(total) 将其包裹起来。这样虽然能掩盖映射问题,但并未真正解决它,下次出现相同问题的列仍会漏掉。真正的解决方案是为每个库记录一次 SQL 语句及结果类型,锁定驱动程序版本,并确保没有两个 ORM 同时写入相同的生产表。
枚举的处理也遵循相同逻辑:生成的枚举属于运行时代码,因此应编译该包并运行 dist/ 目录下的输出结果,而非直接执行生成的 TypeScript 代码。无论最终选择哪个库,都应在 README 中记录决策依据及原因,这样就不会有人后来为了尝试而添加另一个库。
比较之前先记录环境信息
这样的结果只有与生成它们的版本一起时才有意义。此处的参考环境为 Node 24、TypeScript 7 和 Next.js 16.3,运行着一个包含四个路由的简单发票应用。在仓库中保留一个 notes/lab.md 文件,首先记录下这三个版本的信息:
node -v
pnpm exec tsc -v
pnpm exec next --version
把它们写在笔记的开头。如果实际使用的版本与指南中假设的版本不同,请先停止并调整一致,然后再继续操作,因为后续命令可能会以更隐蔽的方式误导你。
接着启动开发服务器并逐一访问各个路由:
pnpm exec next dev
访问 /、/invoices、/invoices/1、/settings,然后在开发工具中开启“保留日志”功能后再访问一次 /invoices。同时记录下筛选框的内容和对应的 URL;这一组合往往就是日后所需的证据。
然后运行类型检查器并打印其退出码:
pnpm exec tsc --noEmit --pretty false
echo $?
退出码为零并非某种特性,只是允许继续进行运行时检查的信号。之后,请在自己的机器上执行上文实验部分中的命令,而不要完全依赖这些结果;硬件、内存压力以及浏览器的各种操作都可能比框架的小版本更新更显著地影响内存使用情况、类型检查耗时以及数据获取时间。
在笔记中保留一行简短的“修复失败”记录也会很有帮助,格式可为“尝试了X,但仍然出现Y”。这样的记录能让文件成为真实的实验记录而非宣传资料,对于接手这项工作的同事来说是最有用的信息。
应避免的错误
这类比较中常会出现三种失误:
- 使用两个迁移工具对同一数据库进行操作以作比较,这样会导致出现两条迁移记录以及一个具有两个名称的表。唯一干净的恢复方式是从备份中还原。
- 将生成的 Prisma 枚举导入文件,再通过 Node 的类型剥离功能处理,但会因上述原因失败。应直接编译该包。
- 根据下载量来判断库的质量,但这与连接类型毫无关联。
添加 ORM 之前的检查清单
- 每个数据库使用一个 ORM。
- 关键连接的 SQL 语句至少记录一次。
- 货币类型列的运行时
typeof值至少记录一次,且在每次驱动程序升级后也需重新记录。 - 包含枚举的生成输出需经过编译,绝不能通过原始类型剥离方式执行。
- README 文件中需注明所选的库及其选择原因。
总结
两张表并不能构成一个可投入生产的数据库架构,而且这个实验也没有对数千次连接操作进行性能测试,也未将结果部署到边缘运行环境中。但它确实表明,那些决定性的差异是具体的,且能在一个下午内得到验证:连接结果的形式、记录下来的SQL语句的可读性、生成步骤的成本,以及驱动程序返回的是数字还是字符串。为每个数据库选择一种库,记下选择理由,并在每次更换驱动程序后都进行typeof total检查。让两种迁移工具同时管理同一个数据库最终只会导致数据恢复问题,因此请务必避免在处理真实资金的业务系统中进行此类实验。