一、Erlang虚拟机内存泄漏的典型症状

内存泄漏这事儿吧,就像家里水管漏水,刚开始可能察觉不到,等发现的时候地板都泡坏了。Erlang虚拟机(BEAM)里的内存泄漏通常有这些表现:

  1. 系统运行时间越长,内存占用越高,就像气球被慢慢吹大
  2. 通过:erlang.memory()查看时,某个特定类型的内存(比如binary或ets)持续增长
  3. 系统响应变慢,垃圾回收(GC)越来越频繁
  4. 最终可能触发OOM(内存不足)导致进程崩溃

举个实际监测的例子(技术栈:Erlang/OTP 23+):

%% 定期检查内存情况的监控模块
-module(mem_monitor).
-export([start/0]).

start() ->
    spawn(fun() -> loop() end).

loop() ->
    %% 获取所有内存类型数据
    MemData = erlang:memory(),
    io:format("Memory snapshot:~n~p~n", [MemData]),
    
    %% 重点关注binary内存
    {binary, BinMem} = lists:keyfind(binary, 1, MemData),
    BinMem > 100_000_000 andalso 
        error_logger:warning_msg("Binary memory over 100MB: ~p", [BinMem]),
    
    %% 每5分钟检查一次
    timer:sleep(300000),
    loop().

二、常见内存泄漏场景分析

2.1 二进制数据(binary)堆积

Erlang处理二进制数据很高效,但用不好就会出问题。比如下面这个HTTP服务处理JSON的例子:

handle_request(Req) ->
    {ok, Body, _} = cowboy_req:read_body(Req),
    %% 问题点:decode后保留原始binary
    Json = jsx:decode(Body, [return_maps]),
    process_data(maps:get(<<"data">>, Json)),
    
    %% 这里Body和Json都保留在内存
    {ok, Req2} = cowboy_req:reply(200, #{}, <<"OK">>, Req),
    Req2.

问题在于decode后的原始Body和Json结构都被保留,正确的做法应该是:

handle_request_fixed(Req) ->
    {ok, Body, _} = cowboy_req:read_body(Req),
    %% 只提取需要的数据
    Data = maps:get(<<"data">>, jsx:decode(Body, [return_maps])),
    process_data(Data),  % 处理完成后立即释放
    {ok, Req2} = cowboy_req:reply(200, #{}, <<"OK">>, Req),
    Req2.

2.2 ETS表失控增长

ETS是内存数据库,但缺乏自动清理机制:

init() ->
    %% 创建公共ETS表存储会话数据
    ets:new(session_cache, [public, named_table, set]),
    ok.

update_session(UserId) ->
    %% 每次访问都插入新记录但从不清理
    ets:insert(session_cache, {UserId, os:timestamp()}).

改进方案应该增加LRU清理逻辑:

update_session_fixed(UserId) ->
    Now = os:timestamp(),
    ets:insert(session_cache, {UserId, Now}),
    
    %% 定期清理30分钟前的记录
    case rand:uniform(100) < 5 of  % 5%概率触发清理
        true -> 
            Threshold = timer:now_diff(Now, {0,30,0}) div 1000000,
            MatchSpec = [{{'$1','$2'}, [{'=<', '$2', Threshold}], [true]}],
            ets:select_delete(session_cache, MatchSpec);
        false -> ok
    end.

三、诊断工具与实战技巧

3.1 使用recon进行内存分析

recon是强大的诊断工具,先看个内存top示例:

%% 在Erlang shell中执行
1> recon_alloc:memory(erlang:memory()).
%% 输出类似:
%% [{binary,245657600},
%%  {ets,73400320},
%%  {processes,45875200}]

2> recon:proc_count(memory, 5).  % 显示内存前5的进程

3.2 二进制内存泄漏定位

这个例子展示如何追踪binary泄漏:

%% 启动二进制追踪
recon:bin_leak(5).  % 监控前5个binary持有者

%% 配合下面代码演示泄漏
leaky_worker() ->
    receive
        {process, Data} ->
            %% 故意保留binary引用
            put(last_data, Data),
            leaky_worker()
    end.

3.3 进程堆分析

有时候泄漏来自进程堆:

%% 检查某个进程的内存详情
Pid = spawn(fun() -> timer:sleep(infinity) end),
recon:info(Pid).

%% 或者生成内存快照对比
{ok, Snap1} = recon:snapshot(),
%% ... 执行一些操作 ...
{ok, Snap2} = recon:snapshot(),
recon:diff_snaps(Snap1, Snap2).

四、防御性编程实践

4.1 进程设计原则

正确的进程模板应该这样:

-module(safe_worker).
-export([start/0, loop/1]).

start() -> spawn(?MODULE, loop, [[]]).  % 初始空状态

loop(State) ->
    receive
        {add, Item} ->
            NewState = [Item|lists:sublist(State, 100)],  % 限制历史记录
            loop(NewState);
        stop ->
            ok;
        _ ->
            loop(State)  % 忽略无法处理的消息
    after
        30000 ->  % 30秒心跳检测
            loop([])  % 定期重置状态
    end.

4.2 二进制处理黄金法则

处理二进制数据的正确姿势:

process_large_file(Path) ->
    %% 使用流式处理
    {ok, Device} = file:open(Path, [read, binary, raw]),
    process_chunks(Device),
    file:close(Device).

process_chunks(Device) ->
    case file:read(Device, 65536) of  % 每次64KB
        {ok, Bin} ->
            parse_chunk(Bin),
            process_chunks(Device);
        eof -> ok
    end.

parse_chunk(Bin) ->
    %% 立即处理不保留引用
    do_something_with(Bin),
    ok.

4.3 ETS表使用规范

安全使用ETS的模板:

-module(safe_cache).
-export([init/0, get/1, put/2]).

init() ->
    %% 带自动清理的ETS表
    Tab = ets:new(?MODULE, [ordered_set, public, {write_concurrency,true}]),
    spawn_link(fun() -> cleaner_loop(Tab) end),
    Tab.

cleaner_loop(Tab) ->
    timer:sleep(60000),  % 每分钟清理
    Now = os:system_time(second),
    %% 删除1小时前的记录
    ets:select_delete(Tab, [{{'$1','_'}, [{'=<', '$1', Now-3600}], [true]}]),
    cleaner_loop(Tab).

put(Tab, Key, Value) ->
    ets:insert(Tab, {os:system_time(second), Key, Value}).

get(Tab, Key) ->
    case ets:lookup(Tab, Key) of
        [{_, _, Value}] -> {ok, Value};
        [] -> not_found
    end.

五、疑难案例解析

5.1 消息队列堆积

这个案例中,进程因为处理速度跟不上导致内存暴涨:

slow_consumer() ->
    receive
        {data, Bin} when is_binary(Bin) ->
            timer:sleep(1000),  % 模拟处理耗时
            slow_consumer()
    end.

解决方案是增加背压机制:

smart_consumer(Parent) ->
    receive
        {data, Bin} ->
            Parent ! {processed, do_work(Bin)},
            smart_consumer(Parent);
        pause ->
            receive
                resume -> smart_consumer(Parent)
            end
    after
        0 ->  % 空队列时立即进入等待
            Parent ! {ready, self()},
            smart_consumer(Parent)
    end.

5.2 原子泄漏

虽然不常见,但原子不会被GC的特性可能导致问题:

%% 危险操作:动态创建大量原子
parse_json(Json) ->
    lists:foreach(fun(K) -> 
        binary_to_atom(K, utf8)  % 不要这样做!
    end, maps:keys(Json)).

应该改用binary或现有原子:

parse_json_safe(Json) ->
    lists:foreach(fun(K) ->
        case binary_to_existing_atom(K, utf8) of
            Atom when is_atom(Atom) -> ok;
            _ -> ignore  % 不认识的key直接跳过
        end
    end, maps:keys(Json)).

六、总结与最佳实践

经过这些案例,我们可以总结出Erlang内存管理的几个要点:

  1. 二进制数据要"用完就扔",避免长期持有引用
  2. ETS表要像数据库一样设计索引和清理策略
  3. 进程状态要定期"瘦身",避免无限增长
  4. 消息处理要设置合理的超时和背压机制
  5. 使用recon等工具定期进行健康检查

最后记住,Erlang的"让它崩溃"哲学在这里也适用——与其让内存泄漏拖垮整个系统,不如让有问题的进程尽早崩溃重启。