Skip to content

Sequelize 数据库迁移详解 - 版本化管理数据库结构

发布时间:2024-03-12
作者:一介布衣
标签:Sequelize, 数据库迁移, 版本控制, 数据库管理

前言

今天咱们来学习 Sequelize 的迁移(Migration)系统。说实话,迁移是团队开发中必不可少的工具,它让数据库结构的变更变得可控、可追溯。

我记得刚开始做项目的时候,每次数据库结构有变化,都要手动在各个环境执行 SQL 语句,经常出现开发环境和生产环境不一致的问题。有时候忘记在某个环境执行了某个 SQL,就会导致各种奇怪的错误。后来学会了迁移,这些问题就彻底解决了。

今天我就把迁移的概念、用法和最佳实践分享给大家。

什么是数据库迁移?

数据库迁移是一种版本化管理数据库结构变更的方法。每个迁移文件都包含了:

  • up 方法:执行变更(如创建表、添加字段等)
  • down 方法:回滚变更(撤销 up 方法的操作)

这样我们就能够:

  • 跟踪数据库结构的每一次变更
  • 在不同环境间同步数据库结构
  • 回滚到任意历史版本
  • 团队协作时避免数据库冲突

安装和配置 Sequelize CLI

1. 安装 CLI 工具

bash
# 全局安装
npm install -g sequelize-cli

# 或者项目内安装
npm install --save-dev sequelize-cli

2. 初始化项目

bash
# 初始化 Sequelize 项目结构
npx sequelize-cli init

这会创建以下目录结构:

project/
├── config/
│   └── config.json          # 数据库配置
├── migrations/              # 迁移文件目录
├── models/                  # 模型文件目录
│   └── index.js
└── seeders/                 # 种子数据目录

3. 配置数据库连接

编辑 config/config.json

json
{
  "development": {
    "username": "root",
    "password": "password",
    "database": "myapp_development",
    "host": "127.0.0.1",
    "dialect": "mysql",
    "logging": true
  },
  "test": {
    "username": "root",
    "password": "password",
    "database": "myapp_test",
    "host": "127.0.0.1",
    "dialect": "mysql",
    "logging": false
  },
  "production": {
    "username": "root",
    "password": "password",
    "database": "myapp_production",
    "host": "127.0.0.1",
    "dialect": "mysql",
    "logging": false
  }
}

或者使用 JavaScript 配置文件 config/config.js

javascript
require('dotenv').config();

module.exports = {
  development: {
    username: process.env.DB_USER || 'root',
    password: process.env.DB_PASS || 'password',
    database: process.env.DB_NAME || 'myapp_development',
    host: process.env.DB_HOST || '127.0.0.1',
    dialect: 'mysql',
    logging: console.log
  },
  test: {
    username: process.env.DB_USER || 'root',
    password: process.env.DB_PASS || 'password',
    database: process.env.DB_NAME_TEST || 'myapp_test',
    host: process.env.DB_HOST || '127.0.0.1',
    dialect: 'mysql',
    logging: false
  },
  production: {
    username: process.env.DB_USER,
    password: process.env.DB_PASS,
    database: process.env.DB_NAME,
    host: process.env.DB_HOST,
    dialect: 'mysql',
    logging: false,
    pool: {
      max: 10,
      min: 0,
      acquire: 30000,
      idle: 10000
    }
  }
};

创建迁移文件

1. 创建表的迁移

bash
# 创建用户表迁移
npx sequelize-cli migration:generate --name create-users

这会生成一个迁移文件 migrations/20240312000000-create-users.js

javascript
'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Users', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      firstName: {
        type: Sequelize.STRING(50),
        allowNull: false
      },
      lastName: {
        type: Sequelize.STRING(50),
        allowNull: false
      },
      email: {
        type: Sequelize.STRING(100),
        allowNull: false,
        unique: true
      },
      password: {
        type: Sequelize.STRING(255),
        allowNull: false
      },
      isActive: {
        type: Sequelize.BOOLEAN,
        defaultValue: true
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
    
    // 添加索引
    await queryInterface.addIndex('Users', ['email']);
    await queryInterface.addIndex('Users', ['isActive']);
  },

  async down(queryInterface, Sequelize) {
    await queryInterface.dropTable('Users');
  }
};

2. 添加字段的迁移

bash
# 添加用户头像字段
npx sequelize-cli migration:generate --name add-avatar-to-users
javascript
'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.addColumn('Users', 'avatar', {
      type: Sequelize.STRING(500),
      allowNull: true,
      comment: '用户头像URL'
    });
  },

  async down(queryInterface, Sequelize) {
    await queryInterface.removeColumn('Users', 'avatar');
  }
};

3. 修改字段的迁移

bash
# 修改邮箱字段长度
npx sequelize-cli migration:generate --name modify-email-length
javascript
'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.changeColumn('Users', 'email', {
      type: Sequelize.STRING(150),  // 从100改为150
      allowNull: false,
      unique: true
    });
  },

  async down(queryInterface, Sequelize) {
    await queryInterface.changeColumn('Users', 'email', {
      type: Sequelize.STRING(100),  // 回滚到原来的长度
      allowNull: false,
      unique: true
    });
  }
};

4. 添加索引的迁移

bash
# 添加复合索引
npx sequelize-cli migration:generate --name add-name-index
javascript
'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    // 添加复合索引
    await queryInterface.addIndex('Users', ['firstName', 'lastName'], {
      name: 'users_name_index'
    });
    
    // 添加唯一索引
    await queryInterface.addIndex('Users', ['email'], {
      unique: true,
      name: 'users_email_unique'
    });
  },

  async down(queryInterface, Sequelize) {
    await queryInterface.removeIndex('Users', 'users_name_index');
    await queryInterface.removeIndex('Users', 'users_email_unique');
  }
};

执行迁移

1. 基本命令

bash
# 执行所有待执行的迁移
npx sequelize-cli db:migrate

# 回滚最后一次迁移
npx sequelize-cli db:migrate:undo

# 回滚到指定迁移
npx sequelize-cli db:migrate:undo:all --to 20240312000000-create-users.js

# 回滚所有迁移
npx sequelize-cli db:migrate:undo:all

# 查看迁移状态
npx sequelize-cli db:migrate:status

2. 指定环境

bash
# 在生产环境执行迁移
NODE_ENV=production npx sequelize-cli db:migrate

# 在测试环境执行迁移
NODE_ENV=test npx sequelize-cli db:migrate

复杂迁移场景

1. 数据迁移

有时候我们不仅要修改表结构,还要迁移数据:

javascript
'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    // 1. 添加新字段
    await queryInterface.addColumn('Users', 'fullName', {
      type: Sequelize.STRING(100),
      allowNull: true
    });
    
    // 2. 迁移数据:将 firstName 和 lastName 合并为 fullName
    await queryInterface.sequelize.query(`
      UPDATE Users 
      SET fullName = CONCAT(firstName, ' ', lastName)
      WHERE firstName IS NOT NULL AND lastName IS NOT NULL
    `);
    
    // 3. 设置字段为非空
    await queryInterface.changeColumn('Users', 'fullName', {
      type: Sequelize.STRING(100),
      allowNull: false
    });
  },

  async down(queryInterface, Sequelize) {
    await queryInterface.removeColumn('Users', 'fullName');
  }
};

2. 重命名表和字段

javascript
'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    // 重命名表
    await queryInterface.renameTable('Users', 'Members');
    
    // 重命名字段
    await queryInterface.renameColumn('Members', 'isActive', 'status');
    
    // 修改字段类型
    await queryInterface.changeColumn('Members', 'status', {
      type: Sequelize.ENUM('active', 'inactive', 'suspended'),
      allowNull: false,
      defaultValue: 'active'
    });
  },

  async down(queryInterface, Sequelize) {
    // 回滚字段类型
    await queryInterface.changeColumn('Members', 'status', {
      type: Sequelize.BOOLEAN,
      allowNull: false,
      defaultValue: true
    });
    
    // 回滚字段名
    await queryInterface.renameColumn('Members', 'status', 'isActive');
    
    // 回滚表名
    await queryInterface.renameTable('Members', 'Users');
  }
};

3. 外键关系迁移

javascript
'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    // 创建文章表
    await queryInterface.createTable('Posts', {
      id: {
        allowNull: false,
        autoIncrement: true,
        primaryKey: true,
        type: Sequelize.INTEGER
      },
      title: {
        type: Sequelize.STRING(200),
        allowNull: false
      },
      content: {
        type: Sequelize.TEXT,
        allowNull: false
      },
      userId: {
        type: Sequelize.INTEGER,
        allowNull: false,
        references: {
          model: 'Users',
          key: 'id'
        },
        onUpdate: 'CASCADE',
        onDelete: 'CASCADE'
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE
      }
    });
    
    // 添加外键索引
    await queryInterface.addIndex('Posts', ['userId']);
  },

  async down(queryInterface, Sequelize) {
    await queryInterface.dropTable('Posts');
  }
};

种子数据(Seeders)

1. 创建种子文件

bash
# 创建用户种子数据
npx sequelize-cli seed:generate --name demo-users
javascript
'use strict';

const bcrypt = require('bcryptjs');

module.exports = {
  async up(queryInterface, Sequelize) {
    const users = [
      {
        firstName: '张',
        lastName: '三',
        email: 'zhangsan@example.com',
        password: await bcrypt.hash('123456', 10),
        isActive: true,
        createdAt: new Date(),
        updatedAt: new Date()
      },
      {
        firstName: '李',
        lastName: '四',
        email: 'lisi@example.com',
        password: await bcrypt.hash('123456', 10),
        isActive: true,
        createdAt: new Date(),
        updatedAt: new Date()
      }
    ];
    
    await queryInterface.bulkInsert('Users', users, {});
  },

  async down(queryInterface, Sequelize) {
    await queryInterface.bulkDelete('Users', {
      email: ['zhangsan@example.com', 'lisi@example.com']
    }, {});
  }
};

2. 执行种子数据

bash
# 执行所有种子文件
npx sequelize-cli db:seed:all

# 执行特定种子文件
npx sequelize-cli db:seed --seed 20240312000000-demo-users.js

# 回滚所有种子数据
npx sequelize-cli db:seed:undo:all

# 回滚特定种子数据
npx sequelize-cli db:seed:undo --seed 20240312000000-demo-users.js

迁移最佳实践

1. 迁移文件命名规范

bash
# 好的命名
20240312120000-create-users.js
20240312120100-add-avatar-to-users.js
20240312120200-create-posts.js
20240312120300-add-foreign-key-posts-users.js

# 避免的命名
migration1.js
update.js
fix.js

2. 原子性操作

javascript
'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    const transaction = await queryInterface.sequelize.transaction();
    
    try {
      // 所有操作在一个事务中执行
      await queryInterface.addColumn('Users', 'phone', {
        type: Sequelize.STRING(20),
        allowNull: true
      }, { transaction });
      
      await queryInterface.addIndex('Users', ['phone'], {
        name: 'users_phone_index'
      }, { transaction });
      
      await transaction.commit();
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  },

  async down(queryInterface, Sequelize) {
    const transaction = await queryInterface.sequelize.transaction();
    
    try {
      await queryInterface.removeIndex('Users', 'users_phone_index', { transaction });
      await queryInterface.removeColumn('Users', 'phone', { transaction });
      
      await transaction.commit();
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }
};

3. 数据备份和验证

javascript
'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    // 1. 备份数据
    await queryInterface.sequelize.query(`
      CREATE TABLE Users_backup AS SELECT * FROM Users
    `);
    
    try {
      // 2. 执行迁移
      await queryInterface.addColumn('Users', 'newField', {
        type: Sequelize.STRING(100),
        allowNull: true
      });
      
      // 3. 数据迁移
      await queryInterface.sequelize.query(`
        UPDATE Users SET newField = CONCAT('prefix_', id)
      `);
      
      // 4. 验证数据
      const [results] = await queryInterface.sequelize.query(`
        SELECT COUNT(*) as count FROM Users WHERE newField IS NULL
      `);
      
      if (results[0].count > 0) {
        throw new Error('数据迁移验证失败');
      }
      
      // 5. 删除备份表
      await queryInterface.sequelize.query('DROP TABLE Users_backup');
      
    } catch (error) {
      // 恢复数据
      await queryInterface.sequelize.query('DROP TABLE IF EXISTS Users');
      await queryInterface.sequelize.query('RENAME TABLE Users_backup TO Users');
      throw error;
    }
  },

  async down(queryInterface, Sequelize) {
    await queryInterface.removeColumn('Users', 'newField');
  }
};

生产环境迁移策略

1. 零停机迁移

javascript
// 第一步:添加新字段(允许为空)
'use strict';
module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.addColumn('Users', 'newEmail', {
      type: Sequelize.STRING(150),
      allowNull: true  // 先允许为空
    });
  },
  async down(queryInterface, Sequelize) {
    await queryInterface.removeColumn('Users', 'newEmail');
  }
};

// 第二步:应用代码同时写入新旧字段

// 第三步:迁移历史数据
'use strict';
module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.sequelize.query(`
      UPDATE Users SET newEmail = email WHERE newEmail IS NULL
    `);
  },
  async down(queryInterface, Sequelize) {
    await queryInterface.sequelize.query(`
      UPDATE Users SET newEmail = NULL
    `);
  }
};

// 第四步:设置新字段为非空,删除旧字段
'use strict';
module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.changeColumn('Users', 'newEmail', {
      type: Sequelize.STRING(150),
      allowNull: false
    });
    
    await queryInterface.removeColumn('Users', 'email');
    await queryInterface.renameColumn('Users', 'newEmail', 'email');
  },
  async down(queryInterface, Sequelize) {
    await queryInterface.renameColumn('Users', 'email', 'newEmail');
    await queryInterface.addColumn('Users', 'email', {
      type: Sequelize.STRING(100),
      allowNull: false
    });
  }
};

2. 迁移监控

javascript
'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    console.log('开始执行迁移...');
    const startTime = Date.now();
    
    try {
      await queryInterface.addColumn('Users', 'lastLoginAt', {
        type: Sequelize.DATE,
        allowNull: true
      });
      
      const duration = Date.now() - startTime;
      console.log(`迁移完成,耗时: \${duration}ms`);
      
    } catch (error) {
      console.error('迁移失败:', error);
      throw error;
    }
  },

  async down(queryInterface, Sequelize) {
    await queryInterface.removeColumn('Users', 'lastLoginAt');
  }
};

团队协作规范

1. 迁移文件管理

bash
# .gitignore 不要忽略迁移文件
# migrations/ 目录应该被版本控制

# 团队成员拉取代码后执行
git pull
npm install
npx sequelize-cli db:migrate

2. 冲突解决

当多个开发者同时创建迁移文件时,可能会出现时间戳冲突:

bash
# 重命名迁移文件,调整时间戳
mv 20240312120000-add-field.js 20240312120001-add-field.js

3. 迁移检查脚本

javascript
// scripts/check-migrations.js
const { exec } = require('child_process');

async function checkMigrations() {
  return new Promise((resolve, reject) => {
    exec('npx sequelize-cli db:migrate:status', (error, stdout, stderr) => {
      if (error) {
        reject(error);
        return;
      }
      
      if (stdout.includes('down')) {
        console.error('发现未执行的迁移文件,请先执行迁移');
        process.exit(1);
      }
      
      console.log('所有迁移文件已执行');
      resolve();
    });
  });
}

checkMigrations().catch(console.error);

总结

今天我们深入学习了 Sequelize 的迁移系统:

  • ✅ 迁移的基本概念和重要性
  • ✅ CLI 工具的安装和配置
  • ✅ 各种迁移场景的实现方法
  • ✅ 种子数据的使用
  • ✅ 生产环境迁移策略
  • ✅ 团队协作最佳实践

掌握了这些知识,你就能够:

  • 版本化管理数据库结构变更
  • 在团队中安全地协作开发
  • 实现零停机的数据库升级
  • 构建可靠的数据库部署流程

下一篇文章,我们将学习 Sequelize 的性能优化技巧,让你的应用跑得更快!


相关文章推荐:

有问题欢迎留言讨论,我会及时回复大家!