对于大多数应用而言,务实的默认方案是:将 ORM 或查询构建器用于占绝大多数的常规 CRUD 操作,而将手写 SQL 留给那些在报表、分析和热点路径查询中真正能带来可量化收益的场景——这并非非此即彼的抉择。真正值得思考的问题不是”哪种方式在抽象层面更优”,而是”针对这个查询、这个团队、这些约束条件,该选哪种方式”。本文将为你提供一套决策规则、一张对比表,以及一个比抽象层开销更常坑害团队的典型反模式——N+1 查询问题。文中示例以 JS/TS 技术栈(Prisma、Drizzle、Kysely、Knex)为基础,因为这正是当前大多数此类决策发生的场景。
核心要点
- 对于常规 CRUD,默认使用 ORM 或查询构建器;对于特定的报表、分析及可量化需要优化的热点查询,再考虑原生 SQL——大多数生产应用最终会同时运用两者。
- ORM 真正的性能代价几乎从来不是抽象层本身,而是 N+1 查询模式:先加载一个列表,再逐行查询其关联数据,会将一次页面加载变成数百次数据库往返。
- 解决 N+1 的方法是通过单次查询预先加载关联数据(使用 Prisma 的
include或JOIN),并只查询实际需要渲染的字段——而不是放弃 ORM。 - Drizzle、Kysely 和 Knex 等查询构建器是常被忽视的第三种选择:它们提供接近 SQL 语法、经过类型检查的查询方式,对生成的 SQL 拥有完全控制权,且没有对象映射层。
- ORM 默认对查询进行参数化处理,从而堵住了最常见的 SQL 注入漏洞;原生 SQL 的安全性则完全取决于你使用参数化语句的自律程度。
什么是 ORM,什么是”原生 SQL”
**ORM(对象关系映射器)**是一种将数据库表映射到编程语言中对象的库,让你可以通过方法调用和类型化模型来查询数据,而无需手写 SQL 字符串。它负责生成 SQL、将其发送到数据库,并将返回的行数据还原为对象,同时处理关联关系、参数化和类型映射。在 JS/TS 生态中,Prisma 和 TypeORM 是典型的全功能 ORM。一个典型的 Prisma 读取操作如下所示:
// 一次调用同时获取用户及其文章
const users = await prisma.user.findMany({
where: { posts: { some: { title: { contains: 'test' } } } },
include: { posts: true },
});
// users[0].posts 已经是一个类型化数组——无需手动重组
原生 SQL 则意味着自己编写查询语句,通过数据库驱动执行,再手动将扁平的结果行重组为应用所需的数据结构。你对查询拥有完全控制权,但映射工作也全部由你负责。同样是”获取用户及其文章”,使用 pg 驱动时需要执行 JOIN 并手动重组数据:
const { rows } = await pool.query(
`SELECT u.id, u.name, p.id AS post_id, p.title
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
WHERE p.title LIKE $1`,
['%test%'],
);
// 扁平行数据 -> 嵌套用户结构,需手动完成
const users = Object.values(
rows.reduce((acc, r) => {
acc[r.id] ??= { id: r.id, name: r.name, posts: [] };
if (r.post_id) acc[r.id].posts.push({ id: r.post_id, title: r.title });
return acc;
}, {} as Record<number, { id: number; name: string; posts: any[] }>),
);
这个 reduce 操作正是原生 SQL 处理关联读取的真实代价:数据库返回的是行数据,而非对象图,每次都需要你自己编写 JOIN 和重组逻辑。其中的 $1 占位符也承担着重要职责——详见下文安全性部分。
ORM vs 原生 SQL:真正影响决策的对比
Discover how at OpenReplay.com.
决策归结为五个维度。以下是全功能 ORM、查询构建器和原生 SQL 三种方式在各维度上的对比:
| 评估维度 | 全功能 ORM(Prisma、TypeORM) | 查询构建器(Drizzle、Kysely、Knex) | 原生 SQL |
|---|---|---|---|
| 注入安全性 | 默认参数化 | 默认参数化 | 仅在使用参数化语句时安全 |
| 开发速度(CRUD) | 最快——关联关系和数据还原均已处理 | 快——接近 SQL 语法,需自行编写 JOIN | 最慢——需手动映射 |
| 迁移 / 模式管理 | 一流支持(Prisma Migrate、TypeORM) | 内置或配套工具(Drizzle Kit、Knex migrations) | 需手写 DDL 脚本 |
| 跨数据库可移植性 | 高——切换数据库提供商改动极少 | 中——具备方言感知能力 | 低——依赖特定方言语法 |
| 控制力 / 数据库特性支持 | 有限;需借助逃生通道 | 高——接近 SQL | 完全——所有特性和查询提示均可使用 |
| 类型安全性 | 从模式到应用端到端类型安全 | 强(Kysely/Drizzle);尽力而为(Knex) | 无额外工具则不具备 |
在安全性方面,实际情况很简单:ORM 和查询构建器默认对参数进行参数化处理,从而堵住了经典的注入漏洞。原生 SQL 的安全性则完全依赖于开发者的自律——Prisma 官方文档明确警告:如果对用户输入使用不安全方法,则会产生 SQL 注入攻击的可能性,可能导致数据被篡改或删除。只要使用占位符($1、?)且绝不将用户输入直接拼接到查询字符串中,原生 SQL 就是安全的;一旦忽视这一原则,则不然。
在迁移和可移植性方面,ORM 在常规场景下占优。模式变更会转化为版本受控、可重复执行的迁移,在各环境中行为一致;切换数据库提供商大多只需修改配置。原生 SQL 两者皆不具备——你需要自行管理 DDL 脚本,且查询语句与特定方言语法绑定。在控制力方面,原生 SQL 则占据绝对优势:窗口函数、递归 CTE、数据库特定的索引提示以及 EXPLAIN ANALYZE 调优,都无需与抽象层博弈即可直接使用。
性能真相:问题在于 N+1,而非抽象层
对 ORM 最常见的批评——“它们生成的查询很慢”——对于 CRUD 场景而言大多是错误的,但对一种特定模式而言却基本属实。现代 ORM 为日常读写操作生成的 SQL 质量是有保障的。真正的性能代价在于 N+1 查询问题:先加载 N 行数据,再对每一行触发一次额外查询来获取关联数据,将一次页面加载变成对数据库的 N+1 次往返。
以下是这种反模式的典型写法,通常隐藏在看似无害的循环中:
// 1 次查询获取用户列表……
const users = await prisma.user.findMany();
// ……然后每个用户再触发 1 次查询——这就是 N+1 陷阱
for (const user of users) {
user.posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
解决方案不是放弃 ORM,而是通过单次查询预先加载关联数据,并只查询实际需要渲染的字段:
const users = await prisma.user.findMany({
include: { posts: { select: { id: true, title: true } } },
});
这样就将 1+N 次查询压缩为一次。N+1 问题未修复时的用户侧表现是:列表页或仪表盘在测试数据下响应迅速,在生产数据量下却明显变慢——这正是会话回放工具从用户视角捕获到的前端延迟类型,它会引导你回到查询层,去统计查询次数并添加预加载。
在归咎于工具之前,先检查生成的 SQL
在断定 ORM 运行缓慢之前,先看看它实际生成了什么。Prisma 在实例化客户端时通过 log: ['query'] 记录每一条查询;Kysely 提供 .compile() 方法返回 SQL 语句和参数;Drizzle 则在查询对象上提供 .toSQL() 方法实现同样的功能。然后对输出结果运行 EXPLAIN ANALYZE,查看数据库实际执行的查询计划。
const prisma = new PrismaClient({ log: ['query'] });
// Prisma 执行的每条查询现在都会被打印出来——统计数量,复制到 EXPLAIN ANALYZE 中分析
这将”ORM 很慢”从一种主观感受转化为可检验的工作流:阅读 SQL、统计往返次数、检查执行计划。
关于引擎本身,值得一提的是:Prisma 7 最重要的变化之一是彻底移除了基于 Rust 的查询引擎,转而采用 TypeScript 实现。新的查询编译器运行在 TypeScript 和 WebAssembly 之上,消除了跨语言序列化步骤,从而带来更快的查询执行速度。由于引擎不再依赖原生二进制文件,Prisma 现在可以在支持 JavaScript 或 WASM 的环境中使用,例如 Cloudflare Workers、Bun 和 Deno。Prisma 表示,此次重写在最关键的场景下——大型复杂查询——往往有显著的性能提升,而在简单查询上则保持同等水平。对于本文的对比而言,这意味着:引擎层面的争论已经改变了形态,但并未消失,且仍然是次要问题。对于绝大多数慢页面,根本原因是 N+1 和过度查询,而非抽象层本身。
查询构建器:二元对立框架中被忽视的第三条路
“ORM vs 原生 SQL”这一框架遮蔽了一个强有力的中间地带。Drizzle、Kysely 和 Knex 等查询构建器让你能够编写接近 SQL 语法、经过类型检查的查询,同时对生成的 SQL 保持完全控制,且没有 ORM 的对象映射层。Kysely 是 TypeScript 中最强大的类型安全 SQL 查询构建器,已在 Deno、Maersk 和 Cal.com 等生产环境中得到应用,具备现代 TypeScript 支持且零运行时开销。它是对 SQL 的轻量抽象,通过命名和结构保持熟悉感,并通过 1:1 编译确保可预测性。
const users = await db
.selectFrom('users')
.innerJoin('posts', 'posts.author_id', 'users.id')
.select(['users.id', 'users.name', 'posts.title'])
.where('posts.title', 'like', '%test%')
.execute();
其结构与 SQL 高度对应,但列名和表名都会根据你的模式进行类型检查。正如 Kysely 文档所述,查询构建器让你对 SQL 拥有完全控制权,同时确保 TypeScript 在代码运行之前就能捕获错误。Knex 是历史悠久的选择,支持 Postgres、MySQL、SQLite 等多种数据库,但需注意其类型支持的局限性:根据其官方指南,TypeScript 支持属于尽力而为,并非所有使用模式都能进行类型检查,且没有类型错误并不能保证生成的查询一定正确,因此即使使用 TypeScript 也建议编写测试。对于希望获得类型安全而不引入 ORM 复杂度的新 TS 项目,Kysely 或 Drizzle 是更好的选择。
按场景划分的决策规则
根据工作负载选择合适的工具,而不是为整个代码库统一选型:
- 简单到中等复杂度的 CRUD、团队协作、模式持续演进 → ORM。 你将获得参数化查询、版本受控的迁移、关联加载和端到端类型安全。这覆盖了大多数应用代码。
- 报表、分析、批量操作或经过量化验证的热点路径 → 原生 SQL、视图或存储过程。 当你需要窗口函数、复杂聚合或通过
EXPLAIN ANALYZE手动调优的执行计划时,直接编写 SQL。 - 希望获得类型安全和 SQL 控制力,但不想引入 ORM → 查询构建器。 Drizzle 或 Kysely 提供接近 SQL 语法的类型化查询,输出可预测,且不存在隐式 N+1 行为。
大多数生产应用最终采用的混合方案
在实践中,成熟的代码库不会非此即彼——它们将 ORM 用于大部分工作,同时为其余场景保留原生 SQL 的逃生通道。Prisma 通过 TypedSQL 将这一模式明确化:这是 Prisma ORM 的一项功能,让你能够以完全类型安全的方式编写原生 SQL 查询。你在 .sql 文件中编写 SQL,并通过生成的类型化函数调用它:
import { getUsersByAge } from './generated/prisma/sql';
const users = await prisma.$queryRawTyped(getUsersByAge(18, 30));
这是一项预览功能:需要在 generator 块中通过 previewFeatures = ["typedSql"] 启用。由于采用静态类型,它可能无法处理某些场景,例如动态生成的 WHERE 子句——这些场景仍需使用无类型的原始查询方法。Prisma 对这一工作流的定位与本文完全一致:一个服务于大多数查询的高层抽象,加上一个在需要直接编写 SQL 时使用的类型安全逃生通道。这也正是为什么”ORM 还是原生 SQL”这一问题的现代答案通常是:“一个允许你在必要时逃逸到原生 SQL 的 ORM(或查询构建器)。“
结语
将 ORM 或查询构建器作为日常 CRUD 的默认选择,在真正能带来可量化收益的查询上才使用原生 SQL,并将两者视为同一数据访问策略中的不同层次,而非相互对立的阵营。对于任何现有项目,最直接的下一步是:开启查询日志,找到列表页和仪表盘接口,统计每次请求的查询次数,并通过预加载和更精确的字段选择来修复 N+1 问题——这一轮优化通常能带来比任何 ORM 与 SQL 重写更显著的性能提升。
常见问题
ORM 和查询构建器有什么区别?
ORM 将数据库表映射到对象,并将查询结果还原为类型化模型,自动处理关联关系和对象映射;而查询构建器生成经过类型检查的 SQL,但返回的是普通行数据,不包含对象映射层。Prisma 和 TypeORM 是 ORM;Drizzle、Kysely 和 Knex 是查询构建器。查询构建器更接近 SQL,对生成的查询拥有更多控制权,抽象程度更低,也不存在隐式关联加载行为。
使用 ORM 能防止 SQL 注入吗?
在正常使用情况下,可以。ORM 和查询构建器默认对参数进行参数化处理,将用户输入作为绑定参数传递,而非拼接到查询字符串中,从而堵住了经典的注入漏洞。例外情况是不安全的原始查询方法:Prisma 文档警告,对用户输入使用不安全方法会产生 SQL 注入的可能性。原生 SQL 的安全性完全取决于你使用 $1 或 ? 等占位符的自律程度。
原生 SQL 比 ORM 快吗?
在大多数实际场景中并非如此。现代 ORM 为日常 CRUD 生成的 SQL 质量有保障,通常的性能差距来自查询次数而非抽象层本身。慢页面的主要原因是 N+1 模式——先加载列表再逐行查询关联数据,会产生数百次往返。原生 SQL 值得在报表、分析以及经过量化验证、真正需要手动调优执行计划的热点查询场景中使用。
如何查看 ORM 实际生成的 SQL?
每种工具都提供了直接查看生成 SQL 的方式。Prisma 在实例化客户端时启用查询日志选项后会记录每条查询。Kysely 提供 compile 方法,返回 SQL 字符串及其参数。Drizzle 在查询对象上提供 toSQL 方法实现同样功能。获取 SQL 后,在 PostgreSQL 中对其运行 EXPLAIN ANALYZE,检查执行计划,确认索引是否按预期被使用。
在 ORM 项目中可以编写原生 SQL 吗?
可以,这也是常见的生产实践模式。大多数 ORM 在生成查询的同时保留了原生 SQL 的逃生通道。Prisma 提供 TypedSQL,这是一项通过 typedSql 预览标志启用的预览功能,允许你在 .sql 文件中编写 SQL,并通过生成的完全类型化函数调用它。由于采用静态类型,它无法处理动态生成的 WHERE 子句,这类场景仍需使用无类型的原始查询方法。