KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
06 · 迁移:从空库、从现成库、从被人改过的库 — keel 龙骨
这一章回答:schema 要变的时候,数据库怎么跟上——以及"跟不上的时候",工具给你的唯一提示为什么会是"把数据清空重来"。
这一章回答:schema 要变的时候,数据库怎么跟上——以及"跟不上的时候",工具给你的唯一提示为什么会是"把数据清空重来"。
现场:同一个改动,三个环境三种结局
需求是给 users 加一个 nickname 字段。你在 schema 里加了一行:
model users {
// ...
nickname String? @db.VarChar(30)
}
然后:
- 本地(空库,从迁移一路建起来的):
migrate dev一把过。 - 测试环境(有数据,但结构和迁移历史对得上):也能过。
- 生产(结构是两年里别人用 SQL 补出来的,从来没跑过 Prisma 的迁移):报错,而且报的错和"加字段"这件事毫无关系。
三套环境跑同一份 schema、同一条命令,三种结果。这一章就是把这三种处境讲清楚——因为"迁移"这件事在教科书里只有一种形态,在真实环境里有三种。
一、它长什么样:不连库也能生成 DDL
先把最安全的一个用法放出来,因为它能让后面所有讨论都有个可对照的基准。下面这条命令不连任何数据库,只读 schema 文件:
npx prisma migrate diff --from-empty --to-schema prisma/schema.prisma --script
输出是一份完整的 DDL:
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "post_status" AS ENUM ('draft', 'review', 'published', 'archived');
CREATE TYPE "user_role" AS ENUM ('reader', 'author', 'admin');
-- CreateTable
CREATE TABLE "comments" (
"id" BIGSERIAL NOT NULL,
"post_id" INTEGER NOT NULL,
"user_id" INTEGER,
"body" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "posts" (
"id" SERIAL NOT NULL,
"author_id" INTEGER NOT NULL,
"title" VARCHAR(200) NOT NULL,
"slug" VARCHAR(200) NOT NULL,
"status" "post_status" NOT NULL DEFAULT 'draft',
"rating" DECIMAL(3,1),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "posts_pkey" PRIMARY KEY ("id")
);
-- 后面还有 post_tags / tags / users 三张表、
-- UNIQUE 约束、外键 ON DELETE CASCADE、以及两个 CREATE INDEX
注意这份 DDL 是文件到文件的差分,没有数据库参与。它的价值在排查场景里立刻显出来:"我想知道这次改 schema 会执行什么 SQL,但不想真的动库"——这条命令就是答案。任何一次结构变更,都不该在没看到这份 DDL 之前就执行。
顺带记一个版本差异:这条命令在旧版本里叫 --to-schema-datamodel,Prisma 7 改成 --to-schema。直接抄旧教程会得到一句 was removed. Please use --[from/to]-schema instead.。
二、第一种处境:从空库开始
库还不存在,迁移历史是干净的。这是最舒服的处境:
npx prisma migrate dev --name init
Datasource "db": PostgreSQL database "labprisma_mig", schema "public" at "127.0.0.1:5433"
Already in sync, no schema change or pending migration was found.
第一次跑它会做三件事:
- 在
prisma/migrations/下生成一个带时间戳的目录(实测是20261006084746_init/),里面是migration.sql; - 把这份 SQL 应用到数据库;
- 建出一张
_prisma_migrations台账表,记下"这个迁移已经应用"。
第三步是整套机制的核心。_prisma_migrations 才是"数据库当前处于哪个版本"的真相,不是你的 schema 文件、也不是数据库的真实结构。后面两节的所有麻烦都源于这一条。
三、第二种处境:库里有数据,但从来没有迁移历史
这是"给一个已经跑了很久的库引入 ORM"的标准场景。库里的表都在,但 _prisma_migrations 这张表不存在。
先看 migrate status 怎么说:
1 migration found in prisma/migrations
Following migration have not yet been applied:
20261006084746_init
To apply migrations in development run prisma migrate dev.
To apply migrations in production run prisma migrate deploy.
它没报错,也没报 drift。 它只是说"有一个迁移还没应用"。看起来按提示做就行。真的去做:
npx prisma migrate deploy
Error: P3005
The database schema is not empty. Read more about how to baseline an existing production database:
https://pris.ly/d/migrate-baseline
P3005:数据库结构不是空的。 它拒绝在"已经有表的库"上执行一份"从零建表"的迁移——因为那份迁移的第一条 CREATE TABLE "comments" 就会撞上已经存在的表。
好消息是它没有破坏任何东西。实测里那个库有 5001 篇文章,报错之后一篇没少。这套工具在这一点上是保守的:宁可不做,不做一半。这是它值得信任的地方。
正确的路叫 baseline(基线):告诉工具"这个迁移其实已经生效了,别执行它,只登记一下"。
npx prisma migrate resolve --applied 20261006084746_init
Migration 20261006084746_init marked as applied.
之后 migrate status 说:
1 migration found in prisma/migrations
Database schema is up to date!
查一下那张台账表,能看出 baseline 的签名:
migration_name | started_at | finished_at | applied_steps_count
--------------------+------------------------------+------------------------------+-------------------
20261006084746_init | 2026-10-06 16:49:54.170399+08 | 2026-10-06 16:49:54.170399+08 | 0
applied_steps_count = 0、logs 为空、started_at 等于 finished_at——三条都在说同一件事:这条记录没有执行过任何 SQL,只是登记。 这就是 baseline 和真正应用的区别。
所以引入迁移的标准流程是三步,顺序不能换:
- 先把现有结构写成第一份迁移文件(用
migrate diff --from-empty --to-schema生成,或者migrate dev --create-only生成但不执行); migrate resolve --applied把它登记成已应用;- 之后才用
migrate dev/migrate deploy走正常流程。
漏掉第 1 步会怎样? 第一份迁移是空的(因为 schema 就是从库里逆向来的,没有差异),baseline 之后台账是干净的,但这份"空迁移"没描述任何东西——将来谁拿这套迁移文件去建一个新库,建出来的是一个空库。这是 baseline 最常见的翻车方式:台账是对的,迁移文件是假的。
四、第三种处境:库被人改过
现在进入最危险的一节。
有人绕过所有流程,直接在库上执行了一句 SQL:
ALTER TABLE users ADD COLUMN nickname varchar(30);
然后你跑 migrate status:
1 migration found in prisma/migrations
Database schema is up to date!
"up to date"——但它不是 up to date。 数据库里现在有一列 nickname,而 schema 里没有,迁移文件里也没有。
migrate status 看的是迁移历史:所有迁移文件都记录了已应用,所以它说一致。它不比较迁移历史与数据库的真实结构。 这个盲区必须记住,因为它给人的安全感是假的。
要看真实差异,得用另一条命令:
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script
-- AlterTable
ALTER TABLE "users" DROP COLUMN "nickname";
差异立刻现形。 注意这句话的方向:它是"从库到 schema"的差分,也就是"要让库和 schema 一致,需要做什么"。结论是要删掉那一列——因为 schema 里没有它。
于是问题变成:"库里有、schema 里没有"的东西,到底该删还是该留?这一次是人手动加的列,你大概想留(那就在 schema 里补上)。但如果是别人手工建的一个临时索引、一张遗留表呢?工具不知道,它只会照 schema 办事。 这就是 drift 需要人来判断的原因。
如果这时你不看差异,直接跑 migrate dev 想"顺手把 schema 改的东西推上去",会得到:
Drift detected: Your database schema is not in sync with your migration history.
The following is a summary of the differences between the expected database schema
given your migrations files, and the actual schema of the database.
[*] Changed the `users` table
[+] Added column `nickname`
We need to reset the "public" schema at "127.0.0.1:5433"
You may use prisma migrate reset to drop the development database.
All data will be lost.
它给的唯一出路是把整个 schema 清空重来。 这里必须把话说重:在本地这是方便功能,在生产上这是一个数据全灭的按钮。 一个"加个字段"的需求,如果你在生产上顺手跑了 migrate dev,得到的是"要不要清空整个数据库"。
好消息是它的默认行为是安全的:非交互环境下它会中止,不会替你按下去(实测里那个库的数据一行没动)。坏消息是在交互式终端里,它会给一个提示,而人是会按错的。
所以 migrate dev 的定位很清楚:它是开发环境的工具。migrate deploy 才是生产用的——后者只应用迁移文件、不做 drift 检测、不给重置选项。把 migrate dev 写进生产部署脚本是一个结构性错误,不是操作失误。
五、Prisma 7 在这个环节上的几处变更
升级时会被绊到的具体几条,全部来自实测:
| 旧写法 | Prisma 7 的行为 |
|---|---|
migrate diff --to-schema-datamodel |
报 was removed,改用 --to-schema |
migrate dev --skip-generate |
不认识这个参数,直接打印帮助 |
migrate status --url <连接串> |
不支持,只能用环境变量 |
datasource { url = ... } |
报 P1012,连接串移入 prisma.config.ts |
最后一条的连带影响最大:任何把连接串通过命令行传给迁移命令的脚本都要改。migrate status 这一类只接受环境变量的命令,会让"切库跑状态检查"这种运维动作需要换一种写法(在命令前设环境变量,而不是加参数)。
本章脉络
flowchart TD
A["schema 要变"] --> B{"库现在是什么状态?"}
B -- "空库 / 历史干净" --> C["migrate dev --name <名字><br/>生成迁移 + 应用 + 登记"]
B -- "有表,但没有迁移历史" --> D["migrate deploy → P3005"]
D --> E["baseline 三步:<br/>① 写成第一份迁移<br/>② migrate resolve --applied<br/>③ 之后走正常流程"]
B -- "有表,有历史,但被人改过" --> F["migrate status 说 up to date ← 假象"]
F --> G["migrate diff --from-config-datasource<br/>差异现形"]
G --> H{"这个差异<br/>该留还是该删?"}
H -- "意外的改动" --> I["migrate dev → Drift detected<br/>唯一出路:reset,All data will be lost"]
H -- "合理的改动" --> J["在 schema 里补上<br/>生成一份正式迁移"]
C --> K["_prisma_migrations 是版本的真相<br/>不是 schema、也不是库结构"]
style D fill:#fff3e0,color:#e65100
style F fill:#fff3e0,color:#e65100
style I fill:#ffebee,color:#b71c1c
style E fill:#e8f5e9,color:#1b5e20
style J fill:#e8f5e9,color:#1b5e20
生产边界
migrate dev永远不要上生产。 它会做 drift 检测,而它的补救手段是重置数据库。生产用migrate deploy——它只应用、不检测、不重置。migrate status说 up to date 不等于库和 schema 一致。 它只比对迁移历史。要验证真实一致性,只能用migrate diff --from-config-datasource --to-schema。 把这条命令放进上线前的检查清单。- baseline 时,第一份迁移文件必须真的描述现有结构。 一份空迁移 + 一条 baseline 记录会让台账看起来正确,而迁移文件是假的——下一次有人用这套文件建新库时才会发现。 验证方法很简单:拿这份迁移去一个空库跑一遍,看建出来的结构对不对。
migrate resolve --applied不会执行任何 SQL。 它只是写台账。所以它既不会帮你建表,也不会坏你的数据——但也因此,用错了它(把一个没生效的迁移登记成已生效)不会有任何提示。- drift 的判断是人做的,工具只负责发现。 发现之后要回答的是"这个差异是意外的还是合理的",工具给不出答案。
- P3005 是保护,不是障碍。 它说明工具拒绝在"未知结构"的库上执行从零建表。遇到它就停下来做 baseline,不要去找绕过的方法。("绕过"通常意味着手动删掉某张表或者直接改台账,两者都很难看。)
- DDL 本身可能是破坏性的。 上面所有讨论都是"迁移有没有正确执行",还有一个更前置的问题:这份 DDL 会不会删列、会不会锁表、在大表上要跑多久。 那是
migrate diff输出之后、执行之前该读的东西——它和你手写一条ALTER TABLE的风险完全一样,ORM 没有让它变安全。
动手:可观察结果
| 产出 | 判断标准 |
|---|---|
一份 migrate diff --from-empty 的 DDL |
能在不连库的情况下拿到完整建表语句,并数出几张表、几个枚举、几个索引 |
一个空库上的 migrate dev |
能指出生成的迁移目录名、migration.sql 的内容、以及 _prisma_migrations 里多了什么 |
| 一次 P3005 | 能复现它,并证明数据没有被破坏 |
| 一次 baseline | 能从台账里的 applied_steps_count 说明它和"真正应用"的区别 |
| 一次 drift 复现 | 能先用 status 拿到"up to date"的假象,再用 diff 拿到真实差异,说出两条命令各自看的是什么 |
一次 migrate dev 遇 drift |
能抄出完整提示,并指出哪一句是"数据会全丢" |
完成标志:拿到一个你不熟悉的库,你能先判断它处在上面三种处境的哪一种,再决定走哪条命令;并且在跑任何 migrate dev 之前,先跑一次 diff 看清差异。
故障注入
| 注入方式 | 观察 |
|---|---|
在空库上只跑 baseline,不生成迁移文件,然后拿这套迁移去建新库 |
建出来的库有几张表 |
手动 ALTER TABLE 加一列,再 migrate status |
它说什么;再说出这个结论错在哪 |
手动加一列,然后 migrate dev |
提示里"数据会全丢"那句出现在哪;非交互下它真的执行了吗 |
手动删掉一列,再看 migrate diff |
差分的方向是 ADD 还是 DROP;据此说明 diff 的方向语义 |
把 migrate deploy 重复跑两次 |
第二次的输出去哪了(幂等吗) |
删掉 _prisma_migrations 表,再 migrate status |
它怎么判断版本;说出这种情况该怎么恢复 |
在 migration.sql 里手动改一行再 migrate deploy |
台账里的校验值(checksum)是否报不一致 |
对一个有数据的库执行一份含 DROP COLUMN 的迁移 |
数据真的没了吗?回滚路径是什么 |
自测题
migrate diff --from-empty --to-schema --script的价值是什么?它在什么场景下能替代"先跑一次看看"?_prisma_migrations为什么才是版本的真相?schema 文件和数据库结构各自能证明什么?- P3005 是什么条件下触发的?为什么它宁可报错也不执行?
- baseline 在台账里留下什么签名?为什么"只会 baseline、不写第一份迁移"是个陷阱?
- 为什么
migrate status在库被人改过之后还说 up to date?它和migrate diff各自比的是什么? - drift 出现后,
migrate dev给的补救手段是什么?为什么这在生产上是不可接受的? - 为什么说"把
migrate dev写进生产部署脚本是一个结构性错误"? - Prisma 7 里
migrate status不接受--url。这对"切换目标库跑检查"的脚本写法提出了什么要求? - 一份 DDL 生成出来了,在它执行之前还该读什么?(提示:ORM 不改变 SQL 的风险。)
现在能解释什么
- 为什么同一个改动在本地、测试、生产会得到三种结果——三种处境对应三个不同的迁移历史状态;
- 为什么"不连库生成 DDL"是一个日常可用的动作,而不是高级技巧;
- 为什么
migrate status的 "up to date" 是一句危险的话,以及要用哪条命令补上它的盲区; - 为什么工具在 drift 之后给的唯一出路是清空数据,以及为什么
migrate deploy和migrate dev不能互换; - 为什么 baseline 必须和一份真实的迁移文件配套使用;
- 为什么"迁移能正确执行"和"迁移是安全的"是两个问题——ORM 不会替你读那条
DROP COLUMN。
下一步:07 章 · 管不到的那部分 —— 迁移讲完了,最后一章收口:连接池、深翻分页、以及那些必须落到原生 SQL 或干脆换工具的场合。