首页 / 文章

从 Prisma 迁移到 Drizzle:六个月的回顾与总结

将 PostgreSQL TypeScript 架构从 Prisma 迁移到 Drizzle ORM 六个月后:真实的性能测试数据、立即得到改善的部分,以及带来负面影响的权衡。

发布者:REACTAPP.TOP阅读约 10 分钟2403 词

来源: I replaced Prisma with Drizzle. Six months later, here’s what I gained and what I missed.

大约八个月前,关于 Prisma 的一个挥之不去的疑虑终于明确到足以促使我采取行动的程度。并非系统存在严重故障——只是随着时间推移,各种问题逐渐累积,最终变得无法忽视。相关架构包括几个小型应用程序,它们都运行在 PostgreSQL 上,使用 TypeScript 编写,并由若干 API 服务支撑。并无什么特别之处或前沿技术。Prisma 正在履行它的功能。即便如此,仍有一些反复出现的麻烦问题不断出现。

生成的 @prisma/client 文件体积巨大。在无服务器函数环境中运行时,由于必须先加载该客户端,冷启动时间明显变长。CI 流水线中的 prisma generate 步骤在每次合并代码时都像是一笔额外的开销。而每当需要执行 ORM 无法处理的复杂查询时,返回类型总是 unknown[],完全没有类型安全保障,迫使开发者到处进行手动类型转换。

最终我们决定转向 Drizzle ORM,迁移工作大约在六个月前完成。以下是关于此次迁移过程的真实记录。

我最初考虑 Drizzle 的原因

包体积问题成为促使我们认真考虑 Drizzle 的第一个重要因素。在迁移时,仅 Prisma 客户端就为 Lambda 部署包增加了近 14MB 的体积,其中 Rust 查询引擎的二进制文件就占了数兆字节。那些不常被访问的接口会出现 2 到 3 秒的冷启动时间——这样的数值在延迟监控面板上会立刻显得十分突出。

相比之下,Drizzle 经过压缩和最小化处理后的体积约为 7.4KB,且完全没有运行时依赖项。没有编译过的二进制文件,没有独立的引擎进程,也没有在 TypeScript 代码与数据库连接之间进行的序列化处理步骤。

第二个产生矛盾的环节在于代码生成步骤。每当架构发生变化时,都必须在运行 prisma generate 后,TypeScript 才能识别新的结构。实际上这会导致两种不良后果:要么手动执行该命令却偶尔忘记提交生成的客户端代码,要么在每次 CI 运行中都自动执行该命令,从而导致每次构建多花费 15 到 20 秒的时间。这两种方案都不理想。

使用 Drizzle 后,架构本身其实就是 TypeScript。只需编辑架构文件,类型就会立即更新——无需额外的生成步骤,编译器也能立刻感知到变化。

第三个考量因素是原始 SQL 的处理方式。Prisma 仅将其视为备用方案:调用 $queryRaw 会返回 unknown[],此时需要通过手动类型转换或 Zod 模式才能确保其安全使用。而 Drizzle 的 sql 模板标签则允许直接声明数据结构——类似 sql<{ id: number; total: number }> 的写法——这样就能直接得到对应类型,无需进行类型转换或额外的验证步骤。

迁移过程是怎样的

为便于说明,以三个表 users、products 和 orders 为例。在 Prisma 中,它们被定义在同一个 schema.prisma 文件中:

model User {
  id        String   @id @default(uuid())
  email     String   @unique
  name      String
  orders    Order[]
  createdAt DateTime @default(now())
}

model Product {
  id          String      @id @default(uuid())
  name        String
  priceInCents Int
  orders      OrderItem[]
}

model Order {
  id        String      @id @default(uuid())
  userId    String
  user      User        @relation(fields: [userId], references: [id])
  items     OrderItem[]
  createdAt DateTime    @default(now())
}

model OrderItem {
  id        String  @id @default(uuid())
  orderId   String
  order     Order   @relation(fields: [orderId], references: [id])
  productId String
  product   Product @relation(fields: [productId], references: [id])
  quantity  Int
}

在 Drizzle 中,同样的架构则变为一个 TypeScript 文件:

import { pgTable, uuid, text, integer, timestamp } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';\

export const users = pgTable('users', {
  id: uuid('id').defaultRandom().primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export const products = pgTable('products', {
  id: uuid('id').defaultRandom().primaryKey(),
  name: text('name').notNull(),
  priceInCents: integer('price_in_cents').notNull(),
});

export const orders = pgTable('orders', {
  id: uuid('id').defaultRandom().primaryKey(),
  userId: uuid('user_id').notNull().references(() => users.id),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export const orderItems = pgTable('order_items', {
  id: uuid('id').defaultRandom().primaryKey(),
  orderId: uuid('order_id').notNull().references(() => orders.id),
  productId: uuid('product_id').notNull().references(() => products.id),
  quantity: integer('quantity').notNull(),
});

export const usersRelations = relations(users, ({ many }) => ({
  orders: many(orders),
}));

export const ordersRelations = relations(orders, ({ one, many }) => ({
  user: one(users, { fields: [orders.userId], references: [users.id] }),
  items: many(orderItems),
}));

export const orderItemsRelations = relations(orderItems, ({ one }) => ({
  order: one(orders, { fields: [orderItems.orderId], references: [orders.id] }),
  product: one(products, { fields: [orderItems.productId], references: [products.id] }),
}));

没错,它的运行时间会稍长一些。但这完全是 TypeScript 的实现方式。你的编辑器能够理解架构中的每一个字段,自动补全功能可以直接基于这些字段工作,而且你可以将架构的各个部分直接导入到应用程序逻辑中。

一个 drizzle.config.ts 文件将所有内容整合在一起:

import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  dialect: 'postgresql',
  schema: './src/schema.ts',
  out: './drizzle/migrations',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  strict: true,
});

注意:务必设置 strict: true。如果省略这一选项,Drizzle 可能会将列名修改理解为“删除旧列并创建新列”——这种更改会在你查看生成的 SQL 代码之前悄无声息地破坏数据。开启 strict: true 后,每当出现含义模糊的更改时,Drizzle 会暂停并要求你确认。

一旦架构就绪,生成迁移命令仅需一条指令:

bunx drizzle-kit generate

这会在./drizzle/migrations目录下生成一个SQL文件。你可以查看该文件,确认其功能符合预期,然后再应用它:

bunx drizzle-kit migrate

该SQL文件会被一并提交到版本控制系统中,与数据库结构一同保存。整个迁移历史都是直观易读的SQL语句,而非只有通过Prisma自身工具才能理解的日志格式。

立即显现的改进

更小的包体积,更快的冷启动速度。这一优势立刻显现且易于衡量。我们的Lambda函数包大小减少了90%以上。那些之前冷启动需要两秒多时间的函数,现在只需不到400毫秒即可启动。后台任务并未从中获得明显好处,但我们的API函数速度确实有了显著提升。

直接输入原始 SQL。在迁移开始后的第二天,这种方法就派上了用场。我们有一个需要连接四个表并进行聚合计算的报表查询,Prisma 查询构建器根本无法以简洁的方式实现它。Prisma 对这类查询的替代方案如下:

// Prisma - returns unknown[], no type safety
const revenue = await prisma.$queryRaw`
  SELECT p.id, p.name, SUM(oi.quantity * p.price_in_cents) as total_revenue
  FROM products p
  JOIN order_items oi ON p.id = oi.product_id
  GROUP BY p.id, p.name
  ORDER BY total_revenue DESC
`;

使用 Drizzle 时,对应的查询会带有实际的类型信息:

import { sql } from 'drizzle-orm';

interface ProductRevenue {
  id: string;
  name: string;
  totalRevenue: number;
}

const revenue = await db.execute(sql<ProductRevenue>`
  SELECT p.id, p.name, SUM(oi.quantity * p.price_in_cents) AS total_revenue
  FROM products p
  JOIN order_items oi ON p.id = oi.product_id
  GROUP BY p.id, p.name
  ORDER BY total_revenue DESC
`);

// revenue.rows typed as ProductRevenue[] at compile-time; validate at runtime if needed

你仍然需要手动编写接口,而非让系统自动推断,但与在代码库中到处使用 unknown 类型转换相比,这算是较小的代价。

Drizzle Studio。运行 bunx drizzle-kit studio 可以启动一个本地数据库浏览器。虽然它的功能比 Prisma Studio 稍显粗糙,但速度很快、实用性强,且无需 Prisma 账户或云连接。对于日常的本地工作而言,这种独立性非常有价值。

需要调整的地方

关系型数据需要更明确的配置。在 Prisma 中,要获取订单及其对应的用户和明细项,只需使用 include 即可:

// Prisma - nested include
const order = await prisma.order.findUnique({
  where: { id: orderId },
  include: {
    user: true,
    items: {
      include: { product: true }
    }
  }
});

Drizzle 的关系型查询 API 也能实现类似的效果:

import { db } from './db';
import { orders, users, orderItems, products } from './schema';

const order = await db.query.orders.findFirst({
  where: (orders, { eq }) => eq(orders.id, orderId),
  with: {
    user: true,
    items: {
      with: { product: true }
    }
  }
});

这两个查询的最终结构很相似。问题在于,with 语法只有在你在模式中定义了之前提到的 relations() 调用之后才能使用。刚开始切换时,我们还没有完成这些设置,结果花了一整个下午时间去排查为什么 with 没有返回任何结果。一旦关系被正确声明,一切都会按预期工作——但那最初的设置步骤其实是由 Prisma 仅通过模式就自动处理好的,无需额外配置。

数据初始化现在需要手动操作。 Prisma 内置了相应的数据初始化机制:只需将 seed.ts 文件放入指定位置,然后运行 prisma db seed 即可完成。Drizzle 则没有类似的内置命令,你需要自己编写普通的 TypeScript 脚本并执行它:

import { db } from './db';
import { users, products } from './schema';

async function seed() {
  await db.insert(users).values([
    { email: 'ana.silva@acme.com', name: 'Ana Silva' },
    { email: 'carlos.reyes@acme.com', name: 'Carlos Reyes' },
  ]);

 await db.insert(products).values([
    { name: 'Starter Plan', priceInCents: 2900 },
    { name: 'Pro Plan', priceInCents: 7900 },
    { name: 'Enterprise Plan', priceInCents: 19900 },
  ]);

  console.log('Seed complete');
  process.exit(0);
}
seed().catch(console.error);

你可以在 package.json 中进行配置:

{
  "scripts": {
    "db:seed": "bun run src/seed.ts"
  }
}

这些都没有什么难度,而且相比 Prisma 的隐式约定更为直观。不过,任何直接调用 prisma db seed 的现有工具或 CI 步骤都需要改写,转而调用你自己的脚本。

直接对比这两款工具。 Drizzle Studio 能完成相应功能,而 Prisma Studio 的界面更美观,还提供了更多功能:内置的过滤控件、分页功能以及更为精致的表格编辑器。相比之下,Drizzle Studio 看起来较为简陋。使用 Drizzle 六个月后,我们发现对这两款工具的依赖程度都低于预期,因为无论使用哪种 ORM,大多数实际的数据探索工作都是通过专门的数据库客户端来完成的。

Prisma 仍占优势的唯一场景

嵌套写入操作。当需要一次性插入订单及其明细项时,Prisma 能够简洁地处理这一需求:

// Prisma - nested create in one call
const order = await prisma.order.create({
  data: {
    userId: user.id,
    items: {
      create: [
        { productId: starterPlanId, quantity: 1 },
        { productId: proPlanId, quantity: 2 },
      ]
    }
  },
  include: { items: true }
});

Drizzle 没有对应的嵌套创建功能,因此需要自己在显式事务中编写相关逻辑:

import { db } from './db';
import { orders, orderItems } from './schema';

const newOrder = await db.transaction(async (tx) => {
  const [order] = await tx
    .insert(orders)
    .values({ userId: user.id })
    .returning();

  await tx.insert(orderItems).values([
    { orderId: order.id, productId: starterPlanId, quantity: 1 },
    { orderId: order.id, productId: proPlanId, quantity: 2 },
  ]);

  return order;
});

这样不仅需要编写更多代码,后期维护也更为复杂。如果应用程序大量依赖此类模式,额外的样板代码会迅速累积。而 Prisma 的实现更为简洁,能更直接地表达设计意图。如果领域模型中经常出现嵌套变更操作,选择 Prisma 是完全合理的选择。

决定是否迁移

如果符合以下任一情况,建议考虑迁移到 Drizzle:您在无服务器或边缘平台上进行部署,而包大小和冷启动延迟会直接影响成本或用户体验;您经常需要编写复杂的分析型 SQL 语句并希望直接操作它们;您的团队对 SQL 熟练度很高,抽象层无法带来太多价值;或者您希望将迁移过程以普通 SQL 文件的形式呈现,以便任何工程师都能阅读和审查,无论其是否熟悉 ORM。

如果属于以下情况,则建议继续使用 Prisma:您正在开发原型或内部工具,快速上线比缩短几毫秒的运行时间更为重要;您的应用需要进行大量深度嵌套关系的读写操作;团队中部分成员对原始 SQL 不太熟悉,能从 Prisma 的声明式建模方式中受益;或者您已经升级到 Prisma 7,此时历史上的包大小问题已大大减轻。

时机在这里也很重要。2025年底发布的Prisma 7彻底去掉了Rust引擎。其当前的打包体积约为1.6MB——虽然仍比Drizzle的体积重200多倍,但已远低于最初促使人们选择替代方案的14MB二进制文件。如果仅因为打包体积而考虑更换框架,那么鉴于Prisma已大幅瘦身,值得重新审视这一理由是否依然成立。

对于推动此次迁移的团队而言,答案依然是肯定的。更完善的原始查询类型定义以及无需代码生成步骤这两点本身就足以成为更换框架的理由,与体积缩减并无直接关联。六个月后回顾,这一决定依然正确。不过这并非放之四海而皆准的答案——任何声称某款ORM在所有场景下都客观上更优的人都是在误导你。

如想自行探索的下一步行动

如果您想在不进行完整重写的情况下,使用 Drizzle 对现有数据库进行测试,Drizzle Kit 的自省工具可以让 drizzle-kit pull 读取您现有的数据库结构,并自动生成相应的 TypeScript 定义。这样您就能拥有一个可用的基础框架,而无需从头开始手动编写每个表的定义。

Drizzle ORM 官方文档详细介绍了关系型查询构建器、sql模板操作符以及完整的迁移命令集。此外,阅读 Prisma 自己的与 Drizzle 的对比页面也很有价值——该页面客观地阐述了 Prisma 所面临的权衡,其坦诚程度远超大多数供应商的对比内容。