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

内存泄漏就像家里漏水的水龙头,刚开始可能只是滴水,但时间久了就会水漫金山。在Erlang系统中,最常见的症状就是beam.smp进程的内存占用曲线像坐了火箭一样往上窜。通过观察erlang:memory()的返回值,你会发现total值持续增长,而processes_used和binary_used往往是最主要的"罪魁祸首"。

举个实际案例:我们有个消息推送服务,运行一周后内存从2GB涨到了16GB。用recon_alloc:memory/1查看时,发现binary_allocators占用了85%的内存。这就像你的衣柜里塞满了从来不穿的衣服,系统内存被各种不再使用的二进制数据占得满满当当。

二、内存泄漏的定位工具箱

工欲善其事,必先利其器。Erlang生态里有几个神器值得常备:

  1. recon库:相当于内存检测的瑞士军刀
% 查看内存占用前100的进程
recon:proc_count(memory, 100).

% 分析二进制内存分布
recon:bin_leak(5). % 检查前5个可疑的二进制泄漏点
  1. etop:实时监控工具
% 启动类似top的监控界面
etop:start([{interval,5}, {sort, memory}]).
  1. erlang:process_info/2:当怀疑某个进程时
% 获取进程详细信息
erlang:process_info(Pid, [memory, messages, garbage_collection]).

最近我们还发现一个隐藏技巧:在Elixir环境下可以用:recon_lib模块的:node_stats_print/4函数,它能生成内存变化趋势图,比纯数字直观多了。

三、常见泄漏场景与修复方案

3.1 二进制数据泄漏

这是最常见的坑。Erlang的二进制分为heap binary和refc binary两种,后者容易引发泄漏。比如下面这个HTTP客户端代码就有问题:

download_large_file(Url) ->
    {ok, {{_, 200, _}, _, Body}} = httpc:request(Url),
    process_file(Body). % 如果Body很大且不立即处理就会泄漏

修复方案是使用二进制匹配及时释放:

download_large_file_fixed(Url) ->
    {ok, {{_, 200, _}, _, Body}} = httpc:request(Url),
    <<Chunk:1024/binary, Rest/binary>> = Body, % 分块处理
    process_chunk(Chunk),
    handle_remaining(Rest).

3.2 ETS表失控

ETS表就像共享内存,忘记清理就会泄漏。见过最夸张的案例是一个会话表设置了public属性却没人清理:

init() ->
    ets:new(session_tab, [public, named_table, {write_concurrency,true}]).
    
store_session(UserId, Data) ->
    ets:insert(session_tab, {UserId, Data}).
    
% 但缺少delete操作!

解决方案是加TTL或定期清理:

start_cleaner() ->
    spawn_link(fun() -> 
        timer:sleep(5 * 60 * 1000),
        clean_expired_sessions()
    end).

3.3 进程字典滥用

进程字典(process dictionary)是全局变量,用不好就会翻车:

handle_call(Request, _From, State) ->
    put(request_count, get(request_count) + 1), % 危险操作!
    {reply, ok, State}.

应该改用状态显式传递:

handle_call(Request, _From, #{count := Count} = State) ->
    NewState = State#{count := Count + 1},
    {reply, ok, NewState}.

四、高级调试技巧

当常规方法失效时,可以祭出这些大招:

  1. 核心转储分析
erl -env ERL_CRASH_DUMP_SECONDS 10 # 崩溃时自动生成dump

然后用crashdump_viewer工具分析:

crashdump_viewer:start().
  1. 二进制引用追踪
% 在recon中启用高级追踪
recon_trace:calls({erlang, binary_to_term, '_'}, 10).
  1. 压力测试复现
spawn_workers(0) -> ok;
spawn_workers(N) ->
    spawn(fun() -> stress_test() end),
    spawn_workers(N-1).

最近处理的一个分布式缓存泄漏问题,就是通过同时开启recon和Wireshark抓包,发现是TCP连接没有正确关闭导致的二进制堆积。

五、防患于未然的实践建议

根据我们团队的血泪教训,总结出这些最佳实践:

  1. 监控三件套

    • 使用observer_cli实时监控
    • 配置Prometheus+Gragana看板
    • 关键节点部署内存阈值告警
  2. 代码审查重点

    % 这些代码要重点检查
    - binary_to_term/1不带安全限制
    - 大消息直接跨进程传递
    - 没有匹配的<<_/binary>>操作
    
  3. 测试阶段检查

    # 在CI中加入内存检查
    dialyzer --plt ./plt -r apps/*/src
    
  4. 运维阶段保障

    % 热补丁加载示例
    update_code() ->
        {module, _} = code:load_file(memory_fix),
        ok.
    

记住,内存泄漏就像牙疼,早发现早治疗。我们去年有个服务因为没及时处理ETS泄漏,最后不得不半夜紧急扩容,那场面简直像消防队救火。

六、不同场景下的应对策略

根据系统类型不同,处理策略也要灵活调整:

  • Web服务:重点关注Cowboy连接池和JSON解析
  • 数据库驱动:检查连接池和查询结果集处理
  • 消息队列:监控进程邮箱堆积情况
  • 实时计算:注意流处理中的中间状态

比如用Phoenix开发时,可以这样检查WebSocket内存:

defmodule MyAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :my_app
  
  plug Plug.Parsers,
    parsers: [:json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library(),
    length: 10_000_000 # 限制最大解析大小
end

七、总结与思考

处理Erlang内存泄漏就像侦探破案,需要:

  1. 监控系统提供线索(observer、recon)
  2. 重现现场收集证据(压力测试)
  3. 分析证据锁定嫌犯(ets、binary、process)
  4. 制定方案解决问题(代码优化、架构调整)

最后送大家一个检查清单:

  • [ ] 所有binary都有明确的生命周期
  • [ ] ETS表有自动清理机制
  • [ ] 进程邮箱定期清空
  • [ ] 关键操作都有内存限制
  • [ ] 监控系统覆盖内存指标

记住,没有银弹能解决所有内存问题,但养成良好的编码习惯,能让你少走很多弯路。就像老司机开车,既要知道怎么修车,更要懂得如何避免故障。