数据库迁移策略
一、为什么需要迁移
在团队协作和生产部署中,直接修改数据库结构会带来严重问题。迁移(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四、迁移工作流对比
| 场景 | TypeORM | Prisma |
|---|---|---|
| 生成迁移 | migration:generate(对比 Entity 差异) | migrate dev(对比 Schema 差异) |
| 执行迁移 | migration:run | migrate dev(开发)/ migrate deploy(生产) |
| 回滚 | migration:revert(执行 down()) | 无内置回滚,需手写逆向迁移 |
| 迁移文件 | TypeScript,手写 up/down | 自动生成纯 SQL |
| 数据填充 | 迁移文件中手写 SQL | 独立 seed 脚本 |
| 生产部署 | migrationsRun: true 或 CLI | migrate 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');六、最佳实践
- 迁移文件进 Git:与代码一起提交,不要加入
.gitignore - 不要修改已提交的迁移:应用后的迁移是不可变历史;如需变更,写新的迁移
down()必须实现(TypeORM):生产出问题需要快速回滚- Prisma 生产用
migrate deploy:migrate dev会重置,绝不在生产运行 - 大表字段变更分阶段:添加 → 双写 → 回填 → 切换 → 清理,每阶段独立部署
- 迁移文件命名清晰:
AddUserRoleColumn、CreateTagsTable,而不是Migration1 - 测试迁移的 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 不是幂等的 | 复杂迁移(如数据回填)应该没有"回滚",而是用新的前向迁移修正 |