引言:数据库层——你 Next.js 全栈应用的"最后一块拼图"
如果你一直在跟我们之前的文章系列,到这里你应该已经知道怎么用 Server Actions 做数据变更、怎么用 fetch + use cache 搞定数据获取和缓存、怎么用中间件拦截请求、怎么用纵深防御策略做认证。但说实话,有一块关键拼图一直缺着——数据库层。
你有没有注意到,之前那些文章里的代码示例总是冒出来一个 db.post.create() 或者 db.product.findMany(),仿佛数据库连接是天上掉下来的一样?
但现实要残酷得多。数据库层的选型、配置和架构设计,往往才是决定一个 Next.js 全栈应用成败的关键。选错了 ORM,连接池配不对,Schema 管理一团糟——开发阶段可能啥事没有,一上生产立马原形毕露。我自己就踩过这个坑。
那为什么选 Drizzle ORM?直接说结论吧:2026 年,Drizzle ORM 已经是 Next.js 全栈开发的首选 ORM 了。体积只有约 7.4KB(minified + gzipped),零依赖,原生支持 Edge Runtime,TypeScript 类型推断做得非常完整,而且——这点很关键——它用的就是你熟悉的 SQL 语法。跟 Prisma 那 2MB+ 的客户端和一堆 Edge 函数限制比起来,Drizzle 在 Serverless 架构下的优势,说"碾压级"毫不夸张。
好了,这篇文章会从零开始,手把手带你搭一套完整的 Next.js + Drizzle ORM + PostgreSQL 数据库层。连接管理、Schema 设计、迁移策略、CRUD 操作、关系查询、Zod 验证集成、生产部署——全都覆盖。读完之后,你就能把前面所有文章里那些"假设已存在"的 db 对象真正落地了。
一、项目初始化:安装与配置
1.1 安装依赖
我们以 PostgreSQL 为例(Drizzle 同样支持 MySQL 和 SQLite)。根据你的部署目标选择对应的数据库驱动:
# 核心依赖
npm install drizzle-orm
# 本地/传统 Node.js 环境 —— 使用 postgres.js 驱动
npm install postgres
# Serverless/Vercel 部署 —— 使用 Neon Serverless 驱动
npm install @neondatabase/serverless
# 开发工具
npm install -D drizzle-kit
这里其实要先想清楚一个问题:你的数据库打算跑在哪里?
- 本地/自托管 PostgreSQL:用
postgres(postgres.js 驱动),走 TCP 连接 - Neon Serverless Postgres:用
@neondatabase/serverless,走 HTTP 或 WebSocket - Vercel Postgres:底层也是 Neon,用
@vercel/postgres或直接用 Neon 驱动 - Supabase:用
postgres驱动,走标准 TCP 连接
本文会同时演示 postgres.js(本地开发)和 Neon Serverless(生产部署)两种方案,这样不管你选哪个路线都能对号入座。
1.2 项目结构
一个组织良好的数据库层,目录结构一定要清晰。我推荐这种布局:
📂 src/
📂 db/
📜 index.ts # 数据库连接实例(单例)
📜 schema.ts # 所有表定义(或拆分为多个文件)
📜 relations.ts # 表关系定义(Drizzle Relations v2)
📜 migrate.ts # 迁移执行脚本
📂 lib/
📂 data/
📜 posts.ts # 文章相关的数据访问函数
📜 users.ts # 用户相关的数据访问函数
📂 validators/
📜 post.ts # 文章相关的 Zod 验证 Schema
📜 user.ts # 用户相关的 Zod 验证 Schema
📂 app/
📂 actions/
📜 post.ts # 文章相关的 Server Actions
📜 drizzle.config.ts # Drizzle Kit 配置文件
📂 drizzle/ # 自动生成的迁移文件(纳入 Git 管理)
这个结构把数据库连接、Schema 定义、数据访问层、验证逻辑和 Server Actions 分得清清楚楚。接下来我们逐一展开。
二、数据库连接:单例模式与连接池
2.1 为什么需要单例模式
Next.js 开发环境有个"隐藏陷阱":热重载(HMR)会频繁重新加载模块。如果每次重载都创建新的数据库连接,连接池很快就会被耗尽。
这不是理论风险——我真的见过有人本地开发跑着跑着,PostgreSQL 就甩过来一个 too many connections 错误。那场面相当令人崩溃。
解决方案是经典的 globalThis 单例模式:
2.2 本地开发连接(postgres.js)
// src/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const createConnection = () => {
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString, {
max: 10, // 最大连接数
idle_timeout: 20, // 空闲连接超时(秒)
connect_timeout: 10, // 连接超时(秒)
});
return drizzle(client, { schema });
};
// 单例模式:开发环境复用连接,避免热重载导致连接泄漏
const globalForDb = globalThis as unknown as {
db: ReturnType<typeof createConnection> | undefined;
};
export const db = globalForDb.db ?? createConnection();
if (process.env.NODE_ENV !== "production") {
globalForDb.db = db;
}
2.3 Serverless 连接(Neon)
如果你部署到 Vercel 或其他 Serverless 平台,就得用 HTTP 或 WebSocket 驱动了——TCP 连接在无状态环境里水土不服:
// src/db/index.ts(Serverless 版本)
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle({ client: sql, schema });
Neon 的 HTTP 驱动不需要连接池管理——每次查询都是独立的 HTTP 请求,天然适合 Serverless 函数的无状态模型。不过要注意,如果你需要事务支持,HTTP 就不够用了,得切换到 WebSocket 驱动:
// 需要事务时使用 WebSocket 驱动
import { Pool, neonConfig } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-serverless";
import ws from "ws";
// 本地开发环境需要 WebSocket polyfill
if (process.env.NODE_ENV === "development") {
neonConfig.webSocketConstructor = ws;
}
const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
export const db = drizzle({ client: pool, schema });
2.4 环境自适应连接
实际项目中,你大概率希望本地开发用 TCP、生产环境用 HTTP。下面是一种很常见的双模式配置方式:
// src/db/index.ts(环境自适应)
import * as schema from "./schema";
function createDb() {
if (process.env.NODE_ENV === "production") {
// 生产环境:Neon HTTP 驱动
const { neon } = require("@neondatabase/serverless");
const { drizzle } = require("drizzle-orm/neon-http");
const sql = neon(process.env.DATABASE_URL!);
return drizzle({ client: sql, schema });
} else {
// 开发环境:postgres.js TCP 驱动
const postgres = require("postgres");
const { drizzle } = require("drizzle-orm/postgres-js");
const client = postgres(process.env.DATABASE_URL!, { max: 10 });
return drizzle(client, { schema });
}
}
const globalForDb = globalThis as unknown as {
db: ReturnType<typeof createDb> | undefined;
};
export const db = globalForDb.db ?? createDb();
if (process.env.NODE_ENV !== "production") {
globalForDb.db = db;
}
三、Schema 设计:用 TypeScript 定义数据模型
3.1 基础表定义
Drizzle 的 Schema 定义是纯 TypeScript——不需要额外的 Schema 文件,也不需要什么代码生成步骤。来看一个博客系统的完整 Schema(我觉得这是最好的入门示例,因为大多数开发者都能直觉理解博客的数据模型):
// src/db/schema.ts
import {
pgTable,
serial,
text,
varchar,
integer,
boolean,
timestamp,
pgEnum,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
// 定义枚举类型
export const postStatusEnum = pgEnum("post_status", [
"draft",
"published",
"archived",
]);
export const userRoleEnum = pgEnum("user_role", [
"admin",
"editor",
"reader",
]);
// 用户表
export const users = pgTable("users", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 100 }).notNull(),
email: varchar("email", { length: 255 }).notNull(),
avatarUrl: text("avatar_url"),
role: userRoleEnum("role").default("reader").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
}, (table) => [
uniqueIndex("users_email_idx").on(table.email),
]);
// 分类表
export const categories = pgTable("categories", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 100 }).notNull(),
slug: varchar("slug", { length: 100 }).notNull(),
description: text("description"),
}, (table) => [
uniqueIndex("categories_slug_idx").on(table.slug),
]);
// 文章表
export const posts = pgTable("posts", {
id: serial("id").primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
slug: varchar("slug", { length: 255 }).notNull(),
content: text("content").notNull(),
excerpt: text("excerpt"),
status: postStatusEnum("status").default("draft").notNull(),
authorId: integer("author_id")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
categoryId: integer("category_id")
.references(() => categories.id, { onDelete: "set null" }),
viewCount: integer("view_count").default(0).notNull(),
publishedAt: timestamp("published_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
}, (table) => [
uniqueIndex("posts_slug_idx").on(table.slug),
index("posts_author_idx").on(table.authorId),
index("posts_category_idx").on(table.categoryId),
index("posts_status_idx").on(table.status),
]);
// 标签表
export const tags = pgTable("tags", {
id: serial("id").primaryKey(),
name: varchar("name", { length: 50 }).notNull(),
slug: varchar("slug", { length: 50 }).notNull(),
}, (table) => [
uniqueIndex("tags_slug_idx").on(table.slug),
]);
// 文章-标签 多对多关联表
export const postTags = pgTable("post_tags", {
postId: integer("post_id")
.references(() => posts.id, { onDelete: "cascade" })
.notNull(),
tagId: integer("tag_id")
.references(() => tags.id, { onDelete: "cascade" })
.notNull(),
}, (table) => [
index("post_tags_post_idx").on(table.postId),
index("post_tags_tag_idx").on(table.tagId),
]);
这里有几个细节值得留意:
- 索引:在外键列和高频查询列上建索引,生产环境的查询性能靠这个撑着
- 枚举:用
pgEnum定义数据库级别的枚举类型,比裸字符串字段安全得多 - 外键约束:通过
.references()定义,别忘了指定onDelete的级联行为 - 时间戳:一定要用
withTimezone: true,不然时区问题能折腾你大半天
3.2 Drizzle Relations v2:定义表关系
从 Drizzle v1.0.0-beta.1 开始,关系定义 API 做了重大更新。好消息是,新 API 更统一了——你不需要为每个表单独定义关系对象,所有关系在一个地方声明就行:
// src/db/relations.ts
import { defineRelations } from "drizzle-orm";
import * as schema from "./schema";
export const relations = defineRelations(schema, (r) => ({
// 用户的关系
users: {
posts: r.many.posts(),
},
// 分类的关系
categories: {
posts: r.many.posts(),
},
// 文章的关系
posts: {
author: r.one.users({
from: schema.posts.authorId,
to: schema.users.id,
}),
category: r.one.categories({
from: schema.posts.categoryId,
to: schema.categories.id,
}),
postTags: r.many.postTags(),
},
// 标签的关系
tags: {
postTags: r.many.postTags(),
},
// 关联表的关系
postTags: {
post: r.one.posts({
from: schema.postTags.postId,
to: schema.posts.id,
}),
tag: r.one.tags({
from: schema.postTags.tagId,
to: schema.tags.id,
}),
},
}));
有一点要明确:关系定义是应用层的概念,不会影响数据库 Schema 或迁移文件。它们的作用仅仅是告诉 Drizzle 的关系查询 API(RQB)表之间怎么关联,以便生成高效的 JOIN 查询。
3.3 类型推断:告别手动类型定义
Drizzle 的类型推断能力算是它的一大杀手锏。你完全不需要手动去定义 TypeScript 类型:
// 从表定义推断类型
import type { InferSelectModel, InferInsertModel } from "drizzle-orm";
import { posts, users } from "./schema";
// 查询结果类型(所有字段)
type Post = InferSelectModel<typeof posts>;
// { id: number; title: string; slug: string; content: string; ... }
// 插入数据类型(自动生成的字段变为可选)
type NewPost = InferInsertModel<typeof posts>;
// { title: string; slug: string; content: string; id?: number; createdAt?: Date; ... }
// 也可以用表的 $inferSelect / $inferInsert 快捷方式
type User = typeof users.$inferSelect;
type NewUser = typeof users.$inferInsert;
这意味着什么?当你改了 Schema,所有相关类型自动更新,根本不用维护单独的类型文件。少一个手动同步步骤,就少一个出 bug 的机会。
四、数据库迁移:安全地演进你的 Schema
4.1 配置 Drizzle Kit
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema.ts",
out: "./drizzle",
strict: true, // 检测到模糊变更(如列重命名)时会提示确认
verbose: true, // 输出详细的迁移信息
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
划重点:strict: true 一定要开。没有它的话,Drizzle 可能把"重命名列"理解成"删掉旧列 + 新建新列",直接导致数据丢失。这不是吓你,我真的见过有项目因为忘了开这个选项而丢了用户数据。血的教训。
4.2 开发工作流:generate → migrate
# 1. 根据 Schema 变更生成迁移文件
npx drizzle-kit generate
# 2. 查看即将执行的 SQL(可选但强烈推荐)
# 生成的迁移文件在 ./drizzle 目录下,是纯 SQL
# 3. 应用迁移到数据库
npx drizzle-kit migrate
# 或者在开发阶段,直接推送 Schema 变更(跳过迁移文件)
npx drizzle-kit push
简单区分一下 push 和 generate + migrate:
push:直接把 Schema 变更同步到数据库,不生成迁移文件。本地快速迭代时用这个就行generate + migrate:先生成可版本控制的迁移文件,再执行。团队协作和生产部署必须走这个路线
生产环境务必用 generate + migrate,而且要把迁移文件纳入 Git 管理。这一点没有商量余地。
4.3 在应用启动时执行迁移
你也可以在应用启动时通过代码执行迁移,这在 CI/CD 流水线里特别方便:
// src/db/migrate.ts
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
const runMigrations = async () => {
const connection = postgres(process.env.DATABASE_URL!, { max: 1 });
const db = drizzle(connection);
console.log("正在执行数据库迁移...");
await migrate(db, { migrationsFolder: "./drizzle" });
console.log("迁移完成!");
await connection.end();
};
runMigrations().catch(console.error);
五、CRUD 操作:用 SQL 思维写 TypeScript
终于到了动手写查询的环节。Drizzle 提供两种查询方式:SQL-like 查询构建器和关系查询 API(RQB),你可以根据场景灵活切换。
5.1 查询(Read)
// 方式一:SQL-like 查询构建器——适合精确控制
import { db } from "@/db";
import { posts, users, categories } from "@/db/schema";
import { eq, desc, and, like, sql } from "drizzle-orm";
// 简单查询
const allPosts = await db.select().from(posts);
// 条件查询 + 排序 + 分页
const publishedPosts = await db
.select()
.from(posts)
.where(eq(posts.status, "published"))
.orderBy(desc(posts.publishedAt))
.limit(10)
.offset(0);
// JOIN 查询
const postsWithAuthor = await db
.select({
id: posts.id,
title: posts.title,
authorName: users.name,
categoryName: categories.name,
})
.from(posts)
.leftJoin(users, eq(posts.authorId, users.id))
.leftJoin(categories, eq(posts.categoryId, categories.id))
.where(eq(posts.status, "published"));
// 聚合查询
const postCountByCategory = await db
.select({
categoryName: categories.name,
count: sql<number>`count(*)`,
})
.from(posts)
.leftJoin(categories, eq(posts.categoryId, categories.id))
.groupBy(categories.name);
如果你觉得 SQL-like 构建器写起来还是太啰嗦了(尤其是嵌套关系查询),RQB 才是真正的大招:
// 方式二:关系查询 API(RQB)——适合嵌套数据获取
// Drizzle 会自动生成高效的 JOIN SQL,始终只有一条查询
// 获取文章及其作者和标签
const postWithRelations = await db.query.posts.findFirst({
where: eq(posts.slug, "my-first-post"),
with: {
author: true,
postTags: {
with: {
tag: true,
},
},
},
});
// 分页获取文章列表
const paginatedPosts = await db.query.posts.findMany({
where: eq(posts.status, "published"),
orderBy: [desc(posts.publishedAt)],
limit: 10,
offset: 0,
columns: {
id: true,
title: true,
slug: true,
excerpt: true,
publishedAt: true,
},
with: {
author: {
columns: {
name: true,
avatarUrl: true,
},
},
category: {
columns: {
name: true,
slug: true,
},
},
},
});
RQB 的最大优势就是始终生成单条 SQL 查询,无论嵌套多深。这从根本上杜绝了 N+1 查询问题。在 Serverless 环境下这一点格外重要——每次数据库往返都意味着额外的延迟和费用。
5.2 插入(Create)
// 插入单条记录
const newPost = await db
.insert(posts)
.values({
title: "我的第一篇文章",
slug: "my-first-post",
content: "文章内容...",
authorId: 1,
categoryId: 2,
})
.returning(); // 返回插入后的完整记录
// 批量插入
const newTags = await db
.insert(tags)
.values([
{ name: "Next.js", slug: "nextjs" },
{ name: "React", slug: "react" },
{ name: "TypeScript", slug: "typescript" },
])
.returning();
// 插入时处理冲突(Upsert)
await db
.insert(users)
.values({
name: "张三",
email: "[email protected]",
role: "reader",
})
.onConflictDoUpdate({
target: users.email,
set: { name: "张三", updatedAt: new Date() },
});
5.3 更新(Update)
// 简单更新
await db
.update(posts)
.set({
title: "更新后的标题",
updatedAt: new Date(),
})
.where(eq(posts.id, 1));
// 基于当前值的更新(比如浏览量 +1)
await db
.update(posts)
.set({
viewCount: sql`${posts.viewCount} + 1`,
})
.where(eq(posts.id, 1));
5.4 删除(Delete)
// 删除单条记录
await db.delete(posts).where(eq(posts.id, 1));
// 条件删除(比如清理 30 天前的草稿)
await db
.delete(posts)
.where(
and(
eq(posts.status, "draft"),
sql`${posts.createdAt} < NOW() - INTERVAL '30 days'`
)
);
5.5 事务
当多个操作需要"全部成功或全部回滚"时,事务就该上场了:
// 创建文章并关联标签(事务保证原子性)
const result = await db.transaction(async (tx) => {
// 插入文章
const [newPost] = await tx
.insert(posts)
.values({
title: "事务示例",
slug: "transaction-example",
content: "这是在事务中创建的",
authorId: 1,
})
.returning();
// 关联标签
await tx.insert(postTags).values([
{ postId: newPost.id, tagId: 1 },
{ postId: newPost.id, tagId: 2 },
]);
return newPost;
});
注意:如果你用的是 Neon HTTP 驱动,事务是不支持的——HTTP 协议本身就是无状态的。需要事务的话,请切换到 WebSocket 驱动或者 postgres.js 驱动。这是个容易踩的坑,提前知道能省不少调试时间。
六、数据访问层(DAL):封装你的查询逻辑
还记得我们认证文章里提到的"数据访问层"吗?现在它终于要有具体实现了。DAL 的核心理念很简单:所有数据库操作都走封装好的函数,组件和 Server Actions 绝不直接写 SQL。
// src/lib/data/posts.ts
import { cache } from "react";
import { db } from "@/db";
import { posts, users, categories, postTags, tags } from "@/db/schema";
import { eq, desc, and, sql } from "drizzle-orm";
// cache() 确保同一请求周期内的重复调用只执行一次查询
export const getPublishedPosts = cache(
async (page: number = 1, pageSize: number = 10) => {
const offset = (page - 1) * pageSize;
const results = await db.query.posts.findMany({
where: eq(posts.status, "published"),
orderBy: [desc(posts.publishedAt)],
limit: pageSize,
offset,
with: {
author: { columns: { name: true, avatarUrl: true } },
category: { columns: { name: true, slug: true } },
},
});
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(posts)
.where(eq(posts.status, "published"));
return {
posts: results,
total: count,
totalPages: Math.ceil(count / pageSize),
};
}
);
export const getPostBySlug = cache(async (slug: string) => {
return db.query.posts.findFirst({
where: eq(posts.slug, slug),
with: {
author: true,
category: true,
postTags: {
with: { tag: true },
},
},
});
});
export const getPostsByCategory = cache(
async (categorySlug: string) => {
return db.query.posts.findMany({
where: and(
eq(posts.status, "published"),
eq(categories.slug, categorySlug)
),
orderBy: [desc(posts.publishedAt)],
with: {
author: { columns: { name: true } },
},
});
}
);
在 Server Components 里直接调用这些函数就行了,干净利落:
// app/blog/page.tsx
import { getPublishedPosts } from "@/lib/data/posts";
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page } = await searchParams;
const currentPage = Number(page) || 1;
const { posts, totalPages } = await getPublishedPosts(currentPage);
return (
<div>
<h1>博客文章</h1>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>作者:{post.author.name}</p>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
七、Zod 验证集成:从 Schema 自动生成验证规则
Drizzle 和 Zod 的集成是我个人很喜欢的一个特性——你能直接从表定义生成验证 Schema,不用手动维护两套类型定义。少维护一份代码,就少一个出错的地方。
7.1 基础用法
// src/lib/validators/post.ts
import { createInsertSchema, createSelectSchema, createUpdateSchema } from "drizzle-orm/zod";
import { posts } from "@/db/schema";
import { z } from "zod";
// 从表定义生成插入验证 Schema
// 自动生成的字段(id、createdAt 等)变为可选
export const insertPostSchema = createInsertSchema(posts, {
// 可以对特定字段添加额外验证
title: z.string().min(1, "标题不能为空").max(255, "标题不能超过 255 个字符"),
slug: z.string()
.min(1, "Slug 不能为空")
.regex(/^[a-z0-9-]+$/, "Slug 只能包含小写字母、数字和连字符"),
content: z.string().min(10, "内容至少需要 10 个字符"),
}).omit({ id: true, createdAt: true, updatedAt: true, viewCount: true });
// 更新验证 Schema(所有字段可选)
export const updatePostSchema = createUpdateSchema(posts, {
title: z.string().min(1).max(255).optional(),
}).omit({ id: true, createdAt: true, updatedAt: true });
// 导出推断类型,供组件使用
export type InsertPostInput = z.infer<typeof insertPostSchema>;
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
7.2 在 Server Actions 中使用
结合我们之前 Server Actions 文章里的模式,完整的实际流程是这样的:
// app/actions/post.ts
"use server";
import { db } from "@/db";
import { posts } from "@/db/schema";
import { insertPostSchema, updatePostSchema } from "@/lib/validators/post";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { eq } from "drizzle-orm";
type ActionState = {
errors?: Record<string, string[]>;
message?: string;
};
export async function createPost(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// 1. 提取并验证表单数据
const rawData = {
title: formData.get("title"),
slug: formData.get("slug"),
content: formData.get("content"),
excerpt: formData.get("excerpt"),
categoryId: Number(formData.get("categoryId")) || undefined,
authorId: Number(formData.get("authorId")),
};
const validated = insertPostSchema.safeParse(rawData);
if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors as Record<string, string[]>,
message: "验证失败,请检查输入",
};
}
// 2. 插入数据库
try {
await db.insert(posts).values(validated.data);
} catch (error) {
return { message: "创建文章失败,请稍后重试" };
}
// 3. 重新验证缓存并重定向
revalidatePath("/blog");
redirect("/blog");
}
export async function updatePost(
id: number,
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const rawData = Object.fromEntries(formData);
const validated = updatePostSchema.safeParse(rawData);
if (!validated.success) {
return {
errors: validated.error.flatten().fieldErrors as Record<string, string[]>,
};
}
try {
await db
.update(posts)
.set({ ...validated.data, updatedAt: new Date() })
.where(eq(posts.id, id));
} catch (error) {
return { message: "更新失败" };
}
revalidatePath("/blog");
revalidatePath(`/blog/${validated.data.slug || ""}`);
redirect("/blog");
}
看到这条链路了吗?Drizzle Schema → Zod 验证 → Server Action → 数据库操作。整条链路都有完整的类型安全保障。从表单输入到数据库写入,任何类型不匹配在编译时就会被抓出来。
八、性能优化:让你的查询更快
8.1 预编译语句(Prepared Statements)
对于那些被反复调用的高频查询,预编译语句可以跳过 SQL 解析阶段直接执行,性能提升是实打实的:
import { db } from "@/db";
import { posts } from "@/db/schema";
import { eq } from "drizzle-orm";
// 预编译查询(模块加载时编译一次就够了)
const getPostById = db.query.posts
.findFirst({
where: eq(posts.id, sql.placeholder("id")),
with: {
author: true,
category: true,
},
})
.prepare("get_post_by_id");
// 使用时传入参数
const post = await getPostById.execute({ id: 42 });
8.2 选择性字段查询
别每次都 SELECT *。只查你需要的字段,数据传输量能减不少:
// 列表页只需要摘要信息,不需要全文
const postList = await db.query.posts.findMany({
columns: {
id: true,
title: true,
slug: true,
excerpt: true,
publishedAt: true,
},
with: {
author: {
columns: { name: true }, // 只取作者名字就够了
},
},
});
8.3 与 React cache() 配合
在 Server Components 中,React 的 cache() 能实现请求级别的记忆化。同一个渲染周期内,哪怕页面上三个组件都调用了同一个函数,数据库查询也只会执行一次:
import { cache } from "react";
// 即使页面上三个组件都调用了 getPost(42),
// 数据库查询只执行一次
export const getPost = cache(async (id: number) => {
return db.query.posts.findFirst({
where: eq(posts.id, id),
with: { author: true },
});
});
这个优化几乎是"免费"的,用起来没什么心理负担。
九、生产部署:Vercel + Neon 最佳实践
9.1 环境变量配置
# .env.local(本地开发)
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
# Vercel 环境变量(生产)
# 使用 Neon 提供的 Pooled 连接字符串
DATABASE_URL="postgresql://user:[email protected]/mydb?sslmode=require"
9.2 CI/CD 迁移策略
在 Vercel 部署时,最简单的方式就是在构建脚本里自动执行迁移:
// package.json
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"build": "npm run db:migrate && next build"
}
}
把 db:migrate 放在 build 命令前面,这样每次部署都会自动应用最新的迁移。简单粗暴但有效。
9.3 Drizzle Studio:可视化数据库管理
Drizzle Kit 还内置了一个浏览器端的数据库管理工具:
npx drizzle-kit studio
它会启动一个本地 Web 界面,让你浏览表数据、执行查询、编辑记录——有点像 Prisma Studio,但更轻量。日常开发调试的时候挺好用的。
十、完整架构回顾:从表单到数据库的全链路
到这里,让我们把所有层次串起来,完整看一遍数据是怎么流动的:
用户提交表单
↓
[Client Component] — 表单数据 → Server Action
↓
[Server Action] — Zod 验证(由 Drizzle Schema 生成)
↓
[数据访问层 DAL] — 认证检查 + 权限验证
↓
[Drizzle ORM] — 类型安全的 SQL 查询
↓
[数据库驱动] — postgres.js (TCP) 或 Neon (HTTP/WS)
↓
[PostgreSQL 数据库]
↓
返回结果 → revalidatePath() → UI 自动更新
每一层都有自己的职责,每一层都有类型安全保障。这就是 2026 年 Next.js 全栈应用该有的数据库架构——简洁、安全、性能靠谱。
常见问题解答
Drizzle ORM 和 Prisma 到底选哪个?
2026 年的简短回答:新项目优先考虑 Drizzle。它的包体积只有 Prisma 的约 1/30,原生支持 Edge Runtime(不需要额外的连接代理),TypeScript 类型推断是即时的不需要代码生成。当然 Prisma 也有自己的强项——更成熟的生态、更自动化的迁移工具、对 MongoDB 等非关系型数据库的支持。如果你的团队已经深度绑定了 Prisma 而且没有 Edge 部署需求,迁移过来的成本可能不划算。
Drizzle 能在 Next.js 的 Edge Runtime 中运行吗?
完全可以,而且零额外配置。Drizzle ORM 核心包就 7.4KB 左右,零依赖,能直接跑在 Cloudflare Workers、Vercel Edge Functions、Deno 等 Edge 环境里。搭配 Neon Serverless 驱动或 Cloudflare D1,你在 Edge 函数里就能直接执行数据库查询。作为对比,Prisma 得通过 Prisma Accelerate 代理才能在 Edge 环境中工作,多了一个中间环节。
开发环境下热重载导致数据库连接过多怎么办?
用 globalThis 单例模式就行。把数据库连接实例存到 globalThis 上,确保热重载时复用同一个实例。本文第二章有完整代码,直接复制粘贴就能用。另外记得配置合理的连接池参数(比如 max: 10),别让单个实例占太多连接。
关系查询和手写 JOIN 有什么区别?
功能上没区别——Drizzle 的 RQB 最终生成的就是带 JOIN 的 SQL 语句,而且始终是单条查询,不存在 N+1 问题。区别在于开发体验:RQB 让你用嵌套对象的方式描述数据需求,代码更直观,类型推断也更完整。如果你需要 GROUP BY 或聚合函数这类 RQB 暂不支持的操作,直接用 SQL-like 查询构建器就好。两种方式在同一个项目里混着用,完全没问题。
生产环境的数据库迁移该怎么管理?
始终用 drizzle-kit generate 生成迁移文件,然后把这些文件提交到 Git。在 CI/CD 或 Vercel 构建流程中通过 drizzle-kit migrate 自动执行。记住一定要开 strict: true,避免列重命名被误判为"删除+新建"导致数据丢失。还有,永远不要在生产环境用 drizzle-kit push——它绕过了迁移文件,变更历史无从追溯。