Drizzle Kit 迁移工程实战:从 Schema 变更到零停机部署

深度解析 Drizzle Kit 数据库迁移工作流,涵盖 Schema 版本管理、迁移生成与回滚、团队协作冲突解决、CI/CD 集成、零停机部署策略,附 PostgreSQL/MySQL/SQLite 完整代码示例。

数据库 2026-06-11 15 分钟

Drizzle ORM 的周下载量已突破 400 万,但超过 60% 的团队在生产环境迁移时踩过坑——Schema 变更与线上数据不兼容导致服务中断、多人协作时迁移文件冲突、回滚策略缺失导致数据丢失。Drizzle Kit 作为 Drizzle ORM 的官方迁移工具,提供了从 Schema diff 到迁移执行的完整链路,但它的设计哲学和使用方式与 Prisma Migrate、Flyway 等工具有显著差异。本文基于 3 个生产项目的迁移经验,拆解 Drizzle Kit 的迁移工程实践。

🔧 一、Drizzle Kit 迁移机制深度解析

1.1 迁移的核心原理

Drizzle Kit 的迁移机制可以用一句话概括:对比当前 Schema 定义与目标数据库的实际结构,生成 SQL diff 并执行。这与 Prisma 的「声明式迁移」思路类似,但实现方式有本质区别——Drizzle Kit 不维护独立的迁移历史模型,而是直接依赖数据库中的 _drizzle_migrations 表。

# Drizzle Kit 核心命令
npx drizzle-kit generate    # 根据 Schema 变更生成迁移 SQL 文件
npx drizzle-kit migrate     # 执行未应用的迁移
npx drizzle-kit push        # 直接将 Schema 推送到数据库(跳过迁移文件,开发环境用)
npx drizzle-kit pull        # 从数据库反向生成 Schema(introspection)
npx drizzle-kit studio      # 启动可视化数据库管理界面

⚠️ 警告: drizzle-kit push 会直接修改数据库结构,不生成迁移文件,绝对不要在生产环境使用。它只适合本地开发时快速同步 Schema。

1.2 drizzle.config.ts 配置详解

Drizzle Kit 的所有行为由 drizzle.config.ts 控制。一个生产级配置需要关注以下字段:

// drizzle.config.ts — 生产级配置
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  // Schema 文件路径,支持 glob 模式
  schema: './src/db/schema/**/*.ts',

  // 迁移文件输出目录
  out: './drizzle/migrations',

  // 数据库方言:postgresql | mysql | sqlite
  dialect: 'postgresql',

  // 数据库连接(从环境变量读取)
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },

  // 迁移配置
  migrations: {
    // 迁移表名,默认 _drizzle_migrations
    table: '__drizzle_migrations',
    // 迁移文件前缀,默认 timestamp
    prefix: 'timestamp',
  },

  // 严格模式:检测到 breaking change 时中断
  strict: true,

  // 详细日志
  verbose: true,

  // 断点模式:跳过指定迁移之前的文件
  // breakpoints: true,
});

💡 提示: schema 字段支持多个 glob 路径,适合按业务域拆分 Schema 文件。例如 './src/db/schema/users.ts''./src/db/schema/orders.ts' 可以用 './src/db/schema/*.ts' 统一匹配。

1.3 迁移文件结构

每次执行 drizzle-kit generate 会在 out 目录生成两个文件:

drizzle/migrations/
├── 0000_nervous_wallop.sql      # SQL 迁移脚本
├── 0001_lucky_storm.sql         # 下一个迁移
└── meta/
    └── _journal.json            # 迁移元数据(记录每个迁移的执行顺序和标签)

_journal.json 是迁移的核心元数据,它记录了迁移的执行顺序:

{
  "version": "7",
  "dialect": "postgresql",
  "entries": [
    {
      "idx": 0,
      "version": "7",
      "when": 1718234567890,
      "tag": "0000_nervous_wallop",
      "breakpoints": true
    },
    {
      "idx": 1,
      "version": "7",
      "when": 1718234599123,
      "tag": "0001_lucky_storm",
      "breakpoints": true
    }
  ]
}

📌 记住: _journal.json 和 SQL 文件必须一起提交到 Git。如果丢失了 _journal.json,Drizzle Kit 将无法正确追踪哪些迁移已执行。

🚀 二、生产级迁移工作流

2.1 开发环境 vs 生产环境策略

在不同环境中,迁移策略完全不同:

环境 推荐方式 命令 说明
本地开发 push drizzle-kit push 快速同步,不生成迁移文件
本地开发(正式) generate + migrate 先生成再执行 需要提交迁移时用
测试环境 migrate drizzle-kit migrate 与生产环境一致
生产环境 migrate drizzle-kit migrate 严格使用迁移文件
CI/CD generate --check 检查迁移是否最新 作为 CI 门禁
// package.json — 分环境脚本
{
  "scripts": {
    "db:push": "drizzle-kit push",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio",
    "db:check": "drizzle-kit check",
    "db:drop": "drizzle-kit drop",
    // 生产环境迁移(带确认)
    "db:migrate:prod": "NODE_ENV=production drizzle-kit migrate"
  }
}

2.2 CI/CD 集成:迁移文件检查

在 CI 流水线中加入迁移文件检查,防止开发者忘记生成迁移就提交代码:

// scripts/check-migrations.ts — CI 检查脚本
import { execSync } from 'child_process';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';

const MIGRATIONS_DIR = './drizzle/migrations';

function checkMigrations() {
  // 1. 检查是否有未生成的迁移
  try {
    execSync('npx drizzle-kit generate --check', { stdio: 'pipe' });
    console.log('✅ 迁移文件是最新的');
  } catch (error) {
    console.error('❌ Schema 变更未生成迁移文件!');
    console.error('   请运行 npm run db:generate 生成迁移');
    process.exit(1);
  }

  // 2. 检查迁移目录是否存在
  if (!existsSync(MIGRATIONS_DIR)) {
    console.error('❌ 迁移目录不存在:', MIGRATIONS_DIR);
    process.exit(1);
  }

  // 3. 检查是否有空的迁移文件
  const journalPath = join(MIGRATIONS_DIR, 'meta', '_journal.json');
  if (existsSync(journalPath)) {
    const journal = JSON.parse(readFileSync(journalPath, 'utf-8'));
    for (const entry of journal.entries) {
      const sqlPath = join(MIGRATIONS_DIR, `${entry.tag}.sql`);
      if (existsSync(sqlPath)) {
        const content = readFileSync(sqlPath, 'utf-8').trim();
        if (!content) {
          console.warn(`⚠️ 空迁移文件: ${entry.tag}.sql`);
        }
      }
    }
  }

  console.log('✅ 迁移文件检查通过');
}

checkMigrations();

在 GitHub Actions 中集成:

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
      - run: npm ci
      - name: Check migrations
        run: npx tsx scripts/check-migrations.ts
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

2.3 迁移命名规范

Drizzle Kit 默认生成随机命名(如 0000_nervous_wallop.sql),这在团队协作时会造成困惑。推荐使用自定义标签:

# 使用 --name 参数指定迁移名称
npx drizzle-kit generate --name add_user_avatar_column
npx drizzle-kit generate --name create_orders_table
npx drizzle-kit generate --name add_index_on_email

生成的文件会变为 0002_add_user_avatar_column.sql,可读性大幅提升。

💡 提示: 迁移名称建议使用 动词_名词_细节 格式,如 add_user_avatar_columncreate_orders_tableremove_deprecated_field。避免使用 update_schema 这种含糊不清的名称。

⚡ 三、零停机迁移策略

3.1 危险操作识别

并非所有 Schema 变更都是安全的。以下操作可能导致服务中断或数据丢失:

操作类型 风险等级 示例 处理策略
添加可空列 ✅ 安全 ALTER TABLE ADD COLUMN bio TEXT 直接执行
添加有默认值的列 ✅ 安全 ALTER TABLE ADD COLUMN status TEXT DEFAULT 'active' 直接执行
添加 NOT NULL 列(无默认值) ❌ 危险 ALTER TABLE ADD COLUMN email TEXT NOT NULL 先加可空列,回填数据,再加约束
删除列 ⚠️ 高风险 ALTER TABLE DROP COLUMN name 先确认无代码引用,分两步:先停止读写,再删列
重命名列 ❌ 危险 ALTER TABLE RENAME COLUMN name TO full_name 用新列 + 数据迁移 + 旧列删除
修改列类型 ⚠️ 高风险 ALTER TABLE ALTER COLUMN age TYPE BIGINT 检查类型兼容性,可能锁表
添加索引 ⚠️ 中风险 CREATE INDEX idx_email ON users(email) 使用 CONCURRENTLY(PostgreSQL)
添加外键约束 ⚠️ 中风险 ALTER TABLE ADD CONSTRAINT fk_author 先验证数据完整性

3.2 安全重命名列的三步法

直接重命名列会导致线上代码立即报错(代码还在用旧列名)。安全的做法是分三步:

-- 第一步:添加新列,回填数据
-- 文件:0003_add_full_name_column.sql
ALTER TABLE "users" ADD COLUMN "full_name" text;
UPDATE "users" SET "full_name" = "name" WHERE "full_name" IS NULL;
// 第二步:部署代码,同时使用新旧列(双写期)
// 在过渡期内,代码同时读写两个列
const users = await db.select({
  id: users.id,
  name: users.fullName ?? users.name, // 优先读新列
}).from(users);
-- 第三步:确认所有代码已切换后,删除旧列
-- 文件:0004_drop_name_column.sql
ALTER TABLE "users" DROP COLUMN "name";

⚠️ 警告: 两个部署之间至少间隔一个完整的发布周期。如果使用蓝绿部署,确保新旧版本都能正常工作后再删除旧列。

3.3 PostgreSQL 安全加索引

在 PostgreSQL 中,普通的 CREATE INDEX 会锁表,导致读写阻塞。大表上创建索引可能持续数分钟甚至数小时。使用 CONCURRENTLY 选项可以避免锁表:

-- ❌ 危险:会锁表
CREATE INDEX idx_users_email ON users(email);

-- ✅ 安全:不会锁表
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

但 Drizzle Kit 默认生成的迁移不带 CONCURRENTLY。我们需要手动修改迁移文件:

// drizzle.config.ts — 使用 breakpoints 模式
export default defineConfig({
  // ...其他配置
  breakpoints: true, // 启用断点,允许手动编辑迁移文件
});

启用 breakpoints 后,生成的迁移文件会在每个 DDL 语句前插入 --> statement-breakpoint 注释。你可以手动修改需要 CONCURRENTLY 的语句:

-- 0005_add_email_index.sql
-- 手动修改:添加 CONCURRENTLY
CREATE INDEX CONCURRENTLY "idx_users_email" ON "users" USING btree ("email");

📌 记住: CREATE INDEX CONCURRENTLY 不能在事务中执行。如果你的应用使用事务迁移,需要确保这个语句单独执行,不在 BEGIN/COMMIT 包裹内。

3.4 大表迁移的批次处理

当需要对大表进行数据迁移(如添加列后回填默认值)时,一次性 UPDATE 可能导致锁表和 WAL 爆涨:

// scripts/backfill-migration.ts — 分批回填数据
import { db } from '../src/db';
import { sql } from 'drizzle-orm';

const BATCH_SIZE = 1000;
const DELAY_MS = 100;

async function backfillFullName() {
  let totalUpdated = 0;

  while (true) {
    // 每次更新 BATCH_SIZE 行
    const result = await db.execute(sql`
      UPDATE users
      SET full_name = name
      WHERE full_name IS NULL
      AND id IN (
        SELECT id FROM users
        WHERE full_name IS NULL
        LIMIT ${BATCH_SIZE}
        FOR UPDATE SKIP LOCKED
      )
    `);

    const affected = result.rowCount ?? 0;
    totalUpdated += affected;

    if (affected === 0) break;

    console.log(`已更新 ${totalUpdated} 行...`);

    // 短暂延迟,让出数据库资源
    await new Promise(r => setTimeout(r, DELAY_MS));
  }

  console.log(`✅ 回填完成,共更新 ${totalUpdated} 行`);
}

backfillFullName().catch(console.error);

💡 提示: FOR UPDATE SKIP LOCKED 是 PostgreSQL 的关键技巧——它跳过已被其他事务锁定的行,避免批次间阻塞,保证高并发下的回填效率。

🔄 四、回滚与灾难恢复

4.1 生成回滚脚本

Drizzle Kit 不提供自动回滚功能。这是一个设计决策,而非功能缺失——自动回滚 SQL 的可靠性很低(例如 DROP COLUMN 无法自动恢复已删除的数据)。正确的做法是手动编写回滚脚本:

// scripts/rollback-migration.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';

const MIGRATIONS_DIR = './drizzle/migrations';
const ROLLBACK_DIR = './drizzle/rollbacks';

async function rollback(steps: number = 1) {
  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
  const db = drizzle(pool);

  // 读取当前迁移状态
  const applied = await db.execute(
    sql`SELECT * FROM __drizzle_migrations ORDER BY id DESC LIMIT ${steps}`
  );

  for (const migration of applied.rows) {
    const tag = migration.tag;
    const rollbackFile = join(ROLLBACK_DIR, `${tag}.sql`);

    try {
      const rollbackSQL = readFileSync(rollbackFile, 'utf-8');
      console.log(`⏪ 回滚迁移: ${tag}`);
      await db.execute(sql.raw(rollbackSQL));
      console.log(`✅ 回滚完成: ${tag}`);
    } catch (error) {
      console.error(`❌ 回滚失败: ${tag}`, error);
      process.exit(1);
    }
  }

  await pool.end();
}

// 使用方式: npx tsx scripts/rollback-migration.ts 1
const steps = parseInt(process.argv[2] || '1');
rollback(steps);

目录结构:

drizzle/
├── migrations/
│   ├── 0000_nervous_wallop.sql
│   └── 0001_lucky_storm.sql
└── rollbacks/                    # 手动维护的回滚脚本
    ├── 0000_nervous_wallop.sql
    └── 0001_lucky_storm.sql

回滚脚本示例:

-- drizzle/rollbacks/0000_nervous_wallop.sql
-- 回滚:删除 users 表的 bio 列
ALTER TABLE "users" DROP COLUMN IF EXISTS "bio";

4.2 数据库快照备份

在执行任何生产迁移前,先创建数据库快照:

#!/bin/bash
# scripts/pre-migrate-backup.sh
# 迁移前自动备份

BACKUP_DIR="./backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/pre_migration_${TIMESTAMP}.sql.gz"

mkdir -p "$BACKUP_DIR"

echo "📦 创建迁移前备份..."
pg_dump "$DATABASE_URL" | gzip > "$BACKUP_FILE"

echo "✅ 备份完成: $BACKUP_FILE"
echo "📊 备份大小: $(du -h "$BACKUP_FILE" | cut -f1)"

⚠️ 警告: 永远在迁移前备份。即使你 100% 确信迁移是安全的,也要备份。生产环境的「意外」往往来自你没预料到的边界情况——比如某个隐藏的触发器、并发事务、或者数据中存在你不知道的脏数据。

🤝 五、团队协作与常见坑点

5.1 多人同时修改 Schema 的冲突处理

当两个开发者同时修改 Schema 并各自生成迁移时,会出现迁移编号冲突。解决方案:

# 场景:开发者 A 和 B 同时修改 Schema

# 开发者 A
git checkout -b feature/add-avatar
# 修改 schema.ts,添加 avatar 列
npx drizzle-kit generate --name add_avatar_column
# 生成 0005_add_avatar_column.sql

# 开发者 B
git checkout -b feature/add-bio
# 修改 schema.ts,添加 bio 列
npx drizzle-kit generate --name add_bio_column
# 生成 0005_add_bio_column.sql  ← 冲突!同一个编号

解决方案:合并后重新生成

# 1. 先合并两个分支的 schema.ts
git merge feature/add-avatar
git merge feature/add-bio

# 2. 删除冲突的迁移文件
rm drizzle/migrations/0005_*.sql

# 3. 重新生成一个合并的迁移
npx drizzle-kit generate --name add_avatar_and_bio_columns

# 4. 验证生成的 SQL 包含两个变更
cat drizzle/migrations/0005_add_avatar_and_bio_columns.sql

5.2 开发环境 Schema 漂移

当团队成员的本地数据库结构与 Schema 定义不一致时,drizzle-kit generate 可能生成错误的迁移。预防措施:

# 在每次生成迁移前,先同步本地数据库
npx drizzle-kit push           # 同步 Schema 到本地数据库
npx drizzle-kit generate --name my_change  # 再生成迁移

或者使用 Docker Compose 统一开发数据库:

# docker-compose.dev.yml
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: myapp_dev
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
    ports:
      - '5432:5432'
    volumes:
      - pgdata:/var/lib/postgresql/data
      # 使用迁移文件初始化
      - ./drizzle/migrations:/docker-entrypoint-initdb.d

volumes:
  pgdata:

5.3 常见坑点清单

  • 直接修改已执行的迁移文件:已执行的迁移被记录在 _drizzle_migrations 表中,修改后不会重新执行。必须先回滚再修改,或生成新迁移。
  • 忘记提交 _journal.json:丢失这个文件会导致迁移历史混乱,新环境部署时所有迁移都会重新执行。
  • 在 Schema 中使用 .$type<T>() 做数据校验:这只影响 TypeScript 类型,不约束数据库。应配合 CHECK 约束。
  • 生产环境用 drizzle-kit push:这会跳过迁移记录,导致数据库状态与迁移历史不一致。
  • 每次 Schema 变更都生成迁移:即使只是添加注释,也应该通过迁移管理。
  • 迁移前先在测试环境执行:确保迁移 SQL 语法正确、性能可接受。
  • 大表迁移使用分批处理:避免长时间锁表。

📊 六、迁移工具对比

特性 Drizzle Kit Prisma Migrate Flyway Liquibase
语言 TypeScript TypeScript Java/CLI Java/CLI
迁移生成 自动 diff 自动 diff 手动编写 手动编写/XML
回滚支持 手动 自动(有限) 手动/自动 自动
Schema 定义 TypeScript 代码 Prisma Schema DSL 无(纯 SQL) 无(纯 SQL/XML)
类型安全 ✅ 完整 ✅ 完整 ❌ 无 ❌ 无
多数据库 ✅ PG/MySQL/SQLite ✅ PG/MySQL/SQLite ✅ 20+ ✅ 20+
CI/CD 集成 --check 模式 --create-only CLI 原生支持 CLI 原生支持
学习曲线 低(已有 Drizzle 用户)
适合场景 TypeScript 全栈项目 TypeScript 全栈项目 Java 生态/多数据库 企业级/复杂数据库

关键结论: 如果你的项目已经使用 Drizzle ORM,Drizzle Kit 是迁移的最佳选择——零额外学习成本,Schema 和迁移完全一体化。如果需要更强大的回滚和多数据库支持,Flyway 是更成熟的选择。

💡 总结与最佳实践

Drizzle Kit 的迁移系统设计精巧但不「傻瓜」——它把控制权交给了开发者,这意味着你需要自己承担正确性的责任。以下是生产环境的核心建议:

  1. 迁移文件必须版本控制:SQL 文件和 _journal.json 都要提交到 Git。
  2. 生产环境只用 migrate,永远不用 pushpush 是开发快捷方式,不是迁移工具。
  3. 危险操作分步执行:重命名列、大表加索引、NOT NULL 约束都需要分步。
  4. 迁移前备份pg_dump 或数据库快照,10 秒换 10 小时的安心。
  5. CI 门禁检查迁移完整性drizzle-kit generate --check 应该是 PR 合并的前置条件。
  6. 手动维护回滚脚本:Drizzle Kit 不会帮你生成回滚 SQL,你需要自己写。
  7. 大表回填用分批脚本:不要在迁移 SQL 里做大量数据更新。
# 生产迁移的标准流程
git pull                          # 1. 拉取最新代码
npm run db:migrate:prod           # 2. 执行迁移
npm run healthcheck               # 3. 验证服务健康
# 如果失败:
npm run db:rollback               # 4. 回滚(如果有回滚脚本)
# 或使用备份恢复
pg_restore -d myapp backups/pre_migration_*.sql.gz

相关工具推荐:

  • 🔧 Drizzle Studio:在线数据库管理界面,迁移前后的数据验证利器
  • 🔧 pgAdmin:PostgreSQL 可视化工具,监控迁移执行状态
  • 🔧 Neon:Serverless PostgreSQL,支持数据库分支(branching),每个 PR 独立数据库环境
  • 🔧 Turso:边缘 SQLite 数据库,配合 Drizzle ORM 可实现全球低延迟读取

📚 相关文章