如果你在 TypeScript 后端里写 SQL,大概率经历过这样的纠结:直接用pg或mysql2写原生 SQL,查询灵活但手写映射实在容易出错;上一套 Prisma 或 TypeORM,模型和迁移确实省心,可真到了高并发或复杂查询场景,莫名的类型不透明、运行时开销和“魔改”出来的 SQL 又让人心里没底。我身边不少从 Java、Go 转过来的同事,第一次见到 Prisma 的引擎进程时都会愣一下:ORM 居然还自带一个二进制服务。
Drizzle ORM 正是冲着这个痛点来的。它是一个为 TypeScript 重新设计的轻量级 ORM,把查询构建器、类型推断和数据库迁移揉进一个极小的包里,没有代码生成步骤,没有隐藏的运行时引擎,查询写法几乎就是 SQL 本身。这篇文章我会从“为什么需要 ORM”“Drizzle 的核心设计差异”“真实项目落地步骤”“和主流方案如何取舍”这几个角度展开,最后附上我自己实操踩过的一些坑。想给 TypeScript 后端减负的读者,应该能从里面找到可以直接抄作业的答案。
1. 先聊通一个基本问题:TypeScript 后端为什么需要 ORM
1.1 手写 SQL、查询构建器与 ORM 的三层博弈
在决定用 Drizzle 之前,得先想明白“ORM 到底解决的是什么”。很多人对 ORM 的认知停留在“不用写 SQL”这个层面,但实际上手写 SQL、查询构建器和 ORM 三者是三种完全不同的取舍。
手写 SQL 在简单场景下最干脆,但在表结构变多后,结果集到 TypeScript 对象的映射会变成纯粹的体力活。你得为每一张表写一套 Row 接口,为每个查询字段的组合维护一份类型定义,改了表结构还要同步改类型。更麻烦的是,动态查询条件一旦多起来,用字符串拼接 SQL 就是一场灾难,稍微漏掉一个空格或者引号,线上接口就给你表演 500。
查询构建器,比如 Knex.js,把 SQL 语句拆成了链式方法,解决了一部分拼接问题,但查询结果仍然是一堆any或手写类型,类型安全的收益基本等于零。它更像“一个不会拼错语法的 SQL 工具”,而不是“一个懂数据模型的类型系统”。
ORM 则往前多走了一步:把表结构直接映射成 TypeScript 类型和对象,让编译器在编码阶段就盯住字段名、关联关系和返回类型。理想状态下,你写user.posts就能拿到已推断出类型的关联数据,删表字段时编译器会告诉你代码里哪里还在引用。这个价值在项目变大后是非常可观的。
1.2 老牌 ORM 们的“重”病
TypeScript 生态里不是没有成熟 ORM,Sequelize 和 TypeORM 都是老前辈,Prisma 也红了很多年。但用久了会发现,它们各自都背着一些“重量”。
TypeORM 核心问题出在装饰器模式上。为了把类和数据库列对应起来,你得在各种字段上挂@Column、@ManyToOne这种装饰器,类的生命周期和数据库实体的生命周期被强行绑在一起。上下文一旦复杂,类继承、装饰器顺序、懒加载这些机制就会互相纠缠。我在好几个项目里见过 TypeORM 的 select 语句自动把关联表全查出来,性能监控一查,一条业务请求背后挂了十几条 SQL。查问题时还要去翻它生成的 SQL 到底是不是你以为的那条。
Prisma 的体验确实现代,schema 文件声明式定义模型,客户端类型也做得很舒服。但它的架构是“客户端 + 引擎二进制”,查询会先经过一层 Rust 引擎处理,然后再发到数据库。这套设计带来了额外的进程和内存开销,在 Serverless 环境尤其明显,冷启动时间会被拖长。而且 Prisma 的自定义 SQL 能力相对受限,碰上复杂窗口函数或数据库特有语法,会被它的抽象层卡住。Drizzle 之所以能在两三年内收获大量关注,本质上就是因为它在“类型安全”和“透明可控”之间找到了更符合 TypeScript 后端需求的平衡点。
1.3 Drizzle 出现的时机
Drizzle 团队一直强调一个理念:TypeScript 后端的 ORM 不应该把开发者从 SQL 身边拉走,而应该把 SQL 的类型安全带给开发者。它不生成一堆运行时代码,也不搞隐藏引擎,所有查询都直接翻译成 SQL 发送给数据库。数据映射发生在真实的 SQL 执行结果上,而不是内存中的代理对象上。
这个定位刚好踩中了几个趋势:TypeScript 在后端的使用率持续上升、Serverless 和边缘计算要求依赖尽量轻量、开发者对“可预测性”的追求超过“零 SQL”。所以标题里我敢用“未来”这个词,并不是赶时髦,而是因为 Drizzle 代表的方向符合 TypeScript 后端本身的发展逻辑:类型即文档,SQL 即真相,运行时要尽量退场。
2. Drizzle ORM 到底动了哪几块蛋糕:核心设计拆解
2.1 它是查询构建器,不是全自动 ORM
第一次打开 Drizzle 文档的人,最容易产生的疑惑是:它到底算查询构建器还是 ORM?我的理解是,它把两层东西合并了。
底层是查询构建器,负责把.select()、.where()、.join()这些链式方法变成 SQL;上层则是类型系统,通过 schema 定义把数据库表结构变成 TypeScript 类型,并在构建期推断出查询结果的结构。因为查询链路短,Drizzle 的分析器能够精确地保留最细粒度的类型信息,连select里某个字段是string | null还是number都能推出来,这就比那种“查询完返回一个广义模型,再自己手动转换”的老式 ORM 高出一个层级。
这种设计最直接的好处是,学习成本极低。你不需要学一套“ORM 世界观”,只要会写 SQL,看到 Drizzle 代码就能猜到它对应什么查询。反过来,你从 SQL 经验去反推 Drizzle 写法,也几乎不会卡壳。
2.2 类型系统没有被“翻译”损耗:schema 即真相
Drizzle 里定义表,用的是pgTable、sqliteTable、mysqlTable这类函数,一张表就是一个普通 TypeScript 对象:
import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core'; export const users = pgTable('users', { id: serial('id').primaryKey(), name: text('name').notNull(), email: text('email').notNull().unique(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), }); export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert;注意这个写法的杀伤力在哪儿:$inferSelect和$inferInsert直接从表定义里反向推出“查询返回的行类型”和“插入时的输入类型”。也就是说,你的数据库 schema 就是唯一的真理来源,类型不是靠手写,也不是靠代码生成,而是由 schema 自己推导出来的。
这样只要表结构一变,整个项目的类型都会跟着联动。改字段名,编译器立刻告诉你哪些代码还引用了旧字段;改类型,所有赋值处都会爆红。这比在 Prisma 里执行完prisma generate再等 IDE 索引刷新要直接得多。
2.3 一次性定义表结构,迁移自动生成
Drizzle 的 schema 不只是用来做类型,它还是迁移工具的元数据来源。配合官方 CLI 工具drizzle-kit,你可以在 schema 文件里直接用 TypeScript 修改表结构,然后让 CLI 对比上一次迁移的状态,自动生成 SQL 迁移文件。
// drizzle.config.ts import { defineConfig } from 'drizzle-kit'; export default defineConfig({ schema: './src/db/schema.ts', out: './drizzle', dialect: 'postgresql', dbCredentials: { url: process.env.DATABASE_URL!, }, });日常流程是:改 schema 文件,跑npx drizzle-kit generate,CLI 会在drizzle目录下生成一个新的.sql文件,里面是CREATE TABLE或ALTER TABLE语句。你 review 一下 SQL,没问题就提交。到部署时再跑npx drizzle-kit migrate执行未执行的迁移。
这个环节能看出 Drizzle 的另一层克制:它生成的是可以被人工检查和修改的标准 SQL 文件,而不是晦涩的内部格式。数据库结构变更不再是黑盒,DBA 也能直接参与 review。
2.4 没有模型实例,没有 proxy,性能自然轻
TypeORM 和 Prisma 在运行时会创建模型实例或代理对象,用于跟踪变更、触发级联加载。这套机制开发时很舒服,但运行时要付出额外成本。Drizzle 绕开了这条路线,查询结果就是普通对象,没有任何隐藏的 getter 或 setter,也没有关系数据的内存代理。
带来的效果非常直观:包体积小、运行时开销低、冷启动快。在 Node.js 进程或 Serverless 函数里,这个优势会被放大。尤其现在很多项目把 TypeScript 后端部署到边缘函数,几百 KB 的依赖差异都会直接影响冷启动时间和计费,Drizzle 这种“零魔法”路线就更讨喜了。
3. 从零到生产:在 Node.js 项目里落地 Drizzle ORM
3.1 安装与连接配置
以 PostgreSQL 为例,安装依赖时除了 Drizzle 本身,还需要对应的驱动包。我一般用node-postgres作为连接池:
npm install drizzle-orm pg npm install -D drizzle-kit @types/pg然后创建数据库连接文件:
// 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 });这里有两个容易被忽略的点。第一,drizzle函数传入的配置对象里的schema不是必须的,但如果不传,后面用关系查询 API(db.query.xxx.findMany)时会拿不到关联关系。第二,连接池建议在应用入口创建一次并复用,不要每次请求都 new 一个 Pool,否则连接数会被打到数据库上限。
3.2 定义带关系的第一组表
单纯单表查询看不出 Drizzle 的优势,真正体现设计功力的是关系定义。比如用户和文章是一对多关系,可以这样写:
import { pgTable, serial, text, integer, timestamp } 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(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), }); export const posts = pgTable('posts', { id: serial('id').primaryKey(), title: text('title').notNull(), content: text('content').notNull(), authorId: integer('author_id') .notNull() .references(() => users.id), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), }); export const usersRelations = relations(users, ({ many }) => ({ posts: many(posts), })); export const postsRelations = relations(posts, ({ one }) => ({ author: one(users, { fields: [posts.authorId], references: [users.id], }), }));表定义和relations分开写,这个设计很值得夸。它让“表结构”和“关系语义”保持独立,不会出现 TypeORM 里装饰器把所有关系混在实体类上的局面。关系配置本质上就是告诉 Drizzle“外键怎么连、连过去叫什么名字”,类型推断依然是从真实列出发的。
3.3 增删改查与事务的核心写法
Drizzle 的查询 API 就是“长得很像 SQL 的 TypeScript 代码”。单条查询:
import { eq, and, desc, gte } from 'drizzle-orm'; import { db } from './db'; import { users, posts } from './schema'; // SELECT * FROM users WHERE email = '...' const user = await db.query.users.findFirst({ where: eq(users.email, 'a@b.com'), }); // SELECT * FROM posts WHERE author_id = $1 ORDER BY created_at DESC LIMIT 10 const recentPosts = await db.query.posts.findMany({ where: eq(posts.authorId, user.id), orderBy: desc(posts.createdAt), limit: 10, }); // 带关联查询 const postsWithAuthor = await db.query.posts.findMany({ with: { author: true }, where: gte(posts.createdAt, someDate), });插入、更新、删除则是直接用insert、update、delete方法:
const newUser = await db .insert(users) .values({ name: '张三', email: 'zhangsan@example.com' }) .returning(); await db .update(users) .set({ name: '李四' }) .where(eq(users.id, 1)); await db .delete(users) .where(eq(users.id, 1));事务支持也很直接,回调里拿到一个事务客户端,所有操作在同一个连接里执行:
import { db } from './db'; await db.transaction(async (tx) => { const [author] = await tx .insert(users) .values({ name: '王五', email: 'wangwu@example.com' }) .returning(); await tx.insert(posts).values({ title: 'Drizzle 实战', content: '...', authorId: author.id, }); });它没有把事务包装成某种“工作单元”或“会话”概念,事务回调里的tx和普通db有几乎一样的 API,只是作用在同一个数据库连接上。这样无论在事务内外,代码心智模型都是统一的。
3.4 迁移与同步:drizzle-kit 的日常用法
数据库 Schema 初版建好后,生成首次迁移:
npx drizzle-kit generate这一步会读取drizzle.config.ts里的 schema 路径,对比out目录下的迁移记录,然后生成 SQL 文件。首次运行会生成建表语句,之后每次修改 schema 再运行,生成的就是增量ALTER语句。
在本地开发或测试环境,还可以用 push 模式直接同步到数据库:
npx drizzle-kit push这个命令相当于“把 schema 当真相,直接改数据库结构”,适合开发期快速迭代。但生产环境我强烈建议走generate+migrate,让每次结构变更都有 SQL 文件可审查、可回滚。
执行迁移:
npx drizzle-kit migrate启动数据库可视化工具:
npx drizzle-kit studioStudio 会在本地起一个网页应用,可以查表数据、执行简单的 CRUD 和查看 schema。对开发期 debug 足够用了,不需要再额外单开数据库管理工具。
4. 同台对比:TypeORM、Prisma、Sequelize、Knex 与 Drizzle 怎么选
4.1 关键维度对照表
不同 ORM 的取舍差异很大,我整理了一张对照表,方便你按项目需求做判断:
| 维度 | Drizzle ORM | TypeORM | Prisma | Sequelize | Knex |
|---|---|---|---|---|---|
| 类型推断粒度 | 精确到具体查询结果 | 基于实体类,推断较粗 | 精确,但依赖 generate | 类型支持一般 | 结果多为 any |
| 运行时代理 | 无 | 有实体对象,存在代理开销 | 有 Rust 引擎层 | 有模型实例 | 无,但无类型安全 |
| 迁移能力 | SQL 文件可审阅,体验好 | 有迁移,但历史包袱重 | 有迁移,但不够透明 | 有迁移,维护成本偏高 | 需配合其他工具 |
| 自定义 SQL 灵活度 | 高,可直接写 sql 模板 | 中,复杂 SQL 需要 query builder | 较低,逃脱口有限 | 中 | 高 |
| 学习成本 | 低,会 SQL 就能上手 | 中,装饰器概念多 | 低,但概念抽象 | 中 | 低 |
| 包体/冷启动 | 极轻 | 中等 | 偏重 | 中等 | 轻 |
| Serverless 场景 | 友好 | 一般 | 冷启动偏慢 | 一般 | 一般 |
这张表可能稍微绝对,但大方向基本没错。Prisma 对业务开发非常友好,Drizzle 则更偏向“我自己清楚 SQL,也想要类型安全”的群体。
4.2 TypeORM:装饰器副作用与历史包袱
TypeORM 的问题不是不能用,而是它的抽象泄漏点太多。用装饰器定义实体时,实体类本身和数据库结构耦合得非常紧密,几乎每个字段都要维护“TS 类型 + 装饰器属性 + 数据库列选项”三份信息。类继承关系复杂后,TypeORM 会自动生成一些我们并不想要的多表查询和子查询,而且问题往往要到运行时才能暴露。
如果你现在的项目已经用 TypeORM 稳定跑了很多年,也不太建议为了追新而强行迁移。但如果是新项目,我会更倾向于选择架构更干净、类型推导更可控的方案。
4.3 Prisma:体验好但引擎偏重
Prisma 的模型定义和客户端体验确实很好,prisma generate生成的类型对业务代码的提示很友好。但它的引擎进程在 Serverless 和需要快速冷启动的场景里是个明显的拖累,部分用户甚至会因为二进制引擎和部署平台不兼容而踩坑。
另外,Prisma 对数据库特定语法的支持相对封闭。比如用 PostgreSQL 的jsonb做复杂查询、用LATERAL JOIN优化某些性能问题时,Prisma 的抽象层就像一层兜不住的网,最后你还是得退回到$queryRaw。既然最终还是要写原生 SQL,那为什么不在第一层就选择一个“SQL 原生”的方案?
4.4 什么时候应该继续用老牌方案
不是所有项目都应该上 Drizzle。如果你的团队对 SQL 不熟悉,希望用一种完全面向对象的思维操作数据库,那 Prisma 或 TypeORM 的开发效率可能更高。又或者,项目里已经有非常成熟的 Prisma/TypeORM 基础设施、代码生成流水线和团队规范,贸然切换只会增加维护成本。工具的迁移成本不是只看代码量,还要看团队心智模型能不能同步切换。
4.5 什么时候果断上 Drizzle
如果你的项目满足下面几个特征,我基本会推荐 Drizzle:团队能写 SQL,但不想手工维护字段映射;项目部署在 Serverless 或容器环境,对依赖体积和冷启动敏感;查询复杂度高,需要写自定义 SQL 和复杂 join;希望迁移文件像普通 SQL 一样可以被 review。尤其是“能写 SQL 但受够了类型不安全”的团队,Drizzle 的体验非常贴合。
5. 实操中出现的高频问题与排查记录
5.1 冷启动明显变慢或连接无法回收
Drizzle 本身很轻,但如果你在 Serverless 场景封装不仔细,连接池仍然会成为性能黑洞。常见做法是把连接池放在函数外部全局复用,而不是每次请求都创建。Node.js 上使用pg的Pool时,还要注意在函数结束前不要调用pool.end(),否则后续请求会不断新建连接。
我遇到过一次线上环境连接数狂飙的情况,最后发现是部署平台对单实例的并发请求没有限制,每个进程都维护了一个很大的连接池上限,数据库直接被连接数压垮。解决办法是把Pool的max调到一个合理的值,比如单实例不超过 10,然后开启connectionTimeoutMillis防止长时间排队。
5.2 查询结果里的 Date 变成了字符串或数字
Drizzle 的timestamp列在 PostgreSQL 上通常能映射成 JavaScript 的Date,但如果你用了pg驱动,并且查询列是timestamp without time zone,解析行为可能不同。还有一个更隐蔽的坑:事务里通过tx查询和通过主连接查询,类型推断一样,但运行时结果可能因为驱动配置差异表现不同。
我的习惯是:在连接初始化时统一设置pg的 type parser,让所有timestamp都转成Date,避免应用代码里拿到时区不一致的字符串再手动解析。Drizzle 本身没有隐藏的序列化逻辑,这个责任实际还是在驱动层。
5.3 迁移版本与实际表结构对不上
drizzle-kit generate是基于 schema 文件和已有迁移记录做 diff 的,如果迁移文件被手动改过,或者数据库里有历史表结构是通过别的工具建出来的,diff 结果就可能和预期不符。我在一个改造项目里就遇到过:旧表是 DBA 手工建的,字段注释、默认值和索引策略跟 schema 文件不一致,第一次 generate 出来的迁移差点把整张表重建了。
这类问题的处理原则是,迁移文件永远以人工 review 后的结果为准。如果 generate 生成了DROP COLUMN或DROP INDEX,一定要先确认是不是自己预期中的删除。生产环境不容错,宁可多用几分钟读一遍 SQL 文件,也不要盲跑迁移。
5.4 复杂联表查询中的 N+1 陷阱
Drizzle 提供的关系查询非常方便,但如果在一个列表接口里循环调用db.query.xxx.findFirst去查关联数据,还是会踩 N+1 查询的坑。比如查询 100 篇文章,再对每篇文章查作者,实际执行 101 条 SQL,性能必然崩。
解决办法是用findMany的with一次性把关联数据带出来,或者直接用显式leftJoin写联表查询。Drizzle 不限制你写复杂 SQL,这正是它的优势:遇到性能敏感接口,可以直接用 SQL 原生写法做最终优化。
5.5 TypeScript 类型推断展开后太深,编辑器卡顿
Drizzle 的类型系统非常激进,复杂 schema 加上多层级with之后,TypeScript 编辑器可能在大文件里出现明显的类型计算延迟。这个不是 bug,而是类型系统能力太强的代价。
我的经验是把 schema 拆成多个文件,不要让一个文件承载全部表;在业务层调用关系查询时,尽量给返回结果定义明确的命名类型,避免让 TS 在每次使用处重新推导一整棵类型树。如果确实遇到推理过慢,可以给db.query.xxx.findMany的结果手动标注一个精简的返回类型,牺牲一点点自动推导换取编辑器和 CI 的流畅度。
6. 我的实际使用感受与一点后续扩展思路
如果用一句话总结 Drizzle ORM,我会说它把“TypeScript 后端”和“写 SQL 的老实人”连接起来了。它不是要替你藏起数据库,而是让你以更安全、更直接的方式去面对数据库。真实跑过几个项目后,我最明显的体感是:查问题和调优的沟通成本降低了。Drizzle 每次发出的 SQL 都基本符合我的预期,不再像某些 ORM 那样,需要靠日志去猜“它到底执行了什么”。
后续如果想继续挖掘,有几个方向值得关注:一是 Drizzle 对多数据库方言的支持,同一套 schema 抽象在 PostgreSQL、SQLite、MySQL 之间切换的成本越来越低,这对做本地开发和测试很有价值;二是关系查询 API 配合事项(transactions)在复杂业务编排里的组合能力,实际用起来比当初文档里感受到的还要顺手;三是 Drizzle Studio 还在快速迭代,等它的可视化能力再补全一些,开发期的数据库管理体验可能会超过不少专用工具。
我个人始终觉得,ORM 的未来不是“越来越黑盒”,而是“越来越透明”。Drizzle 走在这条路上,TypeScript 后端多了一个值得押注的选择。如果你正被 TypeORM 的装饰器和 Prisma 的引擎搞得焦头烂额,不妨花一个下午把 Drizzle 的 schema 和基本查询过一遍,大概率会让你对“原来 ORM 还能这么轻”产生新的认知。