Learning
VOL. XIII · NO. 23 · Elixir · 01 JAN 1970

GenServer / ETS / Code.reload 热重载

Elixir 编程 · 01 JAN 1970 · 7 min read · 1,369 words
· · ·

GenServer 是 OTP 的核心并发原语;ETS 是 BEAM 自带的内存表;把代码文件和它们结合起来,你就能做出"运行时改业务规则"的系统。

本课目标
学完后,你应该能写一个状态化的 GenServer;用 ETS 缓存热点数据;用 Code.compile_file/2 + Code.purge/1 安全地热重载模块;处理热重载时其他进程还在调用旧模块的问题。

一、为什么 GenServer

Elixir 里每个进程都是隔离的,但很多场景需要一个长期存在、有状态、串行处理请求的进程——这就是 GenServer:

  • 计数器、令牌桶、限流器。
  • 配置中心、规则引擎。
  • 客户端连接、订阅协调。
  • 缓存聚合(结合 ETS)。

GenServer 不是”通用服务器”,是”通用状态机”。它处理 call / cast / info 三类消息,按 handle_* 子句分发。

二、第一个 GenServer

defmodule MyApp.RateLimiter do
  use GenServer

  # 客户端 API
  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  def allow?(key), do: GenServer.call(__MODULE__, {:allow, key})

  # 服务器回调
  @impl true
  def init(_opts), do: {:ok, %{}, 5000}  # 5s 内没有活动就 hibernate 节能

  @impl true
  def handle_call({:allow, key}, _from, state) do
    now = System.monotonic_time(:millisecond)
    state = Map.update(state, key, {1, now}, fn {n, t} -> {n + 1, now} end)

    if Map.fetch!(state, key) |> elem(0) <= 100 do
      {:reply, true, state}
    else
      {:reply, false, state}
    end
  end
end

关键点:

  • init/1 返回 {:ok, state}{:ok, state, timeout}
  • handle_call/3 返回 {:reply, reply, new_state}
  • init 里抛错,进程不会启动;supervisor 会按策略重启。

三、GenServer 与并发

GenServer 是串行的——同一时间只有一个 handle_* 在跑。这意味着它不是性能银弹。判断标准:

  • 如果每个请求独立,没有共享状态 → 用 Task,不需要 GenServer。
  • 如果存在共享可变状态、要串行化、要支持 call / cast / 监控 → GenServer。
  • 如果状态可以分片到不同 key → 用 :gen_statem 或多个 GenServer。

四、ETS:进程级内存表

ETS(Erlang Term Storage)是 BEAM 内置的高性能 KV 表。它不属于任何进程,但被一个 owner 进程拥有。

defmodule MyApp.Cache do
  use GenServer

  # 客户端 API
  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  def put(k, v), do: GenServer.call(__MODULE__, {:put, k, v})
  def get(k), do: GenServer.call(__MODULE__, {:get, k})

  @table __MODULE__

  @impl true
  def init(_opts) do
    :ets.new(@table, [:set, :named_table, :public, read_concurrency: true])
    {:ok, %{}}
  end

  @impl true
  def handle_call({:put, k, v}, _from, state) do
    true = :ets.insert(@table, {k, v})
    {:reply, :ok, state}
  end

  def handle_call({:get, k}, _from, state) do
    {:reply, :ets.lookup(@table, k), state}
  end
end

# 用 :ets 直接读,绕过 GenServer
def fast_lookup(k), do: :ets.lookup(MyApp.Cache, k)
:public vs :protected
  • :protected(默认):只有 owner 能写,所有进程可读。推荐。
  • :public:任意进程都能读写。注意 race。
  • :private:只有 owner 能访问。一般给 :named_table + 内部用。

ETS 的关键优势:

  • 读写不经过 GenServer 邮箱(如果 :public),并发可达百万级 ops。
  • 支持 :set:bag:duplicate_bag:ordered_set
  • 可以配合 match_spec 做服务端过滤。

五、ETS 的所有权

ETS 表属于创建它的进程。如果 owner 死了,ETS 也死。所以生产代码里要把 ETS 挂在 supervisor 进程上:

defmodule MyApp.CacheOwner do
  use GenServer

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl true
  def init(_opts) do
    :ets.new(:my_cache, [:set, :named_table, :protected, read_concurrency: true])
    {:ok, %{}}
  end
end

# 监督树里把 MyApp.CacheOwner 放第一位
children = [
  MyApp.CacheOwner,
  MyApp.WebServer
]

或者更现代的做法:用 :persistent_term 存小型、几乎不变的数据;用 ETS 存运行时缓存。

六、热重载:Code.compile_file

BEAM 的核心优势是不停机能改代码Code.compile_file/2 读取源文件,编译成 bytecode:

def reload!(path) do
  [{module, _binary}] = Code.compile_file(path)
  Code.purge(module)
  {:module, module} = Code.ensure_loaded(module)
  {:ok, module}
end

基本流程:

  1. .ex 文件源码。
  2. 编译成字节码。
  3. 把旧模块从进程字典里清除(Code.purge)。
  4. 新进程调用走新代码。
热重载的副作用
  • 正在跑的进程不会自动切到新代码,它们持有旧模块引用。
  • ETS 表不会被重建,状态保留。
  • 如果改了 struct 字段,运行中的进程仍按旧字段名访问。
  • 如果改了 process 的 init 逻辑,需要重启进程。

七、规则引擎:用 ETS + 热重载做业务规则

经典场景:分析师想改风控规则,不用发版,直接改文件热重载。

defmodule MyApp.Rules do
  @table :rules

  def all, do: :ets.tab2list(@table)
  def get(name), do: :ets.lookup(@table, name)

  def reload!(path) do
    Code.compile_file(path)
    # 等所有调用方退出旧模块后再 purge
    :code.purge(Rules)
    :code.load_file(Rules)
    {:ok, length(all())}
  end
end

# 启动时初始化
:ets.new(@table, [:set, :named_table, :protected])
Code.eval_file("priv/rules.exs")  # 执行脚本把规则写入 ETS

# 规则文件示例 (priv/rules.exs)
:ets.insert(MyApp.Rules, {"max_position", 100_000})
:ets.insert(MyApp.Rules, {"min_volume", 10_000})

这样分析师改 priv/rules.exs,调用 Rules.reload!/1,新规则立即生效,不需要重新部署

八、热重载与正在运行的进程

最危险的场景:有个 GenServer 正在用旧版本的模块,改完代码热重载后,它还在跑旧代码。要让所有进程切到新版本:

def reload_all!(paths) do
  Enum.each(paths, &Code.compile_file/1)

  Enum.each([Rules, ScoreEngine, Formatter], fn mod ->
    :code.purge(mod)
    {:module, mod} = :code.load_file(mod)
  end)

  # 主动重启相关进程
  Enum.each([MyApp.Worker, MyApp.Scheduler], &GenServer.stop(&1, :normal, 1000))
end
生产热重载守则
  • 只重载纯函数 / ETS 数据的模块,不重载有进程状态的 GenServer。
  • 改 GenServer 必须重启;用 supervisor 的 :rest_for_one 让依赖进程一起重启。
  • 每次 reload 写一行审计日志(路径、时间、结果),别静默。
  • 提供 API 端点 POST /admin/reload,加 admin 鉴权。

九、用 IEx 做实验

热重载最适合调试:

$ iex -S mix
iex> Code.compile_file("lib/my_app/rules.ex")
iex> MyApp.Rules.max_position()
# 改了 lib/my_app/rules.ex 后
iex> r MyApp.Rules   # 简写:Code.reload/1
iex> MyApp.Rules.max_position()

r Module 是 IEx 的 helper,等价于 Code.reload!(Module)。它会自动 purge + load。

十、生产清单

  • GenServer init 不能阻塞太久,否则 supervisor 会杀进程。
  • ETS 容量要有上限,否则 BEAM 内存会爆。
  • 热重载的文件路径必须在编译期已知(用 :code.priv_dir/1 拿绝对路径)。
  • 不要在 hot path 上做 Code.eval_string——它会显著慢且不安全。
  • 给 GenServer 设置 timeout 参数,避免 init 卡死。

十一、测验

1

GenServer 的 handle_call 默认返回什么?

2

ETS 表的所有权?

3

:protected ETS 表允许?

4

Code.compile_file/2 后必须做什么?

5

热重载后,正在运行的 GenServer 进程会?

6

哪种数据适合放 :persistent_term

7

IEx 的 r MyApp.Module 等价于?

8

init/1 返回 {:ok, state, 5000} 表示?

9

热重载最适合的模块类型是?

10

:code.priv_dir/1 是为了?

下一步:热重载规则可以解决分析师改业务逻辑的问题,但热重载业务数据流还要别的东西——下一课看 Redix + bounded LPUSH 怎么用 Redis 做带背压的队列。