我们阅读了 3,426 篇来自开发者的公开帖子,这些开发者的应用在上线时或上线后出过问题,并核实了 418 个近期案例。其中 6% 与数据库和数据丢失有关。在这些数据中,有 6 份已核实的报告描述了同一种故障:部署成功了,迁移工具显示所有内容都已应用,但生产环境并不认账。
从开发者的角度看,情况是这样的:部署后 API 路由立即返回 500,日志显示 column "plan_id" does not exist。你运行 prisma migrate deploy 或 supabase db push,它们报告没有待应用的内容。功能连续数天无法使用,因为你询问的每个工具都说架构是最新的。较隐蔽的情况是:数据迁移完成时没有报错,几周后才有人发现记录少了。丢失的几乎总是没有父记录的行,例如没有租户的事件或没有账户的订单。
这两种情况都源于同一个习惯:相信成功提示,而不去检查数据库。
你需要 Docker 和 psql。将以下内容保存为 repro.sql:
-- old schema: events.tenant_id was never a foreign key
CREATE TABLE tenants (id int PRIMARY KEY, name text NOT NULL);
CREATE TABLE events (id int PRIMARY KEY, tenant_id int, payload text);
INSERT INTO tenants VALUES (1, 'acme');
INSERT INTO events VALUES
(1, 1, 'signup'),
(2, 1, 'upgrade'),
(3, NULL, 'webhook, before tenants existed'),
(4, 99, 'import from a deleted tenant');
-- the migration: move events into a tenant-scoped table
BEGIN;
CREATE TABLE tenant_events (
id int PRIMARY KEY,
tenant_id int NOT NULL REFERENCES tenants(id),
tenant_name text NOT NULL,
payload text
);
INSERT INTO tenant_events
SELECT e.id, e.tenant_id, t.name, e.payload
FROM events e
JOIN tenants t ON t.id = e.tenant_id;
DROP TABLE events;
COMMIT;
SELECT count(*) AS moved FROM tenant_events;
docker run --rm -d --name pgrepro -e POSTGRES_PASSWORD=pg -p 54329:5432 postgres:17
until pg_isready -q -h localhost -p 54329; do sleep 1; done
psql postgresql://postgres:pg@localhost:54329/postgres -v ON_ERROR_STOP=1 -f repro.sql
psql 报告 INSERT 0 2 和 COMMIT,随后 moved 为 2。写入了 4 条事件,却只取出了 2 条。旧表已经不见了,而 ON_ERROR_STOP 从未遇到错误。
这个例子需要一个 Prisma 项目,所以这里给出操作步骤,而不是测试夹具。一个添加 subscriptions.plan_id 的迁移在生产环境中失败了,prisma migrate deploy 现在拒绝继续应用任何内容。多数人首先会用这种方式解除阻塞:
npx prisma migrate resolve --applied 20261001120000_add_plan_id
npx prisma migrate deploy # reports nothing to apply
npx prisma migrate status # reports the schema as up to date, exits 0
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'subscriptions' AND column_name = 'plan_id';
-- (0 rows)
所有工具都显示正常。列却并不存在,每个读取该列的请求都返回 500。
prisma migrate deploy 会应用文件夹中 _prisma_migrations 未列为已应用的迁移。Prisma CLI 参考文档说明它不会检查偏移。migrate status 会比较同样的两项:迁移文件夹和数据表。Prisma 的基线设置指南称,prisma migrate resolve --applied“会将目标迁移添加到 _prisma_migrations 表中,并标记为已应用”。它只写入一行,不运行任何 SQL。迁移失败时尚未运行的语句,现在将永远不会运行。
Supabase 的行为也一样。supabase db push 会在 supabase_migrations.schema_migrations 中记录每次迁移,其参考文档称“后续推送会跳过已应用的迁移”。supabase migration repair --status applied 会直接插入这条记录。
历史记录表只是对架构状态的一种声明,而且部署时没有任何机制会再次检查这项声明。
如果管道中或运行该命令的笔记本电脑上的 DATABASE_URL 指向开发环境或预览分支,迁移就会在那里运行,而该数据库的历史记录看起来完美无缺。在 Vercel 上,每个变量都限定于 Production、Preview 或 Development,文档说明变更“不会应用于先前的部署,只会应用于新的部署”。更正 URL 后,只有重新部署才会生效。
构建和部署是自动化的,架构变更却不是。新代码上线时会依赖某个列,而该列能否创建取决于是否有人记得运行一条命令。如果开发环境和生产环境共用一个数据库,情况会更糟:每次迁移都会先对真实数据运行,因此没有一个阶段能让它安全失败。
内连接只返回匹配的行。NULL = 1 不成立,所以事件 3 永远匹配不上。没有 id 为 99 的租户,因此事件 4 也匹配不上。INSERT ... SELECT 插入了 SELECT 返回的每一行,而它只被要求做这些。
UPDATE ... FROM 的工作方式也一样,PostgreSQL 文档对此说得很清楚:“如果某个 accounts.sales_person 条目没有匹配项”,连接形式“根本不会更新该行”。命令标签会报告发生变化的行数,而计数为 0“不会被视为错误”。使用 UPDATE 时,被跳过的行会保持原样。像复现案例中那样,在复制后执行 DROP,它们就会被删除。
孤儿记录通常是因为旧列没有外键约束,而缺少约束往往正是最初进行迁移的原因。
这项测试忽略历史记录表,直接检查数据库本身。这样就能捕捉三种情况:历史记录表撒谎、数据库错误,以及迁移从未运行。保存为 scripts/check-schema.sh:
#!/usr/bin/env bash
# Fails if the live schema differs from prisma/schema.prisma.
# Prisma 7: the URL comes from prisma.config.ts.
set -euo pipefail
# exit 1: unapplied or failed migrations, or diverged history
npx prisma migrate status
# exit 2: the live schema differs from schema.prisma
npx prisma migrate diff --exit-code \
--from-config-datasource \
--to-schema=prisma/schema.prisma
如果你的 prisma.config.ts 读取的是 DATABASE_URL,请使用 CI 密钥中的生产 URL 运行它:
DATABASE_URL="$PROD_DATABASE_URL" bash scripts/check-schema.sh
执行上述 resolve --applied 步骤后,migrate status 会通过,而 migrate diff 会以状态码 2 退出。差异就是能发现缺口的检查。要打印缺失的 SQL,请将 --exit-code 换成 --script。
在 Supabase 上,使用 supabase db diff --linked,它会通过在单独容器中创建的影子数据库,“将本地迁移文件与已关联的项目进行差异比较”。参考文档没有说明存在差异时的退出码,因此请检查输出,并在无法确认时直接失败:
supabase migration list
out=$(supabase db diff --linked 2>&1)
grep -q "No schema changes found" <<<"$out" || { echo "$out"; exit 1; }
如果 CLI 更改了那条消息,检查就会明确报错,而不是悄悄通过;这样失败更安全。
用会导致连接失败的行填充一个临时数据库,然后运行迁移。测试只会在一种情况下失败:迁移成功了,但有行丢失。保存为 scripts/test-data-migration.sh:
#!/usr/bin/env bash
# Usage: bash scripts/test-data-migration.sh path/to/migration.sql
set -uo pipefail
DB=postgresql://postgres:pg@localhost:54329/postgres
psql "$DB" -q -v ON_ERROR_STOP=1 <<'SQL' || exit 1
DROP SCHEMA public CASCADE; CREATE SCHEMA public;
CREATE TABLE tenants (id int PRIMARY KEY, name text NOT NULL);
CREATE TABLE events (id int PRIMARY KEY, tenant_id int, payload text);
INSERT INTO tenants VALUES (1, 'acme');
INSERT INTO events VALUES (1, 1, 'ok'), (2, NULL, 'no tenant'), (3, 99, 'dangling');
SQL
if psql "$DB" -q -v ON_ERROR_STOP=1 -f "$1"; then
moved=$(psql "$DB" -tAc "SELECT count(*) FROM tenant_events")
[ "$moved" = 3 ] || { echo "FAIL: migration succeeded but moved $moved of 3 events"; exit 1; }
fi
echo "PASS: every row moved, or the migration refused"
将 repro.sql 中的迁移部分(从 BEGIN 到 COMMIT)复制到 move_events.sql,然后运行:
bash scripts/test-data-migration.sh move_events.sql
# FAIL: migration succeeded but moved 1 of 3 events
引发错误的迁移会通过这项测试。它会使部署失败,但不会丢失任何数据。在真实项目中,先应用较早的迁移来构建临时架构,使用临时 URL 执行 prisma migrate deploy,或在本地堆栈上执行 supabase db reset。然后插入孤儿行。
**将迁移设为可导致部署失败的部署步骤。**在 Render 上,预部署命令会在构建之后、上线之前运行。“如果任何命令失败或超时,整个部署都会失败”,而上一次成功的部署会继续提供服务。在 Railway 上,预部署命令失败后“不会重试,部署也不会继续”。在其他托管平台上,使用生产部署所依赖的 CI 作业运行迁移。在同一步骤中运行架构检查,这样历史记录表撒谎也会导致部署失败:
npx prisma migrate deploy && bash scripts/check-schema.sh
**为生产环境配置独立的数据库和凭据。**为开发、预览和生产环境使用不同的数据库,并在托管平台的设置中将每个 DATABASE_URL 限定到对应环境。不要把生产 URL 放在本地的 .env 文件中,以免本地命令或编程代理读取到它。
**只有检查过架构后才修复历史记录。**先运行 information_schema 查询。如果变更已经存在,resolve --applied 或 repair --status applied 就是正确做法。如果变更不存在,修复方法取决于所用工具。在 Supabase 上,supabase migration repair --status reverted <version> 会删除这条记录,因此下一次 db push 会应用该迁移。使用 Prisma 时,创建一个包含缺失变更的新迁移并部署它。
**在迁移中决定如何处理孤儿记录。**你可以拒绝它们、通过 LEFT JOIN 和可空列保留它们,或将它们移到隔离表中。这些都是明确的决定。意外丢弃它们则不是。最简单的防护措施是统计未匹配的行并引发错误。这会回滚事务,并使部署步骤失败:
DO $$
DECLARE orphans int;
BEGIN
SELECT count(*) INTO orphans
FROM events e
LEFT JOIN tenants t ON t.id = e.tenant_id
WHERE t.id IS NULL;
IF orphans > 0 THEN
RAISE EXCEPTION '% events have no tenant', orphans;
END IF;
END $$;
将它紧接着放在 BEGIN 之后的 move_events.sql 中,测试 2 就会通过。迁移会因 2 events have no tenant 而停止,不会丢弃任何数据。
**生产运行前,先阅读由代理编写的迁移。**如果 AI 编程工具编写了 SQL,请先阅读,并在暂存副本上运行。不要让代理运行环境获得生产凭据,以免它直接应用迁移。
-
- 生产部署会在一个出错就导致部署失败的步骤中运行迁移。
-
- 每次部署后,
prisma migrate diff --exit-code或supabase db diff --linked都会针对生产环境运行,并报告没有变更。
- 每次部署后,
-
-
prisma migrate status以状态码 0 退出,或者supabase migration list显示本地与远程的迁移相同。
-
-
- 开发、预览和生产环境使用独立的数据库,生产 URL 只存在于生产环境的设置中。
-
- 每个包含连接操作的数据迁移都有针对未匹配行的防护,并配有包含 NULL 父项和悬空父项的测试夹具。
- 任何人都不运行
resolve --applied或repair --status applied,直到information_schema查询确认变更。 -
- 在暂存环境中,故意出错的迁移会使部署失败,且先前版本会继续提供服务。
报告中出现的症状及各原因对应的修复方法,都列在故障排查页面:https://research.gemmein.com/guides/database-migration-not-applied-in-production
上次迁移发布后,你凭什么判断它成功了:历史记录表,还是数据库?