Zero-Downtime Database Migrations with Drizzle ORM

Run database migrations without downtime using Drizzle ORM. Covers expand-contract pattern, backward-compatible schema changes, rollback strategies, and CI/CD integration for PostgreSQL.

E
ECOSIRE Research and Development Team
|2026年3月19日6 分钟阅读1.3k 字数|

使用 Drizzle ORM 进行零停机数据库迁移

数据库迁移是生产部署中最危险的操作。锁定表的迁移(即使锁定 200 毫秒)会导致连接超时,进而引发 500 个错误,并在凌晨 2 点引发警报风暴。大多数 ORM 迁移教程都会向您展示“简单”的路径:关闭应用程序,运行迁移,然后重新启动。这并不是零停机时间;而是零停机时间。这就是定期维护。

真正的零停机迁移需要不同的思维模型:您的架构必须在整个部署过程中与当前运行的应用程序代码向后兼容。这意味着旧代码和新代码必须在推出窗口期间同时工作。扩展-收缩模式使这成为可能。 Drizzle ORM 的 SQL 优先方法使您能够正确实施它。

要点

  • 切勿在生产中运行 drizzle-kit push — 生成并检查 SQL,然后应用控制
  • 扩展合同模式:添加(扩展)→部署新代码→删除旧代码(合同)
  • 添加可为空的列是安全的;在没有默认值的情况下添加 NOT NULL 是危险的
  • 重命名列需要:添加新列→回填→更新代码→删除旧列(4步)
  • 使用 PostgreSQL CREATE INDEX CONCURRENTLY 避免创建索引时出现表锁
  • 在应用于生产之前在数据库的生产大小副本上测试迁移
  • 始终有一个回滚计划:对于每次迁移,编写并测试回滚 SQL
  • 需要表重写的架构更改(大表上的 ALTER TYPE)需要维护窗口

Drizzle ORM 架构设置

// packages/db/src/schema/contacts.ts
import { pgTable, uuid, varchar, text, timestamp, pgEnum, index } from 'drizzle-orm/pg-core';

export const contactStatusEnum = pgEnum('contact_status', ['active', 'inactive', 'archived']);

export const contacts = pgTable('contacts', {
  id:             uuid('id').primaryKey().defaultRandom(),
  organizationId: uuid('organization_id').notNull(),
  name:           varchar('name', { length: 255 }).notNull(),
  email:          varchar('email', { length: 255 }),
  phone:          varchar('phone', { length: 50 }),
  status:         contactStatusEnum('status').default('active').notNull(),
  notes:          text('notes'),
  createdAt:      timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  updatedAt:      timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
  orgIdIdx:  index('contacts_org_id_idx').on(table.organizationId),
  emailIdx:  index('contacts_email_idx').on(table.email),
  statusIdx: index('contacts_status_idx').on(table.status),
}));

生成迁移:

# Generate SQL migration (never auto-apply to production)
npx drizzle-kit generate

# Review the generated SQL before applying
cat drizzle/0001_add_contact_status.sql

# Apply to development database
npx drizzle-kit migrate

# For production: apply via your deployment pipeline

扩展-收缩模式

核心原则:每个架构更改都分为多个向后兼容的部署。

第 1 阶段:扩展(添加而不中断)

部署仅附加的架构更改 - 具有默认值的新列、新表、新索引。

-- Migration: 0010_expand_add_company.sql
-- Safe to apply while old code is running: nullable column with a default

ALTER TABLE contacts
  ADD COLUMN IF NOT EXISTS company_id UUID,
  ADD COLUMN IF NOT EXISTS company_name VARCHAR(255);

-- Old code ignores these columns; new code uses them
-- Both versions coexist during rolling deployment
// Drizzle schema after expansion
export const contacts = pgTable('contacts', {
  // ... existing columns ...
  companyId:   uuid('company_id'),           // nullable — old code ignores it
  companyName: varchar('company_name', { length: 255 }), // nullable — safe
});

第 2 阶段:部署新的应用程序代码

部署可读取和写入旧列和新列的新应用程序版本。在滚动部署期间,旧实例(无公司支持)和新实例(公司支持)同时运行 - 两者都可以工作,因为该列可为空。

第 3 阶段:回填

小批量填充现有行的新列以避免表锁:

-- Migration: 0011_backfill_company_name.sql
-- Run in small batches to avoid locking

DO $$
DECLARE
  batch_size INTEGER := 1000;
  offset_val INTEGER := 0;
  rows_updated INTEGER;
BEGIN
  LOOP
    UPDATE contacts
    SET company_name = 'Unknown'
    WHERE company_name IS NULL
      AND id IN (
        SELECT id FROM contacts
        WHERE company_name IS NULL
        ORDER BY id
        LIMIT batch_size
        OFFSET offset_val
      );

    GET DIAGNOSTICS rows_updated = ROW_COUNT;
    EXIT WHEN rows_updated = 0;

    offset_val := offset_val + batch_size;
    -- Brief pause between batches to reduce I/O pressure
    PERFORM pg_sleep(0.1);
  END LOOP;
END $$;

第 4 阶段:合同(删除旧列)

更新所有应用程序代码并完成回填后,删除旧列或约束。


安全迁移模式

添加 NOT NULL 列

-- WRONG — will fail if table has rows (no default, no nullable)
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20) NOT NULL;

-- WRONG — locks table while it writes the default to every row
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20) NOT NULL DEFAULT 'free';

-- CORRECT — three-step approach
-- Step 1: Add nullable column
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20);

-- Step 2: Backfill existing rows
UPDATE contacts SET tier = 'free' WHERE tier IS NULL;

-- Step 3 (next deployment): Add NOT NULL constraint (instant if no NULLs exist)
ALTER TABLE contacts ALTER COLUMN tier SET NOT NULL;
ALTER TABLE contacts ALTER COLUMN tier SET DEFAULT 'free';

重命名列(4 步过程)

-- Step 1: Add the new column
ALTER TABLE contacts ADD COLUMN full_name VARCHAR(255);

-- Step 2: Backfill + keep in sync with a trigger
UPDATE contacts SET full_name = name;

CREATE OR REPLACE FUNCTION sync_full_name() RETURNS trigger AS $$
BEGIN
  NEW.full_name := NEW.name;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER contacts_sync_full_name
  BEFORE INSERT OR UPDATE OF name ON contacts
  FOR EACH ROW EXECUTE FUNCTION sync_full_name();

-- Step 3: Deploy new code that writes to full_name, reads from both
-- Step 4 (next deployment, after all instances updated):
DROP TRIGGER contacts_sync_full_name ON contacts;
DROP FUNCTION sync_full_name;
ALTER TABLE contacts DROP COLUMN name;

更改枚举类型

PostgreSQL 不允许在没有全表重写的情况下删除枚举值。对于安全的枚举更改:

-- Adding a new enum value is safe (instant, no lock)
ALTER TYPE contact_status ADD VALUE IF NOT EXISTS 'pending';

-- Removing an enum value requires a full table rewrite
-- Use the expand-contract pattern: add a new enum type, migrate, drop old
CREATE TYPE contact_status_new AS ENUM ('active', 'inactive', 'archived', 'pending');

-- Migrate data
ALTER TABLE contacts
  ALTER COLUMN status TYPE contact_status_new
  USING status::text::contact_status_new;

DROP TYPE contact_status;
ALTER TYPE contact_status_new RENAME TO contact_status;

不加锁创建索引

常规 CREATE INDEX 获取一个 ShareLock ,该 CODE1 会在持续时间内阻止所有写入。在一张大桌子上,这可能需要几分钟。

-- WRONG — locks writes during index creation
CREATE INDEX contacts_email_idx ON contacts(email);

-- CORRECT — concurrent index creation; no write lock
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_email_idx
  ON contacts(email)
  WHERE email IS NOT NULL; -- Partial index for better performance

-- Drizzle note: drizzle-kit does not generate CONCURRENTLY by default
-- Edit the generated SQL migration to add CONCURRENTLY before applying

在 Drizzle 迁移文件中,生成后手动添加 CONCURRENTLY

-- drizzle/0012_add_email_index.sql (edited after generation)
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_email_idx
  ON contacts (email)
  WHERE email IS NOT NULL;

生产 CI/CD 中的毛毛雨迁移

切勿在生产中使用 drizzle-kit push — 它无需审查步骤即可应用迁移。将 drizzle-kit migrate 与生成的受版本控制的 SQL 文件一起使用:

# scripts/deploy-production.sh

echo "=== Running database migrations ==="

# Check if there are pending migrations
PENDING=$(npx drizzle-kit migrate --dry-run 2>&1 | grep "pending")
if [ -n "$PENDING" ]; then
  echo "Pending migrations detected:"
  echo "$PENDING"

  # Apply migrations
  npx drizzle-kit migrate
  echo "Migrations applied successfully"
else
  echo "No pending migrations"
fi
// Programmatic migration in main.ts (NestJS)
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';

async function runMigrations() {
  const migrationClient = postgres(process.env.DATABASE_URL!, { max: 1 });
  const db = drizzle(migrationClient);

  await migrate(db, {
    migrationsFolder: join(__dirname, '..', '..', '..', 'drizzle'),
  });

  await migrationClient.end();
}

// In bootstrap(), before app.listen():
if (process.env.RUN_MIGRATIONS === 'true') {
  await runMigrations();
}

回滚策略

每个迁移都必须在部署之前编写并测试伴随的回滚迁移:

// drizzle/0013_add_company_id.sql (forward migration)
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS company_id UUID;
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_company_id_idx
  ON contacts(company_id);

// drizzle/rollbacks/0013_rollback_add_company_id.sql
DROP INDEX CONCURRENTLY IF EXISTS contacts_company_id_idx;
ALTER TABLE contacts DROP COLUMN IF EXISTS company_id;

对于紧急回滚:

# Emergency rollback script
# Run the rollback SQL, then redeploy the previous app version

psql "$DATABASE_URL" < drizzle/rollbacks/0013_rollback_add_company_id.sql
git checkout HEAD~1
pnpm build && pm2 restart ecosystem.config.cjs --update-env

迁移测试策略

// packages/db/src/tests/migration.spec.ts
import { describe, it, expect, beforeAll } from 'vitest';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';

describe('Database Migrations', () => {
  let sql: ReturnType<typeof postgres>;

  beforeAll(async () => {
    sql = postgres(process.env.TEST_DATABASE_URL!);
  });

  it('all migrations apply cleanly on a fresh database', async () => {
    const db = drizzle(sql);
    await expect(migrate(db, { migrationsFolder: './drizzle' }))
      .resolves.not.toThrow();
  });

  it('migrations are idempotent (can be applied twice safely)', async () => {
    const db = drizzle(sql);
    await migrate(db, { migrationsFolder: './drizzle' });
    // Running again should be a no-op, not an error
    await expect(migrate(db, { migrationsFolder: './drizzle' }))
      .resolves.not.toThrow();
  });
});

需要维护窗口的危险操作

有些操作无法在非常大的表(数亿行)上实现零停机:

运营风险缓解措施
大列上的 ALTER TYPE USING全表重写、长锁安排维护窗口
ADD COLUMN NOT NULL 无默认值如果行存在则立即失败使用三步扩展契约
CREATE INDEX(没有同时)写锁持续时间始终使用 CONCURRENTLY
代码0即时,但破坏了旧代码仅在代码更新后的扩展阶段
代码0不可逆转始终有备份; 30 天软删除政策
代码0表锁定持续时间在维护期间使用常规 VACUUM + 计划 FULL

常见问题

drizzle-kit pushdrizzle-kit migrate 有什么区别?

push 直接将当前模式应用到数据库,而不生成迁移文件 - 对于不关心迁移历史记录的开发非常有用。 migrate 按顺序应用 drizzle/ 目录中的 SQL 文件,并跟踪已应用的文件。在生产中始终使用 migrate ,以便您拥有每个架构更改的受控、可审查、版本控制的历史记录。

Drizzle 如何处理 drizzle_migrations 跟踪表?

Drizzle 创建一个 drizzle_migrations (或 __drizzle_migrations)表,记录已应用哪些迁移文件以及何时应用。在应用迁移之前,它会检查此表。不要手动在此表中插入或删除行——这是 Drizzle 的状态跟踪机制。如果需要恢复到之前的迁移状态,请运行回滚 SQL 并手动删除相应的行。

如何针对生产规模的数据库测试迁移?

将生产数据库的经过清理(已删除 PII)的副本恢复到登台服务器。在那里应用迁移并测量:挂钟时间、锁等待时间(来自 pg_stat_activity)、表膨胀(来自 pg_stat_user_tables)以及对查询性能的影响(来自 EXPLAIN ANALYZE)。如果迁移在暂存阶段花费的时间超过 5 秒,请重新设计。

我可以使用事务进行数据库迁移吗?

PostgreSQL 中的大多数 DDL 都是事务性的 — 您可以将 ALTER TABLECREATE TABLECREATE INDEX(但不是 CREATE INDEX CONCURRENTLY)包装在事务中,并在任何步骤失败时回滚。默认情况下,Drizzle 将每个迁移文件包装在一个事务中。对于使用 CONCURRENTLY 的迁移,您必须将它们拆分为在事务外部运行的单独迁移文件。

如何在 TypeScript 中处理 Drizzle 枚举比较?

Drizzle 的枚举列从数据库返回字符串值。比较时,TypeScript 可能无法正确缩小类型。显式转换:if ((contact.status as ContactStatus) === 'active') 或在 Drizzle where 子句中使用类型断言:where(eq(contacts.status, 'active' as ContactStatus))。这可以避免运行时错误,同时保持 TypeScript 的正常运行。


后续步骤

零停机数据库迁移需要纪律,但替代方案(停机、数据损坏、凌晨 3 点紧急回滚)的情况要糟糕得多。扩展-契约模式、CREATE INDEX CONCURRENTLY 和适当的回滚准备使模式更改成为常规、安全的操作。

ECOSIRE 使用 Drizzle ORM、PostgreSQL 17 和经过 65 多个架构文件和 300 多个迁移测试的零停机部署管道来管理 NestJS 应用程序的数据库架构。 探索我们的后端工程服务,了解我们如何大规模安全地处理数据库操作。

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天