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

上一篇我们介绍了 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 的解析过程:

  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) 时,背后发生的事:

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 的设计可以用三点概括:

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

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

License: Apache 2.0

相关推荐
leeyi1 天前
erlang_migrate:为 Erlang/OTP 量身打造的数据库迁移库
erlang
深兰科技2 个月前
深兰科技×南京同仁堂达成合作,深兰智养落地:AI+中医探索四高肥胖非药物健康管理新路径
人工智能·erlang·laravel·具身智能·智能机器人·深兰科技·深兰智养
切糕师学AI3 个月前
编程语言 Erlang 简介
开发语言·erlang
塔中妖3 个月前
Windows 安装 RabbitMQ 详细教程(含 Erlang 环境配置)
windows·rabbitmq·erlang
Andy Dennis4 个月前
一文了解异步通信基础消息队列之RabbitMQ(一)
分布式·消息队列·rabbitmq·erlang·异步任务
ZvUUNRLrkJx4 个月前
探索PFC开关电源仿真之全桥LLC
erlang
强化试剂4 个月前
Acridinium-Biotin,吖啶生物素偶联物在化学发光免疫分析中的应用逻辑
erlang·laravel·composer
qq 8762239655 个月前
12脉冲整流器24脉冲整流器matlab仿真 matlab/simulink ~
erlang
武子康6 个月前
Java-207 RabbitMQ Direct 交换器路由:RoutingKey 精确匹配、队列多绑定与日志分流实战
java·消息队列·rabbitmq·erlang·ruby·java-rabbitmq