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 字节) | 引用归零时回收 | 大二进制被长期持有 |
ets | ETS 表数据 | 显式删除 / 表销毁 | 无界写入、无 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 创建的资源对象未被释放。排查手段有限,通常需要:
- 用
valgrind或heaptrack在预发环境跑一次完整业务周期。 - 把可疑 NIF 的调用频率降下来(
recon:trace确认调用量),观察 RSS 增长是否随之放缓。 - 确认 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 缓存与内存管理
。
完整排查流程
把上面的工具串成可执行的顺序:
- 确认是内存问题而非 GC 抖动。连续采样
:erlang.memory(:total)与 RSS,两者都单调上涨才是真泄漏。 - 定位区域。看
processes/binary/ets/atom哪一项在涨,缩小范围。 - 找增长最快的进程。
recon:proc_window(memory, 30, 10),用足够长的窗口避开噪声。 - 单独剖析该进程。
recon:info/1看状态结构、字典、定时器;Process.info(pid, :garbage_collection)看 GC 行为。 - 区分持有与泄漏。如果是二进制,用
recon:bin_leak/1确认是否有binary_part引用陷阱。 - 检查进程外。若 BEAM 自报内存正常而 RSS 上涨,走
recon_alloc→ NIF 审查路线。 - 修复后验证。同样的采样脚本跑满一个业务周期,确认曲线平坦。
把这套流程固化成周期性任务,比事后救火有效得多:
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 时间上升的形式出现,后端性能剖析
的指标体系可以直接复用。
实践建议
- 先看指标分区,再找进程。
memory()的各字段直接指向不同的排查路径,跳过这一步容易在错误的方向上浪费大量时间。 recon:proc_window/3是首选工具。单次快照找不出泄漏,增长速率才能。- 给所有长生命周期 ETS 表设
:limit。默认的:infinity是事故温床。 - 禁止用
String.to_atom/1处理外部输入。原子永不回收,这是唯一能让 VM 直接崩溃的内存问题。 binary_part后记得:binary.copy/1。切断对大二进制的引用,避免「20 字节的小值锁住 100 MB」。- 警惕邮箱堆积。它不报错、不崩溃,只是慢慢吃掉内存,必须靠指标发现。
- 区分 BEAM 内存与 RSS。两者背离时,问题在 NIF 或驱动,
recon_alloc是唯一的入口。 - 把巡检脚本纳入应用。定期记录内存曲线与 top 进程,比任何事后分析都便宜。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。