Mnesia 分布式数据库:事务、容错副本与生产部署

深度解析 Erlang 内置分布式数据库 Mnesia:ram/disc/disc_only 三种表存储策略、事务与锁机制、多节点容错副本、哈希分片与索引设计,以及生产环境部署与监控实践。

Mnesia 是随 OTP 分发的实时分布式数据库,由爱立信为电信应用设计——它在 BEAM 内部直接运行,表就是 Erlang record,访问接口与进程通信无缝衔接。与外部数据库(PostgreSQL、MySQL)相比,Mnesia 没有网络协议开销,亚毫秒级读写;与 ETS 相比,它额外提供了事务、跨节点复制、磁盘持久化与故障恢复。Mnesia 特别适合配置数据、会话状态、用户在线信息、元数据这类规模不大但对一致性敏感的实时数据。本文将深入 Mnesia 的表存储策略、事务与锁模型、容错副本、分片索引,以及生产部署的完整流程。

一、Mnesia 架构与适用边界

1.1 Mnesia 与常规数据库的定位差异

对比维度MnesiaPostgreSQL / MySQL
数据模型Erlang record / tupleSQL 关系模型
访问方式mnesia:transaction / dirty 操作SQL / ORM
网络开销进程内,无协议有连接与序列化开销
事务ACID,两阶段锁ACID,MVCC
复制表级多节点副本主从 / 多主
规模上限适合 GB 级以下、KV/索引查询TB 级、复杂查询
适用场景实时状态、配置、会话业务主数据、复杂分析

判断标准:如果你需要 SQL 复杂查询、超大规模数据、或非 Erlang 系统共享数据,不要用 Mnesia。Mnesia 的强项是与 Erlang 进程共生的实时状态层。

1.2 Mnesia 进程与表存储模型

Mnesia 数据库分布在多个节点上,每个表可以在不同节点上保存不同形式的副本:

Node A                     Node B
┌──────────────┐          ┌──────────────┐
│ Mnesia       │          │ Mnesia       │
│  ┌────────┐  │  ──────▶ │  ┌────────┐  │
│  │ 表 user │  │  复制    │  │ 表 user │  │
│  │ ram_cp  │  │          │  │ disc_cp │  │
│  └────────┘  │          │  └────────┘  │
└──────────────┘          └──────────────┘

二、表存储类型与结构

2.1 三种存储策略

存储类型存储位置读写性能持久性适用场景
ram_copies内存(ETS)最快节点宕机丢失实时状态、缓存、会话
disc_copies内存 + 磁盘(ETS + DETS)快节点宕机不丢业务数据、配置(默认推荐)
disc_only_copies仅磁盘(DETS)慢(约 100x 慢于内存)持久日志、历史、超大数据

2.2 创建表

-module(schema_builder).
-export([init/0]).

-record(user, {id, name, email, status, created_at}).

init() ->
    %% 1. 在集群节点上创建 schema(只需一次)
    mnesia:create_schema([node() | nodes()]),

    %% 2. 启动 Mnesia
    mnesia:start(),

    %% 3. 创建表
    mnesia:create_table(user, [
        {attributes, record_info(fields, user)},
        {type, set},                     % set / ordered_set / bag
        {disc_copies, [node()]},         % 本节点磁盘副本
        {ram_copies, nodes()},           % 其余节点内存副本
        {index, [email]}                 % 次要索引
    ]),
    ok.

2.3 表键结构与记录

record_info(fields, user) 生成记录字段顺序,其中第一个字段 id 默认是主键(keypos)。记录在 Mnesia 中就是带 record tag 的 tuple:

%% 写入一条记录(事务)
mnesia:transaction(fun() ->
    mnesia:write(#user{id = 42, name = "Alice",
                       email = "a@ex.com", status = active})
end).

%% 读
{atomic, [User]} = mnesia:transaction(fun() ->
    mnesia:read({user, 42})
end).

2.4 表结构与存储选择决策

场景存储类型副本策略
用户在线状态(可重建)ram_copies每节点一份,快速读取
商品配置(必须持久)disc_copies主节点 disc + 从节点 ram
审计日志(大数据)disc_only_copies单节点或分片
分布式锁/租赁ram_copies所有节点

三、事务与锁机制

3.1 事务函数与原子性

transfer(FromId, ToId, Amount) ->
    F = fun() ->
        %% 读取双方余额
        [{user, FromId, FromBal}] = mnesia:read({account, FromId}),
        [{user, ToId, ToBal}] = mnesia:read({account, ToId}),

        %% 校验并更新
        true = (FromBal >= Amount),
        mnesia:write({account, FromId, FromBal - Amount}),
        mnesia:write({account, ToId, ToBal + Amount}),
        ok
    end,
    case mnesia:transaction(F) of
        {atomic, ok}      -> ok;
        {aborted, Reason} -> {error, Reason}
    end.

事务的语义:

  • 原子性:F 抛异常或返回 abort 时,整个事务回滚,所有 mnesia:write 撤销;
  • 一致性:事务执行期间读到的数据是「事务开始时的快照 + 本事务未提交写入」(可重复读级别);
  • 隔离性:通过锁实现,见 3.2;
  • 持久性:disc_copies 表事务提交后写磁盘(disc_only 依赖 DETS 刷盘)。

3.2 锁粒度与模式

Mnesia 在两阶段锁协议下自动加锁:

%% 显式请求锁
mnesia:lock({table, account}, read).
mnesia:lock({table, account}, write).
mnesia:lock({global, lock_name}, write).

%% 读记录时自动加 read 锁
mnesia:read({account, FromId}).
%% 写记录时自动加 write 锁
mnesia:write({account, FromId, NewBal}).
锁类型作用对象冲突
read 锁单条记录或整表与写锁冲突
write 锁单条记录或整表与读写锁冲突
sticky 锁记录允许多读单写者持有后继续持有
global 锁全局名跨节点互斥(如分布式锁)

加锁规则:所有写操作自动对整表加 write 锁(Mnesia 的保守策略),因此并发事务写入同一张表的不同记录也会串行化。这是 Mnesia 事务吞吐受限的主因——大量并发写场景应改用 dirty 操作或拆表。

3.3 死锁处理

两个事务以不同顺序锁定相同记录会死锁。Mnesia 通过事务重启解决:检测到死锁时,mnesia:transaction 自动终止较晚的事务并重试(默认最多 10 次),若仍失败返回 {aborted, {badarg, ...}} 或 {aborted, {deadlock, ...}}。

%% 设置重试次数(默认 10)
application:set_env(mnesia, lock_timeout, 5000).

%% 避免死锁的最佳实践:
%% - 所有事务按相同顺序访问表/记录
%% - 事务体保持短小,减少持锁时间
%% - 用 dirty 操作替代事务,或把互不冲突的数据拆到不同表

3.4 Dirty 操作(绕过事务)

对性能敏感且允许牺牲部分一致性的场景,Mnesia 提供 dirty 系列:

%% dirty 读写(不经过事务与锁)
{ok, User} = mnesia:dirty_read({user, 42}),
mnesia:dirty_write(#user{id = 42, name = "Bob"}),
mnesia:dirty_delete({user, 42}),
mnesia:dirty_update_counter({hits, 1}),   % 原子计数
mnesia:dirty_match_object({user, '_', '_', '_', active, '_'}).

%% dirty 查询
mnesia:dirty_index_read(UserTable, "a@ex.com", email).
mnesia:dirty_select(UserTable, MatchSpec).
对比transactiondirty
一致性强,可回滚无事务语义
并发串行化写无锁,极高吞吐
返回值{atomic, R} / {aborted, R}直接值
适用资金、状态机关键路径计数器、缓存、日志

经验法则:更新相关多记录的操作用事务;单记录无依赖的读写(尤其高频热点)用 dirty。绝大多数在线状态读写都可用 dirty 达到 Mnesia 的极限性能。

四、容错副本

4.1 副本配置

表副本分布在多个节点,读写可发生在任意持有副本的节点:

%% 表 user 在两个节点上各保留一份磁盘副本
mnesia:create_table(user, [
    {attributes, record_info(fields, user)},
    {disc_copies, [node_a, node_b]},
    {type, set}
]).

%% 动态添加/移除副本
mnesia:add_table_copy(user, node_c, disc_copies).
mnesia:del_table_copy(user, node_c).

%% 修改整个副本配置
mnesia:change_table_copy_type(user, node_c, ram_copies).

4.2 节点故障行为

  • 写事务:Mnesia 要求所有持有该表副本的存活节点确认写入(同步复制)。节点宕机后,副本列表自动缩减;
  • 读事务:从本地副本读取(若本地有副本),无网络开销;
  • 主节点表(disc_copies):若主节点宕机,其他节点上的副本可能因没有 schema/表而无法访问——需要配置 mnesia:add_table_copy 前先同步 schema。

4.3 网络分区处理

Mnesia 默认在网络分区时阻塞写(保证一致性,牺牲可用性):

%% 设置主节点:分区后主节点继续提供服务
mnesia:set_master_nodes(user, [node_a]).
mnesia:set_master_nodes([node_a, node_b]).

%% 查看分区相关的表配置
mnesia:system_info(db_nodes).
mnesia:table_info(user, all).
CAP 偏好配置效果
强一致默认(多节点同步写)分区时写阻塞
高可用set_master_nodes 指定主节点主节点分区内可写
最终一致降级到 dirty + 定时同步可能读到旧数据

Mnesia 是典型的 CP/CA 偏向系统,与 https://plumephp.com/posts/distributed-systems/ 中 CAP 定理的权衡一致:它优先保证一致性,用「分区时牺牲写入」换取不分裂。

4.4 节点加入集群

%% 新节点加入:连接种子节点,同步 schema 与表
join_cluster(SeedNode) ->
    %% 1. 确保 Mnesia 在 seed 上可访问
    pong = net_adm:ping(SeedNode),

    %% 2. 加入 db_nodes 列表
    ok = mnesia:change_config(extra_db_nodes, [SeedNode]),
    Nodes = mnesia:system_info(db_nodes),

    %% 3. 创建本节点 schema
    mnesia:create_schema([node()]),

    %% 4. 为关键表添加副本
    mnesia:add_table_copy(user, node(), disc_copies),
    mnesia:add_table_copy(config, node(), ram_copies),
    {ok, Nodes}.

五、分片与索引

5.1 哈希分片(Fragments)

当单表数据量超过单个节点内存/磁盘承受能力时,Mnesia 支持把一张逻辑表切分为 N 个分片,按主键哈希分布:

%% 创建 4 个分片的 user 表
mnesia:create_table(user, [
    {attributes, record_info(fields, user)},
    {type, set},
    {frag_properties, [
        {n_fragments, 4},
        {node_pool, [node_a, node_b, node_c, node_d]},
        {n_disc_copies, 1},     % 每个分片 1 个磁盘副本
        {n_ram_copies, 1}       % 每个分片 1 个内存副本
    ]}
]).

%% 分片对业务透明:读写接口不变
mnesia:transaction(fun() -> mnesia:write(#user{id = 1001, ...}) end).
{atomic, [U]} = mnesia:transaction(fun() -> mnesia:read({user, 1001}) end).

%% 查看分片信息
mnesia:table_info(user, frag_properties).
mnesia:table_info(user, base_table).
mnesia:table_info(user, n_fragments).

分片规则:

  • 主键哈希决定记录落在哪个分片(mnesia:hash/2);
  • 分片可分布在多个节点,实现水平扩展;
  • 注意:分片表的查询大多按主键(哈希分布),范围查询会退化为全分片扫描;
  • 分片数在创建后修改成本高,需提前规划。

5.2 次要索引

除主键外,可为任意字段建索引以支持按该字段查询:

%% 创建带索引的表
mnesia:create_table(user, [
    {attributes, record_info(fields, user)},
    {index, [email, status]},
    {type, set}
]).

%% 按索引查询(返回所有匹配记录)
{atomic, Users} = mnesia:transaction(fun() ->
    mnesia:index_read(user, "a@ex.com", email)
end).

%% 动态加索引
mnesia:add_table_index(user, created_at).
mnesia:del_table_index(user, email).

索引代价:每次写操作都要同步更新索引,索引字段越多写越慢。只对高频查询字段建索引。

5.3 MatchSpec 查询

复杂过滤用 mnesia:match_object 与 select:

%% 查询所有 active 用户
{atomic, ActiveUsers} = mnesia:transaction(fun() ->
    mnesia:match_object(#user{_ = '_', status = active})
end).

%% select 更强大(带 guards)
{atomic, Users} = mnesia:transaction(fun() ->
    mnesia:select(user, [
        %% 模式:{id, '_', name, email, status, created_at}
        {{'_', '_', '_', '_', '$1', '_'},
         [{'==', '$1', active}, {'>', '$2', 1000}],  % guard
         ['$$']}
    ])
end).

六、生产部署

6.1 初始化与启动流程

%% 应用启动时初始化
-module(db_bootstrap).
-export([setup/1]).

setup(Nodes) ->
    %% 1. 只在有磁盘副本的节点写 schema
    mnesia:create_schema(Nodes),
    mnesia:start(),

    %% 2. 创建所有表(幂等)
    ensure_table(user, [disc_copies], [node() | nodes()]),
    ensure_table(session, [ram_copies], nodes()),
    ensure_table(audit, [disc_only_copies], [node()]),
    ok.

ensure_table(Name, _Copies, _Nodes) ->
    case mnesia:create_table(Name, [
        {attributes, attributes_for(Name)},
        {type, set}
    ]) of
        {atomic, ok} -> ok;
        {aborted, {already_exists, _}} -> ok
    end.

6.2 备份与恢复

%% 在线备份整个数据库(不停止服务)
{atomic, ok} = mnesia:backup("./backup/mnesia_2026_09_26.bak").

%% 恢复(从备份文件)
{atomic, ok} = mnesia:restore("./backup/mnesia_2026_09_26.bak",
                              [{default_op, recreate_tables}]).

%% 只备份某个表
{atomic, ok} = mnesia:backup_checkpoint({user, ...}).

备份策略:

  • 定期 mnesia:backup 到异地存储;
  • 关键节点每 24h 全量备份,事务日志通过 DETS 文件镜像;
  • 恢复前先停止所有节点上的 Mnesia,避免文件冲突。

6.3 监控与调优

%% 查看系统状态
mnesia:system_info(db_nodes).        % 参与节点
mnesia:system_info(active_tables).   % 活跃表
mnesia:system_info(transaction_log). % 事务日志信息
mnesia:info().                       % 汇总

%% 表状态
mnesia:table_info(user, size).       % 记录数
mnesia:table_info(user, memory).     % 占用内存 words
mnesia:table_info(user, where_to_read). % 读副本分布
mnesia:table_info(user, where_to_write).% 写副本分布

%% 常用调优参数(sys.config)
[{mnesia, [
    {dir, "/var/lib/mnesia"},          % 数据目录
    {dump_log_write_threshold, 5000},  % 日志 dump 阈值
    {max_transaction_restarts, 20},    % 事务重试上限
    {lock_timeout, 5000}               % 锁等待超时(毫秒)
]}].

6.4 常见故障排查

症状可能原因处理
{aborted, {no_exists, ...}}表不存在或 schema 未同步mnesia:add_table_copy 同步 schema
{aborted, {node_not_running, ...}}写事务涉及宕机节点等待节点恢复或移除副本
{aborted, {badarg, ...}}事务内非法写检查 keypos、record 字段
写事务长期挂起网络分区 / 锁死锁lock_timeout 兜底,检查 db_nodes
磁盘爆满disc_only_copies 数据无限增长定期清理 + 分片

七、最佳实践与总结

  • 为表选择正确存储类型:在线状态用 ram_copies,业务数据用 disc_copies,海量历史用 disc_only_copies;
  • 事务保持短小:事务锁整表,长事务会拖垮全库并发——关键写路径尽量用 dirty 或拆表;
  • 副本按读写比设计:读多写少 → 每个读节点加 ram_copies 副本;写频繁 → 减少副本数量降低同步开销;
  • 分片提前规划:n_fragments 创建后难调整,按未来 3 年数据量估算;
  • 索引宁少勿滥:只对高频查询字段建索引,否则写放大严重;
  • 备份 + 监控常备:mnesia:backup 定时执行,mnesia:system_info 纳入监控告警。

Mnesia 是「进程即数据」哲学的极致体现——表存在于 BEAM 内部,事务就是一次函数调用。对于实时状态管理、分布式锁、会话保持这类需要强一致、低延迟的小数据量场景,Mnesia 至今仍是 Erlang 生态中最直接高效的答案。它与 https://plumephp.com/erlang-ets-caching/ 中的 ETS 双写模式、https://plumephp.com/erlang-distributed-programming/ 中的节点互联模型相互配合,构成了完整的实时数据层方案。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「erlang」更多文章

  1. 自定义 OTP Behaviour 实战:Callback 规范与行为封装
  2. Phoenix Channels 实时通信实战:WebSocket 与 PubSub 深入
  3. Mix 工具链与 Elixir 工程化实战