如果你用 Erlang/OTP 做过后端项目,一定遇到过这个场景:数据库 schema 要变更,怎么管理迁移脚本?Ruby 有 ActiveRecord Migrations,Go 有 golang-migrate,Python 有 Alembic------那 Erlang 呢?
之前我们在 IMBoy 开源 IM 项目 中用的是 erlang-pure-migrations,它能工作,但我们越用越觉得缺少一些关键的工程保障:没有 dirty state 保护、没有分布式锁、没有 down 迁移支持。
在看到golang-migrate项目之后,我就萌生了造个轮子的想法,在熟悉golang-migrate之后,现在实现了------erlang_migrate。
一句话定位
像素级对标 golang-migrate/migrate v4 的设计实现,提供 PostgreSQL、MySQL、SQLite 三大数据库支持,零运行时依赖。
为什么选 golang-migrate 作为参照物?
golang-migrate 是 Go 生态中最流行的数据库迁移库(GitHub 16k+ stars),它的设计非常精炼:
- Source/Database 分离:迁移文件来源与数据库执行目标解耦
- Dirty 状态机:两阶段提交保护,防止迁移中断后状态不一致
- Advisory Lock:数据库级排他锁,多节点部署时只允许一个节点执行迁移
- 纯 SQL 文件:不引入 ORM 或 DSL,你写的 SQL 就是运行的 SQL
我们把这套经过验证的设计理念,原封不动地搬到了 Erlang 里。
5 分钟快速上手
1. 添加依赖
erlang
%% rebar.config
{deps, [
{erlang_migrate, "0.2.3"},
{epgsql, "4.8.0"} %% PostgreSQL 驱动,自己按需添加
]}.
erlang_migrate 本身零硬依赖------它不会强制你安装任何数据库驱动。你用什么数据库,就加什么驱动。
2. 创建迁移文件
bash
priv/migrations/
├── 00000001_create_users.up.sql
├── 00000001_create_users.down.sql
├── 00000002_add_email_index.up.sql
└── 00000002_add_email_index.down.sql
文件命名规则:{版本号}_{描述}.up.sql / {版本号}_{描述}.down.sql
版本号规则详解
版本号是正整数,这是唯一的硬性约束。在此基础上,命名方式非常灵活:
命名格式
{版本号}_{描述}.up.sql
{版本号}_{描述}.down.sql
- 版本号与描述之间用 下划线
_分隔 - 描述部分只允许
[a-z0-9_]+(小写字母、数字、下划线) - 版本号必须唯一------两个文件用同一个版本号会直接报错
版本号风格对比
| 风格 | 示例文件名 | 解析结果 | 适用场景 |
|---|---|---|---|
| 补零递增 | 00000001_create_users.up.sql |
版本 1 |
小团队,喜欢文件排序整齐 |
| 裸数字递增 | 1_create_users.up.sql |
版本 1 |
极简主义 |
| 无零递增 | 42_add_roles.up.sql |
版本 42 |
已有大量迁移的项目 |
| Unix 时间戳(14 位) | 20240101120000_add_audit_log.up.sql |
版本 20240101120000 |
多人并行开发,避免版本号冲突 |
| 短时间戳(10 位) | 1719806400_add_index.up.sql |
版本 1719806400 |
同上,更简洁 |
| 大间隔跳号 | 100_init.up.sql, 200_v2.up.sql |
版本 100, 200 |
按里程碑分组,中间留余量 |
合法 vs 非法示例
| 文件名 | 结果 | 原因 |
|---|---|---|
00000001_create_users.up.sql |
✅ 合法 | 正整数,标准格式 |
1_create_users.up.sql |
✅ 合法 | 裸数字也行,前导零不是必须的 |
20240101120000_add_column.up.sql |
✅ 合法 | 时间戳格式的正整数 |
0_init.up.sql |
❌ 非法 | 版本号必须 > 0 |
-1_rollback.up.sql |
❌ 非法 | 负数不是合法版本号 |
create_users.up.sql |
❌ 非法 | 缺少版本号前缀 |
01a_create_users.up.sql |
❌ 非法 | 01a 不是纯整数 |
1_CreateUsers.up.sql |
❌ 非法 | 描述部分包含大写字母 |
1-create-users.up.sql |
❌ 非法 | 分隔符必须是 _ 而非 - |
down 文件的规则
| 场景 | 行为 |
|---|---|
有对应 .down.sql |
正常回滚,执行 down 文件中的 SQL |
无对应 .down.sql |
up 正常执行;down 时报错 {no_down_migration, Version} |
.down.sql 内容为空 |
当作 no-op 版本标记,跳过执行 |
目录规则
| 规则 | 说明 |
|---|---|
| 平铺目录 | 不递归扫描子目录,只扫描 dir 下一层 |
| 混放其他文件 | 非 .up.sql / .down.sql 后缀的文件会被忽略 |
每个版本必须有 .up.sql |
缺少 up 文件会导致扫描失败 |
| 文件权限 | 不可读文件会中止扫描并返回错误 |
IMBoy 项目中的实际命名
我们团队用的是 8 位补零递增,原因是文件排序时天然按版本顺序排列,一目了然:
bash
priv/migrations/
├── 00000001_config.up.sql
├── 00000001_config.down.sql
├── 00000002_app_version.up.sql
├── 00000002_app_version.down.sql
├── 00000003_app_ddl.up.sql
├── 00000003_app_ddl.down.sql
├── ...
├── 00000090_add_message_v2.up.sql
└── 00000090_add_message_v2.down.sql
3. 执行迁移
erlang
%% 连接数据库
{ok, Conn} = epgsql:connect(#{
host => "localhost",
port => 5432,
database => "mydb",
username => "user",
password => "pass"
}),
%% 构建 config
Config = #{
conn => Conn,
dir => "priv/migrations"
},
%% 一键执行所有待迁移
ok = erlang_migrate:up(Config).
就这样,所有待执行的 .up.sql 文件会按版本号升序依次执行。
API 一览
erlang_migrate 的 API 精简到只有 8 个函数,与 golang-migrate 一一对应:
| Erlang | Go (golang-migrate) | 说明 |
|---|---|---|
up(Config) |
Up() |
执行所有待迁移 |
up(Config, N) |
Steps(+N) |
执行最多 N 个待迁移 |
down(Config) |
Down() |
回滚所有已应用迁移 |
down(Config, N) |
Steps(-N) |
回滚 N 个迁移 |
goto(Config, V) |
Migrate(v) |
跳到指定版本(自动判断方向) |
force(Config, V) |
Force(v) |
强制设置版本(恢复 dirty 状态用) |
version(Config) |
Version() |
查询当前版本和 dirty 标志 |
drop(Config) |
Drop() |
删除 schema_migrations 表 |
erlang
%% 查看当前状态
{ok, Version, Dirty} = erlang_migrate:version(Config).
%% => {ok, 2, false}
%% 回滚一个版本
ok = erlang_migrate:down(Config, 1).
%% 跳到指定版本
ok = erlang_migrate:goto(Config, 5).
%% 手动修复后强制设置版本
ok = erlang_migrate:force(Config, 3).
三数据库支持
只需切换 driver 配置项,就能在不同数据库之间无缝切换:
erlang
%% PostgreSQL(默认)
Config = #{conn => Conn, dir => "priv/migrations/pg"}.
%% MySQL 8+
Config = #{conn => Conn, dir => "priv/migrations/mysql",
driver => erlang_migrate_mysql}.
%% SQLite 3+
Config = #{conn => Conn, dir => "priv/migrations/sqlite",
driver => erlang_migrate_sqlite}.
三个驱动的锁实现各不相同,但行为一致:
| 数据库 | 锁机制 | 事务机制 |
|---|---|---|
| PostgreSQL | pg_advisory_lock |
BEGIN/COMMIT |
| MySQL | GET_LOCK/RELEASE_LOCK |
MySQL 自动事务 |
| SQLite | OTP global:set_lock |
SQLite 内置事务 |
Dirty 状态保护:迁移失败怎么办?
这是 golang-migrate 最精妙的设计之一,erlang_migrate 完整继承了它。
每个迁移以两阶段提交模式执行:
ini
set_version(V, dirty=true) ← 标记为执行中
run SQL ← 执行迁移
set_version(V, dirty=false) ← 标记为完成
如果进程在两阶段之间崩溃(OOM、网络中断、SQL 语法错误等),dirty=true 会被永久记录。此后所有 up/down/goto 调用都会被拒绝------不会盲目重试一个可能已经部分执行的迁移。
恢复流程:
erlang
%% 1. 手动检查数据库,修复可能的部分状态
%% 2. 强制设置到正确的版本
ok = erlang_migrate:force(Config, LastGoodVersion).
这种设计在多节点生产环境中尤其重要------你不会希望两个节点同时尝试"修复"同一个 dirty 状态。
并发安全:多节点集群的迁移锁
在多节点 Erlang 集群中,如果两个节点同时启动并尝试执行迁移,就会产生竞态条件。
erlang_migrate 使用数据库自身的 advisory lock 来解决这个问题(PostgreSQL 用 pg_try_advisory_lock,MySQL 用 GET_LOCK):
erlang
%% 只有获得锁的节点才能执行迁移
%% 其他节点等待直到锁释放或超时
Config = #{
conn => Conn,
dir => "priv/migrations",
lock_timeout => 5000 %% 超时时间,默认 15 秒
}.
锁始终在 try/after 块中释放(等价于 Go 的 defer),即使迁移过程抛异常也不会产生孤立锁。
可插拔日志
erlang
Config = #{
conn => Conn,
dir => "priv/migrations",
logger => fun(Level, Msg) ->
logger:log(Level, "[migrate] ~s", [Msg])
end
}.
日志回调是可选的------不传 logger 键就是静默模式。
在 IMBoy 项目中的实际使用
IMBoy 是一个即时通讯平台,后端用 Erlang/OTP 28+,数据库用 PostgreSQL 18+。
迁移文件放在 imboy/priv/migrations/ 目录下,目前有 90+ 个迁移文件,从建表到加索引到数据迁移都有覆盖。
bash
%% 本地开发启动时自动执行迁移
IMBOYENV=local make run
启动流程中,应用会自动调用 erlang_migrate:up(Config) 把所有待执行的迁移跑完。开发者只需要新建 .sql 文件,提交代码,其他同事 pull 后重启就自动同步了 schema。
与 erlang-pure-migrations 的对比
erlang-pure-migrations 是我们之前在 IMBoy 项目中使用的迁移库,由 bearmug 开发,采用纯函数式设计(函数组合、Functor、延迟执行),设计哲学很优雅。
两者定位不同,各有所长。以下对比基于 erlang-pure-migrations 源码 逐项核实:
功能对比
| 特性 | erlang-pure-migrations | erlang_migrate | 备注 |
|---|---|---|---|
| 设计理念 | 纯函数式、副作用外置 | 过程式、对标 golang-migrate | 设计哲学不同,各有道理 |
| PostgreSQL | ✅ 支持 4 种驱动 | ✅ epgsql | pure 支持 epgsql/semiocast/p1_pgsql/processone |
| MySQL | ✅ mysql-otp | ✅ mysql-otp | 两者均支持 |
| SQLite | ❌ | ✅ esqlite | |
| Down 迁移 | ❌ 明确不支持 | ✅ | pure 的 README 写明 "No downgrade calls available" |
| 多步回滚 | ❌ | ✅ down(Config, N) |
|
| Dirty 状态保护 | ❌ 无 dirty 概念 | ✅ 两阶段提交 | pure 的 save_migration 是直接 INSERT |
| 内置分布式锁 | ❌ 锁靠用户的 transaction handler | ✅ advisory_lock / GET_LOCK / global:set_lock | pure 文档写 "if you providing proper transaction handler" |
| 可配置跟踪表名 | ❌ 硬编码 database_migrations_history |
✅ Config 中可指定 | pure 的 db_dialect.erl 硬编码表名 |
| 运行时依赖 | 零依赖(用户提供查询函数) | 零依赖(用户自行添加驱动) | 两者均为零运行时依赖 |
| 迁移脚本格式 | NN_description.sql(单文件) |
NN_description.up.sql / .down.sql(成对文件) |
|
| 版本号约束 | 严格递增(从 0 开始,步长 1) | 任意正整数,允许跳号和时间戳 | pure 要求 "start from 0 and increment strictly by 1" |
erlang-pure-migrations 的优势
实事求是地说,erlang-pure-migrations 也有 erlang_migrate 不具备的优点:
| 优势 | 说明 |
|---|---|
| 迁移历史记录 | database_migrations_history 表记录每一条迁移的版本、文件名、时间戳,可审计;erlang_migrate 只保留当前版本(单行) |
| 纯函数式设计 | migrate/3 返回一个延迟执行的函数,用户决定何时调用;副作用全部外置到 FTx/FQuery 参数中 |
| 数据库库无关 | 不绑定任何具体驱动,通过回调函数接入,支持任意 PostgreSQL/MySQL 库 |
| 代码极简 | 核心仅 pure_migrations.erl(~70 行)+ db_dialect.erl(~30 行),约 100 行代码 |
| 事务由用户控制 | 用户自己传入 FTx,可以完全控制事务边界和错误处理策略 |
为什么我们还是造了新轮子?
核心原因是 IMBoy 项目在生产中遇到了几个具体痛点:
-
无法回滚 :上线后发现某个迁移有问题,只能手动去数据库改,没有
down文件可以执行 -
没有 dirty 保护:偶尔迁移执行到一半出错,下次启动又尝试执行,但前一半的 SQL 已经生效了
-
多节点竞争:IMBoy 有多个 Erlang 节点同时启动,偶尔出现两个节点同时执行迁移的情况
-
版本号约束过严 :pure 要求严格递增(1, 2, 3, 4...),多人并行开发时容易撞号。比如你和张三同时从
v5开始写新迁移,git merge 就冲突了erlang_migrate 支持时间戳版本号(如
20260606143000_add_column.up.sql),两个人几乎不可能在同一秒创建文件,所以撞号的概率非常低。实事求是地说 :时间戳只能缓解"文件名撞号"这个问题。如果两个人同时改了同一张表(比如一个加列、一个删列),即使文件名不冲突,合并后执行也可能出问题------这属于业务逻辑层面的冲突,任何迁移工具都解决不了,只能靠团队沟通来避免。
这些痛点恰好是 golang-migrate 已经解决了的问题,所以我们选择对标它而不是在 pure 上扩展------设计理念差异太大,改动的代价超过重写。
如果你不需要 down 迁移、dirty 保护、分布式锁这些特性,erlang-pure-migrations 是一个非常轻量且优雅的选择。
安装
erlang
%% Hex(推荐)
{deps, [{erlang_migrate, "0.2.3"}]}.
%% GitHub
{deps, [{erlang_migrate, {git, "https://gitee.com/imboy-pub/erlang_migrate.git", {tag, "v0.2.3"}}}]}.
总结
erlang_migrate 的核心设计决策只有一个:像素级复刻 golang-migrate 的行为模型。
这不是偷懒------golang-migrate 经过数万项目的生产验证,它的 Source/Database 分离、Dirty 状态机、Advisory Lock 模式都是经过实战打磨的。我们在 Erlang 里复刻它,意味着任何熟悉 golang-migrate 的开发者都能零学习成本地使用 erlang_migrate。
如果你在做 Erlang/OTP 项目,需要一个可靠的数据库迁移方案,不妨试试:
erlang
{deps, [{erlang_migrate, "0.2.3"}]}.
- Hex: hex.pm/packages/er...
- Gitee: gitee.com/imboy-pub/e...
- Gitcode: gitcode.com/imboy/erlan...
- GitHub: github.com/imboy-pub/e...
License: Apache 2.0