一、为什么Elixir也会资源泄漏?
很多人觉得Elixir有BEAM虚拟机罩着,又有垃圾回收机制,资源泄漏肯定不是问题。但现实很骨感,我就见过一个线上服务因为进程堆积把32G内存吃光的案例。Elixir的泄漏通常藏在三个地方:
- 进程泄漏 - 比如动态创建的GenServer没正确终止
- 二进制堆泄漏 - 大二进制数据没及时释放
- ETS表泄漏 - 无限增长的ETS表
举个真实的例子,我们有个消息转发服务用了动态Supervisor:
# 错误示例:每次收到消息都创建新进程
def handle_cast({:forward, msg}, state) do
{:ok, pid} = DynamicSupervisor.start_child(MySupervisor, {Worker, msg})
{:noreply, state}
end
三个月后线上出现了20多万个僵尸进程。正确的做法应该是复用进程或者设置回收策略。
二、进程监控三板斧
1. Process模块的妙用
Elixir自带的Process模块就是我们的瑞士军刀。这几个函数特别实用:
# 查看进程数(正常服务应该稳定在某个区间)
Process.list() |> length()
# 检查单个进程内存(重点关注>1MB的)
Process.info(pid, :memory)
# 查找内存大户(单位:KB)
Process.list()
|> Enum.map(fn pid ->
{pid, Process.info(pid, :memory)[:memory] / 1024}
end)
|> Enum.sort_by(&elem(&1, 1), :desc)
|> Enum.take(10)
2. Observer可视化工具
混入observer可以启动图形化界面:
:observer.start()
重点关注这几个标签页:
- Applications:查看应用树结构
- Processes:按内存/消息队列排序
- ETS:检查表大小增长
3. 自定义监控策略
给关键进程加上心跳监控:
defmodule LeakDetector do
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, opts)
end
def init(_) do
schedule_check()
{:ok, %{}}
end
defp schedule_check do
Process.send_after(self(), :check, 30_000) # 每30秒检查一次
end
def handle_info(:check, state) do
abnormal = detect_abnormal_processes()
notify_if_leak(abnormal)
schedule_check()
{:noreply, state}
end
defp detect_abnormal_processes do
# 自定义检测逻辑...
end
end
三、内存分析实战技巧
1. 快照对比法
先用:recon工具抓取内存快照:
# 第一次采样
snapshot1 = :recon.allocated(1)
# 间隔一段时间后...
snapshot2 = :recon.allocated(1)
# 对比差异
:recon.diff(snapshot1, snapshot2)
2. 二进制堆分析
大二进制数据最容易导致内存暴涨:
# 检查二进制堆占用
:recon.bin_leak(5) # 显示TOP5
# 典型输出示例:
# [
# {#PID<0.312.0>, 258000, <<255,255,255,...>>},
# {#PID<0.315.0>, 128000, <<123,34,100...>>}
# ]
3. ETS表监控
ETS表忘记删除是常见问题:
# 查看ETS内存占用
:timer.tc(fn ->
:ets.all()
|> Enum.map(&:ets.info(&1, :memory))
|> Enum.sum()
end)
# 查找可疑表
:ets.i()
|> Enum.filter(fn {_, _, _, size, _} -> size > 100_000 end)
四、常见坑与优化方案
1. 进程字典陷阱
进程字典(Process Dictionary)用不好就是定时炸弹:
# 错误示例:在进程字典堆积数据
def handle_call(:get, _from, state) do
Process.put(:cache, load_big_data()) # 每次调用都存储大数据
{:reply, Process.get(:cache), state}
end
# 正确做法是用状态或ETS
def handle_call(:get, _from, state) do
{:reply, state.cache, state}
end
2. 流处理注意事项
处理大文件时一定要用流:
# 危险操作:一次性读取
File.read!("big_file.log") # 可能直接OOM
# 安全做法:流式处理
File.stream!("big_file.log")
|> Stream.chunk_every(1024)
|> Enum.each(&process_chunk/1)
3. 第三方库的隐患
有些库会悄悄持有资源,比如DB连接池:
# 在应用停止时需要明确关闭
defmodule MyApp do
use Application
def stop(_state) do
MyApp.Repo.stop() # 确保连接池关闭
end
end
五、构建防护体系
1. 熔断机制
给关键进程设置内存熔断:
defmodule SafeServer do
use GenServer
@max_memory 100_000 # 100MB
def handle_info(:check_memory, state) do
case Process.info(self(), :memory)[:memory] do
mem when mem > @max_memory ->
Logger.warning("内存溢出,主动终止")
{:stop, :normal, state}
_ ->
Process.send_after(self(), :check_memory, 10_000)
{:noreply, state}
end
end
end
2. 监控告警集成
与Prometheus集成示例:
defmodule MetricsExporter do
use Prometheus.PlugExporter
def collect_mc do
Process.list()
|> Enum.each(fn pid ->
memory = Process.info(pid, :memory)[:memory]
:prometheus_gauge.set(
:process_memory_bytes,
[pid: inspect(pid)],
memory
)
end)
end
end
3. 压力测试策略
用Perf工具模拟泄漏场景:
defmodule LeakTest do
def simulate do
1..100_000
|> Enum.each(fn i ->
Task.async(fn ->
:timer.sleep(1000)
# 故意不退出进程
end)
end)
end
end
六、总结与最佳实践
经过多次实战,我总结出这些经验:
- 新功能上线前必须做内存基准测试
- 关键进程要设置内存上限
- 定期执行:recon_diff检查
- 第三方库要重点监控
- 建立自动化巡检机制
记住,Elixir不是银弹,BEAM的GC虽然强大但也不能解决所有问题。养成良好的编码习惯,才能从根本上避免资源泄漏。
评论