数据库迁移:Flyway/Exposed迁移、版本控制、回滚策略

数据库迁移这个话题,说实话,很多团队一开始都不太重视。我记得刚入行那会儿,项目组的数据库结构全靠口头传,谁改了表结构就在群里吼一声。结果呢?上线前一天发现测试环境和生产环境的表对不上,那叫一个酸爽。

后来我养成了一个习惯:数据库的每一次变更,都必须有版本号,必须能回滚。今天我们就聊聊在 Kotlin 项目里,怎么用 Flyway 和 Exposed 把数据库迁移这件事做得漂漂亮亮。

核心观点:数据库迁移不是「改表结构」,而是「管理数据库的版本历史」。每一次变更都是一次提交,可以前进,也可以回退。

为什么需要数据库迁移?

你想想看,代码有 Git 管理版本,数据库凭什么没有?

  • 多人协作时,每个人本地数据库结构不一致
  • 上线时,忘记执行某个 SQL 脚本导致功能异常
  • 回滚时,不知道改过哪些表,不敢轻易操作

说白了,数据库迁移就是给数据库结构加上版本控制。每次变更都写成一个脚本,按顺序执行,出了问题可以回滚到上一个版本。

Flyway:最成熟的迁移工具

Flyway 是 Java 生态里最常用的数据库迁移工具,在 Kotlin 项目里用起来也很顺手。它的工作方式很简单:

  1. 在数据库中创建一个 flyway_schema_history 表,记录所有执行过的脚本
  2. 按照文件名中的版本号顺序执行脚本
  3. 检查脚本的 checksum,防止被篡改

Flyway 的脚本命名规范

类型 命名格式 示例
版本化迁移 V{版本号}__{描述}.sql V1__create_users.sql
可重复迁移 R__{描述}.sql R__add_indexes.sql
撤销迁移 U{版本号}__{描述}.sql U1__drop_users.sql

我个人习惯用版本化迁移来做所有结构变更,可重复迁移只用来创建视图或存储过程。为什么?因为版本化迁移有明确的顺序,出了问题好排查。

在 Kotlin 项目里集成 Flyway

// build.gradle.kts
implementation("org.flywaydb:flyway-core:9.22.3")
implementation("org.flywaydb:flyway-mysql:9.22.3") // 根据数据库选择

// 初始化 Flyway
val flyway = Flyway.configure()
    .dataSource(url, username, password)
    .locations("classpath:db/migration")
    .baselineOnMigrate(true)  // 对已有数据库启用基线
    .load()

flyway.migrate()

这里有个坑:baselineOnMigrate 这个参数,我第一次用的时候没注意,结果在已有数据库上执行迁移时直接报错。它的作用是告诉 Flyway:「这个数据库已经有数据了,不要从头开始,从当前基线版本开始」。嗯,这个一定要记住。

小技巧:在开发环境,可以把 flyway.cleanDisabled 设为 false,这样可以用 flyway.clean() 清空所有表重新来过。但生产环境千万别这么干!

Exposed 的迁移方案

如果你用的是 JetBrains 的 Exposed ORM,它自带了轻量级的迁移机制。虽然不如 Flyway 功能强大,但对于中小项目来说完全够用。

Exposed 的迁移思路是:在代码里定义数据库版本号,启动时检查并执行未运行的迁移。

// 定义迁移接口
interface DatabaseMigration {
    val version: Int
    fun migrate(transaction: Transaction)
}

// 实现一个迁移
object V1CreateUsers : DatabaseMigration {
    override val version = 1

    override fun migrate(transaction: Transaction) {
        transaction.exec("""
            CREATE TABLE users (
                id INT AUTO_INCREMENT PRIMARY KEY,
                name VARCHAR(100) NOT NULL,
                email VARCHAR(255) NOT NULL UNIQUE,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        """)
    }
}

// 迁移管理器
class MigrationManager(private val migrations: List<DatabaseMigration>) {
    private val versionTable = "schema_version"

    fun migrate() {
        // 检查版本表是否存在,不存在则创建
        // 获取当前数据库版本
        // 按版本号顺序执行未运行的迁移
        // 更新版本号
    }
}

我在一个中型项目里用过这种方案,好处是纯 Kotlin 代码,不需要写 SQL 文件。但坏处也很明显:没有 checksum 校验,别人改了历史迁移代码你也不知道。

警告:Exposed 自带的迁移方案不支持回滚。如果你需要回滚能力,建议还是用 Flyway。我曾经因为这个吃过亏——上线后发现迁移脚本有 bug,结果只能手动修复数据库。

版本控制策略

数据库迁移的版本控制,说白了就是回答三个问题:

  • 版本号怎么定?我建议用时间戳或者递增数字。时间戳的好处是不会冲突,递增数字的好处是容易理解。
  • 多人并行开发怎么办?每个人在自己的分支上写迁移脚本,合并时按时间顺序排列。如果两个脚本修改了同一张表,那就需要人工 review。
  • 已经上线的迁移能修改吗?绝对不能!已经执行过的迁移脚本,它的 checksum 已经被记录。修改后 Flyway 会报错。正确的做法是写一个新的迁移来修复。

我记得有一次,团队里有个同事觉得 V3 脚本的字段名起得不好,直接改了文件名和内容。结果所有人的本地环境都跑不起来了。从那以后,我定了一条铁律:已合并到主分支的迁移脚本,一个字都不许改

回滚策略

回滚是数据库迁移里最让人头疼的部分。Flyway 支持撤销迁移(Undo Migration),但说实话,我不太推荐在生产环境用。

为什么?因为撤销迁移本质上是一个反向操作的 SQL 脚本。比如 V2 加了列,U2 就删掉列。但问题在于:如果 V2 执行后,V3 又对这个列做了操作,那回滚到 V2 之前就会出问题。

我个人的回滚策略是这样的:

  1. 开发环境:直接 flyway.clean() 清空,重新 migrate。简单粗暴。
  2. 测试环境:写撤销迁移脚本,但只用于回滚最近一次迁移。
  3. 生产环境:不执行回滚,而是写一个新的迁移来「修复」问题。比如加错了列,就写一个 V4 来删掉它。

核心原则:生产环境的数据库迁移,只进不退。回滚不是靠撤销迁移,而是靠「向前修复」。

知识体系总览

下面这张图概括了数据库迁移的核心逻辑:

数据库迁移核心逻辑 Flyway 迁移 Exposed 迁移 版本控制 SQL 脚本 + 版本表 Checksum 校验 撤销迁移 (U 脚本) Kotlin 代码定义 版本号 + 迁移管理器 不支持回滚 时间戳 / 递增数字 已合并脚本不可修改 向前修复代替回滚 核心原则:只进不退,向前修复

实战建议

最后,分享几个我在实战中总结的经验:

  • 迁移脚本要原子化:一个脚本只做一件事。加字段就加字段,建索引就建索引。别混在一起,否则回滚时很麻烦。
  • 上线前在预发布环境跑一遍:我曾经在本地测试通过,结果上线时发现生产环境的 MySQL 版本不支持某个语法。嗯,从那以后我每次上线前都会在预发布环境完整跑一遍迁移。
  • 迁移脚本要写注释:别觉得「加个字段而已,谁看不懂」。三个月后你自己回来看,可能都想不起来为什么要加这个字段。
  • 用 CI/CD 自动执行迁移:手动执行迁移脚本是灾难的根源。把 flyway.migrate() 集成到部署流水线里,让机器来做这件事。

我的个人习惯:每个迁移脚本的注释里,除了说明变更内容,还会写上「为什么要做这个变更」。比如「V2__add_status_column.sql — 为了支持订单取消功能,在 orders 表增加 status 字段」。这样以后排查问题的时候,能省很多时间。

数据库迁移这件事,说白了就是给数据库结构加上版本管理。工具选 Flyway 还是 Exposed 自带的方案,取决于你的项目规模。但不管选哪个,记住一条:生产环境只进不退,出了问题用新脚本来修复。这个原则,我在多个项目里验证过,从来没出过问题。

公众号:蓝海资料掘金营,微信deep3321