一、Erlang虚拟机内存泄漏的典型症状
内存泄漏这事儿吧,就像家里水管漏水,刚开始可能察觉不到,等发现的时候地板都泡坏了。Erlang虚拟机(BEAM)里的内存泄漏通常有这些表现:
- 系统运行时间越长,内存占用越高,就像气球被慢慢吹大
- 通过
:erlang.memory()查看时,某个特定类型的内存(比如binary或ets)持续增长 - 系统响应变慢,垃圾回收(GC)越来越频繁
- 最终可能触发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内存管理的几个要点:
- 二进制数据要"用完就扔",避免长期持有引用
- ETS表要像数据库一样设计索引和清理策略
- 进程状态要定期"瘦身",避免无限增长
- 消息处理要设置合理的超时和背压机制
- 使用recon等工具定期进行健康检查
最后记住,Erlang的"让它崩溃"哲学在这里也适用——与其让内存泄漏拖垮整个系统,不如让有问题的进程尽早崩溃重启。
评论