Next.js + Drizzle ORM 全栈数据库实战:从零搭建到生产部署

从零搭建 Next.js App Router + Drizzle ORM + PostgreSQL 全栈数据库层。涵盖连接池单例模式、Schema 设计、Relations v2、CRUD 操作、Zod 验证集成与 Vercel + Neon 生产部署实践。

引言:数据库层——你 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

简单区分一下 pushgenerate + 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——它绕过了迁移文件,变更历史无从追溯。

关于作者 Editorial Team

Our team of expert writers and editors.