Skip to content

数据库迁移策略

一、为什么需要迁移

在团队协作和生产部署中,直接修改数据库结构会带来严重问题。迁移(Migration) 是用代码文件描述 Schema 变更的机制,解决:

问题无迁移的风险迁移的解决方案
团队同步某同事手动改了 DB,其他人不知道迁移文件提交到 Git,所有人自动同步
环境一致性开发/测试/生产 DB 结构不同迁移文件确保各环境执行相同变更
可回滚出现 Bug 无法快速还原down() 方法描述如何回滚
审计不知道何时改了什么字段文件名包含时间戳,记录变更历史

二、TypeORM 迁移体系

配置 DataSource

TypeORM CLI 需要独立的 data-source.ts 文件(与 app.module.ts 解耦):

typescript
// src/data-source.ts
import { DataSource } from 'typeorm';
import * as dotenv from 'dotenv';

dotenv.config();

export const AppDataSource = new DataSource({
  type: 'postgres',
  url: process.env.DATABASE_URL,
  entities: [__dirname + '/entities/**/*.entity{.ts,.js}'],
  migrations: [__dirname + '/migrations/**/*{.ts,.js}'],
  // ⚠️ 迁移模式下绝对不能开 synchronize!
  synchronize: false,
  logging: false,
});

package.json 添加脚本:

json
{
  "scripts": {
    "migration:generate": "typeorm migration:generate -d src/data-source.ts",
    "migration:run":      "typeorm migration:run -d src/data-source.ts",
    "migration:revert":   "typeorm migration:revert -d src/data-source.ts",
    "migration:show":     "typeorm migration:show -d src/data-source.ts",
    "migration:create":   "typeorm migration:create"
  }
}

常用 CLI 命令

bash
# 1. 自动生成迁移(对比 Entity 与数据库差异)
npm run migration:generate -- src/migrations/AddUserRoleColumn

# 2. 查看所有迁移及状态(是否已执行)
npm run migration:show

# 3. 执行所有未运行的迁移
npm run migration:run

# 4. 回滚最后一次迁移(执行 down())
npm run migration:revert

# 5. 空白迁移文件(手写复杂逻辑时用)
npm run migration:create -- src/migrations/SeedAdminUser

迁移文件结构

typescript
// src/migrations/1710000000000-AddUserRoleColumn.ts
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';

export class AddUserRoleColumn1710000000000 implements MigrationInterface {
  name = 'AddUserRoleColumn1710000000000';

  // up:正向变更(升级数据库)
  async up(queryRunner: QueryRunner): Promise<void> {
    // 添加枚举列
    await queryRunner.addColumn('users', new TableColumn({
      name: 'role',
      type: 'enum',
      enum: ['user', 'admin', 'moderator'],
      enumName: 'user_role_enum',   // PostgreSQL 枚举类型名
      default: "'user'",
      isNullable: false,
    }));

    // 添加索引
    await queryRunner.createIndex('users', new TableIndex({
      name: 'IDX_USER_ROLE',
      columnNames: ['role'],
    }));
  }

  // down:回滚(必须实现!与 up 操作相反)
  async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.dropIndex('users', 'IDX_USER_ROLE');
    await queryRunner.dropColumn('users', 'role');
    // PostgreSQL 枚举类型需要单独删除
    await queryRunner.query(`DROP TYPE IF EXISTS "user_role_enum"`);
  }
}

常见迁移操作示例

typescript
export class ComplexMigration1710000000001 implements MigrationInterface {
  async up(queryRunner: QueryRunner): Promise<void> {
    // ── 建表 ─────────────────────────────────────
    await queryRunner.createTable(new Table({
      name: 'tags',
      columns: [
        { name: 'id', type: 'int', isPrimary: true, isGenerated: true, generationStrategy: 'increment' },
        { name: 'name', type: 'varchar', length: '100', isUnique: true },
        { name: 'created_at', type: 'timestamp', default: 'CURRENT_TIMESTAMP' },
      ],
    }), true);   // true = 若已存在则跳过(ifNotExists)

    // ── 修改列类型 ────────────────────────────────
    await queryRunner.changeColumn('posts', 'content', new TableColumn({
      name: 'content',
      type: 'text',            // varchar → text
    }));

    // ── 重命名列 ──────────────────────────────────
    await queryRunner.renameColumn('users', 'username', 'name');

    // ── 添加外键 ──────────────────────────────────
    await queryRunner.addForeignKey('posts', new TableForeignKey({
      columnNames: ['author_id'],
      referencedTableName: 'users',
      referencedColumnNames: ['id'],
      onDelete: 'CASCADE',
      onUpdate: 'CASCADE',
    }));

    // ── 数据迁移(结构变更后补填数据)────────────
    await queryRunner.query(`
      UPDATE users SET role = 'admin'
      WHERE email IN ('admin@example.com', 'superadmin@example.com')
    `);
  }

  async down(queryRunner: QueryRunner): Promise<void> {
    // 逆序撤销所有操作
    await queryRunner.dropTable('tags');
    await queryRunner.changeColumn('posts', 'content', new TableColumn({
      name: 'content',
      type: 'varchar',
      length: '255',
    }));
    await queryRunner.renameColumn('users', 'name', 'username');
    // 外键需要先查名称再删除
    const table = await queryRunner.getTable('posts');
    const fk = table.foreignKeys.find(fk => fk.columnNames.includes('author_id'));
    if (fk) await queryRunner.dropForeignKey('posts', fk);
  }
}

生产环境自动运行迁移

typescript
// app.module.ts
TypeOrmModule.forRoot({
  // ...
  migrationsRun: process.env.NODE_ENV === 'production',  // 应用启动时自动运行
  synchronize: false,  // 生产绝对不开
});

三、Prisma 迁移体系

开发工作流

bash
# 1. 修改 schema.prisma 后,生成并应用迁移(开发环境)
npx prisma migrate dev --name add_user_role_column

# 这一个命令做了三件事:
# ① 生成 prisma/migrations/20240310_add_user_role_column/migration.sql
# ② 执行该 SQL 到开发数据库
# ③ 重新生成 Prisma Client(相当于 prisma generate)

# 2. 仅重置数据库(清空所有数据并重跑迁移)
npx prisma migrate reset   # ⚠️ 仅开发环境!会清空数据

# 3. 查看迁移状态
npx prisma migrate status

# 4. 仅生成 SQL,不执行(代码审查时有用)
npx prisma migrate dev --create-only --name add_column

生成的迁移文件

prisma/migrations/
├── 20240310120000_init/
│   └── migration.sql
├── 20240311090000_add_user_role/
│   └── migration.sql
└── migration_lock.toml    ← 锁定数据库类型,不要手动修改
sql
-- prisma/migrations/20240311090000_add_user_role/migration.sql
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN', 'MODERATOR');

-- AlterTable
ALTER TABLE "users" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'USER';

-- CreateIndex
CREATE INDEX "users_role_idx" ON "users"("role");

生产环境部署

bash
# 生产环境:只执行迁移,不生成新的(安全)
npx prisma migrate deploy

# 典型 Docker 启动命令
# Dockerfile / docker-compose.yml
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"]

处理迁移冲突(团队协作)

bash
# 情景:你和同事同时修改了 schema,产生迁移冲突
# 解决步骤:

# 1. 拉取最新代码后,重置本地数据库
npx prisma migrate reset

# 2. Prisma 会自动合并迁移文件(按时间戳顺序)

# 3. 如果有真正的逻辑冲突,手动编辑 migration.sql 后:
npx prisma migrate resolve --applied <migration_name>

数据迁移(Seed + Migration 配合)

typescript
// prisma/seed.ts(数据填充脚本)
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
  // 数据迁移:将现有用户的空 role 字段填充默认值
  await prisma.user.updateMany({
    where: { role: null },
    data: { role: 'USER' },
  });

  // 初始化管理员账户
  await prisma.user.upsert({
    where: { email: 'admin@example.com' },
    update: {},
    create: {
      email: 'admin@example.com',
      name: 'Admin',
      role: 'ADMIN',
      password: '$2b$10$...',   // 预先哈希的密码
    },
  });
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());
json
// package.json:配置 prisma seed 命令
{
  "prisma": {
    "seed": "ts-node prisma/seed.ts"
  }
}
bash
# 执行 seed
npx prisma db seed

# migrate dev 会在迁移后自动询问是否执行 seed

四、迁移工作流对比

场景TypeORMPrisma
生成迁移migration:generate(对比 Entity 差异)migrate dev(对比 Schema 差异)
执行迁移migration:runmigrate dev(开发)/ migrate deploy(生产)
回滚migration:revert(执行 down()无内置回滚,需手写逆向迁移
迁移文件TypeScript,手写 up/down自动生成纯 SQL
数据填充迁移文件中手写 SQL独立 seed 脚本
生产部署migrationsRun: true 或 CLImigrate deploy

五、大规模迁移的零停机策略

生产环境有大量数据时,直接 ALTER TABLE 会锁表,造成停机。分阶段迁移:

阶段一:添加新列(向后兼容,不破坏现有代码)

sql
-- 新增可空列,不设默认值(不锁表)
ALTER TABLE users ADD COLUMN display_name VARCHAR(255);

阶段二:双写期(新旧代码兼容)

typescript
// 新代码写入两个字段
await userRepo.update(id, {
  name: dto.name,          // 旧字段(兼容老版本)
  displayName: dto.name,   // 新字段
});

阶段三:数据回填(批量,避免锁表)

typescript
// 分批回填,每批 1000 条,避免大事务
async function backfillDisplayName(dataSource: DataSource) {
  let offset = 0;
  const batchSize = 1000;

  while (true) {
    const result = await dataSource.query(`
      UPDATE users
      SET display_name = name
      WHERE display_name IS NULL
      LIMIT ${batchSize}
    `);

    if (result.affectedRows === 0) break;
    offset += batchSize;
    await new Promise(r => setTimeout(r, 100));  // 给数据库喘息
  }
}

阶段四:切换读取 & 清理旧列

typescript
// 确认新列数据完整后,移除旧列
await queryRunner.dropColumn('users', 'name');

六、最佳实践

  1. 迁移文件进 Git:与代码一起提交,不要加入 .gitignore
  2. 不要修改已提交的迁移:应用后的迁移是不可变历史;如需变更,写新的迁移
  3. down() 必须实现(TypeORM):生产出问题需要快速回滚
  4. Prisma 生产用 migrate deploymigrate dev 会重置,绝不在生产运行
  5. 大表字段变更分阶段:添加 → 双写 → 回填 → 切换 → 清理,每阶段独立部署
  6. 迁移文件命名清晰AddUserRoleColumnCreateTagsTable,而不是 Migration1
  7. 测试迁移的 down():至少在本地验证回滚不会报错
typescript
// 测试迁移回滚(集成测试示例)
it('migration can be rolled back', async () => {
  await dataSource.runMigrations();         // up
  await dataSource.undoLastMigration();     // down(验证无报错)
  await dataSource.runMigrations();         // 重新 up
});

可运行 Demo: practice/03-crud-app/typeorm-version — TypeORM migration 生成、执行、回滚


常见错误

错误原因解决
迁移执行后数据丢失在迁移中删除列而没有先备份零停机策略:先加新列,双写,再删旧列(分多次迁移)
synchronize: true 删表TypeORM 自动同步删除了多余列/表生产环境强制关闭 synchronize,只用迁移文件
migrate deploy 找不到迁移文件迁移文件未提交到 Git确保 prisma/migrations/ 目录在版本控制中
迁移回滚后数据状态不一致回滚 SQL 不是幂等的复杂迁移(如数据回填)应该没有"回滚",而是用新的前向迁移修正

NestJS 深度学习体系