Erlang ETS 与内存管理:从表类型到生产级缓存

全面解析 Erlang 内置内存数据库 ETS:四种表类型(set/ordered_set/bag/duplicate_bag)的选择、并发访问保护机制、工业级缓存模式、DETS 磁盘持久化以及性能调优实战。

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).
特性setordered_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 缓存、计数器setO(1) 查找,覆盖更新
排行榜、时间序列范围查询ordered_set范围扫描、有序遍历
多标签、多成员关系bag去重自动,节省内存
事件日志、审计流水duplicate_bag保留全部历史
需快速判断键存在setets: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:lookupO(1),无锁竞争~几十 ns
ets:insertO(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 上构建出读写均接近内存极限、且天然支持多进程共享的高性能数据层。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「erlang」更多文章

  1. Elixir 测试工程:ExUnit 深入、属性测试与 Mock 策略
  2. Erlang 热代码升级与 Release:从 appup 到滚动升级
  3. Elixir Ecto 数据访问层:Schema、Query、迁移与变更集