erlang_migrate 架构拆解:behaviour 驱动的多数据库迁移引擎

上一篇我们介绍了 erlang_migrate 的定位和快速上手。这篇从源码角度拆解它的设计------6 个源文件、约 1000 行代码,如何同时支持 PostgreSQL、MySQL、SQLite 三种数据库。

先说一个类比

如果你用过 Java,可以把 erlang_migrate 的设计理解为"接口 + 实现类":

  • 接口(behaviour):定义了"数据库驱动必须实现哪些能力"
  • 实现类(driver):PostgreSQL 一种、MySQL 一种、SQLite 一种

它们之间的关系长这样:

复制代码
你写的代码
    │
    │  调用 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 个文件各管什么

复制代码
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 做的事很简单------在指定目录里找出所有迁移文件,按版本号排好队。

迁移文件的命名规则

复制代码
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 的解析过程:

  1. 去掉 .up.sql 后缀 → 1_create_users
  2. 找到第一个 _ → 版本号 1,描述 create_users
  3. 版本号转成整数,必须是正数
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) 时,背后发生的事:

复制代码
up(Config)
  │
  ├── 1. 获取排他锁(防止多个进程同时跑迁移)
  │     ├── 确保追踪表存在
  │     └── 调用 Driver:lock()
  │
  ├── 2. 检查状态
  │     ├── 有没有"脏状态"(上次跑一半挂了的)
  │     ├── 读当前版本号
  │     └── 扫描目录里的迁移文件
  │
  ├── 3. 计算待执行列表
  │     └── 比对"文件里有哪些版本"和"数据库里记录到哪个版本了"
  │
  └── 4. 逐个执行
        │
        └── 对每个待执行的迁移:
              ├── 读 SQL 文件内容
              ├── 标记 dirty = true("我开始跑了")
              ├── 执行 SQL
              ├── 标记 dirty = false("我跑完了")
              │     └── 如果标记失败,自动重试 3 次
              └── 检查有没有收到中止信号

关键设计:两阶段 dirty 标记

这是整个库最核心的安全机制:

复制代码
时间线:
  ──→ 标记 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 的设计可以用三点概括:

  1. behaviour 驱动:定义 8 个回调函数,驱动实现只管"怎么做",不管"做什么"。扩展新数据库只需加一个文件。
  2. 两阶段 dirty 保护:每个迁移先标记"正在跑",跑完再标记"跑完了"。中途崩溃会留下脏状态,阻断后续操作,防止数据被搞乱。
  3. 零侵入:没有全局状态、没有进程注册------一个 Config map 就是一切。

6 个源文件、约 1000 行代码,三种数据库全覆盖。如果你需要为 Erlang 项目添加数据库迁移能力,或者想学习一个简洁的 behaviour 设计模式,欢迎看源码:

License: Apache 2.0

相关推荐
xiezhr17 分钟前
逛GitHub发现了一款免费的带AI功能的数据库管理工具
数据库·ai编程·dba
阳光是sunny10 小时前
Vue 项目怎么做用户行为全链路监控?轻量插件方案详解
前端·面试·架构
EMA15 小时前
Docker虚拟化失败解决方案
架构
李斯维16 小时前
从历史的角度看 Android 软件架构
android·架构·android jetpack
JouYY18 小时前
聊一下多 Agent 编排架构的应用实践
架构·llm·agent
Sunia19 小时前
《AgentX 专栏》10-生产部署:3台2C4G云服务器把企业级Agent真正跑起来的完整方案
java·架构
吃糖的小孩1 天前
给 QQ AI 机器人设计“可控记忆”:会话摘要、手动长期记忆与角色卡边界
数据库
笃行3502 天前
金仓数据库数据安全双防线:静态存储加密与传输加密实战
数据库
笃行3502 天前
金仓数据库物理备份实战:sys_rman 全流程演练与误覆盖抢救
数据库
笃行3502 天前
金仓数据库逻辑备份实战:从全库导出到 Schema 替换的完整闭环
数据库