Mnesia 是随 OTP 分发的实时分布式数据库,由爱立信为电信应用设计——它在 BEAM 内部直接运行,表就是 Erlang record,访问接口与进程通信无缝衔接。与外部数据库(PostgreSQL、MySQL)相比,Mnesia 没有网络协议开销,亚毫秒级读写;与 ETS 相比,它额外提供了事务、跨节点复制、磁盘持久化与故障恢复。Mnesia 特别适合配置数据、会话状态、用户在线信息、元数据这类规模不大但对一致性敏感的实时数据。本文将深入 Mnesia 的表存储策略、事务与锁模型、容错副本、分片索引,以及生产部署的完整流程。
一、Mnesia 架构与适用边界
1.1 Mnesia 与常规数据库的定位差异
| 对比维度 | Mnesia | PostgreSQL / MySQL |
|---|---|---|
| 数据模型 | Erlang record / tuple | SQL 关系模型 |
| 访问方式 | 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).
| 对比 | transaction | dirty |
|---|---|---|
| 一致性 | 强,可回滚 | 无事务语义 |
| 并发 | 串行化写 | 无锁,极高吞吐 |
| 返回值 | {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/ 中的节点互联模型相互配合,构成了完整的实时数据层方案。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。