6月19日 15:47

TypeORM migrations 如何创建、运行和回滚?

如果数据库结构只靠 synchronize: true 自动同步,开发环境看起来很省事,到了生产环境就容易变成“谁也说不清表结构为什么变了”。TypeORM migrations 解决的就是这个问题:把每一次表结构或数据修正写成可追踪、可回滚的脚本,让团队里的每台机器、测试库和生产库按同一套顺序变更。

简单说,migration 是数据库变更的版本记录。up 负责执行本次变更,down 负责撤回本次变更。TypeORM 会把已经执行过的迁移记录在数据库的 migrations 表里,之后只运行还没执行过的文件。

migrations 主要解决什么问题

TypeORM migrations 常用来处理这些变更:

  • 创建、删除表
  • 添加、删除、修改字段
  • 创建、删除索引
  • 添加、删除外键约束
  • 执行少量数据修正或数据搬迁
  • 用原生 SQL 处理 ORM API 覆盖不到的数据库能力

它和 synchronize 最大的区别是:migration 是显式、可审查、可纳入代码评审的;synchronize 是根据 Entity 自动改库,方便但不适合生产。生产环境的 DataSource 里应该明确设置 synchronize: false

一个迁移文件长什么样

迁移类需要实现 MigrationInterface,核心就是 updown 两个方法。QueryRunner 是执行数据库操作的入口,TableTableColumnTableIndexTableForeignKey 则用来描述表、字段、索引和外键。

typescript
import { MigrationInterface, QueryRunner, Table, } from 'typeorm'; export class CreateUserTable1710000000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.createTable( new Table({ name: 'user', columns: [ { name: 'id', type: 'int', isPrimary: true, isGenerated: true, generationStrategy: 'increment', }, { name: 'name', type: 'varchar', length: '100', }, { name: 'email', type: 'varchar', length: '255', isUnique: true, }, { name: 'createdAt', type: 'timestamp', default: 'CURRENT_TIMESTAMP', }, ], }), true, ); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.dropTable('user'); } }

up 里写“上线时要做什么”,down 里写“需要回滚时怎么撤销”。哪怕一开始觉得不会回滚,也要认真写 down,因为出问题时它就是最后一道保险。

DataSource 应该怎么配置

TypeORM 0.3 以后以 DataSource 为中心,CLI 也需要通过 -d 指向这个配置文件。

typescript
import { DataSource } from 'typeorm'; import { User } from './entity/User'; export const AppDataSource = new DataSource({ type: 'mysql', host: process.env.DB_HOST, port: 3306, username: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, entities: [User], migrations: ['src/migration/**/*.ts'], subscribers: ['src/subscriber/**/*.ts'], synchronize: false, logging: ['error', 'warn', 'migration'], });

本地开发如果短期使用 synchronize,也应该只放在本地配置里,别让它跟生产配置共用同一个开关。迁移文件才应该是生产数据库结构变更的来源。

TypeORM 0.3+ 如何创建和运行迁移

很多旧文章还在写 migration:generate -n CreateUserTable,这是 TypeORM 0.2 时代常见的写法。0.3+ 更推荐传入迁移文件路径,并用 -d 指定 DataSource。

如果项目使用 TypeScript,可以先在 package.json 里配置脚本:

json
{ "scripts": { "typeorm": "typeorm-ts-node-commonjs" } }

ESM 项目通常改用 typeorm-ts-node-esm。之后常用命令如下:

bash
npm run typeorm -- migration:create src/migration/CreateUserTable npm run typeorm -- migration:generate src/migration/AddUserEmailIndex -d src/data-source.ts npm run typeorm -- migration:run -d src/data-source.ts npm run typeorm -- migration:revert -d src/data-source.ts npm run typeorm -- migration:show -d src/data-source.ts

migration:generate 依赖当前数据库状态,所以生成前要确认连的是正确环境。生产库上一般只运行已经评审过的迁移,不在生产环境临时生成迁移。

QueryRunner 常见操作怎么写

添加和修改字段

typescript
import { TableColumn } from 'typeorm'; public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.addColumn( 'user', new TableColumn({ name: 'avatar', type: 'varchar', length: '255', isNullable: true, }), ); await queryRunner.changeColumn( 'user', 'name', new TableColumn({ name: 'name', type: 'varchar', length: '200', }), ); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.changeColumn( 'user', 'name', new TableColumn({ name: 'name', type: 'varchar', length: '100', }), ); await queryRunner.dropColumn('user', 'avatar'); }

字段改动最容易踩坑。缩短长度、修改类型、删除字段都可能造成数据丢失,提交前要先确认线上数据是否真的满足条件。

创建索引

typescript
import { TableIndex } from 'typeorm'; public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.createIndex( 'user', new TableIndex({ name: 'IDX_USER_EMAIL', columnNames: ['email'], isUnique: true, }), ); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.dropIndex('user', 'IDX_USER_EMAIL'); }

索引名最好显式指定。否则不同数据库、不同生成策略下名字可能不一致,回滚和排查都会更麻烦。

添加外键

typescript
import { TableForeignKey } from 'typeorm'; public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.createForeignKey( 'post', new TableForeignKey({ columnNames: ['authorId'], referencedTableName: 'user', referencedColumnNames: ['id'], onDelete: 'CASCADE', }), ); } public async down(queryRunner: QueryRunner): Promise<void> { const table = await queryRunner.getTable('post'); const foreignKey = table?.foreignKeys.find((fk) => fk.columnNames.includes('authorId'), ); if (foreignKey) { await queryRunner.dropForeignKey('post', foreignKey); } }

外键的 onDelete: 'CASCADE' 不要随手加。它很方便,也可能在删除一条主记录时带走一大片业务数据。

执行原生 SQL

typescript
public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.query(` CREATE TRIGGER update_user_timestamp BEFORE UPDATE ON user FOR EACH ROW SET NEW.updatedAt = CURRENT_TIMESTAMP `); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.query('DROP TRIGGER update_user_timestamp'); }

原生 SQL 适合处理触发器、复杂索引、数据库特定语法。缺点是可移植性差,团队里如果同时支持 MySQL 和 PostgreSQL,就要特别标注这段迁移只适用于哪个数据库。

如何在代码中执行迁移

有些项目不会直接在命令行里跑迁移,而是在部署脚本或启动流程里调用 runMigrations。写法大致如下:

typescript
import { AppDataSource } from './data-source'; async function runPendingMigrations() { await AppDataSource.initialize(); try { await AppDataSource.runMigrations({ transaction: 'each' }); } finally { await AppDataSource.destroy(); } } runPendingMigrations().catch((error) => { console.error(error); process.exit(1); });

如果需要回滚最后一次迁移,可以调用 AppDataSource.undoLastMigration({ transaction: 'each' })。是否把迁移放进应用启动流程,要看团队部署方式。小项目可以接受;大项目更常见的做法是把迁移作为独立发布步骤,执行成功后再启动新版本应用,避免多个实例同时抢着改库。

数据迁移应该怎么处理

结构迁移和数据迁移经常绑在一起,比如把 firstNamelastName 合并成 fullName。安全的顺序通常是:先加新字段,再回填数据,确认代码已切到新字段后,最后再删旧字段。

typescript
public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.addColumn( 'user', new TableColumn({ name: 'fullName', type: 'varchar', length: '200', isNullable: true, }), ); await queryRunner.query(` UPDATE user SET fullName = CONCAT(firstName, ' ', lastName) WHERE fullName IS NULL `); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.dropColumn('user', 'fullName'); }

如果表很大,不要一条 UPDATE 扫完整张表。可以按主键分批处理,比 LIMIT/OFFSET 更稳,因为 OFFSET 在大表上会越来越慢,也容易受并发写入影响。

typescript
public async up(queryRunner: QueryRunner): Promise<void> { const batchSize = 1000; let lastId = 0; while (true) { const rows: Array<{ id: number }> = await queryRunner.query( `SELECT id FROM user WHERE id > ? ORDER BY id ASC LIMIT ?`, [lastId, batchSize], ); if (rows.length === 0) break; const ids = rows.map((row) => row.id); const placeholders = ids.map(() => '?').join(','); await queryRunner.query( `UPDATE user SET migrated = 1 WHERE id IN (${placeholders})`, ids, ); lastId = rows[rows.length - 1].id; } }

批量迁移还要考虑锁表时间、主从延迟、事务日志膨胀。越接近核心业务表,越应该拆成小步发布,而不是把所有动作塞进一个巨大迁移。

事务、冲突和生产安全要注意什么

TypeORM 可以用事务包裹迁移,CLI 里可以通过 --transaction all|each|none 控制,代码里也可以在 runMigrations 传入 transaction。但数据库支持程度不一样:PostgreSQL 对很多 DDL 的事务支持较好,MySQL 的不少 DDL 会隐式提交。不要以为写在事务里就一定能完整回滚,关键迁移仍然要有备份和回滚方案。

多人协作时,迁移冲突通常出现在两个人同时改了同一张表。处理方式不是随便改已经执行过的迁移文件,而是先合并代码,再基于最新数据库状态重新生成或手写一个新的迁移。已经跑到共享环境或生产环境的迁移,原则上只追加新迁移修正,不直接改历史文件。

生产环境建议记住这几条:

  • synchronize 必须是 false
  • 迁移前备份数据库,并确认备份可恢复
  • 先在测试环境或预发环境跑一遍同样的迁移
  • 大表字段变更优先使用“先新增、再回填、后切换、最后清理”的节奏
  • 创建索引、修改字段类型前评估锁表时间
  • 删除字段和删除表尽量延后一个版本
  • 发布日志里记录迁移开始、结束、耗时和失败原因
  • 回滚不只看 down 能不能执行,还要看业务数据是否允许回到旧结构

TypeORM migrations 本身不复杂,真正难的是把数据库变更当成发布的一部分来管理。MigrationInterfaceQueryRunner 和现代 CLI 命令只是工具;能不能安全上线,取决于迁移是否可审查、可回滚、可分批,并且没有把生产数据库交给自动同步去碰运气。

标签:TypeORM