1. TypeScript 后端的"胖"与"瘦"之争:ORM 到底该承担多少职责?
如果你最近两年一直在写 TypeScript 后端,大概能感受到一个明显的变化:项目启动时,依赖安装从 npm install 变成了一场漫长的等待,node_modules 动不动就几百 MB;启动一个简单的 CRUD 服务,内存占用轻松超过 200MB;改一个数据库字段,要从 entity 改到 migration、从 migration 改到 service、再从 service 改到类型定义,改完还要担心运行时会不会冒出某个你没见过的隐式行为。
这种体感上的"臃肿",正是当下 TypeScript 后端工程化走到某个临界点之后的自然反应。它不是某一个库的问题,而是整个工具链叠加出来的结果。但当我把目光聚焦到数据访问层——也就是 ORM 这个环节时,发现大多数项目的"重"其实并不是必须的。
1.1 大多数 TypeScript 后端项目里的 ORM 现状
先说一个我自己的观察。在很多 TypeScript 项目里,ORM 的选型路径基本是这样一个顺序:最早接触的是 TypeORM,因为教程多、中文资料多、网上搜"TypeScript 后端"基本绕不开它;后来有人推荐 Prisma,因为 schema 文件写起来舒服、Studio 可视化工具好看、官网文档做得像艺术品;再后来,一部分追求性能的团队换成了 Knex 甚至纯 SQL。
这个路径反映了三个不同阶段的诉求:第一阶段是"能跑就行",第二阶段是"开发体验好就行",第三阶段是"性能可控才行"。但你会发现,从头到尾没有哪个阶段在认真回答一个问题——ORM 到底要不要替我做这么多事?
TypeORM 的装饰器模型,把数据库实体、列类型、索引、关联关系全部塞进 class 里,再用装饰器标注。写起来确实挺"面向对象"的,但运行时它需要维护大量元数据,DataSource 初始化时要扫描所有 entity,建立实体关系图。一旦项目规模上去,你会碰到各种奇怪行为:synchronize: true 在开发环境里自动改表结构,改完还没法回滚;关联查询的 relations 写错一个字段名,报错信息像是在打哑谜;find 方法的 where 条件稍微复杂一点,就开始拼字符串。
Prisma 走的是另一条路。它用一个独立的 schema 语言描述数据模型,然后生成客户端。开发体验确实好,prisma studio 打开就能看数据,prisma migrate 的迁移流程也算顺畅。但它的"重"藏在另一个地方:Prisma Client 在运行时维护了一个很厚的查询引擎层,所有查询都会经过引擎翻译、序列化、执行、反序列化。你 findMany 一个简单的列表,背后可能走了好几层 RPC。更麻烦的是,它生成的 client 体积不小,冷启动时间明显比其他方案长,在 Serverless 场景下,这个开销会被无限放大。
1.2 "臃肿"的本质:隐性状态管理与不必要的抽象
说了这么多,我想表达的真正问题是:传统 ORM 的"臃肿"不只是体积大、启动慢,更关键的是它们引入了太多隐性的状态管理和抽象层次,而这些抽象往往是你并不需要的。
举个例子。用 TypeORM 的时候,Repository 对象内部有缓存、有实体元数据、有数据库连接的状态管理。你对这个 repository 调 save(),它可能帮你做:实体校验、级联保存、更新 updatedAt 字段、触发订阅者事件。这些"帮你做的事"在简单的 CRUD 里是便利,但在复杂业务里就是黑盒。你想知道一条数据最终以什么 SQL 落库,得开 logging: true 看日志——但日志一开,性能损耗和输出噪音又来了。
Prisma 的抽象更彻底。它的 client 层对你完全屏蔽了 SQL,你在代码里写的是 prisma.user.findMany({ where: { email: { contains: 'foo' } } }),然后引擎帮你翻译成 SQL。乍一听很美好,但问题在于:当查询涉及多表 join、子查询、窗口函数时,Prisma 的表驱动风格会让你非常别扭。你不得不先 include 或 select 嵌套关系,但生成的 SQL 可能不是你想要的执行计划——尤其是分页之后再做 count、group by 之后再排序这类稍微带点复杂度的查询,你都开始怀疑自己写 Prisma 的姿势是不是有问题。
我不否认这些 ORM 的贡献:它们降低了初学者上手数据库操作的门槛,也提供了一套还算完整的解决方案。但对于一个追求可预测性、可控性和性能的 TypeScript 后端而言,这些框架的"好心"反而成了负担。这也是我最终转向 Drizzle 的核心动因——它选择了一种完全不同的姿态:贴近 SQL,但又不放弃类型安全;提供抽象,但拒绝黑盒。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Drizzle 的设计哲学:SQL 优先、类型安全、零魔法
Drizzle 这个项目第一次出现在我的视野里,是看到有人在讨论"TypeScript 生态里终于有了一个不像 ORM 的 ORM"。我当时的第一反应是:又是个营销话术。但真正读了一遍文档、跑了几个 demo 之后,我意识到这确实不是同一类东西。
Drizzle 把自己的定位描述为"headless ORM",这个名字很有意思。它不强制你用什么 schema 格式,不把查询构建隐藏在一个封闭的引擎后面,也几乎没有任何运行时魔法——它提供的是一个类型安全的 SQL 查询构建器 + schema 定义工具。你可以用接近 SQL 的语义去写查询,所有类型检查都在编译期完成。
2.1 SQL-first:让你始终知道 SQL 在做什么
Drizzle 的查询语法非常直接,几乎没有 DSL 的影子。你想查用户表里所有 email 包含某个关键字的用户:
typescript复制import { eq, like } from 'drizzle-orm';
import { users } from './schema';
const result = await db
.select()
.from(users)
.where(like(users.email, '%example%'));
这段代码的语义和 SQL 基本是一一对应的:SELECT * FROM users WHERE email LIKE '%example%'。你没有发明新的查询语言,没有嵌套的对象描述符,也没有"引擎自动帮你优化"的讨巧。你在写的是结构化的 SQL,只是它的每一段都有类型保护。
再来看一个带 join 的例子。如果你要查询订单表并连带取出对应的用户信息:
typescript复制import { eq } from 'drizzle-orm';
import { orders, users } from './schema';
const result = await db
.select({
orderId: orders.id,
amount: orders.amount,
userName: users.name,
})
.from(orders)
.leftJoin(users, eq(orders.userId, users.id));
你注意一下这个片段的几个细节:
select的字段列表是显式的,你要什么列就列什么列,不会像某些框架一样先SELECT *再在内存里筛选。- join 条件就是
eq(orders.userId, users.id),和你手写 SQL 时的ON子句是完全一致的。 - 结果的类型:
{ orderId: number; amount: number; userName: string | null }[],这个类型是 Drizzle 在编译期从 schema 推断出来的,不需要你写任何类型断言。
这就是 SQL-first 的意义:你看得懂自己在写什么,也知道数据库会执行什么,中间没有"翻译层"的猜测空间。
2.2 真正的类型安全:从 schema 到查询结果全链路
很多框架都说自己有类型安全,但实际上能做到"全链路"的少之又少。TypeORM 的类型安全建立在装饰器和 class 之上,当你在 find 里写 where: { email: 'foo' } 时,TypeScript 确实会校验 email 是否是 user 实体的属性,但一旦涉及关系查询、嵌套筛选、复杂逻辑运算,类型就开始"松动",最终你拿到了一个 any。
Prisma 的类型安全在 schema 生成的 client 层面做得很好,但它的类型推导依赖 Prisma 引擎的运行时数据。你定义了一个 @updatedAt 字段,生成的类型里就会出现 DateTime @updatedAt,但这个更新逻辑是引擎在运行时帮你做的——你在编译期看不到任何"这个字段什么时候被更新"的明确信息。
Drizzle 的路径更干净:你的 schema 是 TypeScript 类型本身,迁移文件是 SQL,查询结果是类型推断的直接产物。schema 定义长这样:
typescript复制// schema.ts
import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull(),
name: text('name'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
定义好之后,users 本身就是一个携带了完整列类型信息的对象。当你写 db.select().from(users) 时,返回的结果类型会自动被推导为:
typescript复制{
id: number;
email: string;
name: string | null;
createdAt: Date;
}[]
注意 name 被推断为 string | null,因为 schema 里没有 .notNull();createdAt 被推断为 Date 而不是字符串,因为底层列类型是 timestamp。类型系统和数据库 schema 是同一份源代码,不存在"两端不同步"的问题。
更进一步,Drizzle 还支持用 TypeScript 的 satisfies 或 typeof 操作符对查询结果做二次类型约束,这在很多场景下非常有用。我在项目里经常这样写:
typescript复制import { type InferSelectModel } from 'drizzle-orm';
import { users } from './schema';
type User = InferSelectModel<typeof users>;
// User = { id: number; email: string; name: string | null; createdAt: Date }
这个 InferSelectModel 直接从 schema 对象里提取出"查询返回的行类型",你再也不用在 entity 和 DTO 之间手动搬运字段。
2.3 零魔法:没有隐式级联、隐藏缓存、运行时依赖
Drizzle 最打动我的一点是"克制"。它不像 TypeORM 那样默认启用实体监听器、订阅者、事务装饰器,也不像 Prisma 那样在运行时维护一套完整的查询引擎。Drizzle 执行查询时,做的就是:将查询构建成 SQL 字符串 → 交给数据库驱动执行 → 返回结果。
这意味着什么?意味着没有隐式级联保存。你 insert 一个 user,Drizzle 不会"好心"地帮你顺便把关联的 orders 也插进去;你 update 一个 user,Drizzle 也不会自动帮你更新 updated_at 字段——除非你明确告诉它这么做。所有操作都是显式的,你完全掌控每次写操作的范围。
同时,Drizzle 没有运行时依赖注入容器,没有隐藏的 DataSource 初始化流程,没有"自动加载所有 entity"的扫描机制。你需要做的只是创建一个 drizzle 实例,传入一个数据库连接和你的 schema 定义:
typescript复制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 });
const db = drizzle(pool, { schema });
就这么简单。db 对象上的每个方法都是纯函数式调用,不会在你背后维护状态。它的"魔法"含量接近于零,但这恰恰是它在大型项目中最大的优势——越是复杂的系统,越不需要意外之喜。
3. Drizzle 与主流 ORM 的正面交锋:选型不靠情怀
聊到这里,肯定有人会问:你说了 Drizzle 这么多好处,那其他 ORM 是不是就一无是处?当然不是。选型永远是在特定上下文里做权衡,没有放之四海而皆准的银弹。这一节我把 TypeORM、Prisma、Knex 和 Drizzle 放到同一个桌面上比一比,结合我在真实项目里的感受,说清楚各自的优势和代价。
3.1 TypeORM:最熟悉的陌生人,问题在哪里
TypeORM 在我心里的定位是"老牌、全面,但隐性问题多"。它最大的优势是生态成熟、资料齐全,你在百度或 Stack Overflow 上遇到任何问题基本都能搜到答案。它的实体定义方式也让很多从 Java 系转过来的开发者感到亲切——毕竟 JPA/Hibernate 的 @Entity 装饰器模式,在 TypeScript 世界里 TypeORM 做得最像。
但 TypeORM 的问题恰恰出在"全面"上。为了兼容各种数据库、各种使用方式,它内部积累了大量条件分支和兼容性代码。我在一个中型项目里遇到过几个印象深刻的坑:
- synchronize 的自动建表在多人协作时是灾难。 本地数据库同步没问题,但一旦有人改了实体定义,其他人的本地库结构就和 migration 对不上。解决方式只能是把
synchronize关掉、老老实实写 migration,但 TypeORM 的 migration 生成器在某些场景(比如字段重命名)会产生误导性的 SQL,需要手动改。 - 关联查询的 N+1 问题。 用
relations: ['orders', 'orders.items']加载嵌套关系时,生成的 SQL 可能是多条,数据量大时性能直线下降。 - 装饰器的运行时元数据开销。 每次启动
DataSource,TypeORM 都要读取并解析所有实体的装饰器元数据。项目实体一多,启动时间肉眼可见地变长。
这不是说 TypeORM 不能用,它在中小型项目、快速原型阶段依然很顺手。但如果你要做一个长期演进、对性能和可维护性都有要求的产品,"顺手"的代价会慢慢浮现。
3.2 Prisma:体验优雅,但重量藏在背后
Prisma 是我见过"文档最漂亮、上手最快"的 ORM 之一。schema.prisma 文件写起来非常舒服,类型推导准确,Studio 工具也能提升调试效率。如果你在做一个纯新的、以 CRUD 为主的应用,Prisma 的开发体验确实没什么可挑剔的。
但"重量藏在背后"这个判断,在几个场景里会被放大:
- Serverless 环境的冷启动。 Prisma Client 在每次调用时都需要和查询引擎通信,这个引擎是一个独立的二进制。在 Lambda 或者 Cloudflare Workers 这类冷启动敏感的环境里,Prisma 的开销非常明显。
- 复杂查询的表达力瓶颈。 当查询需要分组、聚合、子查询嵌套时,Prisma 的 API 往往会让你绕路。它更适合用"声明式对象"表达简单查询,一旦业务复杂,你就得退回
$queryRaw写裸 SQL——但这样又失去了 Prisma 的类型保护。 - 数据库迁移的"重"。 Prisma Migrate 工作流本身不差,但在已有数据的表上做修改时,它倾向于产生重建表的迁移,这在生产环境里风险不小。
我并不是说 Prisma 不好——它在"开发体验"维度的优秀是公认的。只是当你把 部署环境、查询复杂度、长期维护成本 这些维度也纳入考量时,它的优势会被稀释,而劣势会凸显。
3.3 Knex:查询构建器不是 ORM,差在哪一层
Knex 是另一个绕不开的名字。它严格来说不算 ORM,而是一个查询构建器——你不定义实体,直接写类 SQL 的链式调用。Knex 的优点是轻、灵活、贴近 SQL,团队里如果有一个 SQL 高手,用 Knex 写复杂查询会非常痛快。
但 Knex 有个核心短板:它不提供类型安全的查询结果。你能得到 any 类型的行对象,所有字段的类型得自己定义或者自己断言。这在小型项目里不是大事,但项目一大,类型不一致的问题就会像漏水一样渗透到业务层。而且 Knex 的链式调用虽然灵活,却没有编译期的表名、列名校验——你写错一个字符串,只能等运行时才知道。
表格对比一下这四种方案的侧重点:
| 维度 | TypeORM | Prisma | Knex | Drizzle |
|---|---|---|---|---|
| 类型安全 | 部分(实体层) | 强(client 层) | 弱(无) | 强(全链路) |
| 查询表达力 | 中等 | 中等 | 高 | 高 |
| 运行时开销 | 中 | 高 | 低 | 极低 |
| 学习曲线 | 低 | 低 | 中 | 中(SQL 友好) |
| 隐式行为 | 多 | 中等 | 少 | 几乎没有 |
| Serverless 适配 | 一般 | 较差 | 好 | 好 |
需要说明的是,这个表格不是"打分排名",而是选型时的对比坐标。如果你的核心诉求是"团队上手快、CRUD 为主、不太关心极端场景下的开销",TypeORM 或 Prisma 完全合理;如果你追求 SQL 可控、类型安全从源头到结尾、能在 Serverless 和传统部署之间无缝切换,那么 Drizzle 会是更合适的那一个。
4. 从零接入 Drizzle:一个最小可跑的实战示例
前面说了这么多设计哲学和对比分析,接下来进入实操环节。我用一个最经典的"用户 + 订单"模型演示 Drizzle 的完整接入流程,从 schema 定义、迁移生成到实际查询,把每一步的关键点讲清楚。
4.1 环境准备与 schema 声明
首先安装依赖。这里以 PostgreSQL + Node.js 为例:
bash复制npm install drizzle-orm pg
npm install -D drizzle-kit @types/pg
drizzle-orm 是运行时库,drizzle-kit 是命令行工具,负责迁移生成和 schema 推送。pg 是 PostgreSQL 的 Node.js 驱动。
接下来定义 schema。我喜欢把 schema 单独放一个文件,方便 Drizzle Kit 读取和后续维护:
typescript复制// src/db/schema.ts
import { pgTable, serial, text, integer, timestamp, index } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const orders = pgTable('orders', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id),
amount: integer('amount').notNull(),
status: text('status', { enum: ['pending', 'paid', 'shipped', 'cancelled'] })
.notNull()
.default('pending'),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => ({
userIdx: index('orders_user_id_idx').on(table.userId),
}));
这里有几个细节值得注意:
- 列名用了 snake_case,但字段名是 camelCase。Drizzle 支持这种映射,数据库里看到的是
user_id,代码里使用的是userId,兼顾了数据库规范和 TypeScript 习惯。 text('status', { enum: [...] })这种写法会在数据库层生成一个 enum 类型,同时 TypeScript 端也能把status推导成联合类型'pending' | 'paid' | 'shipped' | 'cancelled'。我就特别喜欢这个特性——业务枚举在编译期就有了约束,不必在业务层再手动校验一遍。index定义写在第三个参数里,通过一个函数返回索引配置。这样索引定义和表结构在一起,迁移工具能根据它生成对应的索引 DDL。
4.2 创建连接并执行 CRUD 操作
schema 定义好之后,创建一个 db 实例:
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,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
});
export const db = drizzle(pool, { schema });
export { schema };
然后写一个最简单的 CRUD 示例:
typescript复制// src/services/user.service.ts
import { eq, and, gt, desc } from 'drizzle-orm';
import { db, schema } from '../db';
import { users, orders } from '../db/schema';
// 插入用户
const [newUser] = await db
.insert(users)
.values({
email: 'alice@example.com',
name: 'Alice',
})
.returning();
// 查询用户(带条件)
const alice = await db
.select()
.from(users)
.where(eq(users.email, 'alice@example.com'));
// 更新用户
await db
.update(users)
.set({ name: 'Alice Smith' })
.where(eq(users.id, newUser.id));
// 删除用户
await db
.delete(users)
.where(eq(users.id, newUser.id));
insert 后面的 .returning() 是 PostgreSQL 特性,可以在插入后返回整行数据。Drizzle 对这个特性的支持是原生的,你不需要额外配置。如果需要批量插入,直接传数组:
typescript复制const insertedUsers = await db
.insert(users)
.values([
{ email: 'a@example.com', name: 'A' },
{ email: 'b@example.com', name: 'B' },
])
.returning({ id: users.id, email: users.email });
.returning() 里还可以只选择你关心的列,返回的数组元素类型会精确到这几个字段,不会塞给你一整行无关数据。
4.3 关联查询、聚合与动态条件
关联查询是任何 ORM 都绕不开的场景。Drizzle 的 join 语法前面已经展示过,这里补充一个实际业务中更复杂的例子:查询所有已支付订单,并带上用户名和邮箱,按订单金额降序排列:
typescript复制import { eq, desc } from 'drizzle-orm';
import { orders, users } from '../db/schema';
const paidOrders = await db
.select({
orderId: orders.id,
amount: orders.amount,
userName: users.name,
userEmail: users.email,
status: orders.status,
createdAt: orders.createdAt,
})
.from(orders)
.innerJoin(users, eq(orders.userId, users.id))
.where(eq(orders.status, 'paid'))
.orderBy(desc(orders.amount))
.limit(20);
这里所有返回字段的类型都会被正确推导:userName 是 string | null(因为 users.name 可空),status 是联合类型,amount 是 number。你几乎不需要写任何 interface 来描述查询结果,类型直接从 schema 一路流传到了业务层。
聚合查询也同样直接。统计每个用户的订单总金额:
typescript复制import { sum, count, eq } from 'drizzle-orm';
import { orders, users } from '../db/schema';
const stats = await db
.select({
userId: orders.userId,
totalAmount: sum(orders.amount),
orderCount: count(),
})
.from(orders)
.innerJoin(users, eq(orders.userId, users.id))
.groupBy(orders.userId);
针对动态条件(搜索、筛选这类需求),Drizzle 支持把条件放在数组里拼接,配合 and 合并:
typescript复制import { and, eq, gte, like, type SQL } from 'drizzle-orm';
const conditions: SQL[] = [];
if (searchKeyword) {
conditions.push(like(users.name, `%${searchKeyword}%`));
}
if (minAmount !== undefined) {
conditions.push(gte(orders.amount, minAmount));
}
if (status) {
conditions.push(eq(orders.status, status));
}
const result = await db
.select()
.from(orders)
.innerJoin(users, eq(orders.userId, users.id))
.where(and(...conditions))
.orderBy(desc(orders.createdAt));
空数组 and() 的情况 Drizzle 会默认不加 where 条件,不会报错,也不会生成错误的 SQL。这个模式我几乎在每个查询接口里都用,稳定、直观、类型安全。
4.4 schema 迁移与类型同步的工作流
Drizzle 的迁移工具 drizzle-kit 使用起来非常简单。配置好 drizzle.config.ts:
typescript复制// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
然后用一条命令生成迁移文件:
bash复制npx drizzle-kit generate
它会读取 schema 文件的当前状态,和 drizzle 目录下已有的迁移记录做对比,生成一个新的 SQL 迁移文件。这个文件是纯 SQL,你可以打开检查,也可以手动修改再执行。
要应用迁移,用 migrate 命令或者在启动脚本里跑:
bash复制npx drizzle-kit migrate
也可以像很多项目那样,在 CI/CD 流程里自动执行。Drizzle Kit 生成的迁移文件是自包含的,不依赖特定框架,可审计、可回滚,这点我很喜欢——在任何大型项目里,数据库迁移都应该当作文档来对待,而 Drizzle 恰好把生成物做成了可读的 SQL。
5. 突破舒适区的代价:Drizzle 的短板与避坑经验
任何一个工具都不是完美的。Drizzle 目前还很年轻,它在某些场景下并不比老牌 ORM 更舒服。这一节我把自己实际踩过的坑和觉得还不够好的地方一次性说清楚,希望你在选型时能有更完整的判断。
5.1 哪些场景不建议无脑上 Drizzle
第一类是团队完全没有 SQL 基础的项目。Drizzle 的学习曲线本质上是 SQL 的学习曲线。如果你的团队成员都是从 ORM 起步、对 JOIN 和 GROUP BY 本身就不太熟练,用 Drizzle 的初期效率反而会低于 TypeORM 或 Prisma。工具不会让不会 SQL 的人自动会 SQL,它只会让会 SQL 的人写得更顺手。
第二类是快速原型阶段、需求变化极频繁的项目。Drizzle 的 schema 定义需要你明确表结构、列类型、关联关系,这个"显式"特性在需求一天三变的时候会显得笨重。相比之下,TypeORM 的 synchronize 和 Prisma 的 db push 在原型阶段确实更方便——改个实体,数据库结构马上跟着变,开发节奏快多了。但正如前面说的,这个便利在团队协作后会很危险。
第三类是深度依赖框架生态的项目。比如 NestJS 社区里 TypeORM 的集成方式和配套模块很成熟,Prisma 也有对应的 PrismaService。Drizzle 目前对这类框架的集成支持还不够完善,你得自己写一些样板代码来适配。虽然不难,但在"开箱即用"这件事上确实还有差距。
5.2 实际使用中踩过的坑
先说一个最常见的:在 SQLite 环境下使用 JSON 字段时的类型坑。 Drizzle 对 PostgreSQL 的 jsonb 支持很好,但如果你在 SQLite 里定义 json 列,某些操作符(比如包含判断)行为会和 PostgreSQL 不一致,而且类型推导也可能退化成 unknown。解决方式是尽量在 schema 层明确字段类型,或者直接用 .text() 存 JSON 字符串,在业务层做解析。
第二个坑是 pg 驱动的参数化查询与 in 条件。当你传入的数组很大时,in 查询生成的参数数量会膨胀,可能导致数据库报参数超限错误。这个问题其实所有 ORM 都有,但 Drizzle 的"零魔法"意味着它不会帮你自动分批处理。我的做法是写一个工具函数,把大数组切成小块再并发查询,然后合并结果。
第三个坑是关于 migration 的默认值处理。Drizzle Kit 生成迁移时,如果 schema 里某个字段有 defaultNow(),生成的 SQL 会是 DEFAULT now(),这在 PostgreSQL 里没问题。但如果你之后在应用层又给这个字段设置了值,Drizzle 会同时带上字段名和默认 SQL,这有时候会导致你预期的"数据库自动填充"没有生效。我的经验是:在 ORM 层设置的值,和数据库层的默认值,只能二选一。 尽量让默认值在数据库层统一管理,应用层只传真正有业务意义的值。
5.3 什么时候 Drizzle 反而是最优解
尽管有这些短板,在下面几类项目里,Drizzle 已经是我不犹豫的首选。
第一类:Serverless / Edge 环境的后端。 这类环境对冷启动和包体积极其敏感,Drizzle 的轻量和零运行时依赖是巨大的优势。我自己在 Cloudflare Workers 上跑过一个 Drizzle + PostgreSQL 的小服务,整个打包体积比之前用 Prisma 的那版小了将近一半,冷启动时间也明显缩短。如果你在 Serverless 上被 Prisma 的引擎启动折磨过,一定会理解这个爽点。
第二类:复杂查询占比高的业务系统。 比如后台管理系统、数据报表平台、运营后台,这些项目的核心逻辑就是大量聚合、分组、多表关联查询。Drizzle 的 SQL-first 模型让这类查询几乎不需要绕路,你写的代码和最终执行的 SQL 之间没有"翻译损耗",调试和调优都更直接。
第三类:长期演进、团队有 SQL 基础的产品。 这种项目最怕的是 ORM 的"隐形行为"在某个时间点突然给你搞事。Drizzle 把所有操作都显式化,行为可预期、迁移可审计、类型全链路安全。这份确定性在长期维护中是非常值钱的——比"省了初期写几行实体定义的时间"更值钱。
6. 我个人的选型建议:什么时候切换到 Drizzle
我不打算劝你把所有项目都迁移到 Drizzle。工具的"先进"和"适合"从来是两件事。但如果你正好处在下面这些情境里,我会强烈建议你认真评估一下 Drizzle:
- 你的项目对包体积、启动速度、内存占用有明确的约束,尤其是准备上 Serverless。
- 你团队里有 SQL 基本功扎实的人,大家受够了 ORM 的隐式行为和黑盒优化。
- 你能接受 "显式 > 隐式" 的工程理念,愿意多写一点代码来换取可控性。
- 你需要跨数据库的支持(Drizzle 对 PostgreSQL、MySQL、SQLite 都有较好的适配),并且不想为每种数据库维护不同的查询写法。
反过来说,如果你的项目是一个演示用的 MVP,团队里没有人想学新工具,或者你非常依赖某个框架的官方集成,那继续用 TypeORM 或 Prisma 也完全没问题——工具是拿来用的,不是拿来"超前"的。
最后说一个我在多个项目里反复验证过的体会:ORM 的选择往往是工程理念的分水岭。 一个"厚 ORM + 自动同步 + 尽量不写 SQL"的团队,和另一个"薄 ORM + 显式迁移 + SQL 随手即来"的团队,在代码可读性、性能可调性和故障排查效率上,会走向完全不同的方向。Drizzle 是我目前见过的、能把"薄"和"强"同时做到极致的选择,它的设计哲学契合了 TypeScript 后端走向工程化、精细化的大趋势。这也是我敢说"它是 TypeScript 后端的未来"的原因——不是因为它新,而是因为它在正确的地方,做了正确的取舍。
