使用 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 migrate。 push 命令用于快速本地开发 - 它直接应用架构更改而不创建迁移文件,因此没有审计跟踪并且回滚很困难。在部署管道中,在启动应用程序之前运行 drizzle-kit migrate 以确保架构同步。
如何使用 Drizzle 处理数据库播种?
创建一个单独的种子文件并直接导入您的架构。使用 onConflictDoNothing() 作为幂等种子,重新运行时不会失败。将种子数据存储在常量中以便于更新,并在开发设置脚本中迁移后运行种子。
如何使用 Drizzle 和 PostgreSQL 执行全文搜索?
将 sql 模板文字与 PostgreSQL 的 to_tsvector 和 to_tsquery 函数一起使用。 Drizzle并没有抽象出全文搜索,但是你可以直接写出来。在 tsvector 列上添加 GIN 索引以提高性能,并使用 websearch_to_tsquery 作为用户提供的搜索词以安全地解析自然语言查询。
如何在不停机的情况下处理架构更改?
使用附加迁移 — 首先将列添加为可为空,回填数据,然后添加 NOT NULL 约束。切勿在从代码中删除列的同一迁移中删除列。顺序是:1) 部署忽略旧列的代码,2) 部署迁移以删除它。这确保始终可以回滚而不会丢失数据。
后续步骤
精心设计的数据库层是构建每个生产应用程序的基础。 ECOSIRE 的工程团队每天与 Drizzle ORM 和 PostgreSQL 合作,跨复杂的多租户系统管理 65 个以上的架构文件。
无论您需要数据库架构咨询、Odoo ERP 集成还是使用现代 TypeScript 工具构建的完整后端系统,探索我们的服务 以了解我们如何提供帮助。
作者
ECOSIRE Research and Development Team
在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。
相关文章
Zero-Downtime Database Migrations with Drizzle ORM
Run database migrations without downtime using Drizzle ORM. Covers expand-contract pattern, backward-compatible schema changes, rollback strategies, and CI/CD integration for PostgreSQL.
Odoo Performance Tuning: PostgreSQL and Server Optimization
Expert guide to Odoo 19 performance tuning. Covers PostgreSQL configuration, indexing, query optimization, Nginx caching, and server sizing for enterprise deployments.
Natural Language Database Queries with OpenClaw
How OpenClaw enables natural language database queries, translating plain English business questions into accurate SQL without exposing database credentials or query complexity.