数据库迁移:Flyway/Exposed迁移、版本控制、回滚策略
数据库迁移这个话题,说实话,很多团队一开始都不太重视。我记得刚入行那会儿,项目组的数据库结构全靠口头传,谁改了表结构就在群里吼一声。结果呢?上线前一天发现测试环境和生产环境的表对不上,那叫一个酸爽。
后来我养成了一个习惯:数据库的每一次变更,都必须有版本号,必须能回滚。今天我们就聊聊在 Kotlin 项目里,怎么用 Flyway 和 Exposed 把数据库迁移这件事做得漂漂亮亮。
核心观点:数据库迁移不是「改表结构」,而是「管理数据库的版本历史」。每一次变更都是一次提交,可以前进,也可以回退。
为什么需要数据库迁移?
你想想看,代码有 Git 管理版本,数据库凭什么没有?
- 多人协作时,每个人本地数据库结构不一致
- 上线时,忘记执行某个 SQL 脚本导致功能异常
- 回滚时,不知道改过哪些表,不敢轻易操作
说白了,数据库迁移就是给数据库结构加上版本控制。每次变更都写成一个脚本,按顺序执行,出了问题可以回滚到上一个版本。
Flyway:最成熟的迁移工具
Flyway 是 Java 生态里最常用的数据库迁移工具,在 Kotlin 项目里用起来也很顺手。它的工作方式很简单:
- 在数据库中创建一个
flyway_schema_history表,记录所有执行过的脚本 - 按照文件名中的版本号顺序执行脚本
- 检查脚本的 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 之前就会出问题。
我个人的回滚策略是这样的:
- 开发环境:直接
flyway.clean()清空,重新 migrate。简单粗暴。 - 测试环境:写撤销迁移脚本,但只用于回滚最近一次迁移。
- 生产环境:不执行回滚,而是写一个新的迁移来「修复」问题。比如加错了列,就写一个 V4 来删掉它。
核心原则:生产环境的数据库迁移,只进不退。回滚不是靠撤销迁移,而是靠「向前修复」。
知识体系总览
下面这张图概括了数据库迁移的核心逻辑:
实战建议
最后,分享几个我在实战中总结的经验:
- 迁移脚本要原子化:一个脚本只做一件事。加字段就加字段,建索引就建索引。别混在一起,否则回滚时很麻烦。
- 上线前在预发布环境跑一遍:我曾经在本地测试通过,结果上线时发现生产环境的 MySQL 版本不支持某个语法。嗯,从那以后我每次上线前都会在预发布环境完整跑一遍迁移。
- 迁移脚本要写注释:别觉得「加个字段而已,谁看不懂」。三个月后你自己回来看,可能都想不起来为什么要加这个字段。
- 用 CI/CD 自动执行迁移:手动执行迁移脚本是灾难的根源。把
flyway.migrate()集成到部署流水线里,让机器来做这件事。
我的个人习惯:每个迁移脚本的注释里,除了说明变更内容,还会写上「为什么要做这个变更」。比如「V2__add_status_column.sql — 为了支持订单取消功能,在 orders 表增加 status 字段」。这样以后排查问题的时候,能省很多时间。
数据库迁移这件事,说白了就是给数据库结构加上版本管理。工具选 Flyway 还是 Exposed 自带的方案,取决于你的项目规模。但不管选哪个,记住一条:生产环境只进不退,出了问题用新脚本来修复。这个原则,我在多个项目里验证过,从来没出过问题。