ORM vs Query Builder vs 原生 SQL:怎么选
ORM 省心但可能生成烂 SQL,原生 SQL 灵活但样板多。本文客观对比 Prisma/Drizzle/Kysely/原生 SQL 各自的哲学与适用场景:类型安全、迁移、N+1、性能、学习曲线,给一份"什么项目该用哪个"的结论。
本文要点
- 主题聚焦:TypeScript、DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
数据库访问层选型是 TypeScript 后端里最容易吵起来的话题。一派说 ORM 是"给不会写 SQL 的人用的",生成的 SQL 烂、N+1 满天飞;另一派说手写 SQL 是"拒绝抽象",团队大了根本维护不住。两边都没说错,但都没说全。
事实是:Prisma、Drizzle、Kysely、原生 SQL 这四样东西,抽象层级完全不同,要解决的问题也不同。把"谁更好"换成"谁更适合我的项目和团队",答案会清晰很多。本文按抽象从高到低讲一遍各自的哲学、代码长相、和真实项目里的坑,最后给一份不站队的选型建议。
一、三种数据访问方式的哲学差异
数据访问方式本质上是抽象层级的选择,而抽象层级决定了你写得多爽、以及出问题时能往下钻多深。
- ORM(对象关系映射):高抽象。你定义"模型"和"关系",框架替你把对象图翻译成 SQL。心智里全是业务实体,没有 JOIN。
- Query Builder(查询构造器):中抽象。你用链式 API 一段一段拼 SQL,
select().from().where(),看得见结构,但不用手写字符串。 - 原生 SQL:低抽象。你直接写 SQL 字符串,框架最多帮你做参数化、连接池、行到对象的映射。
抽象不是越高越好,也不是越低越好。高抽象省脑力、藏细节;低抽象给控制权、要你亲自管细节。 一个快速迭代的 SaaS 后台用 ORM 能省一半工时,一个高频读取的支付系统用 ORM 可能第一周就被慢查询拖垮。下面四节按这条线分别拆。
二、Prisma:schema-first 的类型安全优等生
Prisma 是典型的"重 ORM"。它的核心是一份 schema.prisma,定义模型和关系,然后生成出强类型的 client。
// schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
authorId Int
author User @relation(fields: [authorId], references: [id])
}
const user = await prisma.user.findUnique({
where: { id: 1 },
include: { posts: true }, // 显式预加载,避免 N+1
});
Prisma 的优势集中在三点:类型安全是编译期真实推导出来的(不是手写 interface),schema.prisma 是 single source of truth,迁移工具(prisma migrate)对大多数业务项目够用。开箱即用,新手半天能上手。
但它的代价也是真实的。第一,生成的 SQL 不透明——你写的是 include: { posts: true },Prisma 内部可能发多条查询而不是一条 JOIN,你看日志才知道。第二,复杂查询很难写:多表聚合、窗口函数、CTE、ON CONFLICT 这些,要么绕路,要么退化成 prisma.$queryRaw 去手写 SQL。第三,抽象层有性能税,大表上的某些查询会比你手写的 SQL 慢一截。
一句话:Prisma 适合"模型清晰、查询以 CRUD 为主、团队想要类型安全和迁移开箱即用"的场景。不适合复杂报表、高频热点查询。
三、Drizzle:Prisma 和 Kysely 之间的甜区
Drizzle 是这几年最值得学的选项。它的 API 是 SQL-like 的——你写出来的代码,几乎能一对一映射到 SQL。
// schema.ts
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
authorId: integer('author_id').notNull().references(() => users.id),
});
// 查询
const rows = await db
.select()
.from(users)
.leftJoin(posts, eq(users.id, posts.authorId))
.where(eq(users.id, 1));
Drizzle 的哲学是薄抽象:它不藏 SQL,你写 leftJoin 就是 LEFT JOIN,你写 eq 就是 =。生成的 SQL 你能一眼看穿,复杂查询(CTE、窗口函数、原生 SQL 片段)都直接支持。schema 用 TS 写,类型从 schema 自动推出来,不需要 codegen 那么重的流程。体积也很轻,Edge Runtime、Serverless 上跑都没负担。
它的"甜"在于位置:类型安全和 Prisma 一样强,但对 SQL 的控制力接近 Kysely。代价是相比 Prisma 心智稍重——你得真的会读 SQL,链式拼错了会得到错的查询;关系嵌套不像 Prisma 的 include 那么"自动",要用 with 配合 relational-style API,或者自己写 JOIN。
一句话:Drizzle 适合"既想要类型安全、又不想失去对 SQL 控制力"的中大型项目。是当前 TS 后端的默认推荐。
四、Kysely:纯 query builder,无 ORM 负担
Kysely 是纯 query builder,它只有 query,没有 model。你手写一份类型定义(或者用工具从数据库 schema 生成),然后用链式 API 拼 SQL。
interface User { id: number; email: string; }
interface Post { id: number; author_id: number; }
const rows = await db
.selectFrom('users')
.leftJoin('posts', 'posts.author_id', 'users.id')
.where('users.id', '=', 1)
.selectAll()
.execute();
和 Drizzle 的核心区别在于:Kysely 完全没有 ORM 的概念——没有模型、没有关系图、没有"对象图"。你拿到的是"SQL 的类型化构造器",行就是行,表就是表。
这意味着几件事。好处是零 ORM 负担:没有懒加载陷阱、没有 N+1 因为"懒"导致的、生成的 SQL 你写什么样就是什么样。复杂查询、动态拼条件(if (filter) query = query.where(...)),Kysely 处理得非常自然。坏处是类型要自己维护(虽然能 codegen),关系查询要自己 JOIN、自己拼结果形状,没有 Prisma 那种"include 一下就给你嵌套对象"的便利。
一句话:Kysely 适合"要对 SQL 精确控制、不想引入任何 ORM 心智"的场景。报表系统、数据层薄的微服务、团队里 SQL 功底扎实的项目。
五、原生 SQL:最透明也最容易踩坑
最后是直接写 SQL。Node.js 里通常是 pg 库配模板字符串,或者用 @database/pg、postgres 这类带类型推导的轻量库。
const rows = await sql`
SELECT u.*, p.*
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
WHERE u.id = ${1}
`;
原生 SQL 的好处是绝对的透明和灵活:你写什么就执行什么,窗口函数、LATERAL、数据库特定语法、性能优化的每一招都能用上。没有抽象层的性能税。
但代价也最重。第一,类型安全要自己搞:返回类型默认是 any,要么靠库推导、要么手写。第二,样板多:映射、分页、动态 WHERE 拼接,全是自己写的循环。第三,SQL 注入风险——上面用模板字符串参数化是安全的,但一旦你忍不住用 ${variable} 拼表名/列名/排序字段(这些不能参数化),就埋了雷。第四,迁移工具要自己选(node-pg-migrate 之类),没有 Prisma 那种开箱体验。
一句话:原生 SQL 适合"查询极度复杂、性能要求极端、团队对注入和迁移都有纪律"的场景。不是默认选项,是特殊选项。
六、各自的坑:N+1、慢查询、注入、样板
选型前要先看清坑,坑决定了每个工具的天花板。
ORM 的 N+1 陷阱:ORM 默认懒加载关系。你在循环里 user.posts,每次都触发一条 SQL,100 个用户就是 101 条查询。Prisma 的解法是显式 include,Drizzle 用 with,但前提是你得起 awareness。N+1 不是 ORM 的 bug,是高抽象的固有副作用——你不去想 SQL,就不会想到合并。
ORM 生成的 SQL 慢:Prisma 的 include 有时拆成多条查询而非 JOIN,大表上会慢。这种慢往往上线才暴露,因为开发库数据少。
Query Builder 的复杂查询冗长:Kysely/Drizzle 写 5 表 JOIN + 子查询 + CTE,代码会比原生 SQL 长,可读性也下降。中等复杂度是甜区,极复杂度反而不如原生 SQL 直观。
原生 SQL 的注入风险:参数化能挡值,挡不住表名、列名、ORDER BY 字段。一旦动态拼这些(ORDER BY ${sortField}),就是注入入口。需要白名单校验。
原生 SQL 的样板税:每个查询都要写类型、写映射、写分页,团队大了容易各写各的,没有统一的 query 风格。
七、选型:什么项目该用哪个(别教条)
给一份可操作的结论,但先说前提:没有银弹,只有匹配。
- 快速开发、MVP、CRUD 为主、团队规模小:用 Prisma。类型安全 + 迁移 + 极低心智成本,能把"从 0 到上线"压缩到最快。复杂查询那部分用
$queryRaw兜底。 - 中大型项目、想要类型安全又不想失去 SQL 控制力:用 Drizzle。当前 TS 生态的默认推荐,长期看投资回报最高。
- 报表系统、数据层薄、团队 SQL 功底扎实、要精确控制 SQL:用 Kysely。没有 ORM 负担,复杂动态查询最顺手。
- 极端性能、极复杂 SQL(大量数据库特定语法、窗口函数、LATERAL):用原生 SQL + 一个带类型推导的库。把抽象留给该抽象的地方,核心查询不抽象。
混合用也是合理的:Prisma 管业务 CRUD,复杂报表用 Kysely 或原生 SQL 走同一张库。不要为了"统一"在一个不适合 ORM 的地方硬上 ORM,也不要为了"纯粹"在一个纯 CRUD 的地方硬写 SQL。
最后两条反教条:ORM 不总是烂——它让大量中小项目用一半的时间跑完了比赛,N+1 和慢查询是可控的、有最佳实践的;原生 SQL 不总是对的——一个 5 人的 SaaS 团队全程手写 SQL,迁移、类型、风格全靠自觉,半年后就维护不动了。按项目规模、查询复杂度、团队能力三个维度选,而不是按信仰选。
小结
- 抽象层级是核心权衡:ORM 高抽象省脑力、藏细节;Query Builder 中抽象看得见 SQL;原生 SQL 低抽象给控制权、要亲自管细节。
- Prisma:类型安全最强、迁移开箱即用,适合 CRUD 为主的快速开发;代价是 SQL 不透明、复杂查询难。
- Drizzle:SQL-like API + 强类型,薄抽象看得见生成 SQL,是 Prisma 和 Kysely 的甜区,TS 后端当前默认推荐。
- Kysely:纯 query builder 无 ORM 负担,适合要精确控 SQL、团队功底扎实的场景;关系查询要自己拼。
- 原生 SQL:最灵活最透明,但类型、迁移、注入、样板全是自己的活,是特殊场景选项而非默认。
金句:ORM 省的是脑力,原生 SQL 省的是抽象税——你到底该付哪一边,取决于你的查询有多复杂、团队有多大,而不是你信哪一派。
Related