BEAM 内存剖析与泄漏排查:recon、observer 与堆分析

BEAM 内存问题的系统排查方法:进程堆、二进制堆、ETS 与进程外内存的构成与观测入口、recon 的 proc_window/bin_leak/alloc 工具链、引用计数二进制与堆大小的分析手段、邮箱堆积与原子泄漏等典型模式,以及从 RSS 到具体进程的完整定位流程。

BEAM 的内存管理与 C/Java 有本质差别:每个进程有独立的私有堆,GC 以进程为单位进行,进程退出时其内存被整体回收。这带来两个后果——一是「某个进程内存爆了」通常不会拖垮其他进程,二是**「内存泄漏」在 BEAM 里很少是真正的泄漏,多数是某个数据结构或某个进程在无界增长**。

排查的困难在于内存分散在五个互不相干的区域:进程堆、二进制堆(binary heap)、ETS 表、代码区与原子表,外加 NIF/驱动占用的进程外内存(off-heap)。:erlang.memory/0 只能告诉你总量,定位到具体进程要靠 recon 与 observer。本文按「从整体到个体」的顺序组织排查手段。

BEAM 内存的五个区域

1> erlang:memory().
[{total, 86_245_376},
 {processes, 24_117_248},
 {processes_used, 24_101_192},
 {system, 62_128_128},
 {atom, 1_052_529},
 {atom_used, 1_015_204},
 {binary, 8_441_112},
 {code, 22_384_513},
 {ets, 4_194_304}]

各字段的含义与归属:

字段归属回收方式常见增长原因
processes所有进程的私有堆进程 GC / 进程退出单进程数据膨胀、进程数增长
binary引用计数二进制(>64 字节)引用归零时回收大二进制被长期持有
etsETS 表数据显式删除 / 表销毁无界写入、无 TTL
atom原子表永不回收动态生成原子
code已加载模块的字节码模块卸载热更新累积旧版本
system其余(进程控制块、调度器等)—进程数、端口数增长

processes 与 system 相加约等于 total(system 已包含 atom/code/ets)。判断问题类型的第一步就是看哪一项在增长:

# 每 10 秒采样一次,观察趋势
defmodule MemWatch do
  def sample do
    m = :erlang.memory()
    %{
      total: m[:total],
      processes: m[:processes],
      binary: m[:binary],
      ets: m[:ets],
      atom: m[:atom],
      processes_count: :erlang.system_info(:process_count),
      ets_count: :erlang.system_info(:ets_count),
      port_count: :erlang.system_info(:port_count)
    }
  end
end

进程数(process_count)与 ETS 表数(ets_count)是两个容易被忽略的指标。进程数持续上涨说明有进程在泄漏(spawn 后未退出);ETS 表数上涨说明有代码在动态建表而未销毁。两者都可以设上限,超过直接拒绝,把无界增长变成显式错误:

%% vm.args
+P 1000000          %% 进程数上限
+Q 65536            %% 端口数上限
+t 2000000          %% 原子表上限

+t(原子数上限)是保护性配置:原子永不回收,如果代码把用户输入 String.to_atom/1,攻击者可以用海量不同字符串耗尽原子表并让 VM 崩溃。设上限后,超限时 VM 直接终止而非静默耗尽内存——这是「快速失败优于慢性死亡」的典型取舍。

整体观测:recon 与 observer

recon 是 BEAM 运维的事实标准工具库,纯 Erlang 实现,生产环境可安全使用(不开 tracing、不阻塞调度器)。

%% 最常用的几把刀
recon:memory().                    %% 等价于 erlang:memory() 但排版更清晰
recon:node_stats_print(10, 5000).  %% 打印 10 次,每次间隔 5 秒
recon:proc_count(memory, 10).      %% 内存占用前 10 的进程
recon:proc_count(message_queue_len, 10).  %% 邮箱最长的 10 个进程
recon:bin_leak(10).                %% 持有最多引用计数二进制的进程

recon:node_stats_print/2 是最省事的第一步,它输出一段包含进程数、运行队列、内存分布、二进制与 ETS 占用的快照。连续打印多次,增长的部分就是问题所在。

2> recon:node_stats_print(3, 2000).
Node: app@10.0.1.5
Now: 2026-10-07 21:12:00
Proc Count: 1042  (limit: 1000000)
...
Memory:
  Total: 86.2 MB
  Processes: 24.1 MB
  Binary: 8.4 MB
  ETS: 4.2 MB
  Atom: 1.0 MB

observer 是图形化工具,本地开发与预发环境很好用:

:observer.start()

它的 System 标签页给出内存分布饼图,Processes 标签页可按内存排序,Table Viewer 能直接浏览 ETS 表内容。生产环境无法开 GUI 时,用 observer_cli——它是 observer 的终端版本:

observer_cli:start().

observer_cli 在 SSH 会话里就能运行,显示的指标与 observer 基本一致,且支持按内存/邮箱排序的实时视图。这是排查线上问题时最先打开的工具。

进程级剖析

锁定可疑进程后,:erlang.process_info/2 给出单个进程的全部细节:

3> Pid = list_to_pid("<0.123.0>").
4> erlang:process_info(Pid, [memory, message_queue_len, heap_size,
                             stack_size, total_heap_size, reductions,
                             current_function, registered_name]).
[{memory, 12_345_678},
 {message_queue_len, 0},
 {heap_size, 4_194_304},
 {stack_size, 12},
 {total_heap_size, 4_194_304},
 {reductions, 987_654_321},
 {current_function, {gen_server, loop, 7}},
 {registered_name, my_cache_server}]

关键字段的判读:

字段含义异常信号
memory进程总内存(堆 + 栈 + 控制块)持续增长
message_queue_len邮箱中未处理消息数持续增长 → 消费慢于生产
heap_size当前活跃堆大小(字)与 total_heap_size 差距大 → 堆内空闲空间多
total_heap_size堆 + 已分配但未使用的空间远大于实际数据
reductions累计执行量结合 memory 判断是忙还是卡
current_function当前执行的函数长期停在同一位置 → 阻塞

heap_size 与 total_heap_size 的比值反映内存利用效率。BEAM 的 GC 按代进行,total_heap_size 包含了已分配但尚未使用的空间;如果 total_heap_size 是 heap_size 的数倍,说明进程刚经历大量数据释放但堆还没收缩,可以主动触发一次 fullsweep:

:erlang.garbage_collect(pid)

GC 统计信息能看出 GC 是否在正常工作:

Process.info(pid, :garbage_collection)
# {:garbage_collection,
#   [minor_gcs: 1240, major_gcs: 12, fullsweep_after: 65535,
#    max_heap_size: :infinity, heap_block_size: 1024, ...]}

major_gcs 长期不增长而内存持续上涨,说明进程持有大量长期存活的数据结构,GC 无法回收——这才是真正的「内存增长」而非「GC 不及时」。此时要检查进程状态里存了什么。

需要区分的是:进程堆的增长是 GC 策略问题,还是数据真的变多了。前者可以通过调参缓解——fullsweep_after 控制多少次 minor GC 后做一次 fullsweep,默认 65535 意味着几乎不做;把它调小(如 1000)能让长生命周期进程更早收缩堆,代价是 GC 频率上升。这类分代策略与调优思路与 GC 调优 中讨论的取舍一致,只是 BEAM 的 GC 单位是进程而非整个堆。

recon:info/1 把上述信息组织成人类可读的形式,并额外给出 links、monitors、dictionary:

recon:info(list_to_pid("<0.123.0>")).

进程字典(process dictionary)是常被忽略的内存黑洞。它存在进程控制块里,不受 GC 管理,用 Process.put/2 写入的数据会一直存在直到进程退出。第三方库滥用进程字典时,recon:info/1 的 dictionary 字段会直接暴露。

时间窗口:找到「增长最快」的进程

单次快照只能看到「谁现在最大」,但泄漏的特征是持续增长。recon:proc_window/3 在两个时间点采样并对比,找出增长最快的进程:

%% 在 3 秒窗口内,按内存增长排序取前 10
recon:proc_window(memory, 3, 10).

%% 按邮箱增长排序
recon:proc_window(message_queue_len, 3, 10).

%% 按 reductions 增长排序(找最忙的进程)
recon:proc_window(reductions, 3, 10).

这是定位泄漏最有效的单条命令。一个持有 5MB 但稳定的进程不是问题,一个 3 秒内从 1MB 涨到 8MB 的进程才是。proc_window 的窗口参数需要覆盖至少一次完整的业务周期——如果业务是每分钟一批任务,窗口设 3 秒会漏掉;设 120 秒又太粗。折中做法是先用 node_stats_print/2 观察整体增长速率,再定窗口。

配合 recon:trace/3 做调用计数,可以确认某个函数是否被异常频繁调用:

recon:trace(5000, {my_module, my_function, 2}).

二进制:最隐蔽的一类增长

BEAM 的二进制分两种,理解它们的差别是排查二进制问题的前提:

类型大小存储位置回收
堆二进制(heap binary)≤ 64 字节进程堆内随进程 GC
引用计数二进制(refc binary)> 64 字节共享二进制堆引用归零时

引用计数二进制是「跨进程共享」的:多个进程持有同一份二进制时,只有一份数据在内存里,引用计数为 0 才回收。这带来了一个经典陷阱——切分一个大二进制产生的小二进制,会保持对整个大二进制的引用:

big = File.read!("huge.log")          # 100 MB refc binary
line = binary_part(big, 0, 20)        # 只取 20 字节
# 此时 line 仍然引用着 100 MB 的 big,只要 line 活着,big 就不能回收

修复方式是拷贝出一份独立的小二进制:

line = :binary.copy(binary_part(big, 0, 20))
# 或者用 :binary.part/3 的拷贝语义

Erlang 的 binary:part/3 与 binary:copy/1 同理。这个陷阱在解析大文件、处理 HTTP 大 body、读取数据库大字段时都会出现,而且从代码上看完全无害。

recon:bin_leak/1 专门定位这类问题,它返回持有引用计数二进制最多的进程:

4> recon:bin_leak(10).
[{3000, "<0.456.0>", my_binary_cache, 104_857_600}, ...]
%% {引用数, pid, 注册名, 持有字节数}

输出中 引用数 高但 持有字节数 也高的进程,就是需要检查的对象。常见的持有者包括:做缓存的 GenServer、消息队列的中间缓冲、未及时清理的会话进程。

:erlang.memory(:binary) 反映的是二进制堆总量。如果它持续增长,用 bin_leak 定位;如果它稳定但 RSS 持续增长,问题在别处(大概率是进程外内存)。

进程外内存:RSS 与 BEAM 对不上

最棘手的情况是 :erlang.memory(:total) 稳定而操作系统看到的 RSS 持续上涨。这说明内存在 BEAM 的分配器之外——典型来源是 NIF、端口驱动(port driver)与 C 库。

%% recon_alloc 提供分配器级别的视图
recon_alloc:memory(used).        %% 各分配器当前使用量
recon_alloc:memory(allocated).   %% 各分配器向 OS 申请的总量
recon_alloc:snapshot().          %% 完整快照,可多次对比
recon_alloc:bin_allocators().    %% 二进制分配器专项

used 与 allocated 的差值反映了分配器的缓存行为:BEAM 的分配器会保留空闲块以备复用,所以 allocated 略大于 used 是正常的;但如果差值持续扩大,说明内存碎片化严重。

如果 recon_alloc 也解释不了增长,就要怀疑 NIF:

# 观察 RSS 与 BEAM 自报的内存差
ps -o rss= -p $(pgrep -f 'beam.smp')

NIF 的内存由 NIF 自己管理,BEAM 完全不知情。常见泄漏源包括:NIF 里 malloc 后未 free、C 库内部缓存无上限、enif_alloc_resource 创建的资源对象未被释放。排查手段有限,通常需要:

  1. 用 valgrind 或 heaptrack 在预发环境跑一次完整业务周期。
  2. 把可疑 NIF 的调用频率降下来(recon:trace 确认调用量),观察 RSS 增长是否随之放缓。
  3. 确认 NIF 是否在脏调度器上执行——长耗时的普通 NIF 会阻塞调度器并让资源释放延迟。

这也解释了为什么 BEAM 性能调优 里反复强调 NIF 的边界:性能与内存风险都集中在同一处。

典型泄漏模式与对策

绝大多数 BEAM 内存问题可以归入下面几类,对照排查效率最高:

模式特征定位手段对策
邮箱堆积message_queue_len 持续增长proc_window(message_queue_len, ...)背压、限流、丢弃策略
进程泄漏process_count 持续上涨按 spawn 点审查用 Task.Supervisor 约束生命周期
ETS 无界memory(:ets) 上涨:ets.info(tab, :size)设 :limit、加 TTL、定期清理
原子泄漏memory(:atom) 上涨:erlang.system_info(:atom_count)禁止 to_atom 处理外部输入
二进制引用memory(:binary) 上涨recon:bin_leak/1:binary.copy/1 切断引用
定时器堆积无对应内存指标但进程忙recon:info/1 看 timers取消不再需要的定时器
状态膨胀单进程 memory 持续增长recon:proc_window(memory, ...)检查状态结构、定期清理
NIF 泄漏RSS 涨但 memory(:total) 不涨recon_alloc:memory/1 + RSS审查 NIF 的 C 代码

邮箱堆积是最常见的,因为它在功能测试里完全不可见——只要最终能处理完,测试就通过。生产环境的高峰流量下,生产速率超过消费速率,邮箱就开始积压。对策不是「加大内存」,而是背压:在生产者侧限制并发,或对消息队列设上限并在超限时丢弃最旧的消息。

def handle_cast({:event, data}, state) do
  case :erlang.process_info(self(), :message_queue_len) do
    {:message_queue_len, len} when len > 10_000 ->
      # 拒绝新事件,保护自己
      {:noreply, state}
    _ ->
      {:noreply, process_event(data, state)}
  end
end

定时器堆积同样隐蔽。Process.send_after/3 创建的定时器挂在进程上,用 recon:info/1 的 timers 字段可以看到数量。如果每次请求都创建定时器却从不取消,定时器会无限累积。正确的做法是保存引用并在不再需要时 Process.cancel_timer/1。

进程泄漏的典型来源是 spawn/1 与 Task.start/1——它们创建不受监督的进程,出错后静默消失,或者因为逻辑缺陷永不退出。Task.Supervisor.start_child/2 与 DynamicSupervisor 是替代方案:它们把进程纳入监督树,崩溃有记录、数量有上限。进程与调度的关系见 BEAM 进程调度 。

ETS 表的内存在 :erlang.memory(:ets) 之外还有一部分隐藏在进程堆里(ordered_set 的索引结构)。检查单表占用:

:ets.info(:my_table, :memory)     # 字为单位,64 位系统乘 8 得字节
:ets.info(:my_table, :size)       # 条目数
:ets.info(:my_table, :limit)      # 条目上限,:infinity 表示无限制

:limit 默认是 :infinity,这意味着表可以无限增长。为所有长生命周期的表显式设 :limit 是一道廉价保险,超限时写入返回 :error 而非静默膨胀。ETS 与 Cachex 的取舍见 ETS 缓存与内存管理 。

完整排查流程

把上面的工具串成可执行的顺序:

  1. 确认是内存问题而非 GC 抖动。连续采样 :erlang.memory(:total) 与 RSS,两者都单调上涨才是真泄漏。
  2. 定位区域。看 processes / binary / ets / atom 哪一项在涨,缩小范围。
  3. 找增长最快的进程。recon:proc_window(memory, 30, 10),用足够长的窗口避开噪声。
  4. 单独剖析该进程。recon:info/1 看状态结构、字典、定时器;Process.info(pid, :garbage_collection) 看 GC 行为。
  5. 区分持有与泄漏。如果是二进制,用 recon:bin_leak/1 确认是否有 binary_part 引用陷阱。
  6. 检查进程外。若 BEAM 自报内存正常而 RSS 上涨,走 recon_alloc → NIF 审查路线。
  7. 修复后验证。同样的采样脚本跑满一个业务周期,确认曲线平坦。

把这套流程固化成周期性任务,比事后救火有效得多:

defmodule MemoryAuditor do
  use GenServer

  @interval :timer.minutes(5)

  def handle_info(:audit, state) do
    top = :recon.proc_window(:memory, 10, 5)

    Enum.each(top, fn {_delta, pid, info} ->
      if info[:memory] > 50 * 1024 * 1024 do
        Logger.warning("large process #{inspect(pid)}: #{inspect(info[:registered_name])} #{info[:memory]}")
      end
    end)

    Process.send_after(self(), :audit, @interval)
    {:noreply, state}
  end
end

这类轻量巡检的开销极低(proc_window 只做两次 process_info 采样),却能在问题爆发的几小时前就发出信号。内存指标应当与 CPU、延迟指标放在同一块面板上,因为内存问题往往先以延迟抖动或 GC 时间上升的形式出现,后端性能剖析 的指标体系可以直接复用。

实践建议

  1. 先看指标分区,再找进程。memory() 的各字段直接指向不同的排查路径,跳过这一步容易在错误的方向上浪费大量时间。
  2. recon:proc_window/3 是首选工具。单次快照找不出泄漏,增长速率才能。
  3. 给所有长生命周期 ETS 表设 :limit。默认的 :infinity 是事故温床。
  4. 禁止用 String.to_atom/1 处理外部输入。原子永不回收,这是唯一能让 VM 直接崩溃的内存问题。
  5. binary_part 后记得 :binary.copy/1。切断对大二进制的引用,避免「20 字节的小值锁住 100 MB」。
  6. 警惕邮箱堆积。它不报错、不崩溃,只是慢慢吃掉内存,必须靠指标发现。
  7. 区分 BEAM 内存与 RSS。两者背离时,问题在 NIF 或驱动,recon_alloc 是唯一的入口。
  8. 把巡检脚本纳入应用。定期记录内存曲线与 top 进程,比任何事后分析都便宜。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「erlang」更多文章

  1. 分布式一致性与网络分区:CRDT、libcluster 与脑裂治理
  2. 缓存、限流与熔断:Cachex、Hammer 与降级策略
  3. Elixir 元编程与宏:AST、quote/unquote 与 DSL 设计