一、为什么Elixir也会资源泄漏?

很多人觉得Elixir有BEAM虚拟机罩着,又有垃圾回收机制,资源泄漏肯定不是问题。但现实很骨感,我就见过一个线上服务因为进程堆积把32G内存吃光的案例。Elixir的泄漏通常藏在三个地方:

  1. 进程泄漏 - 比如动态创建的GenServer没正确终止
  2. 二进制堆泄漏 - 大二进制数据没及时释放
  3. 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

六、总结与最佳实践

经过多次实战,我总结出这些经验:

  1. 新功能上线前必须做内存基准测试
  2. 关键进程要设置内存上限
  3. 定期执行:recon_diff检查
  4. 第三方库要重点监控
  5. 建立自动化巡检机制

记住,Elixir不是银弹,BEAM的GC虽然强大但也不能解决所有问题。养成良好的编码习惯,才能从根本上避免资源泄漏。