BEAM 的性能特征与 JVM、Go 等运行时截然不同:进程极轻量、GC 以进程为单位、消息传递是唯一通信方式、调度器默认按核数铺开。这些设计让 Erlang 系统在长连接、高并发场景下表现优异,但也意味着用错模式会以非常隐蔽的方式退化——比如单个进程堆积百万条消息、binary 反复复制导致 GC 抖动、ETS 表成为全局串行点。性能调优的第一步不是优化代码,而是建立指标体系,用数据定位瓶颈所在层级。本文将沿着「方法论 → 进程调度 → GC 内存 → binary/ETS → 背压」的路径,给出一套可操作的调优框架。
一、性能调优方法论与指标体系
1.1 调优的正确顺序
调优的常见错误是「凭直觉改代码」。正确顺序应是:
- 量化现状:先测出吞吐、延迟分位、资源占用;
- 定位瓶颈层:区分是 CPU、内存、IO 还是调度;
- 最小改动验证:一次只改一个变量,测出增量;
- 回归对比:与基线对比,确认无副作用。
| 层级 | 典型症状 | 主要工具 |
|---|---|---|
| 调度器 | CPU 打满但吞吐不涨 | msacc、scheduler:utilization |
| 进程 | 单进程成为热点 | recon:top、process_info |
| 内存 | RSS 持续增长、GC 频繁 | erlang:memory、recon_alloc |
| 消息 | msg_queue 堆积 | recon:process_info、recon:proc_window |
| IO | 端口/文件阻塞 | recon:port_info、observer |
1.2 关键指标清单
%% 1. 系统级内存分布
erlang:memory().
%% [{total, 123456789}, {processes, ...}, {binary, ...},
%% {ets, ...}, {atom, ...}, {code, ...}]
%% 2. 进程与端口总数
erlang:system_info(process_count).
erlang:system_info(port_count).
erlang:system_info(process_limit).
%% 3. 调度器利用率(需启用)
scheduler:utilization(). % 需先 scheduler:utilization(0, 1000)
erlang:statistics(run_queue). % 可运行进程队列长度
erlang:statistics(reductions). % 归约数(近似 CPU 工作量)
%% 4. GC 统计
erlang:statistics(garbage_collection).
%% {NumberOfGCs, WordsReclaimed, 0}
1.3 压测方法论
压测必须回答三个问题:多快、多稳、多贵。
%% 用 wrk 压 HTTP 接口,关注分位而非均值
wrk -t8 -c400 -d60s --latency http://localhost:8080/api/users
%% 输出关注点
%% Latency P50 / P90 / P99 —— 分位比均值更能反映用户体验
%% Req/Sec 平均吞吐
%% Socket errors —— 连接是否被拒
| 指标 | 含义 | 红线 |
|---|---|---|
| 吞吐 QPS | 每秒完成请求数 | 随并发增长应趋于饱和而非下降 |
| P99 延迟 | 尾部延迟 | 与 P50 差距不应超过 10 倍 |
| 错误率 | 5xx 占比 | 应为 0 |
| 内存 RSS | 常驻内存 | 稳定期不应持续爬升 |
| run_queue | 可运行进程数 | 长期 > 核数说明 CPU 饱和 |
压测陷阱:只压到瓶颈出现就停,无法区分「饱和」与「崩溃」。应持续加压直到吞吐下降,才能找到系统的真实拐点。
二、进程与调度器剖析
2.1 调度器工作原理
BEAM 默认为每个 CPU 核启动一个调度器(+S N),每个调度器有自己的 run queue,进程通过 work stealing 迁移:
%% 查看调度器配置
erlang:system_info(schedulers). % 调度器数量
erlang:system_info(schedulers_online). % 在线调度器
erlang:system_info(dirty_cpu_schedulers). % dirty CPU 调度器(NIF 用)
%% 查看每个调度器的负载
[erlang:statistics(scheduler_wall_time) || _ <- [1]].
2.2 recon:进程剖析利器
recon 是生产环境最实用的诊断库,它能在不阻塞系统的情况下抓取快照:
%% 1. 按消息队列长度排序,找出被淹没的进程
recon:proc_window(msg_queue_len, 10, 1000).
%% 返回 [{Pid, {msg_queue_len, N}, {memory, M}}, ...]
%% 2. 按归约数排序,找出 CPU 消耗大户
recon:proc_window(reductions, 10, 1000).
%% 3. 按内存排序
recon:proc_window(memory, 10, 1000).
%% 4. 单进程详情(含当前函数栈)
recon:process_info(Pid, [current_stacktrace, message_queue_len, memory]).
%% 5. 找出占用内存最多的 ETS 表
recon_alloc:memory(allocated_types).
2.3 observer 与 observer_cli
开发期用 observer 图形界面,生产环境用 observer_cli(终端版):
%% 开发环境启动
observer:start().
%% 生产环境(observer_cli 依赖)
observer_cli:start().
%% 按键导航:
%% m - 内存分布 p - 进程列表
%% e - ETS 表 s - 系统信息
%% r - 进程详情(含栈回溯)
2.4 调度器饱和的诊断
%% run_queue 长期高于调度器数 → CPU 饱和
case erlang:statistics(run_queue) of
N when N > erlang:system_info(schedulers_online) * 2 ->
logger:warning("scheduler saturated: run_queue=~p", [N]);
_ -> ok
end.
%% 启用调度器 wall time 统计(开销约 1%,仅诊断期开启)
scheduler:utilization(0, 1000),
timer:sleep(5000),
scheduler:utilization().
%% [{normal, 0, [{total, 5000}, {weighted, 4820}, ...]}, ...]
%% weighted/total 接近 1 表示该调度器满载
经验:run_queue 高但 CPU 未满,通常是锁竞争(如
global、ets单点)或 dirty NIF 阻塞;CPU 满且 run_queue 高,则是真的算力不足,需要水平扩展或减少归约数。
三、GC 与内存优化
3.1 分代 GC 模型
每个 Erlang 进程有独立的堆与 GC,采用分代复制式回收:
- Young(新生代):新分配的数据,回收频繁但快(minor GC);
- Old(老年代):存活过一次 GC 的数据,回收少但慢(major GC);
- 消息传递时 term 在进程间复制,因此进程隔离天然,但也带来复制成本。
%% 查看进程 GC 信息
process_info(Pid, garbage_collection).
%% [{max_heap_size, 0}, {min_bin_vheap_size, 46422},
%% {min_heap_size, 233}, {fullsweep_after, 65535}, {minor_gcs, 12}]
%% 手动触发 GC(生产慎用,会暂停进程)
erlang:garbage_collect(Pid).
3.2 进程堆大小调优
频繁 minor GC 说明堆太小;调大初始堆可减少 GC 次数,代价是内存占用:
%% 启动时设置默认堆大小(words,1 word = 8 字节)
%% erl +hms 233 +hmbs 32768
%% 单进程设置(spawn_opt)
Pid = spawn_opt(fun worker/0, [{min_heap_size, 8192},
{min_bin_vheap_size, 65536}]),
%% 设置最大堆,超限直接 kill(防内存泄漏拖垮节点)
spawn_opt(fun worker/0, [{max_heap_size, #{size => 100_000_000,
kill => true,
error_logger => true}}]).
3.3 binary 堆与 refc binary
大于 64 字节的 binary 存放在共享堆(refc binary),不占进程堆;小于 64 字节的存进程堆。误用 refc binary 会导致 GC 负担与内存泄漏:
%% 危险:子 binary 引用整个大 binary,导致大 binary 无法回收
big = <<1,2,3,...>>, % 1MB
Sub = binary:part(big, 0, 10), % Sub 引用 big,big 无法释放
%% 修复:copy 出来,切断引用
Sub = binary:copy(binary:part(big, 0, 10)).
%% 查看 binary 内存
erlang:memory(binary).
recon_alloc:memory(allocated).
3.4 内存泄漏定位
%% 1. 总量趋势:RSS 与 erlang:memory(total) 是否同步增长
erlang:memory(total).
%% 2. 找出内存最多的进程
recon:proc_window(memory, 5, 5000).
%% 3. 找出最大的 ETS 表
[{ets:info(T, name), ets:info(T, memory), ets:info(T, size)} || T <- ets:all()].
%% 4. atom 泄漏(atom 不回收)
erlang:system_info(atom_count).
erlang:system_info(atom_limit). % 默认 1048576,超限直接崩溃
| 泄漏源 | 症状 | 治理 |
|---|---|---|
| 进程堆 | 单进程 memory 持续增长 | max_heap_size + 定位累积结构 |
| refc binary | memory(binary) 增长 | binary:copy 切断子引用 |
| ETS 表 | memory(ets) 增长 | TTL + 定期清理 |
| atom | atom_count 逼近上限 | 禁止 binary_to_atom 处理用户输入 |
atom 泄漏是 Erlang 最致命的泄漏:atom 表只增不减,用
list_to_atom处理外部输入会被攻击者耗尽内存并导致节点崩溃。必须用binary_to_existing_atom。
四、binary 与 ETS 高效使用
4.1 binary 拼接的正确姿势
%% 错误:O(N^2) 复制
BadConcat(Chunks) ->
lists:foldl(fun(C, Acc) -> <<Acc/binary, C/binary>> end, <<>>, Chunks).
%% 正确一:用 iolist 延迟拼接,最后一次性转换
GoodIolist(Chunks) ->
iolist_to_binary(Chunks).
%% 正确二:用列表累积再 join
GoodJoin(Chunks) ->
binary:list_to_binary(Chunks).
4.2 iolist 与协议编码
iolist 是「binary 与字节列表的嵌套结构」,编码时避免中间复制:
%% 构造响应:头 + 体,零复制
encode_response(Status, Body) ->
Len = byte_size(Body),
[<<"HTTP/1.1 ">>, Status, <<"\r\nContent-Length: ">>,
integer_to_binary(Len), <<"\r\n\r\n">>, Body].
%% 一次性发到 socket
gen_tcp:send(Socket, encode_response(<<"200 OK">>, <<"hello">>)).
4.3 ETS 性能要点
ETS 的调优细节在 https://plumephp.com/erlang-ets-caching/ 中已有系统论述,这里补充与性能直接相关的三点:
%% 1. 读多写少:开启读并发
ets:new(cache, [set, protected, {read_concurrency, true}]).
%% 2. 写多读少:开启写并发(set 表以分桶锁实现)
ets:new(counter, [set, public, {write_concurrency, true}]).
%% 3. 批量操作远快于逐条
ets:insert(Tab, ListOfTuples). % 一次调用插入 N 条
ets:select_delete(Tab, MatchSpec). % 批量删除
| 操作 | 复杂度 | 并发性 | 备注 |
|---|---|---|---|
lookup | O(1) | 完全并发 | 无锁 |
insert | O(1) | 分桶锁 | 批量更快 |
match 全表 | O(N) | 阻塞写 | 大表用 select 分页 |
update_counter | O(1) | 原子 | 热点计数器首选 |
4.4 用 ETS 替代进程内状态
把高频读的状态从 gen_server 状态搬到 ETS,可让读路径完全并行:
%% 反模式:所有读都经过 gen_server,单点串行
get(Key) -> gen_server:call(?MODULE, {get, Key}).
%% 正解:读走 ETS,写走 gen_server
get(Key) ->
case ets:lookup(?TAB, Key) of
[{_, V}] -> {ok, V};
[] -> not_found
end.
五、消息队列与背压治理
5.1 消息队列堆积的成因
Erlang 的消息传递是异步无界的:发送方 ! 永不阻塞,接收方处理慢就会无限堆积,最终 OOM。
%% 检测堆积进程
recon:proc_window(msg_queue_len, 10, 1000).
%% 单进程详情
{message_queue_len, Len} = process_info(Pid, message_queue_len),
case Len > 10_000 of
true -> logger:warning("pid ~p queue=~p", [Pid, Len]);
false -> ok
end.
5.2 背压策略
| 策略 | 做法 | 适用 |
|---|---|---|
| 同步调用 | 用 gen_server:call 替代 cast | 天然背压,但阻塞调用方 |
| 有界队列 | 队列满时丢弃或拒绝 | 允许丢消息的场景 |
| 限流信号 | 发送方先检查接收方水位 | 生产者可控 |
| 批量拉取 | 消费方主动 call 拉一批 | 高吞吐消费 |
| 池化并发 | 多个 worker 分担 | 提升消费能力 |
%% 有界队列 + 拒绝策略
handle_cast({enqueue, Item}, State = #state{queue = Q, max => Max}) ->
case queue:len(Q) >= Max of
true ->
logger:warning("queue full, rejecting item"),
{noreply, State}; % 丢弃或回压
false ->
{noreply, State#state{queue = queue:in(Item, Q)}}
end.
5.3 主动背压:call 拉取模式
%% 消费者主动拉取,天然限速
consumer_loop(Producer) ->
case gen_server:call(Producer, fetch, 5000) of
{ok, Batch} ->
process_batch(Batch),
consumer_loop(Producer);
empty ->
timer:sleep(100),
consumer_loop(Producer)
end.
5.4 一个真实退化案例
某服务在高峰期内存暴涨,排查发现:
recon:proc_window(msg_queue_len, 5, 1000)显示一个日志进程队列达 200 万条;- 该进程用
cast接收日志,但写磁盘速度受限于 IO; - 上游业务进程发送速度远超磁盘写入,消息全堆在邮箱里。
修复:把日志改为有界队列 + 丢弃低优先级日志,并把磁盘写入放到独立进程池。修复后内存回落 60%,P99 延迟下降一半。
六、总结
BEAM 性能调优的核心是「先测量、再定位、后优化」。本文给出的框架可归纳为五条:
- 方法论:以吞吐、P99、错误率、RSS、run_queue 五个指标为锚,压测到拐点而非饱和点;
- 进程调度:用 recon 抓快照,按
msg_queue_len/reductions/memory三个维度排序定位热点; - GC 与内存:理解分代 GC 与 refc binary,用
max_heap_size兜底,重点防 atom 泄漏; - binary 与 ETS:用 iolist 避免 O(N^2) 复制,把高频读从 gen_server 迁到 ETS;
- 背压:消息队列无界是最大隐患,用 call 拉取、有界队列、水位限流三招治理。
性能问题的表象往往在应用层,根因却在运行时。理解了调度器、GC 与消息传递的机制,才能在读 https://plumephp.com/erlang-process-scheduling-beam/ 时把理论变成可执行的调优动作,也能更好地配合 https://plumephp.com/erlang-logging-telemetry-observability/ 中的指标采集,把调优从「事后救火」变成「持续观测」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。