BEAM 性能调优:从调度器指标到背压治理

以指标体系为主线,系统讲解 BEAM 的进程与调度器剖析、GC 与内存优化、binary 与 ETS 高效使用、消息队列与背压治理,以及可复用的压测方法论。

BEAM 的性能特征与 JVM、Go 等运行时截然不同:进程极轻量、GC 以进程为单位、消息传递是唯一通信方式、调度器默认按核数铺开。这些设计让 Erlang 系统在长连接、高并发场景下表现优异,但也意味着用错模式会以非常隐蔽的方式退化——比如单个进程堆积百万条消息、binary 反复复制导致 GC 抖动、ETS 表成为全局串行点。性能调优的第一步不是优化代码,而是建立指标体系,用数据定位瓶颈所在层级。本文将沿着「方法论 → 进程调度 → GC 内存 → binary/ETS → 背压」的路径,给出一套可操作的调优框架。

一、性能调优方法论与指标体系

1.1 调优的正确顺序

调优的常见错误是「凭直觉改代码」。正确顺序应是:

  1. 量化现状:先测出吞吐、延迟分位、资源占用;
  2. 定位瓶颈层:区分是 CPU、内存、IO 还是调度;
  3. 最小改动验证:一次只改一个变量,测出增量;
  4. 回归对比:与基线对比,确认无副作用。
层级典型症状主要工具
调度器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 binarymemory(binary) 增长binary:copy 切断子引用
ETS 表memory(ets) 增长TTL + 定期清理
atomatom_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).      % 批量删除
操作复杂度并发性备注
lookupO(1)完全并发无锁
insertO(1)分桶锁批量更快
match 全表O(N)阻塞写大表用 select 分页
update_counterO(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 一个真实退化案例

某服务在高峰期内存暴涨,排查发现:

  1. recon:proc_window(msg_queue_len, 5, 1000) 显示一个日志进程队列达 200 万条;
  2. 该进程用 cast 接收日志,但写磁盘速度受限于 IO;
  3. 上游业务进程发送速度远超磁盘写入,消息全堆在邮箱里。

修复:把日志改为有界队列 + 丢弃低优先级日志,并把磁盘写入放到独立进程池。修复后内存回落 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/ 中的指标采集,把调优从「事后救火」变成「持续观测」。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「erlang」更多文章

  1. rebar3 构建与发布:Erlang 工程化的完整工具箱
  2. RabbitMQ 与消息中间件:AMQP 模型与可靠投递
  3. Erlang 数据库集成与连接池实战