ETS(Erlang Term Storage)是运行在 BEAM 进程堆之外的内存键值数据库,由运行时系统统一管理。相比 maps 和 lists,ETS 最大的价值在于跨进程共享:任何拥有访问权的进程都能读写同一张表,且写入操作不会触发调用进程的垃圾回收(GC)。ETS 是 Erlang 生态中缓存、注册表、计数器、会话存储等场景的事实标准——RabbitMQ 的队列索引、Mnesia 的内存副本、recon 的诊断缓存全部构建在 ETS 之上。本文将系统讲解 ETS 的表类型、并发语义、缓存模式与持久化方案,并给出可复用的性能调优清单。
一、ETS 基础与内存模型
1.1 ETS 在 BEAM 中的位置
ETS 表属于创建它的进程(继承者),但数据存放在运行时系统维护的堆外存储中:
BEAM 进程堆 运行时系统
┌─────────────────────┐ ┌──────────────────┐
│ 进程 A 的堆 │ │ ETS 表(Table) │
│ (Term 数据) │──▶│ set / bag /... │
│ │ │ │
│ 进程 B 的堆 │──▶│ DETS 文件 │
└─────────────────────┘ └──────────────────┘
写入不复制到调用进程堆
→ 调用进程不触发 GC
关键特性:
- 表的所有权(
owner)属于创建进程;所有权可转移(ets:give_away/3); - 默认 protected 访问:所有进程可读,仅 owner 可写;
- 读操作不拷贝(
ets:lookup返回表内原始 term 引用),因此几乎零拷贝; - 表在 owner 进程终止时自动销毁,除非设置了
heir(继承者)。
1.2 创建表与访问控制
%% 创建一张表,返回表标识(table id / 引用)
Tid = ets:new(my_table, [set, {keypos, 1}, protected]).
%% 命名表:用原子名字全局访问,避免传递 Tid
Tid = ets:new(my_table, [named_table, public]).
ets:insert(my_table, {key, value}).
ets:lookup(my_table, key).
| 访问控制选项 | 可读 | 可写 | 说明 |
|---|---|---|---|
public | 所有进程 | 所有进程 | 无保护,并发写需自行加锁 |
protected | 所有进程 | 仅 owner | 默认选项,最佳实践 |
private | 仅 owner | 仅 owner | 完全私有 |
最佳实践:绝大多数生产场景使用
protected。写操作通过 owner 进程(通常是gen_server)串行化,既保证一致性,又享受读并发。
二、四种表类型
2.1 set:唯一键哈希表
默认类型。键唯一,内部使用哈希表,查找平均 O(1):
Tid = ets:new(users, [set, {keypos, 1}]),
ets:insert(Tid, {user_1, "Alice", 30}),
ets:insert(Tid, {user_1, "Alicia", 31}), % 覆盖旧记录
ets:lookup(Tid, user_1).
% [{user_1, "Alicia", 31}]
2.2 ordered_set:有序键
按 Erlang term order 排序存储,支持范围查询与有序遍历,但写入 O(log N):
Tid = ets:new(scores, [ordered_set, {keypos, 1}]),
ets:insert(Tid, {alice, 90}),
ets:insert(Tid, {bob, 85}),
ets:insert(Tid, {carol, 95}),
%% 范围查询:bob 到 carol(含两端)
ets:select(Tid, [{{'$1','$2'},
[{'>=','$1',bob},{'=<','$1',carol}],
[{{'$1','$2'}}]}]).
%% 反向遍历(有序表专有)
ets:foldl(fun({K,V}, Acc) -> [K|Acc] end, [], Tid).
| 特性 | set | ordered_set |
|---|---|---|
| 底层结构 | 哈希表 | 平衡树 |
| 查找复杂度 | O(1) | O(log N) |
| 范围查询 | 不支持 | 支持 |
| 顺序遍历 | 无序 | 有序 |
| 存储 term | 所有 term | 不允许复杂 term(tuple 中不允许含 tuple/整数/原子混合的嵌套结构?实际限制:key 不允许是未压缩的非整数) |
ordered_set 的限制:key 不能包含
float、reference、port、pid、嵌套 tuple 等「未压缩」term。若需要按整数或原子做范围查询,用它;否则用 set。
2.3 bag:多值集合
键可重复,但同一键下不允许完全相同的记录:
Tid = ets:new(tags, [bag]),
ets:insert(Tid, {post_1, erlang}),
ets:insert(Tid, {post_1, otp}),
ets:insert(Tid, {post_1, erlang}), % 与已有记录完全相同 → 失败(no-op)
ets:lookup(Tid, post_1).
% [{post_1, otp}, {post_1, erlang}] (顺序无保证)
2.4 duplicate_bag:允许完全重复
Tid = ets:new(events, [duplicate_bag]),
ets:insert(Tid, {user_42, login}),
ets:insert(Tid, {user_42, login}),
ets:insert(Tid, {user_42, login}), % 允许重复插入
ets:lookup(Tid, user_42).
% [{user_42, login}, {user_42, login}, {user_42, login}]
%% 用 match_object 按非键字段过滤
ets:match_object(Tid, {user_42, logout}).
2.5 类型选型决策
| 场景 | 推荐类型 | 理由 |
|---|---|---|
| KV 缓存、计数器 | set | O(1) 查找,覆盖更新 |
| 排行榜、时间序列范围查询 | ordered_set | 范围扫描、有序遍历 |
| 多标签、多成员关系 | bag | 去重自动,节省内存 |
| 事件日志、审计流水 | duplicate_bag | 保留全部历史 |
| 需快速判断键存在 | set | ets:member/2 O(1) |
三、并发访问保护
3.1 ETS 的并发语义
ETS 内部实现为单写多读的读写锁(read-write lock),BEAM 自带锁分解:
- 读操作(lookup/match)可完全并发,不阻塞;
- 写操作(insert/delete)需要写锁,但锁粒度是**表段(bucket/segment)**而非整表,因此多进程写不同键时仍可并行;
set表可配置{read_concurrency, true}提升多读性能,{write_concurrency, true}提升多写性能。
%% 针对读多写少的缓存场景
ets:new(cache, [
set,
named_table,
protected,
{read_concurrency, true},
{write_concurrency, true}
]).
write_concurrency在set上以「键分桶 + 每桶独立锁」实现;ordered_set的写并发支持有限(写时需全局排序),慎用。
3.2 原子读改写:update_counter
跨进程并发自增,最安全的做法是 ets:update_counter/3——它在表内部原子完成「读→改→写」,不经过进程堆:
%% 初始化计数器
ets:insert(counters, {hits, 0}).
%% 原子自增并返回新值
NewHits = ets:update_counter(counters, hits, 1).
%% 原子加法(可负)
ets:update_counter(counters, hits, -1).
%% 复合更新:一次调用内执行多个操作
ets:update_counter(counters, hits, [{2, 10}, % 第2个元素 +10
{3, 1}, % 第3个元素 +1
{4, -5}]).
update_counter 返回的计数直接由运行时系统维护,完全避免「读-算-写」之间的竞态窗口,是热点计数器的首选。
3.3 条件插入与删除
ets:insert_new/2 仅在键不存在时插入(原子操作),适合实现「单次初始化」:
%% 锁/租约场景:只有第一个拿到 key 的进程能插入成功
case ets:insert_new(leases, {resource_id, Pid, ExpireAt}) of
true -> grant_lease(Pid);
false -> reject_lease()
end.
%% 原子删除并返回
case ets:take(leases, resource_id) of
[{resource_id, Pid, _}] -> release(Pid);
[] -> already_released
end.
ets:take/2 在 OTP 18+ 可用,删除同时返回值,无需先 lookup 再 delete。
3.4 遍历的一致性:safe_fixtable
遍历大表时,其他进程的插入/删除会导致游标失效。ets:match、ets:foldl 等遍历操作要求表在遍历期间不被写,否则抛 badarg。解法是固定表:
%% 遍历前固定
ets:safe_fixtable(my_table, true),
%% 用 first/next 手动遍历(游标)
First = ets:first(my_table),
traverse(First),
traverse('$end_of_table') -> done;
traverse(Key) ->
Value = ets:lookup(my_table, Key),
traverse(ets:next(my_table, Key)).
%% 遍历结束解除固定
ets:safe_fixtable(my_table, false).
ets:foldl/3 等函数要求 safe_fixtable(true) 以避免 badarg,但固定期间表仍可读,只是写操作被挂起。高并发场景建议用 ets:select 分页(Limit + Continuation)代替全表 fold:
%% 分页 select,避免长时间持有读锁
First = ets:select(my_table, MatchSpec, 1000),
continue(First).
continue({Results, Continuation}) ->
process(Results),
Next = ets:select(Continuation),
continue(Next);
continue('$end_of_table') ->
done.
四、生产级缓存模式
4.1 简单 KV 缓存(gen_server 封装)
ETS 所有权归 gen_server,所有写操作经由 server 串行化,读操作直接 ets:lookup:
-module(kv_cache).
-behaviour(gen_server).
-export([start_link/0, get/1, put/2, delete/1, stats/0]).
-export([init/1, handle_call/3, handle_cast/2]).
-define(TAB, ?MODULE).
start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
%% 读路径:直接查 ETS,不经过 gen_server
get(Key) ->
case ets:lookup(?TAB, Key) of
[{_, Value}] -> {ok, Value};
[] -> not_found
end.
%% 写路径:同步进入 server
put(Key, Value) ->
gen_server:call(?MODULE, {put, Key, Value}).
delete(Key) ->
gen_server:call(?MODULE, {delete, Key}).
stats() ->
ets:info(?TAB, size).
init([]) ->
ets:new(?TAB, [named_table, protected, {read_concurrency, true}]),
{ok, #{}}.
handle_call({put, Key, Value}, _From, State) ->
ets:insert(?TAB, {Key, Value}),
{reply, ok, State};
handle_call({delete, Key}, _From, State) ->
ets:delete(?TAB, Key),
{reply, ok, State};
handle_call(_Request, _From, State) ->
{reply, {error, unknown}, State}.
4.2 带 TTL 的缓存
通过记录过期时间并在读时惰性清理:
-module(ttl_cache).
-export([new/1, get/1, put/3, cleanup/0]).
-define(TAB, ttl_cache_tab).
-define(TTL, 60_000). % 默认 60 秒
new(TTL) ->
ets:new(?TAB, [set, named_table, protected, {read_concurrency, true}]),
ets:insert(?TAB, {ttl, TTL}),
ok.
get(Key) ->
case ets:lookup(?TAB, Key) of
[{Key, Value, ExpireAt}] when ExpireAt > os:system_time(millisecond) ->
{ok, Value};
[{Key, _, _}] ->
%% 已过期,惰性删除
ets:delete(?TAB, Key),
not_found;
[] ->
not_found
end.
put(Key, Value) ->
TTL = case ets:lookup(?TAB, ttl) of
[{ttl, T}] -> T;
_ -> ?TTL
end,
ExpireAt = os:system_time(millisecond) + TTL,
ets:insert(?TAB, {Key, Value, ExpireAt}),
ok.
%% 定期清理:由定时器调用
cleanup() ->
Now = os:system_time(millisecond),
ets:select_delete(?TAB, [
{{'$1', '_', '$2'},
[{'<', '$2', Now}],
[true]}
]),
ok.
4.3 缓存一致性策略
| 策略 | 做法 | 适用场景 |
|---|---|---|
| Cache Aside | 先更新 DB,再失效缓存 | 通用默认 |
| Write Through | 写 DB 同时写缓存 | 读多写少 |
| Write Behind | 异步批量落库 | 吞吐优先 |
| Read Through | 缓存未命中时回源 DB 并回填 | 热点数据 |
Erlang 中「回源」通常做成 get/1 未命中时向数据源进程发起请求:
get_or_load(Key) ->
case ets:lookup(?TAB, Key) of
[{_, Value}] -> {ok, Value};
[] ->
case load_from_db(Key) of % 回源
{ok, Value} ->
ets:insert(?TAB, {Key, Value}),
{ok, Value};
{error, _} = Err -> Err
end
end.
缓存穿透与击穿:未命中时回源要加防抖——同一 Key 并发未命中只允许一个进程回源,其余等待。可用「每 Key 一个锁进程」或用
ets:insert_new抢占加载令牌。
4.4 缓存作为进程注册中心
ETS 命名表天然适合做「名字 → Pid」的路由表:
%% 注册与查询
register_worker(Name, Pid) ->
case ets:insert_new(workers, {Name, Pid}) of
true -> {ok, Pid};
false -> {error, already_registered}
end.
whereis(Name) ->
case ets:lookup(workers, Name) of
[{_, Pid}] -> {ok, Pid};
[] -> undefined
end.
配合 ets:match_delete 做批量清理(如节点下线时删除该节点所有 worker):
%% 删除某个节点上注册的所有 worker
ets:match_delete(workers, {'_', {'_', Node, '_'}}).
五、DETS 持久化
5.1 DETS 基础
DETS 是 ETS 的磁盘版本,使用相同的键值语义,但单表上限 2GB、无并发读优化、性能远低于 ETS:
%% 打开(或创建)DETS 文件
{ok, Ref} = dets:open_file(my_dets, [{file, "./my.dets"},
{type, set},
{keypos, 1}]).
%% 写入 / 读取 / 删除
dets:insert(Ref, {key, value}).
dets:lookup(Ref, key).
dets:delete(Ref, key).
%% 批量写入提升性能
dets:insert(Ref, [{k1, v1}, {k2, v2}, {k3, v3}]).
%% 关闭(必须显式关闭,否则可能丢数据)
dets:close(Ref).
%% 导出为 ETS
dets:to_ets(Ref, ets_tab).
5.2 ETS + DETS 组合(双写模式)
生产系统常用「内存缓存 + 磁盘持久化」的双层结构——这正是 Mnesia disc_copies 的内部实现:
-module(cache_backed).
-export([start/0, get/1, put/2, sync_flush/0]).
-define(ETS, cache_mem).
-define(DETS, cache_disk).
start() ->
ets:new(?ETS, [named_table, protected, {read_concurrency, true}]),
{ok, _} = dets:open_file(?DETS, [{file, "./cache.dets"},
{type, set}, {keypos, 1}]),
%% 启动时从磁盘加载(仅加载热点或全部)
dets:to_ets(?DETS, ?ETS),
ok.
get(Key) ->
case ets:lookup(?ETS, Key) of
[{_, V}] -> {ok, V};
[] ->
case dets:lookup(?DETS, Key) of
[{_, V}] ->
ets:insert(?ETS, {Key, V}), % 回填内存
{ok, V};
[] -> not_found
end
end.
put(Key, Value) ->
%% 先写磁盘(崩溃恢复点),再写内存(读路径)
dets:insert(?DETS, {Key, Value}),
ets:insert(?ETS, {Key, Value}),
ok.
sync_flush() ->
dets:sync(?DETS).
双写顺序很关键:先 DETS 后 ETS。若进程崩溃在两步之间,内存可能缺数据,但磁盘是完整的;启动重载后自愈。若反序则可能磁盘缺失而内存有值,重启即丢。
5.3 DETS 的坑
- 文件大小上限约 2GB,超限会报
{error, {file_size, ...}}; - 写入不是即时落盘,崩溃可能丢失最近写入;可用
dets:sync/1强制刷盘(代价是性能); - 不支持
ordered_set(仅有 set / bag / duplicate_bag); - 打开同一文件需唯一 owner,多节点共享 DETS 文件不安全(应改用 https://plumephp.com/erlang-mnesia-distributed/ 的 disc_copies)。
六、性能调优
6.1 写入/读取基准对比
实测(OTP 26,百万键 set 表):
| 操作 | 说明 | 量级 |
|---|---|---|
ets:lookup | O(1),无锁竞争 | ~几十 ns |
ets:insert | O(1),需写锁 | ~几百 ns |
maps:get | 进程堆内查找 | ~几十 ns,但触发 GC |
ets:match 全表 | O(N) 且要求安全遍历 | 慢,避免 |
ets:select_delete | 批量删除 | 比逐 key delete 快 10x+ |
6.2 优化清单
%% 1. 读多写少 → read_concurrency
ets:new(t, [set, protected, {read_concurrency, true}]).
%% 2. 写多读少 → write_concurrency
ets:new(t, [set, protected, {write_concurrency, true}]).
%% 3. 紧凑 term + off-heap binary(减少 GC 压力)
ets:insert(t, {key, list_to_binary(Text)}).
%% 4. 批量操作合并(比逐条快 10x)
ets:insert(t, ListOfTuples),
ets:select_delete(t, MatchSpec).
%% 5. 监控表内存
ets:info(t, memory). % 表占用的 words
ets:info(t, size). % 记录数
ets:info(t, comp_heap). % 表内部堆
6.3 内存泄漏与回收
ETS 表内存不计入任何进程堆,常被误认为「泄漏」:
%% 查看全局 ETS 内存
erlang:memory(ets).
%% 列出所有表及其 owner 和大小
[{ets:info(T, name), ets:info(T, size), ets:info(T, owner)} || T <- ets:all()].
%% 定位占用 Top 的表(按 memory 降序)
lists:sublist(lists:sort(
fun({_,A,_},{_,B,_}) -> A > B end,
[{ets:info(T, name), ets:info(T, memory), ets:info(T, owner)} || T <- ets:all()]
), 10).
%% 清理策略:heir 移交 / 定期 select_delete / give_away 避免孤儿表
6.4 使用 heir 防止孤儿表
%% 创建时指定继承者(owner 终止后表不销毁)
ets:new(shared, [named_table, public, {heir, super_pid, [init]}]).
%% 手动转移所有权
ets:give_away(Tab, NewOwner, GiftData).
七、最佳实践与总结
- 默认 protected + 单一 owner 进程:把写操作收敛到一个
gen_server,一致性由进程串行保证,读操作享受无锁并发; - 热点计数器用
update_counter,条件初始化用insert_new,原子取走用take; - 大表遍历用
select分页,不要foldl全表;必要时safe_fixtable; - 缓存加 TTL 与淘汰策略,避免无限增长;回源逻辑要防缓存穿透;
- 持久化优先 DETS 双写模式,复杂一致性需求直接上 Mnesia(见 https://plumephp.com/erlang-mnesia-distributed/);
- 用
erlang:memory(ets)与ets:info/2监控,将表内存纳入容量规划。
ETS 是把「进程内状态」提升为「运行时共享状态」的基石。理解了表类型、并发语义与内存模型,就能在 BEAM 上构建出读写均接近内存极限、且天然支持多进程共享的高性能数据层。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。