ECC 的 database-migrations 技能全解:PostgreSQL 与多 ORM 的零停机数据库迁移模式
本文基于 ECC(Everything Claude Code)仓库中 .kiro/skills/database-migrations/SKILL.md 技能文档展开,系统讲解生产级数据库迁移的五大核心原则、迁移前安全检查清单、PostgreSQL 下加列/建索引/改列名/删列/大数据回填的无锁操作模式,以及 Prisma、Drizzle、Kysely、Django、golang-migrate 五套迁移工具链的完整工作流与零停机 Expand-Contract 策略。读完后,你可以直接复制其中的 SQL 与命令完成一次可回滚、不锁表的 Schema 变更,并理解 ECC 如何通过该技能与 database-reviewer Agent 协作约束 AI 的数据库改动行为。
技能定位:ECC 中的按需工作流
在 ECC 仓库中,skills 目录存放"按需调用"(on-demand)的工作流定义:每个技能是一个带 YAML frontmatter 的 SKILL.md,描述其激活场景与完整操作指南。database-migrations 技能 的 frontmatter 声明如下:
---
name: database-migrations
description: >
Database migration best practices for schema changes, data migrations, rollbacks,
and zero-downtime deployments across PostgreSQL, MySQL, and common ORMs (Prisma,
Drizzle, Django, TypeORM, golang-migrate). Use when planning or implementing
database schema changes.
metadata:
origin: ECC
---
其设计目标是明确的一句话:"Safe, reversible database schema changes for production systems"——面向生产系统的安全、可回滚的 Schema 变更。该技能声明了五类激活场景:
- 创建或修改数据库表
- 增删列或索引
- 执行数据迁移(回填、转换)
- 规划零停机 Schema 变更
- 为新项目搭建迁移工具链
在 Kiro 集成版中,.kiro/README.md 展示了该技能的典型使用方式:通过 kiro-cli --agent database-reviewer 切换到数据库审查 Agent,再在会话中输入 /database-migrations 调用本技能,必要时叠加 /postgres-patterns 做 PostgreSQL 专项优化。仓库中同时存在 skills/database-migrations/SKILL.md 作为面向其他 harness 的同名技能变体,两者内容高度一致,.kiro 版本额外包含 Kysely(kysely-ctl)工作流小节。
核心原则:五条不可动摇的迁移纪律
技能文档在 Core Principles 一节 给出五条原则,这是整个技能的方法论骨架:
- Every change is a migration(每一次变更都是迁移)——严禁手工修改生产数据库。任何 DDL/DML 改动都必须落成迁移文件,留下可审计、可重放的记录。
- Migrations are forward-only in production(生产环境只向前)——回滚不通过"逆向执行"已上线迁移实现,而是编写一条新的前向迁移来修复状态。
- Schema and data migrations are separate(Schema 与数据迁移分离)——DDL 和 DML 绝不混在同一条迁移里,否则回滚困难且长事务易锁表。
- Test migrations against production-sized data(在生产量级数据上测试迁移)——在 100 行数据上能跑的迁移,放到 1000 万行上可能因锁竞争而挂起。
- Migrations are immutable once deployed(已部署的迁移不可变)——永远不要编辑一条已在生产执行过的迁移文件,只允许追加新迁移。
迁移前安全检查清单
技能将检查清单(Checklist 一节)前置到流程最前面,要求在任何迁移应用之前逐项确认:
- [ ] 迁移同时具备 UP 和 DOWN(或已显式标记为不可逆)
- [ ] 大表上不存在全表锁(使用并发操作)
- [ ] 新列必须有默认值或可为 NULL(绝不直接加 NOT NULL 且无默认值)
- [ ] 索引以并发方式创建(对已有表不内联在 CREATE TABLE 中)
- [ ] 数据回填是与 Schema 变更分离的独立迁移
- [ ] 已在生产数据副本上测试过
- [ ] 回滚方案已文档化
这份清单与后文的反模式表、各数据库具体模式形成闭环:清单里每一条都能在后文找到对应的 SQL 实现或失败案例。
PostgreSQL 深度模式
安全地添加列
PostgreSQL 11 之后,ALTER TABLE ADD COLUMN 对"可空列"和"带常量默认值的列"都是 metadata-only 操作,不会重写表;而 NOT NULL 且无默认值的列则要求全表重写。技能的三组对比示例(第 44–54 行):
-- GOOD: 可空列,无锁
ALTER TABLE users ADD COLUMN avatar_url TEXT;
-- GOOD: 带默认值的列(Postgres 11+ 即时生效,无表重写)
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT true;
-- BAD: 已有表上 NOT NULL 且无默认值(需要全表重写)
ALTER TABLE users ADD COLUMN role TEXT NOT NULL;
-- 这会锁住表并重写每一行
第三条是清单中"绝不加 NOT NULL 无默认值"规则的直接落地。若业务确实需要非空约束,正确路径是:先加可空列 → 数据迁移回填 → 最后 SET NOT NULL,这也正是反模式表给出的 Better Approach。
无停机创建索引
-- BAD: 在大表上阻塞写入
CREATE INDEX idx_users_email ON users (email);
-- GOOD: 非阻塞,允许并发写入
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);
-- 注意:CONCURRENTLY 不能在事务块内运行
-- 大多数迁移工具需要为此做特殊处理
这里有一个工程上的关键细节:CREATE INDEX CONCURRENTLY 无法在事务中执行,而多数迁移工具默认把每条迁移包在事务里,所以 Prisma 等工具无法自动生成该语句——这也是技能在 Prisma 小节专门提供"手写 SQL 迁移"流程的原因。另外结合同仓库的 postgres-patterns 技能,对稀疏写入的列可以进一步用部分索引(WHERE ... IS NOT NULL)减小索引体积。
零停机重命名列:Expand-Contract 四步法
技能明确警告"绝不在生产环境直接重命名列",而是拆成两次迁移 + 两次发版(第 73–85 行):
-- Step 1: 加新列(迁移 001)
ALTER TABLE users ADD COLUMN display_name TEXT;
-- Step 2: 回填数据(迁移 002,数据迁移)
UPDATE users SET display_name = username WHERE display_name IS NULL;
-- Step 3: 更新应用代码,同时读写两个列
-- 部署应用变更
-- Step 4: 停止写旧列并删除(迁移 003)
ALTER TABLE users DROP COLUMN username;
注意 Step 2 的回填本身在千万行级表上也不能一条 UPDATE 完成,应套用下文的批量回填模式。
安全地删除列
删除列的方向与添加相反——先改代码,后动库(第 89–97 行):
-- Step 1: 移除应用代码中对该列的所有引用
-- Step 2: 部署不含该列引用的应用版本
-- Step 3: 在下一条迁移中删除列
ALTER TABLE orders DROP COLUMN legacy_status;
-- Django 中:使用 SeparateDatabaseAndState 先把字段从 model 状态中移除
-- 而不生成 DROP COLUMN(下一次迁移再真正删除)
技能同时指向 Django 的 SeparateDatabaseAndState 机制作为工具级实现(后文 Django 小节给出完整代码)。
大数据迁移:批处理 + SKIP LOCKED
单条 UPDATE 全表数据会把表锁在长事务里。技能给出的生产级写法是一个 DO 块:按 batch_size 分批、用 FOR UPDATE SKIP LOCKED 抢占一批行、每批独立提交,并输出进度(第 106–125 行):
-- BAD: 单事务更新全部行(锁表)
UPDATE users SET normalized_email = LOWER(email);
-- GOOD: 带进度的批量更新
DO $$
DECLARE
batch_size INT := 10000;
rows_updated INT;
BEGIN
LOOP
UPDATE users
SET normalized_email = LOWER(email)
WHERE id IN (
SELECT id FROM users
WHERE normalized_email IS NULL
LIMIT batch_size
FOR UPDATE SKIP LOCKED
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
RAISE NOTICE 'Updated % rows', rows_updated;
EXIT WHEN rows_updated = 0;
COMMIT;
END LOOP;
END $$;
这个模式有几个值得注意的实现要点:
LIMIT batch_size FOR UPDATE SKIP LOCKED使多个回填 worker 可以并行跑而互不阻塞——同仓库 database-reviewer Agent 也把 "SKIP LOCKED for queues" 列为关键并发原则;WHERE normalized_email IS NULL充当进度游标,重复执行幂等;- 每批
COMMIT把锁持有时间压缩到毫秒级,WAL 日志与事务大小都可控; RAISE NOTICE提供可观测的批次进度。
对应的 Django 版批量回填见后文 RunPython 示例(bulk_update + 固定 batch_size),思想完全一致。
ORM 与迁移工具链
Prisma(TypeScript / Node.js)
工作流命令(第 132–148 行):
# 从 schema 变更创建迁移
npx prisma migrate dev --name add_user_avatar
# 在生产应用待执行的迁移
npx prisma migrate deploy
# 重置数据库(仅限开发环境)
npx prisma migrate reset
# schema 变更后重新生成客户端
npx prisma generate
Schema 示例展示了 @map 控制列名映射(camelCase 属性对应 snake_case 列)、@unique 与 @@index 声明索引:
model User {
id String @id @default(cuid())
email String @unique
name String?
avatarUrl String? @map("avatar_url")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
orders Order[]
@@map("users")
@@index([email])
}
由于 Prisma 无法表达 CONCURRENTLY 索引与任意数据回填这类操作,技能给出"生成空迁移再手写 SQL"的标准流程:
# 创建空迁移,然后手工编辑 SQL
npx prisma migrate dev --create-only --name add_email_index
-- migrations/20240115_add_email_index/migration.sql
-- Prisma 无法生成 CONCURRENTLY,所以手写
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users (email);
这正好呼应了前面"CONCURRENTLY 不能进事务块、需要工具特殊处理"的说明——--create-only 是 Prisma 用户绕过该限制的官方途径。
Drizzle(TypeScript / Node.js)
# 从 schema 变更生成迁移
npx drizzle-kit generate
# 应用迁移
npx drizzle-kit migrate
# 直接推送 schema(仅限开发,不产生迁移文件)
npx drizzle-kit push
Schema 以 TypeScript 代码声明表结构,属性名与列名分离(第 196–206 行):
import { pgTable, text, timestamp, uuid, boolean } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
name: text("name"),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
需要留意 drizzle-kit push 不落迁移文件,只适合开发环境,生产环境必须走 generate + migrate 的迁移文件路径,否则会违背"每次变更都是迁移"的核心原则。
Kysely(TypeScript / Node.js,kysely-ctl)
这是 .kiro 版本独有的小节。CLI 工作流(第 212–227 行):
# 初始化配置文件(kysely.config.ts)
kysely init
# 创建新的迁移文件
kysely migrate make add_user_avatar
# 应用所有待执行迁移
kysely migrate latest
# 回滚最后一条迁移
kysely migrate down
# 查看迁移状态
kysely migrate list
迁移文件是 TypeScript 函数,up/down 成对出现。技能特别强调一个容易被忽视的约束:迁移文件必须使用 Kysely<any> 而非当前项目的强类型 DB 接口,因为迁移在时间上是"冻结"的,不能依赖当前 schema 的类型定义,否则历史迁移在旧 schema 上会类型编译失败:
// migrations/2024_01_15_001_create_user_profile.ts
import { type Kysely, sql } from 'kysely'
// 重要:始终使用 Kysely<any>,不要用强类型 DB 接口。
// 迁移在时间上是冻结的,不能依赖当前 schema 类型。
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('user_profile')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('email', 'varchar(255)', (col) => col.notNull().unique())
.addColumn('avatar_url', 'text')
.addColumn('created_at', 'timestamp', (col) =>
col.defaultTo(sql`now()`).notNull()
)
.execute()
await db.schema
.createIndex('idx_user_profile_avatar')
.on('user_profile')
.column('avatar_url')
.execute()
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('user_profile').execute()
}
除了 CLI,还给出了嵌入应用启动流程的程序化迁移器(第 262–300 行):
import { Migrator, FileMigrationProvider } from 'kysely'
import { promises as fs } from 'fs'
import * as path from 'path'
// 仅 ESM——CJS 可直接使用 __dirname
import { fileURLToPath } from 'url'
const migrationFolder = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'./migrations',
)
// `db` 是你的 Kysely<any> 数据库实例
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder,
}),
// 警告:仅在开发环境启用。会禁用时间戳顺序校验,
// 可能导致环境间 schema 漂移。
// allowUnorderedMigrations: true,
})
const { error, results } = await migrator.migrateToLatest()
results?.forEach((it) => {
if (it.status === 'Success') {
console.log(`migration "${it.migrationName}" executed successfully`)
} else if (it.status === 'Error') {
console.error(`failed to execute migration "${it.migrationName}"`)
}
})
if (error) {
console.error('migration failed', error)
process.exit(1)
}
其中被注释掉的 allowUnorderedMigrations 值得展开:开启后跳过"迁移文件必须按时间戳升序执行"的校验,一旦在环境间产生执行顺序差异,就会造成 schema 漂移——这正是核心原则中"环境漂移"风险的代码级体现,所以文档将其限定为仅开发可用。
Django(Python)
工作流命令(第 306–318 行):
# 从 model 变更生成迁移
python manage.py makemigrations
# 应用迁移
python manage.py migrate
# 查看迁移状态
python manage.py showmigrations
# 生成空迁移用于自定义 SQL
python manage.py makemigrations --empty app_name -n description
数据迁移通过 RunPython 实现。示例展示了与 PostgreSQL DO 块同构的批处理回填——按固定 batch_size 取一批、bulk_update 落库、循环直到处理完毕(第 322–344 行):
from django.db import migrations
def backfill_display_names(apps, schema_editor):
User = apps.get_model("accounts", "User")
batch_size = 5000
users = User.objects.filter(display_name="")
while users.exists():
batch = list(users[:batch_size])
for user in batch:
user.display_name = user.username
User.objects.bulk_update(batch, ["display_name"], batch_size=batch_size)
def reverse_backfill(apps, schema_editor):
pass # 数据迁移,无需逆向操作
class Migration(migrations.Migration):
dependencies = [("accounts", "0015_add_display_name")]
operations = [
migrations.RunPython(backfill_display_names, reverse_backfill),
]
两个实现细节体现了迁移最佳实践:
apps.get_model("accounts", "User")取的是历史状态下的模型,而非当前models.py里的模型——这保证迁移在任何版本回放时行为一致;reverse_backfill故意pass:数据回填通常不可逆,符合"生产只向前、回滚靠新迁移"的原则。
SeparateDatabaseAndState 是 Django 实现"删列三步走"的官方原语:把字段从 Django 的状态机中移除(此后 ORM 不再读写该列、后续 makemigrations 也不会再生成 RemoveField),同时不立即对数据库执行 DROP COLUMN(第 350–360 行):
class Migration(migrations.Migration):
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(model_name="user", name="legacy_field"),
],
database_operations=[], # 暂不触碰数据库
),
]
这与 PostgreSQL 小节"先改代码、再删列"的通用流程完全对应:Django 把"应用侧引用移除"与"数据库列删除"拆成了两条独立迁移,中间可以隔任意多次发版。
golang-migrate(Go)
工作流命令(第 366–378 行):
# 创建迁移对
migrate create -ext sql -dir migrations -seq add_user_avatar
# 应用所有待执行迁移
migrate -path migrations -database "$DATABASE_URL" up
# 回滚最后一条迁移
migrate -path migrations -database "$DATABASE_URL" down 1
# 强制指定版本(修复 dirty 状态)
migrate -path migrations -database "$DATABASE_URL" force VERSION
其中 force VERSION 是处理"迁移中途失败、版本表处于 dirty 状态"的运维手段——注意它只修复版本指针,并不撤销已执行的 SQL,使用时须先核对实际 schema 状态。迁移文件为成对的 .up.sql / .down.sql,示例同时展示了加可空列与并发创建部分索引(第 382–390 行):
-- migrations/000003_add_user_avatar.up.sql
ALTER TABLE users ADD COLUMN avatar_url TEXT;
CREATE INDEX CONCURRENTLY idx_users_avatar ON users (avatar_url) WHERE avatar_url IS NOT NULL;
-- migrations/000003_add_user_avatar.down.sql
DROP INDEX IF EXISTS idx_users_avatar;
ALTER TABLE users DROP COLUMN IF EXISTS avatar_url;
WHERE avatar_url IS NOT NULL 是典型的部分索引:多数用户没有头像时,索引只覆盖有值行,体积显著更小。DOWN 文件严格逆序执行(先删索引再删列),并全部使用 IF EXISTS 保证幂等。
零停机迁移策略:Expand-Contract 三阶段
对关键生产变更,技能给出完整的三阶段模型(第 392–409 行):
Phase 1: EXPAND(扩展)
- 新增列/表(可空或带默认值)
- 部署:应用同时写旧字段和新字段
- 回填存量数据
Phase 2: MIGRATE(迁移)
- 部署:应用从新字段读、仍双写
- 校验数据一致性
Phase 3: CONTRACT(收缩)
- 部署:应用只使用新字段
- 在独立迁移中删除旧列/表
配套的时间线示例展示了"迁移与发版交错推进"的实际节奏:
Day 1: 迁移新增 new_status 列(可空)
Day 1: 部署应用 v2 —— 同时写 status 和 new_status
Day 2: 执行存量行回填迁移
Day 3: 部署应用 v3 —— 只从 new_status 读
Day 7: 迁移删除旧 status 列
该模型的工程意义在于:任何单一时刻,线上同时存在"新版本应用 + 旧 Schema 依赖"的兼容窗口,因此每一步都可独立回退到上一阶段——回滚应用版本不需要回滚数据库,回滚数据库(新增的新列)也不会破坏旧应用。
反模式速查表
技能最后以对照表总结六类高频错误(第 421–429 行),建议作为迁移 PR 的审查依据:
| 反模式 | 失败原因 | 更优做法 |
|---|---|---|
| 生产环境手工执行 SQL | 无审计轨迹、不可重放 | 始终使用迁移文件 |
| 编辑已部署的迁移 | 造成环境间漂移 | 改为新建迁移 |
| NOT NULL 且无默认值 | 锁表、重写所有行 | 先加可空列,回填后再加约束 |
| 大表内联建索引 | 索引构建期间阻塞写入 | CREATE INDEX CONCURRENTLY |
| 同一迁移混用 Schema + 数据 | 回滚困难、长事务 | 拆成独立迁移 |
| 先删列后移除代码 | 应用因列缺失报错 | 先移除代码,下次发版再删列 |
在 ECC 生态中的使用方式
从仓库结构看,这套技能的落地路径是清晰可复现的:
- 安装:.kiro/install.sh 以非破坏性拷贝方式把
agents、skills、steering、hooks等组件装入任意 Kiro 项目(./install.sh /path/to/project),不覆盖已有文件; - 调用:在会话中输入
/database-migrations触发技能工作流;做 Schema 设计审查时切换database-reviewerAgent; - 协同:database-reviewer Agent 的职责清单覆盖查询性能、Schema 设计、安全与 RLS、连接管理与并发,其 Reference 一节明确把索引模式与迁移安全的细节指向
postgres-patterns与database-migrations两个技能,形成"Agent 管审查标准、Skill 管操作手册"的分工; - 约束:
.kiro/steering/下的常驻规则文件在每次会话自动加载,保证迁移类改动始终处于项目级编码规范约束之下。
需要说明的适用边界:本文中的 SQL 模式以 PostgreSQL 为主(CONCURRENTLY、SKIP LOCKED、DO 块均为 PG 特性),golang-migrate 一节虽支持多数据库,但示例仍按 PG 书写;Prisma/Drizzle/Kysely 命令与 Django 命令均假定对应 CLI 已随项目安装。仓库中的该技能文档是模式参考而非可执行库代码,实际使用时应以你所用数据库版本与 ORM 版本的官方语义为准。
小结:database-migrations 技能的价值不在于罗列 SQL 语法,而在于提供了一套从原则(只向前、不可变、Schema/数据分离)到清单(7 项检查)再到工具链(5 套 ORM/CLI 工作流)的完整迁移工程方法,并以 Expand-Contract 三阶段模型收尾。将这套清单直接纳入迁移 PR 的审查标准,可以显著降低生产 Schema 变更的锁表与回滚风险。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00