首页
/ ECC 的 database-migrations 技能全解:PostgreSQL 与多 ORM 的零停机数据库迁移模式

ECC 的 database-migrations 技能全解:PostgreSQL 与多 ORM 的零停机数据库迁移模式

2026-09-06 17:14:41作者:秋泉律Samson

本文基于 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 一节 给出五条原则,这是整个技能的方法论骨架:

  1. Every change is a migration(每一次变更都是迁移)——严禁手工修改生产数据库。任何 DDL/DML 改动都必须落成迁移文件,留下可审计、可重放的记录。
  2. Migrations are forward-only in production(生产环境只向前)——回滚不通过"逆向执行"已上线迁移实现,而是编写一条新的前向迁移来修复状态。
  3. Schema and data migrations are separate(Schema 与数据迁移分离)——DDL 和 DML 绝不混在同一条迁移里,否则回滚困难且长事务易锁表。
  4. Test migrations against production-sized data(在生产量级数据上测试迁移)——在 100 行数据上能跑的迁移,放到 1000 万行上可能因锁竞争而挂起。
  5. 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 生态中的使用方式

从仓库结构看,这套技能的落地路径是清晰可复现的:

  1. 安装.kiro/install.sh 以非破坏性拷贝方式把 agentsskillssteeringhooks 等组件装入任意 Kiro 项目(./install.sh /path/to/project),不覆盖已有文件;
  2. 调用:在会话中输入 /database-migrations 触发技能工作流;做 Schema 设计审查时切换 database-reviewer Agent;
  3. 协同database-reviewer Agent 的职责清单覆盖查询性能、Schema 设计、安全与 RLS、连接管理与并发,其 Reference 一节明确把索引模式与迁移安全的细节指向 postgres-patternsdatabase-migrations 两个技能,形成"Agent 管审查标准、Skill 管操作手册"的分工;
  4. 约束.kiro/steering/ 下的常驻规则文件在每次会话自动加载,保证迁移类改动始终处于项目级编码规范约束之下。

需要说明的适用边界:本文中的 SQL 模式以 PostgreSQL 为主(CONCURRENTLYSKIP LOCKEDDO 块均为 PG 特性),golang-migrate 一节虽支持多数据库,但示例仍按 PG 书写;Prisma/Drizzle/Kysely 命令与 Django 命令均假定对应 CLI 已随项目安装。仓库中的该技能文档是模式参考而非可执行库代码,实际使用时应以你所用数据库版本与 ORM 版本的官方语义为准。

小结database-migrations 技能的价值不在于罗列 SQL 语法,而在于提供了一套从原则(只向前、不可变、Schema/数据分离)到清单(7 项检查)再到工具链(5 套 ORM/CLI 工作流)的完整迁移工程方法,并以 Expand-Contract 三阶段模型收尾。将这套清单直接纳入迁移 PR 的审查标准,可以显著降低生产 Schema 变更的锁表与回滚风险。

登录后查看全文
热门项目推荐
相关项目推荐