迁移是一种版本化的 Schema 变更,可在任意环境中重放,将数据库带到特定状态。之所以重要,是因为直接在生产控制台执行临时 ALTER TABLE 会在短短一个月内让本地开发环境与生产环境的 Schema 产生偏差。迁移让 Schema 演进变得可预期、可审查。
迁移之所以作为独立概念存在——而不是"需要时直接 ALTER TABLE"——是因为 Schema 会发生漂移。开发数据库在功能开发过程中自由演进,测试数据库上个季度的演进路径又有所不同,生产数据库则按历史操作的顺序应用了各自的变更。没有统一的管理机制,"当前实际的 Schema 是什么"就只有拥有管理员权限的运维工程师才能回答,而在开发环境运行正常的变更在生产环境失败时往往原因不明。有了迁移,Schema 就是仓库中一系列版本化的文本文件;任何环境都可以通过重放对应文件升级到任意版本;只需查看迁移文件夹,就能回答"当前 Schema 是什么"这个问题。
核心概念很简单:迁移是一个文件(通常是两个——一个应用变更的 up 步骤和一个回滚变更的 down 步骤),绑定到唯一的版本号(时间戳或序列号)。迁移工具负责追踪每个数据库上已应用的迁移(通常通过 schema_migrations 表),并按顺序应用未执行的迁移。不同工具生成的文件格式各异——原生 SQL、ORM 专属 DSL、声明式 Schema 差异——但结构是一致的。选定一种工具,提交迁移文件,每次部署时运行该工具,绝不修改已发布的迁移。
本教程涵盖 2026 年值得考虑的三种工具——Prisma Migrate、Drizzle Kit 和原生 SQL 搭配小型迁移工具——解释各自的适用场景,演示每种工具的首次迁移流程,并以防范迁移灾难的操作规范作为收尾。
schema.prisma 中描述 Schema,Prisma 对比数据库差异并生成 SQL 迁移文件。最适合已在使用 Prisma 作为 ORM 的项目。强大的类型生成能力;Schema、类型与数据库紧密耦合。.ts 文件中的表定义)定义 Schema,Drizzle 生成 SQL 迁移文件。耦合度低于 Prisma——生成的迁移是可读、可编辑、可独立运行的纯 SQL。可与 Drizzle ORM 配合,也可单独使用。简单判断标准:已在使用 Prisma?选 Prisma Migrate。想要类型安全的数据库代码但迁移文件是纯 SQL?选 Drizzle Kit。追求最大移植性或技术栈不是 TypeScript?选原生 SQL。
后期切换代价较高,请慎重选择。
编辑 prisma/schema.prisma 描述目标 Schema。新增一张表的示例:
model Post {
id Int @id @default(autoincrement())
title String
body String
authorId Int
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
}
生成并应用迁移:
npx prisma migrate dev --name add_post_table
此命令会创建 prisma/migrations/<timestamp>_add_post_table/migration.sql——Prisma 根据差异生成的可读 SQL——对开发数据库执行迁移,并重新生成 TypeScript 类型。打开该文件仔细阅读生成的内容。Prisma 的差异计算通常是正确的,但偶尔会有意外行为(将列删除后重建而不是重命名,生成你未预期的索引)——应在代码审查阶段发现,而不是等到生产环境。
将迁移文件夹提交到仓库。部署时:npx prisma migrate deploy 会对目标数据库应用所有未执行的迁移。切勿对生产环境运行 migrate dev——它可能触发数据库重置提示。
在 db/schema.ts 中以 TypeScript 定义 Schema:
import { pgTable, serial, varchar, integer, timestamp } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
email: varchar("email", { length: 255 }).notNull().unique(),
createdAt: timestamp("created_at").defaultNow(),
});
export const posts = pgTable("posts", {
id: serial("id").primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
body: varchar("body").notNull(),
authorId: integer("author_id").references(() => users.id).notNull(),
createdAt: timestamp("created_at").defaultNow(),
});
在 drizzle.config.ts 中配置 Drizzle:
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: { url: process.env.DATABASE_URL! },
});
生成迁移 SQL:
npx drizzle-kit generate
# 创建 drizzle/0001_add_posts.sql,包含实际的 SQL 语句。
# 打开它,阅读它,提交它。
# 应用迁移:
npx drizzle-kit migrate
Drizzle 的优势在于:生成的 SQL 是纯粹的。如果你不满意生成的差异,可以在提交前手动编辑 .sql 文件。migrate 命令通过 __drizzle_migrations 表追踪已应用的迁移。
对于 Node.js 项目,node-pg-migrate 是一个轻量且维护良好的迁移工具。其他技术栈也有对应工具:knex 迁移、Flyway(JVM 及独立版本)、sqitch(语言无关)、Django/Rails/Laravel 内置迁移。
npm i node-pg-migrate
npx node-pg-migrate create add-posts-table
# 创建 migrations/<timestamp>_add-posts-table.js,包含 up() 和 down() 导出。
编写 SQL:
// migrations/1715000000000_add-posts-table.js
exports.up = pgm => {
pgm.createTable("posts", {
id: "id", // shorthand for serial primary key
title: { type: "varchar(255)", notNull: true },
body: { type: "text", notNull: true },
author_id: { type: "integer", notNull: true, references: "users" },
created_at: { type: "timestamp", default: pgm.func("now()") },
});
};
exports.down = pgm => {
pgm.dropTable("posts");
};
应用/回滚:
npx node-pg-migrate up # 应用所有未执行的迁移
npx node-pg-migrate down # 回滚最近一次迁移
迁移记录存储在 pgmigrations 表中。对于更高级的需求(纯 SQL,不使用 DSL),可以在 up() 中使用 pgm.sql("CREATE TABLE ...")。工具不关心内容,只负责执行你写的 SQL。
.sql 文件形式存在,任何语言的任何工具都可以应用它们。如果你有任何理由认为技术栈可能迁移,原生 SQL 值得优先考虑。
大多数迁移在小表上是即时完成的,在大表上则可能很慢甚至造成锁表。以下是需要了解的关键模式:
NOT VALID 添加约束(快速,跳过校验),再在单独的迁移中执行 VALIDATE CONSTRAINT(耗时,但不阻塞写操作)。CREATE INDEX CONCURRENTLY 进行在线构建。不能在事务内运行——需要为该迁移禁用事务包装(各工具均有对应参数)。UPDATE big_table SET col = ...",这会导致长达数小时的表锁。应通过应用代码或独立脚本分批回填;迁移只负责新增列。对于超大表(Postgres 上 5000 万行以上),可以研究 pg_repack 和在线 Schema 变更工具。迁移工具本身无法帮你规避锁问题,关键在于精心编写 SQL。
down 迁移主要在开发阶段有用——应用迁移后发现有误,回滚,重新生成。在生产环境中,"回滚迁移"通常不是正确答案:
生产环境的正确模式是:向前滚动,而非向后回滚。如果某个迁移部署后出现问题,就写一个新迁移来修正。删掉本不该添加的列,还原约束,诸如此类。这样可以保持迁移序列的线性,使所有环境收敛到同一个最终状态。
"种子数据"——开发用的虚拟用户、查找表、演示内容——很容易被放进迁移文件,因为它离 Schema 很近。但请不要这样做。迁移应只包含 Schema;数据应放在单独的脚本中。
// scripts/seed.ts
import { db } from "./db";
await db.users.create({ email: "admin@local", role: "admin" });
await db.users.createMany([
{ email: "alice@local" },
{ email: "bob@local" },
]);
在全新数据库搭建完成后运行 npm run seed。种子脚本应是幂等的(使用 upsert 或先检查再插入),可以多次安全运行。这种分离让迁移文件保持纯 Schema,而种子数据与其代表的测试夹具放在一起。