一、什么是内存泄漏?为什么Erlang也会遇到?

内存泄漏就像你家水龙头没关紧,水一直流却用不上。在Erlang中,虽然虚拟机有垃圾回收机制,但某些情况下资源仍会像漏水的桶一样持续增长。比如进程长期持有不再需要的数据、ETS表忘记删除、二进制大对象未及时释放等。

举个实际例子:

%% 技术栈:Erlang/OTP 25
%% 有问题的代码:进程字典滥用导致泄漏
leaky_function() ->
    put(heavy_data, lists:duplicate(1000000, 42)), % 存储1MB数据到进程字典
    do_some_work(),
    % 忘记执行erase(heavy_data) 
    ok.

这个案例中,每次调用函数都会在进程字典留下"数据垃圾",就像在房间里不断堆放快递盒却不清理。

二、如何发现内存泄漏?

2.1 观察系统指标

使用Erlang自带的工具就像检查汽车的仪表盘:

%% 查看内存概况
erlang:memory().
%% 输出示例:
%% [{total,2097463104},
%%  {processes,1084212224},
%%  {ets,32883328}]

如果processes或ets部分内存持续增长,就像油箱表一直往右偏,就要警惕了。

2.2 使用recon工具

这个第三方工具就像给虚拟机做X光检查:

%% 安装后使用:
recon:proc_count(memory, 5). % 显示内存前5的进程
recon:bin_leak(5).           % 检查二进制泄漏

2.3 实战诊断示例

假设我们发现一个ETS表异常:

%% 技术栈:Erlang/OTP 25
%% 泄漏场景:不断增长的ETS表
init() ->
    ets:new(cache, [public, ordered_set]), % 创建ETS表
    ok.

handle_request(Data) ->
    ets:insert(cache, {now(), Data}), % 持续插入但不清理
    ok.

通过recon:ets_count()可以看到这个表的大小像吹气球一样膨胀。

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

3.1 进程堆积

就像餐厅里不断来客人却没人离开:

%% 问题代码:进程不退出
start_worker() ->
    spawn(fun() -> 
        receive _ -> ok end % 阻塞但永不结束
    end).

fix_worker() ->
    spawn(fun() -> 
        receive 
            stop -> ok;
            Msg -> handle(Msg)
        after 30000 -> exit(normal) % 30秒超时自动退出
        end
    end).

3.2 二进制大对象

处理图片时容易踩坑:

%% 危险操作:二进制未及时释放
process_image(Data) ->
    Bin = read_large_file(Data), % 读取10MB图片
    Thumbnail = resize(Bin),
    % 忘记binary:copy/1或主动释放
    {ok, Thumbnail}.

%% 正确做法
safe_process_image(Data) ->
    Bin = read_large_file(Data),
    try resize(Bin) of
        Thumbnail -> {ok, Thumbnail}
    after
        erlang:binary_to_list(Bin) % 强制释放内存
    end.

3.3 定时器泄漏

就像忘记关掉的闹钟:

%% 错误示例:重复创建定时器
schedule() ->
    erlang:send_after(1000, self(), tick),
    ok.

%% 修复方案:取消旧定时器
schedule_fixed() ->
    case get(timer_ref) of
        undefined -> ok;
        OldRef -> erlang:cancel_timer(OldRef)
    end,
    Ref = erlang:send_after(1000, self(), tick),
    put(timer_ref, Ref),
    ok.

四、高级调试技巧

4.1 崩溃dump分析

当BEAM虚拟机崩溃时,会生成dump文件。使用erl_crash.dump分析工具:

# 在Linux终端执行
strings erl_crash.dump | grep -A 10 '=proc:' | less

这就像查看飞机的黑匣子,能找到内存暴涨前的最后状态。

4.2 压力测试中的内存监控

用TSUNG工具模拟负载时,可以这样观察:

%% 在测试节点上执行
observer:start(),
%% 切换到Memory标签页观察曲线

4.3 自定义内存监控

编写自己的监控模块:

-module(mem_monitor).
-export([start/0]).

start() ->
    spawn(fun() -> 
        loop(erlang:memory())
    end).

loop(PrevMem) ->
    Current = erlang:memory(),
    case Current > PrevMem of
        true -> alert_memory_jump(PrevMem, Current);
        false -> ok
    end,
    timer:sleep(5000),
    loop(Current).

五、预防胜于治疗

5.1 代码审查要点

  • 检查所有spawn调用是否有退出条件
  • 确认每个ets:new都有对应的ets:delete
  • 大二进制处理使用引用计数
  • 定时器必须有取消机制

5.2 监控体系搭建

建议在生产环境部署:

  1. Prometheus + Grafana监控内存曲线
  2. 设置erlang:system_monitor阈值
  3. 定期执行recon_alloc:settings/1检查内存分配器

5.3 内存优化技巧

%% 使用binary:referenced_byte_size/1
check_bin_leak(Bin) ->
    case binary:referenced_byte_size(Bin) > 1024*1024 of
        true -> warning;
        false -> ok
    end.

%% 及时压缩大消息
compress_large_term(Term) ->
    case erts_debug:size(Term) > 10000 of
        true -> {compressed, term_to_binary(Term)};
        false -> Term
    end.

六、真实案例剖析

某电商平台在促销时出现的内存泄漏:

%% 问题代码:购物车进程泄漏
handle_call({add, Item}, _From, State) ->
    NewItems = [Item | State#state.items],
    {reply, ok, State#state{items=NewItems}}.
    
%% 诊断发现:购物车进程平均持有200MB商品数据
%% 修复方案:定期清理或分页存储
handle_call({add, Item}, _From, State) ->
    NewItems = case length(State#state.items) > 100 of
        true -> lists:sublist([Item | State#state.items], 100);
        false -> [Item | State#state.items]
    end,
    {reply, ok, State#state{items=NewItems}}.

七、工具链推荐

  1. recon:内存分析瑞士军刀
  2. etop:类似Linux top的Erlang版
  3. observer_cli:终端版的observer
  4. memsup:OTP自带的内存监控
  5. fprof:性能分析时顺带检查内存

安装示例:

%% 在rebar.config中添加
{deps, [
    {recon, "2.5.1"}
]}.

八、总结与最佳实践

内存泄漏就像慢性病,初期不易察觉但危害巨大。我们的防治策略是:

  1. 预防阶段:代码审查时重点关注资源生命周期
  2. 检测阶段:部署多层级监控(进程/ETS/二进制)
  3. 诊断阶段:使用recon+observer组合拳
  4. 修复阶段:优先解决持续增长型泄漏
  5. 优化阶段:对高频操作实施内存限制

记住三个黄金法则:

  • 每个spawn都要有退出路径
  • 每个ets:new都要配对ets:delete
  • 大二进制要像对待打开的文件一样及时关闭

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

%% 内存安全快速自查表
checklist() ->
    [
        {process_count, length(processes()) < 10000},
        {ets_memory, ets:info(system_memory) < 100000000},
        {binary_memory, binary:memory() < 50000000}
    ].