如果你一直在 AWS EC2 上用 Docker Compose 跑自托管应用,你就知道升级这条路最容易中招。昨晚我的容器死都不肯启动,而那个错误,正是那种会让你想把笔电砸掉的类型。
日志是这样写的:
corrupted migrations: expected previously executed migration
20260205T214213-add-settings-to-spaces to be at index 25
but 20250831T191600-add-group-sync-to-auth-providers was found in its place.
New migrations must always have a name that comes alphabetically after the
last executed migration.
看起来像很清楚的指示,对吧?把缺失的 migration 插进去,按字母排序,搞定。可惜 — 那根本不是真正的问题。差得远。让我告诉你实际发生了什么。
症状
容器每次开机都崩。错误是 Kysely 的 migrator 在 onApplicationBootstrap 期间抛出的。Postgres 连得上。Redis 连得上。migration 文件在镜像里明明存在。唯一"不对"的东西,就是那个顺序检查。
第一个错误假设
我假设 — 像任何正常的开发者一样 — "alphabetically after the last executed migration" 是指按文件名排序。我把缺失的那一行加进 kysely_migration,重启,还是同一个错误。
我又试了一次。换了个 index。同样的错误。
我试过清理"重复"。同样的错误。
我甚至拉了一个全新的镜像,把 migrations 文件夹复制出来,确认它跟我的 repo 一模一样,byte-for-byte。磁盘上 46 个 migration 文件。数据库里 46 行。用 ls | sort 对齐,全部一致。还是同样的错误。
真正的元凶
折腾了差不多两个小时,我终于去看源码。Kysely 0.28.17 的 migrator.js 里,#getExecutedMigrations 有这样一段:
return (executedMigrations
.sort((a, b) => {
if (a.timestamp === b.timestamp) {
return nameComparator(a.name, b.name);
}
return (new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
})
.map((it) => it.name));
仔细读。已执行的列表是按 timestamp(解析成 Date)排序的,不是按 name。name 只是用来打破平局。
而文件系统的列表,是按 name 排序的。所以 Kysely 在走两个长度都是 46 的列表,两个列表按不同的 key 排序,然后当它们在同一个 index 对不上时就报错。
这两种排序只有在每个 migration 的 timestamp 跟它的 name 顺序单调一致时才会吻合。对于遵循 YYYYMMDDTHHMMSS-name.ts 命名的文件名,日期前缀刚好是按字母顺序的,所以只要你的 timestamp 跟文件名前缀对应,两种排序就会对齐。
为什么我一开始的插入失败
我第一次修 kysely_migration 表的时候,timestamp 用了 NOW() — 因为,你知道的,"这个 migration 什么时候跑的"听起来是合理的记录。那个 timestamp 落在 2026 年 6 月,比大多数行都晚。于是 Kysely 把我这行排到 timestamp 顺序的另一个位置,而文件系统排序又把它放在另一个 index。Kysely 接着就在那个位置报错。
你看到的错误信息 — 那个具体的 index、那些具体的名字 — 只是 Kysely 在告诉你"我预期在这个 index 的那行不在这里"。它是 timestamp 与 name 错位的症状,不是原因。
修复方法
诀窍是把每个 migration 的 timestamp 从它 name 里的 YYYYMMDDTHHMMSS 前缀推导出来。格式化成 YYYY-MM-DDTHH:MM:SS.000Z。这样:
- 每个 timestamp 都唯一(每个 name 都有唯一前缀),所以永远不用触发 tiebreaker。
- timestamp 的顺序跟文件名顺序完全一致,两个列表就完美对齐。
我跑的脚本是这样(在运行中的容器里面执行,这样 DATABASE_URL 环境变量才是准的):
const rows = [
['20240324T085400-uuid_v7_fn', '2024-03-24T08:54:00.000Z'],
['20240324T085500-workspaces', '2024-03-24T08:55:00.000Z'],
// ... 44 more rows
['20260509T121236-labels', '2026-05-09T12:12:36.000Z'],
];
for (const [name, ts] of rows) {
await sql`
INSERT INTO kysely_migration (name, timestamp)
VALUES (${name}, ${ts})
ON CONFLICT (name) DO UPDATE SET timestamp = EXCLUDED.timestamp
`;
}
我用 ON CONFLICT DO UPDATE 跑,所以可以安全地重复执行,并且用运行中 app 容器的 DATABASE_URL(通过 docker exec <container> node -e "..."),确保我 100% 打在正确的数据库上。脚本还会把结果按 Kysely 的排序方式 dump 出来,让我可以在重启前用眼睛确认顺序。
之后:
sudo docker compose restart <app-service>
sudo docker compose logs --tail 30 <app-service>
→ DatabaseMigrationService: No pending database migrations
App 启动。搞定。
关键洞察
在 Kysely 0.28.17 里,timestamp 栏是主要的排序键,不是元数据。当你手动插入 kysely_migration 来修复损坏状态时,timestamp 的值必须从 name 自己的 YYYYMMDDTHHMMSS 前缀推导,而不是用 NOW() 或原本的 run_at。否则两种排序会不一致,你就会收到这个误导性的"corrupted migrations"错误,指向错误的 index。
错误信息说"alphabetically"。它骗你的。它是按时间排序的,name 只是后备的 tiebreaker。
实用贴士
- 永远在运行中的容器里面跑诊断 SQL,用它自己的
DATABASE_URL环境变量。如果你的 compose 有environment:覆盖,就不要相信本地的.env— 它们经常不一致。 - 当错误的 index 在每次重启之间一直变,那就是有别的什么东西在改这张表的提示。我这次是一个过时的 placeholder timestamp;其他情况下可能是第二个 app 实例指向同一个 DB、一个 cron job,或者一个备份还原的循环。
kysely_migration表以name作为 PRIMARY KEY,所以ON CONFLICT (name) DO UPDATE这种写法对幂等性很重要。没有它,重跑要么会因为 unique-constraint 冲突失败,要么 — 如果你不知怎么把 PK 删了 — 会产生重复行,把之后的每个 index 都挪一位。kysely_migration.name里的文件名是不含.ts扩展名的文件名。如果你的代码库有任何带多余点的文件名(例如20250729T213756-add-unaccent-..ts),只去掉.ts后缀;里面的点会留在name栏里。手动拼数组的时候很容易搞错。
两个小时值得吗?
值得。我现在有一个能跑的生产 stack,对 Kysely 内部的了解比我想要的深得多,还有这篇文章,可以帮下一个人省掉同样的麻烦。如果你遇到同样的错误,解法就是上面那个从 name 推 timestamp 的技巧。这个教训可以推广:当 library 的错误信息跟实际的排序算法不一致时,去读源码。
你知道吗?Kysely 的 issue tracker 上有issue #843 在讲这个排序行为。维护者知道。他们是故意保持这样,为了 migrator 多次运行之间排序的稳定性。这没问题,直到它咬你一口。
Stack:NestJS 11、Kysely 0.28.17、Postgres 18、EC2 Ubuntu 24.04 上的 Docker Compose。总停机时间:约 2 小时。头痛:RM 0(对 aspirin 过敏)。

发表回复
要发表评论,您必须先登录。