上一篇我们介绍了 erlang_migrate 的定位和快速上手。这篇从源码角度拆解它的设计------6 个源文件、约 1000 行代码,如何同时支持 PostgreSQL、MySQL、SQLite 三种数据库。
先说一个类比
如果你用过 Java,可以把 erlang_migrate 的设计理解为"接口 + 实现类":
- 接口(behaviour):定义了"数据库驱动必须实现哪些能力"
- 实现类(driver):PostgreSQL 一种、MySQL 一种、SQLite 一种
它们之间的关系长这样:
css
你写的代码
│
│ 调用 erlang_migrate:up(Config)
▼
erlang_migrate(入口模块,负责整体流程)
│
├── erlang_migrate_source(负责扫描迁移文件)
│
└── erlang_migrate_driver(定义接口)
├── erlang_migrate_pg(PostgreSQL 实现)
├── erlang_migrate_mysql(MySQL 实现)
└── erlang_migrate_sqlite(SQLite 实现)
下面逐层拆解。
一、6 个文件各管什么
erlang
src/
├── erlang_migrate.erl %% 入口:提供 up/down/goto/force 等命令
├── erlang_migrate_driver.erl %% 接口定义(behaviour),声明 8 个回调函数
├── erlang_migrate_source.erl %% 文件扫描:找到迁移文件、解析版本号
├── erlang_migrate_pg.erl %% PostgreSQL 驱动
├── erlang_migrate_mysql.erl %% MySQL 驱动
└── erlang_migrate_sqlite.erl %% SQLite 驱动
每个文件只做一件事。这种设计的好处是:如果要支持新数据库(比如 TiDB),只需要新增一个驱动文件,不用改其他任何东西。
二、Source 层:扫描和读取迁移文件
erlang_migrate_source 做的事很简单------在指定目录里找出所有迁移文件,按版本号排好队。
迁移文件的命名规则
bash
priv/migrations/
├── 1_create_users.up.sql ← 版本号_描述.方向.sql
├── 1_create_users.down.sql
├── 2_add_email_index.up.sql
├── 2_add_email_index.down.sql
└── 20240101120000_add_column.up.sql ← 用时间戳做版本号也行
规则:
- 版本号必须是正整数(1、2、100、时间戳都行)
- 每个版本号必须唯一,重复了直接报错
.down.sql文件可以省略------不影响升级,但回滚时会报错
版本号怎么解析的
文件名 1_create_users.up.sql 的解析过程:
- 去掉
.up.sql后缀 →1_create_users - 找到第一个
_→ 版本号1,描述create_users - 版本号转成整数,必须是正数
erlang
parse_version_title(Base) ->
case string:split(Base, "_", leading) of
[VerStr, Rest] ->
case string:to_integer(VerStr) of
{V, []} when V > 0 -> {V, list_to_binary(Rest)};
_ -> {error, bad_version}
end;
_ -> {error, bad_format}
end.
解析失败不会崩溃,而是返回 {error, ...} 让调用方决定怎么处理。
去重检查
erlang
check_duplicates([]) -> {ok, []};
check_duplicates(Sorted) ->
Versions = [maps:get(version, M) || M <- Sorted],
case length(Versions) =:= length(lists:usort(Versions)) of
true -> {ok, Sorted};
false -> {error, duplicate_versions}
end.
两个文件用了同一个版本号?直接报错。这能防止一个容易踩的坑:复制粘贴迁移文件时忘了改版本号。
三、Driver 层:接口定义
erlang_migrate_driver 定义了 8 个回调函数(callback)。如果你来自 Java/Go 世界,这就像 interface 里的方法声明:
| 回调函数 | 干什么的 | 通俗理解 |
|---|---|---|
ensure_table |
创建或验证版本追踪表 | 准备一张"迁移记录表" |
current_version |
查当前迁移到哪个版本了 | 问"上次跑到哪了?" |
lock |
获取排他锁 | "我来跑迁移,别人别抢" |
unlock |
释放锁 | "我跑完了,别人可以来了" |
set_version |
更新版本追踪行 | 记录"我跑到第 N 版了" |
is_dirty |
检查是否有中断的迁移 | 问"上次有没有跑一半挂了?" |
exec_sql |
执行 SQL | 真正执行迁移文件里的 SQL |
drop_table |
删除追踪表 | 测试专用,正常不用 |
其中前 7 个是核心功能,drop_table 只用于测试清理。
为什么要用 behaviour 而不直接调用?
因为不同数据库的同一个操作,实现方式完全不同。最典型的例子是"锁":
| 数据库 | 锁的实现方式 |
|---|---|
| PostgreSQL | pg_try_advisory_lock(锁ID) --- 数据库内置的咨询锁 |
| MySQL | GET_LOCK('锁名', 超时) --- MySQL 的命名锁 |
| SQLite | global:set_lock(...) --- Erlang 运行时自带的分布式锁 |
behaviour 让上层代码只管调用 Driver:lock(),不用关心底层用的是哪种锁。这和手机充电线一个道理------接口(USB-C)是统一的,但线材内部构造各不相同。
怎么扩展新数据库?
只需要实现这 8 个回调函数,然后在配置里指定驱动名:
erlang
Config = #{
conn => Conn,
dir => "priv/migrations",
driver => erlang_migrate_cockroach %% 你自己写的驱动模块
}.
库会在启动时检查驱动模块是否存在:
erlang
driver(#{driver := D}) when is_atom(D) ->
case code:which(D) of
non_existing -> error({unknown_driver, D});
_ -> D
end.
注意这里用的是 error/1(会崩溃),而不是返回 {error, ...}。这是故意的------驱动名写错了是编程错误,应该尽早暴露,而不是在运行时默默忽略。
四、核心流程:一次 up 迁移的完整过程
调用 erlang_migrate:up(Config) 时,背后发生的事:
sql
up(Config)
│
├── 1. 获取排他锁(防止多个进程同时跑迁移)
│ ├── 确保追踪表存在
│ └── 调用 Driver:lock()
│
├── 2. 检查状态
│ ├── 有没有"脏状态"(上次跑一半挂了的)
│ ├── 读当前版本号
│ └── 扫描目录里的迁移文件
│
├── 3. 计算待执行列表
│ └── 比对"文件里有哪些版本"和"数据库里记录到哪个版本了"
│
└── 4. 逐个执行
│
└── 对每个待执行的迁移:
├── 读 SQL 文件内容
├── 标记 dirty = true("我开始跑了")
├── 执行 SQL
├── 标记 dirty = false("我跑完了")
│ └── 如果标记失败,自动重试 3 次
└── 检查有没有收到中止信号
关键设计:两阶段 dirty 标记
这是整个库最核心的安全机制:
ini
时间线:
──→ 标记 dirty=true ──→ 执行SQL ──→ 标记 dirty=false ──→
↑ ↑
"我开始跑了" "我跑完了"
如果这一步之后崩溃 如果这一步之前崩溃
→ 数据库记录:版本N, dirty=true → 数据库记录:版本N, dirty=true
→ 下次运行会检测到并拒绝执行 → 下次运行会检测到并拒绝执行
不管在哪个环节崩溃,数据库里都会留下 dirty=true 的记录。下次再跑迁移时,库会检测到这个状态并拒绝执行,提示你用 force/2 手动修复。这能防止数据被搞乱。
重试机制
SQL 执行成功后,需要更新追踪表把 dirty 标记清除。万一这一步因为网络闪断失败了怎么办?库会自动重试,默认 3 次,间隔 200ms:
erlang
set_version_with_retry(Driver, Conn, Table, Version, Dirty, Retries, RetryMs, Logger)
这个设计解决了一个现实问题:SQL 已经执行成功了,如果因为瞬态故障导致标记没更新,系统会一直处于 dirty 状态卡住。
其他实用功能
预览模式(dry_run) :设置 dry_run => true,只打印"会执行什么",不真正执行。适合上线前检查。
中止信号 :往迁移进程发送 erlang_migrate_abort 原子,迁移会在当前文件执行完后停止,不会中断正在执行的 SQL。
跳转到指定版本(goto) :erlang_migrate:goto(Config, 5) 会自动判断需要 up 还是 down,迁移到目标版本。
锁的生命周期
获取锁之后,整个迁移过程被包在 try/after 里:
erlang
try Fun(Conn, Table, Logger, Driver)
after
Driver:unlock(Conn, LockId) %% 不管成功失败,锁一定会释放
end
这和 Go 语言的 defer 一个道理------确保锁不会因为异常而忘了释放。
五、三种数据库的锁实现对比
这是最能体现 behaviour 设计价值的部分:同一种语义,三种完全不同的实现。
PostgreSQL:咨询锁(Advisory Lock)
PostgreSQL 内置了 pg_try_advisory_lock 函数,用整数作为锁 ID:
erlang
%% 尝试获取锁
SQL = io_lib:format("SELECT pg_try_advisory_lock(~b)", [LockId]),
case epgsql:squery(Conn, lists:flatten(SQL)) of
{ok, _, [{<<"t">>}]} -> ok; %% 拿到了
{ok, _, [{<<"f">>}]} -> %% 被别人占着
timer:sleep(100), %% 等 100ms 再试
try_lock(Conn, LockId, Deadline);
Err -> {error, {lock_failed, Err}} %% 其他错误(比如连接断了)
end
特点:非阻塞------拿不到锁立即返回,然后用 100ms 间隔重试模拟等待。
MySQL:命名锁(Named Lock)
MySQL 用 GET_LOCK 函数,用字符串作为锁名:
erlang
Name = "erlang_migrate_" ++ integer_to_list(LockId), %% 加前缀防冲突
SQL = "SELECT GET_LOCK('" ++ Name ++ "', 0)",
case mysql:query(Conn, SQL) of
{ok, _, [[1]]} -> ok; %% 拿到了
{ok, _, [[0]]} -> %% 被别人占着
timer:sleep(100),
try_lock(Conn, LockId, Deadline);
Err -> {error, {lock_failed, Err}}
end
和 PostgreSQL 的模式一样,只是锁的"名字格式"不同:PG 用整数,MySQL 用字符串。
SQLite:Erlang 运行时全局锁
SQLite 是嵌入式数据库,没有数据库层面的锁机制。所以直接用 Erlang/OTP 自带的 global:set_lock:
erlang
case global:set_lock({{erlang_migrate_lock, LockId}, self()}, [node()], 0) of
true -> ok;
false ->
timer:sleep(100),
try_lock(LockId, Deadline)
end
注意:这个锁只在同一个 Erlang 节点内有效。跨节点跑 SQLite 迁移时,需要依赖 SQLite 自身的文件级写锁。
锁 ID 怎么来的
如果没有手动指定,锁 ID 会从追踪表名自动计算:
erlang
lock_id(_, Table) -> erlang:phash2(Table, 1 bsl 30).
这意味着:不同表名的迁移天然隔离。你可以用同一个数据库跑多套迁移,它们互不干扰:
erlang
%% 主应用迁移
Config1 = #{conn => Conn, dir => "priv/migrations", table => <<"app_schema">>},
%% 插件系统迁移(独立的锁和版本追踪)
Config2 = #{conn => Conn, dir => "priv/plugin_migrations", table => <<"plugin_schema">>},
erlang_migrate:up(Config1),
erlang_migrate:up(Config2).
因为 erlang:phash2(<<"app_schema">>) ≠ erlang:phash2(<<"plugin_schema">>),两套迁移的锁不会冲突。
六、版本追踪表的"单行语义"
schema_migrations 表里永远只有 0 或 1 行记录。这是从 golang-migrate 继承的设计。
set_version 的实现逻辑:
erlang
%% Version = undefined → 清空所有行(表示没有迁移过)
set_version(Conn, Table, undefined, _Dirty) ->
SQL = "DELETE FROM " ++ table_ref(Table);
%% Version = 具体数字 → 删掉其他版本,插入/更新当前版本
set_version(Conn, Table, Version, Dirty) ->
%% 步骤 1:删掉不等于当前版本的所有行
Del = "DELETE FROM " ++ Table ++ " WHERE version != " ++ Version,
%% 步骤 2:插入当前版本(如果已存在则更新)
Upsert = "INSERT INTO " ++ Table ++ " (...) VALUES (...) ON CONFLICT ...",
%% 两步包在一个事务里,防止中间崩溃导致数据不一致
with_transaction(Conn, fun() ->
exec(Del),
exec(Upsert)
end).
为什么不用简单的 UPDATE?
因为 force() 操作需要"绝对干净的状态"------不管之前表里有什么残留,执行后必须是干净的一行。DELETE + INSERT(在事务中)能保证这一点。三种数据库各有各的写法:
| 数据库 | Upsert 语法 |
|---|---|
| PostgreSQL | INSERT ... ON CONFLICT DO UPDATE |
| MySQL | REPLACE INTO |
| SQLite | INSERT OR REPLACE INTO |
语义一样,语法不同。
七、安全设计
表名校验防 SQL 注入
表名会被拼接到 SQL 语句里,所以在拼接前做正则校验:
erlang
validate_table_name(Name) ->
case re:run(Name, "^[a-zA-Z_][a-zA-Z0-9_]*(\\.[a-zA-Z_][a-zA-Z0-9_]*)?$",
[{capture, none}]) of
match -> Name;
nomatch -> error({invalid_table_name, Name})
end.
只允许字母、数字、下划线,支持 schema.table 格式(比如 public.schema_migrations)。不合法的表名会直接崩溃------这是编程错误,应该在开发阶段暴露。
SQL 执行包在事务里
每个驱动的 exec_sql 都把迁移 SQL 包在 BEGIN/COMMIT 事务中:
erlang
%% PostgreSQL 示例
exec_sql(Conn, SQL) ->
with_pg_transaction(Conn, fun() ->
run_sql(Conn, SQL)
end).
这保证了多语句迁移的原子性------要么全部成功,要么全部回滚。
八、配置:一个 map 搞定一切
所有行为通过一个 map 控制,没有全局状态,没有 application env:
erlang
Config = #{
conn => Conn, %% 必填:数据库连接
dir => "priv/migrations", %% 必填:迁移文件目录
driver => erlang_migrate_pg, %% 可选,默认 PostgreSQL
table => <<"schema_migrations">>, %% 可选,默认值
lock_id => 7369284, %% 可选,默认从表名派生
lock_timeout => 15000, %% 可选,默认 15 秒
dry_run => true, %% 可选,预览模式
logger => fun(Level, Msg) -> io:format("~p: ~s~n", [Level, Msg]) end,
set_version_retries => 3, %% 可选,默认 3 次
set_version_retry_ms => 200 %% 可选,默认 200ms
}.
零全局状态意味着:同一个 Erlang 节点可以同时管理多套数据库的迁移,互不影响。
总结
erlang_migrate 的设计可以用三点概括:
- behaviour 驱动:定义 8 个回调函数,驱动实现只管"怎么做",不管"做什么"。扩展新数据库只需加一个文件。
- 两阶段 dirty 保护:每个迁移先标记"正在跑",跑完再标记"跑完了"。中途崩溃会留下脏状态,阻断后续操作,防止数据被搞乱。
- 零侵入:没有全局状态、没有进程注册------一个 Config map 就是一切。
6 个源文件、约 1000 行代码,三种数据库全覆盖。如果你需要为 Erlang 项目添加数据库迁移能力,或者想学习一个简洁的 behaviour 设计模式,欢迎看源码:
- 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