KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

06 · 迁移:从空库、从现成库、从被人改过的库 — keel 龙骨

这一章回答:schema 要变的时候,数据库怎么跟上——以及"跟不上的时候",工具给你的唯一提示为什么会是"把数据清空重来"。

这一章回答:schema 要变的时候,数据库怎么跟上——以及"跟不上的时候",工具给你的唯一提示为什么会是"把数据清空重来"。

现场:同一个改动,三个环境三种结局

需求是给 users 加一个 nickname 字段。你在 schema 里加了一行:

model users {
  // ...
  nickname String? @db.VarChar(30)
}

然后:

三套环境跑同一份 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.

第一次跑它会做三件事:

  1. 在 prisma/migrations/ 下生成一个带时间戳的目录(实测是 20261006084746_init/),里面是 migration.sql;
  2. 把这份 SQL 应用到数据库;
  3. 建出一张 _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 和真正应用的区别。

所以引入迁移的标准流程是三步,顺序不能换:

  1. 先把现有结构写成第一份迁移文件(用 migrate diff --from-empty --to-schema 生成,或者 migrate dev --create-only 生成但不执行);
  2. migrate resolve --applied 把它登记成已应用;
  3. 之后才用 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 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 的迁移 数据真的没了吗?回滚路径是什么

自测题

  1. migrate diff --from-empty --to-schema --script 的价值是什么?它在什么场景下能替代"先跑一次看看"?
  2. _prisma_migrations 为什么才是版本的真相?schema 文件和数据库结构各自能证明什么?
  3. P3005 是什么条件下触发的?为什么它宁可报错也不执行?
  4. baseline 在台账里留下什么签名?为什么"只会 baseline、不写第一份迁移"是个陷阱?
  5. 为什么 migrate status 在库被人改过之后还说 up to date?它和 migrate diff 各自比的是什么?
  6. drift 出现后,migrate dev 给的补救手段是什么?为什么这在生产上是不可接受的?
  7. 为什么说"把 migrate dev 写进生产部署脚本是一个结构性错误"?
  8. Prisma 7 里 migrate status 不接受 --url。这对"切换目标库跑检查"的脚本写法提出了什么要求?
  9. 一份 DDL 生成出来了,在它执行之前还该读什么?(提示:ORM 不改变 SQL 的风险。)

现在能解释什么

下一步:07 章 · 管不到的那部分 —— 迁移讲完了,最后一章收口:连接池、深翻分页、以及那些必须落到原生 SQL 或干脆换工具的场合。

进入 keel 阅读