1. TypeScript 后端的数据访问层,到底烂在哪儿了
这几年只要一提到"重写后端",团队里最先达成的共识几乎都是:别再让 Prisma 那套自创语义继续往上叠业务了。TypeScript 本身是一个静态类型系统非常强的语言,按理说从 API 入口到数据库边界,类型应该一路畅通。真做起来却是另一个局面:绝大多数 ORM 在数据访问层都搞了一套自己的运行时魔法,类型安全在最后这一百米断档,导致你在接口层写得很爽的 interface,一落到数据库交互就变成了 any 或者一堆靠猜的字段。
1.1 ORM 的重型运行时,是一次性的"魔法税"
先别急着杠,我并不是说 ORM 没有价值。项目早期用 ORM 快速建模、自动迁移,确实能把效率拉满。但当你维护一个两年以上的 TypeScript 后端,ORM 的累积成本会呈指数上升。
以 TypeORM 为例,它是典型的装饰器 + 反射方案。实体类上挂满 @Entity、@Column、@ManyToOne,运行时要靠 reflect-metadata 去解析这些装饰器,然后把类实例映射成数据库行。这套机制在 JavaScript 时代还能接受,但在 TypeScript 后端里就很别扭:你的数据库结构被"藏"在类装饰器和运行时元数据里面,IDE 跳转看到的是一堆装饰器工厂,类型推断偶尔还要靠 FindOptionsWhere<T> 这种半吊子泛型兜底。
Prisma 是另一个方向的典型。它通过 schema.prisma 声明模型,然后跑 prisma generate 生成客户端代码。思路很干净:模型文件里写清楚表结构,生成的客户端带着完整类型。问题是它的二进制引擎和生成代码都很重,每次 Schema 一改就要重新 generate,CI 里必须多一步生成流程,换机器、换 Node 版本、跑 Docker 构建时经常莫名踩坑。更麻烦的是,Prisma 的自创查询语法偏离 SQL 原义,遇到复杂的 join、子查询、窗口函数,你得上 $queryRaw 写原生 SQL,等于绕回起点。
你会发现这两种主流方案都躲不开同一个问题:**它们都在你的代码和数据库之间垫了一层"翻译",而这层翻译本身还要维护,还要升级,还会出 bug。**我把这称之为魔法税,它不是一次性交完,而是每年都要续费。
1.2 类型安全在数据库这头断档,后端代码就失去了一半价值
TypeScript 后端最值钱的东西是什么?我的答案非常朴素:把能静态检查的错误尽量在编译期拦下来。请求参数校验、业务逻辑判断、数据组装,这些都是前端可以理解的层面。但真正危险的往往是数据库边界——字段名拼错、类型不匹配、可空性判断失误,这些错误通常不会在编译期暴露,而是等到运行时返回 undefined 或者抛出一个模糊的数据库驱动报错。
传统 ORM 在这一点上的表现参差不齐。TypeORM 的查询结果类型基本靠泛型和实体类推导,看着有类型,其实在复杂关联查询里经常是 any 或者变成联合类型的噩梦。Prisma 比 TypeORM 好很多,生成类型的精确度确实高,但它的类型系统是"代码生成出来的二次类型",一旦你写 prisma.user.findMany() 并且没有把 include 和 select 写对,IDE 给你的报错信息可能要花十分钟才能看懂——因为那是在一堆由 generate 产生的 .d.ts 里推出来的错误。
在我接触 Drizzle ORM 之前,我一直以为自己只能在这两种方案里选一个凑合。后来看到一个观点让我印象很深:**Drizzle 不是又一个 ORM,它是把 SQL 本身作为类型推导的核心,让 SQL 的每一段都变成 TypeScript 可表达的 AST。**它不是去"封装" SQL,而是去"复刻" SQL 的类型结构。这意味着类型从你的代码一路流到 SQL 语句再回到查询结果,几乎不会变形。
1.3 为什么我这时候开始认真看 Drizzle
我是从一次重构开始彻底转向 Drizzle 的。当时项目里用的 Prisma,Schema 文件四十多个模型,编译产物客户端超过 8MB,每次部署构建都要重新生成。为了排查一个诡异的关联查询,我不得不打开 Prisma 生成的内部类型文件去追字段映射。折腾到凌晨三点,我意识到这个方向有问题:如果 ORM 让我越来越远离 SQL 和数据库本身,而我又必须在生产环境里面对真实的 SQL 行为和数据库特性,那我为什么要多付一层高昂的维护成本?
于是我开始拿小项目试 Drizzle。试用两周后,感受可以用四个字概括:手脚清爽。它没有定义一套全新的查询世界,它的核心 API 就是 select、insert、update、delete,语义上跟 SQL 一一对应,只是补上了 TypeScript 推断。然后我很快把简历里能写 "熟练使用 Prisma" 的地方全都改掉了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Drizzle 的底层思路:它没想取代 SQL,它想给 SQL 配上 TypeScript 类型
很多第一次接触 Drizzle 的人会问:这玩意儿到底是 ORM 还是 query builder?我的理解是:它两个都是,但它最本质的定位更接近"SQL 的类型安全表达式"。
2.1 headless schema:零代码生成,零重复依赖
先聊架构层面最特别的设计——headless。这个词听起来玄乎,翻译成大白话就是:Drizzle 的核心运行时不依赖任何自动生成的代码,也不依赖任何 Prisma engine 那样的二进制进程。你需要做的事情非常简单:在 TypeScript 里用 pgTable() 定义好表结构,然后直接调用 db.select() 查询。
对比一下就清楚了。Prisma 的工作流程是:定义 schema.prisma → 跑 generate → 生成客户端 → import 客户端 → 查询。一旦你的开发机器和 CI 环境里的 generate 步骤出问题,或者两个环境的 Prisma 版本不一致,你连代码都跑不起来。Drizzle 没有这一步,因为表定义本身就是类型和运行时共用的真源。你写:
typescript复制import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
这一个文件既能在运行时用来拼接 SQL,又能让 TypeScript 推导出 typeof users.$inferSelect 和 typeof users.$inferInsert 作为查询结果的类型。没有额外代码生成,没有重复的类型定义,没有要从某个隐藏目录 import 的 client。你的表和你的类型在同一个文件里,改一处,另一处自动跟着变。
这一点带来的工程收益是巨大的。代码 review 的时候,一个 PR 里如果改了 users 表的字段定义,IDE 能立刻高亮出所有使用到旧字段的查询语句,因为类型系统已经感知到了。这在 Prisma 里也能做到,但前提是你得保证所有开发者都记得改完 schema 后执行 generate,并且在 build 产物里把生成的类型提交上去。Drizzle 则是从根上消灭了"改了真源忘记同步"这种低级问题的土壤。
2.2 真正的查询构建器,贴近 SQL 原义而不是自创语法
ORM 最容易犯的毛病就是为了让开发者"忘记 SQL",设计一套看起来很友好的语法,最后反而让开发者重新学习一门 ORM 方言。Prisma 的 where: { createdAt: { gte: someDate } } 就是一种嵌套对象方言。它确实好读,但一旦条件复杂起来,你需要去记忆 Prisma 的各种操作符嵌套规则,还要小心关联过滤到底应该放 where 还是放 AND 数组里的哪个位置。
Drizzle 没有走这条路。它选择让 SQL 的关键字直接成为 API。最简单的查询:
typescript复制import { eq, and, gt, desc } from "drizzle-orm";
const result = await db
.select()
.from(users)
.where(
and(
eq(users.email, email),
gt(users.createdAt, someDate)
)
)
.orderBy(desc(users.createdAt));
这看起来像一段组装出来的 SQL,实际上它在类型层面就是一整条 SQL 的映射。你要写 LEFT JOIN 就有 leftJoin,要写 GROUP BY 就有 groupBy,要写 HAVING 就有 having。如果你写了一句不可能成立的 SQL(比如在 groupBy 之后 select 一个既不在分组里也不是聚合函数的字段),TypeScript 能直接给你报出来,而传统 ORM 往往要等到数据库返回错误。
这种"贴近 SQL 而不是发明方言"的思路有很现实的好处:你的 SQL 知识不会浪费,团队里没人需要为了 Drizzle 去学习一套全新的查询语言。更关键的是,当某条复杂查询 Drizzle 表达不了时,你还能在同一个文件里写原生 SQL 片段,用 sql 模板标签操作:
typescript复制import { sql } from "drizzle-orm";
const rows = await db.execute(sql`
select
date_trunc('month', created_at) as month,
count(*) as cnt
from users
group by 1
`);
相比 Prisma 用 $queryRaw 时那种"脱离类型体系"的感觉,Drizzle 的 sql 标记在模板字符串里仍然可以被解析,某些参数还可以联动类型推断。
2.3 类型推导为什么能做到"不跑偏"
这是 Drizzle 给我惊喜最大的一块。早期我用它的 db.select() 方法时,一度怀疑 IDE 提示的类型是不是真的准。后来我去刷了源码里类型相关的部分才明白,Drizzle 的查询返回类型不是靠泛型手动指定的,而是通过大量条件类型和映射类型,把每一段查询表达式在编译期"算"出最终的返回行结构。
拿最常见的场景举例,你只 select 几个字段,返回类型就只包含这几个字段;你 leftJoin 了一张表,返回类型就会自动带上那张表的字段。这就是完整的类型流:
typescript复制const rows = await db
.select({
id: users.id,
email: users.email,
postTitle: posts.title,
})
.from(users)
.leftJoin(posts, eq(posts.userId, users.id));
// rows 的类型被自动推导为:
// { id: number; email: string; postTitle: string | null }[]
看到这个返回类型,你根本不需要再去定义 DTO 来回折腾。直接在 route 里 return 给前端,你的 API 响应在类型上就已经和数据库字段完全对齐了。特别是字段可空性,leftJoin 的右侧字段肯定有 null 的可能,Drizzle 在类型上就标注好了,倒逼你在返回前端之前处理掉 null,这类 bug 在运行时基本被提前消灭。
这种能力让我在写 Drizzle 代码时最大的感受是:IDE 不是在辅助我写代码,而是真的知道我这条 SQL 查出来后会长什么样。
3. Drizzle 全链路代码实操:一个社区用户系统的改造过程
光讲概念容易虚,我拿最近重写的一个社区用户系统来演示整体代码长什么样。这个系统主体是:用户表、帖子表、帖子的点赞关系,以及一个按时间分页取帖子信息流的接口。需求本身很普通,用它来对比 Prisma 和 Drizzle 最直观。
3.1 表结构定义:用 TypeScript 写"DDL"
先定义核心三张表和一个关联表:
typescript复制// src/db/schema.ts
import { pgTable, serial, text, integer, timestamp, boolean } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
bio: text("bio"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const posts = pgTable("posts", {
id: serial("id").primaryKey(),
authorId: integer("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull(),
content: text("content").notNull(),
publishedAt: timestamp("published_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const postLikes = pgTable("post_likes", {
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
postId: integer("post_id")
.notNull()
.references(() => posts.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
// 联合主键,避免同一用户重复点赞同一帖子
uniqueIndex("post_likes_user_post_unique").on(table.userId, table.postId),
]);
定义关系,是为了后面可以使用 db.query 这套 API:
typescript复制export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
likes: many(postLikes),
}));
export const postLikesRelations = relations(postLikes, ({ one }) => ({
user: one(users, {
fields: [postLikes.userId],
references: [users.id],
}),
post: one(posts, {
fields: [postLikes.postId],
references: [posts.id],
}),
}));
注意这里没有任何装饰器、没有任何 magic import。pgTable 的第二个参数如果传入一个函数,函数里可以定义索引和约束,这种写法在代码组织上非常像一个"没有 SQL 文件的 DDL",但类型是活的。
3.2 关键查询:单表、join、嵌套关系查询
单表查询,按邮箱找用户:
typescript复制const user = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
// user 类型: typeof users.$inferSelect[]
用户主页的场景,一次性查出用户信息和最近 10 篇帖子标题:
typescript复制const userWithRecentPosts = await db.query.users.findFirst({
where: eq(users.id, userId),
with: {
posts: {
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
limit: 10,
columns: {
content: false, // 列表页不需要正文
},
},
},
});
这个 db.query 的 API 和 Prisma 的嵌套查询有点像,但它的底层是一套关系映射器,会根据你的 relations 定义自动把嵌套关系转换成合理的 SQL 执行,避免典型的 N+1 问题。在内部实现上,它用了一个"查询计划"的机制,不会傻乎乎地对每条记录都发一次额外查询。
帖子信息流接口,需要拿到每个帖子、作者姓名、当前用户是否点过赞。这个接口用 SQL 写其实是标准的三表 join:
typescript复制const feed = await db
.select({
postId: posts.id,
title: posts.title,
authorId: users.id,
authorName: users.name,
liked: sql<boolean>`CASE WHEN ${postLikes.id} IS NULL THEN false ELSE true END`,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.leftJoin(
postLikes,
and(eq(postLikes.postId, posts.id), eq(postLikes.userId, currentUserId))
)
.orderBy(desc(posts.createdAt))
.offset(offset)
.limit(pageSize);
你注意最后返回的 liked 字段,它来自一段原生 SQL 表达式。Drizzle 的类型系统允许你把 sql<boolean> 嵌入到 select 的字段里,于是结果类型中这个名字就叫 liked,并且被推导为 boolean。这种程度的融合,是 Prisma 和 TypeORM 都给不了的——它们要么只能用原生 SQL 整段查询丢掉类型,要么必须在自己体系里造一个表达式。
3.3 事务与批量写入
社区系统里最常见的写操作就是"点赞"和"取消点赞",我把它包在事务里,确保创建/删除行为和数据一致性:
typescript复制await db.transaction(async (tx) => {
const existing = await tx
.select({ id: postLikes.id })
.from(postLikes)
.where(
and(eq(postLikes.userId, userId), eq(postLikes.postId, postId))
)
.limit(1);
if (existing.length === 0) {
await tx.insert(postLikes).values({
userId,
postId,
});
}
});
事务回调的参数 tx 不是原来那个 db,它是在当前事务里绑定的连接。你在里面写所有查询都用 tx,Drizzle 的类型系统会保证 tx 同样拥有完整查询 API。这样做的好处是不会出现"事务内查询没用同一个连接"这种隐蔽错误。
批量插入也很自然:
typescript复制await db.insert(postLikes).values(
userIds.map((uid) => ({
userId: uid,
postId,
}))
);
Drizzle 会根据传入数组自动拼出 multi-row insert 语句,在 pg 下是 insert into ... values (...), (...) 这种一条语句完成批量插入。性能和代码简洁度都可以。
3.4 接入驱动、初始化连接、跑迁移
Drizzle 本身不是一个数据库驱动,它需要配合具体的驱动来执行 SQL。在 Node 环境里最常见的组合就是 pg 库或 postgres 库。我比较推荐 postgres 这个库,因为它在连接池和 prepared statement 上的表现很现代。当然 pg 也很稳,基本上是无脑接入:
typescript复制// src/db/index.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export const db = drizzle(pool, { schema });
注意我传了一个 schema 参数,这一行为后面用 db.query 这套关系查询 API 提供了模型元信息。如果你只打算写最基础的 db.select 那套,这个参数可以省略。
迁移方面,Drizzle 官方提供了 drizzle-kit 工具。它有两种主流用法:一种是根据你的 TypeScript 表定义生成 SQL 迁移文件,适合有 DBA review SQL 的团队;另一种是 push 模式,直接把表结构同步到开发库,适合快速开发。
bash复制npx drizzle-kit generate
npx drizzle-kit migrate
执行 generate 后会得到类似 drizzle/0000_your_migration.sql 这样的文件,里面是纯 SQL DDL。可以打开看、手改、也可以提交进 review。用 Prisma 的时候,我是信任 Prisma migrate 的自动化;用 Drizzle 之后,我反而更喜欢把每一次 schema 变更当 SQL 文件来 review,因为我能百分百确定它要去数据库执行什么操作。
4. 从 Prisma / TypeORM 迁移到 Drizzle,最值得关注的几个映射关系
我在多个项目里做过迁移,也帮朋友远程改过代码。这里直接把最常见、最容易踩坑的映射关系整理出来。
4.1 Prisma 查询到 Drizzle 查询的对照
先看最基本的 findUnique:
typescript复制// Prisma
const user = await prisma.user.findUnique({
where: { email },
});
// Drizzle
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
findFirst 带条件:
typescript复制// Prisma 的关联 include
const author = await prisma.user.findFirst({
where: { id: userId },
include: { posts: { take: 5 } },
});
// Drizzle,使用 db.query
const author = await db.query.users.findFirst({
where: eq(users.id, userId),
with: {
posts: { limit: 5 },
},
});
update 和 delete:
typescript复制// Prisma
await prisma.post.update({
where: { id: postId },
data: { title: newTitle },
});
// Drizzle
await db
.update(posts)
.set({ title: newTitle })
.where(eq(posts.id, postId));
你能明显感受到的差异是:Drizzle 永远把"操作目标表"放在第一位,然后用 .where() 精确刻画范围,和 SQL 的心理模型一致。Prisma 的 update 是"先通过 where 找对象,再给 data",这种设计是 ORM 的对象思维,不是数据集合思维。业务简单时两种写法都行;一旦遇到 updateMany 里带有复杂的子查询条件,Prisma 写法会变得非常割裂。
4.2 TypeORM 实体类到 Drizzle 表对象的迁移
TypeORM 改造到 Drizzle 要动的代码结构更多,因为 TypeORM 的实体类本身承担了业务对象的职责:
typescript复制// TypeORM
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
email: string;
@OneToMany(() => Post, (post) => post.user)
posts: Post[];
}
到 Drizzle 后,我并不建议把表对象直接当领域对象塞给业务代码。更稳的做法是:表定义只放在 schema.ts 这一层负责数据库映射;业务层如果要定义领域对象,在查询返回后做一次显式映射。这不是 Drizzle 的限制,反而是它透明带来的好处——你本来就不该让一个"数据库实体"满业务层乱飞。
迁移最大的成本其实在业务代码里所有 user.posts.push() 这种靠实体关系自动持久化的写法。Drizzle 不会帮你维护内存里的实体关系图,你改了数据就要显式调 update/insert。习惯面向对象写法的人会觉得从"自动持久化"退回"显式持久化"是退步,但从工程可控性来看,显式持久化能避免大量把实体对象改到一半然后忘记 save 的隐晦 bug。
4.3 关于迁移到 Drizzle 的渐进策略
如果你手上是一个正在线上运行的中型项目,千万不要搞一次性全部迁移。我建议用"模块化手术"的方式。
首先把 Drizzle 和现有 ORM 并排接入,共享同一个数据库连接池。其次找出业务最独立、查询最简单的一个模块,比如用户资料查询,用 Drizzle 重写该模块的全部数据访问代码。上线观察一段时间,确认没有性能回退,再继续切下一个模块。因为 Drizzle 不占 runtime 的全局状态,不会跟 Prisma/TypeORM 在同一进程里打架,这种渐进改造完全可行。
我用这种方式帮一个老项目切了第一个模块之后,最明显的变化是那个模块的 API 响应时间中位数降了 20%。原因不大复杂,Prisma 生成的 query 在序列化参数、构造查询时多了一些额外开销,而且它有自己的一套批量数据加载策略,在这个模块里并不能做到最优。Drizzle 生成的 SQL 几乎就是手写水准,没有多余包装。
4.4 CI/CD 和部署体验的简化
从 Prisma 切到 Drizzle 之后,CI 里少了一步 prisma generate,Docker 镜像里少了一个 Prisma engine 的二进制文件。部署体积和构建时间都肉眼可见地下降。
我以前用 Prisma 还遇到过 Alpine Linux 容器里 engine 二进制缺少 glibc 依赖的问题,被迫换 distroless 镜像折腾了很久。Drizzle 没有独立 engine,查询时就是走 Node 进程直接调用 pg / postgres 驱动,这类跨平台问题基本消失。对于 serverless 和边缘函数场景,Drizzle 更是友好到不行,因为它的 core 可以在 edge runtime 里直接跑,只要配一个支持边缘的数据库驱动即可。这是我在标题里敢说"未来"的重要原因:架构不是越来越重型,而是越来越细粒度,Drizzle 的设计天然贴合这种趋势。
5. 实测之后,这些坑和限制我必须摆出来
Drizzle 优点讲得不少,但没有一个工具是银弹。实际跑了大半年,有几个坑确实花了我不少时间。
5.1 时间戳时区、JSON 和枚举的序列化细节
PostgreSQL 的 timestamp with time zone 和 timestamp without time zone 在 Drizzle 里区分很明显,你定义列时必须想清楚:
typescript复制// 推荐:把时间统一存成带时区的时间戳
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
如果你沿用 Prisma 时代喜欢存的 UTC 字符串,Drizzle 默认不会帮你隐式做时区转换,数据库拿到 timestamp 字符串后按连接时区解析。时间字段一旦出现八小时漂移,排查起来相当疼。我现在一律在表定义阶段明确 withTimezone: true,应用层传标准 ISO 字符串,数据库端全链路统一 UTC。
JSON 字段同样要显式声明列类型:
typescript复制import { jsonb } from "drizzle-orm/pg-core";
metadata: jsonb("metadata").$type<{ theme: string; tags: string[] }>().default({ theme: "default", tags: [] }),
这里 .$type() 是 Drizzle 给 TypeScript 类型做的标记,运行时它依然把这个字段当 JSON 存进 PostgreSQL。不加 $type,查出来就是个 unknown 或者 any,等于把类型判断义务丢给你自己。
5.2 事务回调里的异步陷阱:不要随便 await 外部查询
Drizzle 的 db.transaction(async (tx) => {}) 看起来只是传了个回调,实际对你的事务边界有严格要求。
我最开始犯过的错误是事务里查了一些数据之后,调用了一个分布在不同文件里的 service 方法,而这个方法自己内部又去用全局 db 发查询。表面看代码能跑,但某些情况下事务内查询和该 service 的查询落在不同连接上,结果读到的是事务外未提交的数据,或者导致连接池被事务长期占用。
特别是连接池很小时,这种"事务吃着连接、回调里又向连接池要连接"的模式可能直接导致连接池耗尽,整个接口卡死。正确写法是:事务回调内能完成的查询,全部通过回调参数 tx 来执行;需要调外部 service 时,把 tx 作为参数传进去,让那个 service 内部也使用 tx 操作数据库。
这是一个看起来简单、但能坑到很多人的设计约束。我不是说 Drizzle 实现得不好,恰恰相反,它为了保证事务语义的一致性,强制要求你把事务内所有数据库操作统一收敛到同一个连接。这正是"透明"的表现——换了 Prisma 你根本看不出事务内到底走的哪个连接。
5.3 关系查询里嵌套分页和数据膨胀问题
db.query.users.findMany({ with: { posts: { limit: 3 } } }) 这种写法听起来很自然,似乎拿到了每个用户的前 3 篇帖子。但如果你对底层 SQL 没有认知,会在某些场景下被数据量吓一跳。
Drizzle 的 relation query 生成策略在遇到一对一和一对多混合时,往往会用"先取主表,再按外键批量 IN 查子表"的方式,这个整体策略没问题。可一旦关系链深度超过两层,比如 users -> posts -> comments,你要的其实是每个用户前 5 个帖子以及每个帖子的前 3 条评论,这已经不是一个简单的嵌套读取能表达的了。Drizzle 本身也意识到了这一点,它的嵌套查询里 limit 是"最内层列表的 limit",但如何精确定位到每个父记录下面的前 N 个子记录,底层还是要靠窗口函数或复杂 SQL 实现。
遇到这种需求,我建议直接跳出 db.query,手写 SQL 里用 LATERAL JOIN 或者窗口函数 row_number() 来实现"每组取前 N 条"。Drizzle 提供了足够强大的底层能力,你不想让它替你自动规划的地方,完全可以自己掌控。
5.4 生态和学习资料相对较少
这是所有新工具共有的问题。Drizzle 目前的文档质量还不错,query builder 的 API 设计也比较直观,但你遇到疑难杂症时,没法像 Prisma 那样一搜就有大量中文帖子。我的应对方法是遇到问题时直接去 GitHub 仓库翻 issue 和 Discussions,很多边界情况和版本兼容问题在那里已经有现成讨论。Drizzle 的维护者对类型和 SQL 细节的回答很专业,这点体验让我比较放心。
另外提醒一句,Drizzle 的版本迭代速度不慢,API 有小概率调整。你在看网上教程时,最好以当前 npm 包的版本文档为准,不要照着半年前的旧 API 死磕。这个问题在它 0.x 时代比较明显,项目进入 1.x 后稳定了不少,但历史教程里的 drizzle-kit generate:pg 之类的旧命令还是不要再用了。
6. 给团队引入 Drizzle 前,我建议你先确认的几件事
如果你看完前面这些已经动了心,别急着把老项目推倒重来。先做三个小验证,确认 Drizzle 适合你们团队,再谈大规模切换。
第一个验证是"团队 SQL 熟练度"。Drizzle 不掩盖 SQL,反而要求你懂 SQL。成员如果习惯用 ORM 的封装来逃避 SQL,转 Drizzle 初期会有一定阵痛。我的观察是,Drizzle 项目里成长的初级开发,过了两周反而比之前更懂数据库了,因为每次调用 Drizzle API 就像是在拼 SQL,他们被迫理解 join、group by、索引这些东西。从长期看这是好事。
第二个验证是"项目是否存在大量动态查询"。Drizzle 对动态条件的处理是我见过的最舒服的一种,你可以在函数里一个条件一个条件地往 and() 数组里 push 表达式,最后一次性展开。这一点比 Prisma 强太多了,Prisma 的动态条件最后会变成长长的一坨 where 对象,深层的类型推导经常让人抓狂。
第三个验证是"现有 ORM 是否真的让你痛苦"。如果现在项目里的 Prisma 用得挺好,模型不复杂,查询也没有性能问题,并不需要为了追新而换。我转换 Drizzle 的根本原因是重量级 ORM 带来的工程摩擦已经超过它节约的开发量,如果你的项目还没到那个临界点,不换也没有错。
我在实际项目里最后一个体会是:用 Drizzle 之后,团队里关于"这段查询怎么写"的讨论变多了。因为它把 SQL 放在了明面上,写代码的人必须搞清楚自己在让数据库做什么,而不是丢给 ORM 一层魔法。这种透明性带来的代码可读性和可维护性提升,比任何花哨的类型推导都更有长期价值。
如果你也想从零开始试,建议直接建一个最小的 express 或 hono 项目,接上 PostgreSQL 或 SQLite,用 Drizzle 定义三张有关联的表,把增删改查和迁移完整跑一遍。整个过程大概一个下午就能完成,但你对"数据访问层应该长什么样"的认知,可能会从此不一样。
