12k
All articles

ORM vs 原生 SQL:该如何选择?

ORM 与原生 SQL 对比:比较安全性、速度和控制力,了解 N+1 为何比抽象层更伤性能,并按查询选对工具。

OpenReplay Team
OpenReplay Team
ORM vs 原生 SQL:该如何选择?

对于大多数应用而言,务实的默认方案是:将 ORM 或查询构建器用于占绝大多数的常规 CRUD 操作,而将手写 SQL 留给那些在报表、分析和热点路径查询中真正能带来可量化收益的场景——这并非非此即彼的抉择。真正值得思考的问题不是”哪种方式在抽象层面更优”,而是”针对这个查询、这个团队、这些约束条件,该选哪种方式”。本文将为你提供一套决策规则、一张对比表,以及一个比抽象层开销更常坑害团队的典型反模式——N+1 查询问题。文中示例以 JS/TS 技术栈(Prisma、Drizzle、Kysely、Knex)为基础,因为这正是当前大多数此类决策发生的场景。

核心要点

  • 对于常规 CRUD,默认使用 ORM 或查询构建器;对于特定的报表、分析及可量化需要优化的热点查询,再考虑原生 SQL——大多数生产应用最终会同时运用两者。
  • ORM 真正的性能代价几乎从来不是抽象层本身,而是 N+1 查询模式:先加载一个列表,再逐行查询其关联数据,会将一次页面加载变成数百次数据库往返。
  • 解决 N+1 的方法是通过单次查询预先加载关联数据(使用 Prisma 的 includeJOIN),并只查询实际需要渲染的字段——而不是放弃 ORM。
  • Drizzle、Kysely 和 Knex 等查询构建器是常被忽视的第三种选择:它们提供接近 SQL 语法、经过类型检查的查询方式,对生成的 SQL 拥有完全控制权,且没有对象映射层。
  • ORM 默认对查询进行参数化处理,从而堵住了最常见的 SQL 注入漏洞;原生 SQL 的安全性则完全取决于你使用参数化语句的自律程度。

什么是 ORM,什么是”原生 SQL”

**ORM(对象关系映射器)**是一种将数据库表映射到编程语言中对象的库,让你可以通过方法调用和类型化模型来查询数据,而无需手写 SQL 字符串。它负责生成 SQL、将其发送到数据库,并将返回的行数据还原为对象,同时处理关联关系、参数化和类型映射。在 JS/TS 生态中,PrismaTypeORM 是典型的全功能 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:真正影响决策的对比

决策归结为五个维度。以下是全功能 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 是更好的选择。

按场景划分的决策规则

根据工作负载选择合适的工具,而不是为整个代码库统一选型:

  1. 简单到中等复杂度的 CRUD、团队协作、模式持续演进 → ORM。 你将获得参数化查询、版本受控的迁移、关联加载和端到端类型安全。这覆盖了大多数应用代码。
  2. 报表、分析、批量操作或经过量化验证的热点路径 → 原生 SQL、视图或存储过程。 当你需要窗口函数、复杂聚合或通过 EXPLAIN ANALYZE 手动调优的执行计划时,直接编写 SQL。
  3. 希望获得类型安全和 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 子句,这类场景仍需使用无类型的原始查询方法。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.