Drizzle ORM with PostgreSQL: Complete Guide

Complete guide to Drizzle ORM with PostgreSQL: schema design, migrations, type-safe queries, relations, transactions, and production patterns for TypeScript apps.

E
ECOSIRE Research and Development Team
|2026年3月19日7 分钟阅读1.4k 字数|

使用 PostgreSQL 进行 Drizzle ORM:完整指南

Drizzle ORM 在 TypeScript 数据库生态系统中占据着独特的地位:它的 ORM 实际上只是一个伪装的查询构建器,具有生成您可以阅读的 SQL 的模式即代码,以及将您的数据库视为事实来源的迁移系统。经过多年的 Prisma 和 TypeORM 的使用,Drizzle 感觉就像回到了原始 SQL 中一样——但具有完整的 TypeScript 推理和零运行时开销。

本指南涵盖了从初始设置到生产模式、从包含 65 个以上 Drizzle 模式文件的代码库中进行绘制、多租户查询以及在 Next.js 和 NestJS 上下文中可靠工作的惰性代理连接模式的所有内容。

要点

  • 使用惰性代理进行数据库连接 - 永远不要在模块加载时创建急切连接
  • 本地 PostgreSQL 应在非默认端口 (5433) 上运行,以避免与系统安装发生冲突
  • 所有表都需要 organizationId 来实现多租户 — 在架构级别强制执行
  • Drizzle 枚举比较需要显式 TypeScript 转换以缩小类型范围
  • 切勿使用 sql.raw() — 始终使用带有参数化值的 sql 模板文字
  • 关系与外键是分开的——定义两者以实现完整的类型安全
  • 交易包装多步骤操作;将 tx 对象传递给服务
  • 架构索引 (schema/index.ts) 必须导出所有表才能进行迁移

安装和设置

Drizzle 需要两个包:核心 ORM 和数据库驱动程序。

pnpm add drizzle-orm pg
pnpm add -D drizzle-kit @types/pg

对于包括迁移和工作室在内的完整堆栈:

pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit

postgres 包(与 pg 不同)是现代 TypeScript 项目中 Drizzle 的推荐驱动程序 — 它支持连接池、准备好的语句,并且具有更好的 TypeScript 类型。


惰性代理模式

在 Next.js 或 NestJS monorepo 中,Drizzle 最关键的架构决策是永远不要创建急切的数据库连接。模块加载时的急切连接会导致以下问题:

  • Next.js:在构建过程中导入模块,没有可用的数据库
  • NestJS:包导入发生在环境变量加载之前
  • 无服务器:冷启动因连接开销而延迟

惰性代理模式解决了所有这些问题:

// packages/db/src/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';

let _db: ReturnType<typeof drizzle> | null = null;

function getDb() {
  if (!_db) {
    const connectionString = process.env.DATABASE_URL;
    if (!connectionString) {
      throw new Error('DATABASE_URL environment variable is not set');
    }

    const client = postgres(connectionString, {
      max: 10, // connection pool size
      idle_timeout: 20,
      connect_timeout: 10,
    });

    _db = drizzle(client, { schema });
  }
  return _db;
}

// Export a Proxy that initializes the connection on first use
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
  get(_, prop) {
    return getDb()[prop as keyof ReturnType<typeof drizzle>];
  },
});

export * from './schema';

此模式意味着导入 @ecosire/db 永远不会打开数据库连接。仅当第一个查询运行时才会创建连接。


架构设计

Drizzle 架构是导出表定义的纯 TypeScript 文件。这里的规则是将相关表保存在同一个文件中,并通过中央索引导出所有内容。

// packages/db/src/schema/contacts.ts
import {
  pgTable,
  uuid,
  text,
  varchar,
  timestamp,
  pgEnum,
  boolean,
  index,
} from 'drizzle-orm/pg-core';

export const contactTypeEnum = pgEnum('contact_type', ['individual', 'company', 'partner']);

export const contacts = pgTable(
  'contacts',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    organizationId: uuid('organization_id').notNull(), // Multi-tenancy
    name: varchar('name', { length: 255 }).notNull(),
    email: varchar('email', { length: 255 }),
    phone: varchar('phone', { length: 50 }),
    type: contactTypeEnum('type').default('individual').notNull(),
    isActive: boolean('is_active').default(true).notNull(),
    notes: text('notes'),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().notNull(),
  },
  (table) => ({
    organizationIdx: index('contacts_organization_idx').on(table.organizationId),
    emailIdx: index('contacts_email_idx').on(table.email),
  })
);

export type Contact = typeof contacts.$inferSelect;
export type NewContact = typeof contacts.$inferInsert;

$inferSelect$inferInsert 类型为您提供完整的 TypeScript 推理 — Drizzle 根据您的架构约束知道插入时哪些字段是必填字段,哪些字段是可选字段。

// packages/db/src/schema/index.ts
export * from './contacts';
export * from './orders';
export * from './licenses';
export * from './products';
// ... all tables

索引文件必须导出 drizzle-kit 的每个表,以便在迁移生成期间拾取它们。


关系

Drizzle 将外键约束(由 PostgreSQL 强制执行)与关系定义(由 Drizzle 的关系查询 API 使用)分开。你需要两者:

// packages/db/src/schema/relations.ts
import { relations } from 'drizzle-orm';
import { contacts } from './contacts';
import { orders } from './orders';
import { orderItems } from './order-items';
import { products } from './products';

export const contactsRelations = relations(contacts, ({ many }) => ({
  orders: many(orders),
}));

export const ordersRelations = relations(orders, ({ one, many }) => ({
  contact: one(contacts, {
    fields: [orders.contactId],
    references: [contacts.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],
  }),
}));

定义好关系后,您可以使用 Drizzle 的关系查询 API:

// Fetch order with contact and items in one query
const order = await db.query.orders.findFirst({
  where: eq(orders.id, orderId),
  with: {
    contact: true,
    items: {
      with: {
        product: true,
      },
    },
  },
});

Drizzle 生成单个 JOIN 查询 — 没有 N+1 问题。


类型安全查询

Drizzle 的查询 API 是完全类型安全的。选择类型根据您包含的列而缩小:

import { db } from '@ecosire/db';
import { contacts } from '@ecosire/db/schema';
import { eq, and, like, sql, count } from 'drizzle-orm';

// Full select — returns Contact[]
const allContacts = await db
  .select()
  .from(contacts)
  .where(eq(contacts.organizationId, orgId))
  .limit(50);

// Partial select — returns only specified columns
const contactNames = await db
  .select({
    id: contacts.id,
    name: contacts.name,
    email: contacts.email,
  })
  .from(contacts)
  .where(
    and(
      eq(contacts.organizationId, orgId),
      eq(contacts.isActive, true)
    )
  );

// Count query
const [{ total }] = await db
  .select({ total: count() })
  .from(contacts)
  .where(eq(contacts.organizationId, orgId));

// Search with ILIKE (case-insensitive)
const searchResults = await db
  .select()
  .from(contacts)
  .where(
    and(
      eq(contacts.organizationId, orgId),
      like(contacts.name, `%${searchTerm}%`)
    )
  )
  .limit(20);

对于动态 where 子句(从 UI 过滤),有条件地构建条件:

import { SQL } from 'drizzle-orm';

async function searchContacts(orgId: string, filters: {
  search?: string;
  type?: 'individual' | 'company';
  isActive?: boolean;
}) {
  const conditions: SQL[] = [eq(contacts.organizationId, orgId)];

  if (filters.search) {
    conditions.push(like(contacts.name, `%${filters.search}%`));
  }

  if (filters.type) {
    // Enum comparisons need explicit casting
    conditions.push(eq(contacts.type, filters.type as 'individual' | 'company' | 'partner'));
  }

  if (filters.isActive !== undefined) {
    conditions.push(eq(contacts.isActive, filters.isActive));
  }

  return db
    .select()
    .from(contacts)
    .where(and(...conditions))
    .limit(100);
}

请注意枚举值的显式转换:filters.type as 'individual' | 'company' | 'partner'。如果没有强制转换,Drizzle 的枚举类型不会自动接受较窄的字符串子类型。


插入、更新和删除

import { db } from '@ecosire/db';
import { contacts, NewContact } from '@ecosire/db/schema';
import { eq, and } from 'drizzle-orm';

// Insert with RETURNING
async function createContact(data: NewContact) {
  const [created] = await db
    .insert(contacts)
    .values(data)
    .returning();

  return created; // Fully typed as Contact
}

// Upsert (insert or update on conflict)
async function upsertContact(data: NewContact) {
  const [result] = await db
    .insert(contacts)
    .values(data)
    .onConflictDoUpdate({
      target: [contacts.organizationId, contacts.email],
      set: {
        name: data.name,
        updatedAt: new Date(),
      },
    })
    .returning();

  return result;
}

// Update with RETURNING
async function updateContact(
  id: string,
  orgId: string,
  updates: Partial<NewContact>
) {
  const [updated] = await db
    .update(contacts)
    .set({ ...updates, updatedAt: new Date() })
    .where(
      and(
        eq(contacts.id, id),
        eq(contacts.organizationId, orgId) // Always filter by org
      )
    )
    .returning();

  return updated;
}

// Soft delete (recommended over hard delete)
async function deleteContact(id: string, orgId: string) {
  return db
    .update(contacts)
    .set({ isActive: false, updatedAt: new Date() })
    .where(
      and(
        eq(contacts.id, id),
        eq(contacts.organizationId, orgId)
      )
    );
}

始终在 INSERT 和 UPDATE 语句上使用 .returning() — 它避免了第二次 SELECT 往返来获取创建/更新的记录。


交易

必须一起成功或一起失败的多步骤操作需要事务:

async function createOrderWithItems(
  orderData: NewOrder,
  items: NewOrderItem[]
) {
  return db.transaction(async (tx) => {
    // Create the order
    const [order] = await tx
      .insert(orders)
      .values(orderData)
      .returning();

    // Create order items
    const createdItems = await tx
      .insert(orderItems)
      .values(items.map((item) => ({ ...item, orderId: order.id })))
      .returning();

    // Update inventory (must succeed or entire transaction rolls back)
    for (const item of items) {
      await tx
        .update(products)
        .set({
          stock: sql`${products.stock} - ${item.quantity}`,
        })
        .where(eq(products.id, item.productId));
    }

    return { order, items: createdItems };
  });
}

将事务上下文传递给服务方法时:

// Service accepts optional transaction for composability
async function activateLicense(
  licenseId: string,
  tx?: typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]
) {
  const queryRunner = tx || db;

  return queryRunner
    .update(licenses)
    .set({ status: 'active', activatedAt: new Date() })
    .where(eq(licenses.id, licenseId))
    .returning();
}

使用 drizzle-kit 进行迁移

drizzle-kit CLI 根据架构更改生成迁移:

// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';

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

工作流程:

# Generate migration SQL from schema changes
npx drizzle-kit generate

# Apply migrations to database
npx drizzle-kit migrate

# Push schema directly (dev only — skips migration files)
npx drizzle-kit push

# Open Drizzle Studio (visual database browser)
npx drizzle-kit studio

对于生产,请始终使用 migrate (而不是 push)。 push 命令用于本地开发迭代 - 它不会创建迁移文件,因此不会跟踪更改。


分页模式

高效的分页需要数据查询和计数查询:

interface PaginationParams {
  page: number;
  pageSize: number;
}

async function getContactsPaginated(orgId: string, { page, pageSize }: PaginationParams) {
  const offset = (page - 1) * pageSize;

  const [data, [{ total }]] = await Promise.all([
    db
      .select()
      .from(contacts)
      .where(eq(contacts.organizationId, orgId))
      .limit(pageSize)
      .offset(offset)
      .orderBy(contacts.createdAt),
    db
      .select({ total: count() })
      .from(contacts)
      .where(eq(contacts.organizationId, orgId)),
  ]);

  return {
    data,
    pagination: {
      page,
      pageSize,
      total,
      totalPages: Math.ceil(total / pageSize),
    },
  };
}

与顺序执行相比,使用 Promise.all 并行运行两个查询可将响应时间缩短一半。


常见陷阱和解决方案

陷阱 1:使用 sql.raw() 进行动态查询

// Dangerous — SQL injection vulnerability
const results = await db.execute(
  sql.raw(`SELECT * FROM contacts WHERE name = '${userInput}'`)
);

// Safe — parameterized
const results = await db.execute(
  sql`SELECT * FROM contacts WHERE name = ${userInput}`
);

陷阱 2:经常查询的列上缺少索引

始终向您筛选所依据的列添加索引。最常见的缺失索引:

(table) => ({
  organizationIdx: index('contacts_org_idx').on(table.organizationId),
  emailIdx: index('contacts_email_idx').on(table.email),
  compositeIdx: index('contacts_org_active_idx').on(
    table.organizationId,
    table.isActive
  ),
})

陷阱 3:手动关系加载的 N+1 查询

// N+1 problem — one query per order
const orders = await db.select().from(ordersTable);
for (const order of orders) {
  order.contact = await db
    .select()
    .from(contacts)
    .where(eq(contacts.id, order.contactId));
}

// Solution — use Drizzle's relational query API
const orders = await db.query.orders.findMany({
  with: { contact: true },
});

陷阱 4:忘记从 schema/index.ts 导出新表

如果您创建新的架构文件但忘记从 schema/index.ts 重新导出,则 drizzle-kit 将看不到新表,并且不会生成迁移。


常见问题

对于 TypeScript 项目来说,Drizzle 与 Prisma 相比如何?

Drizzle 更接近 TypeScript 类型的原始 SQL — 您编写的查询将 1:1 映射到 SQL。 Prisma 使用自定义查询语言和单独的架构文件进行更多抽象。 Drizzle 在复杂查询中具有更好的 TypeScript 推理能力,运行时开销更小,并且不需要代码生成步骤。 Prisma 拥有更大的生态系统和更多内置功能,例如软删除和审核日志记录。对于熟悉 SQL 的大型团队来说,Drizzle 通常是首选。

我应该在 CI/CD 中使用 drizzle-kit 推送还是迁移?

在 CI/CD 中始终使用 drizzle-kit migratepush 命令用于快速本地开发 - 它直接应用架构更改而不创建迁移文件,因此没有审计跟踪并且回滚很困难。在部署管道中,在启动应用程序之前运行 drizzle-kit migrate 以确保架构同步。

如何使用 Drizzle 处理数据库播种?

创建一个单独的种子文件并直接导入您的架构。使用 onConflictDoNothing() 作为幂等种子,重新运行时不会失败。将种子数据存储在常量中以便于更新,并在开发设置脚本中迁移后运行种子。

如何使用 Drizzle 和 PostgreSQL 执行全文搜索?

sql 模板文字与 PostgreSQL 的 to_tsvectorto_tsquery 函数一起使用。 Drizzle并没有抽象出全文搜索,但是你可以直接写出来。在 tsvector 列上添加 GIN 索引以提高性能,并使用 websearch_to_tsquery 作为用户提供的搜索词以安全地解析自然语言查询。

如何在不停机的情况下处理架构更改?

使用附加迁移 — 首先将列添加为可为空,回填数据,然后添加 NOT NULL 约束。切勿在从代码中删除列的同一迁移中删除列。顺序是:1) 部署忽略旧列的代码,2) 部署迁移以删除它。这确保始终可以回滚而不会丢失数据。


后续步骤

精心设计的数据库层是构建每个生产应用程序的基础。 ECOSIRE 的工程团队每天与 Drizzle ORM 和 PostgreSQL 合作,跨复杂的多租户系统管理 65 个以上的架构文件。

无论您需要数据库架构咨询、Odoo ERP 集成还是使用现代 TypeScript 工具构建的完整后端系统,探索我们的服务 以了解我们如何提供帮助。

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天