GenServer / ETS / Code.reload 热重载
GenServer 是 OTP 的核心并发原语;ETS 是 BEAM 自带的内存表;把代码文件和它们结合起来,你就能做出"运行时改业务规则"的系统。
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)
: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
基本流程:
- 读
.ex文件源码。 - 编译成字节码。
- 把旧模块从进程字典里清除(
Code.purge)。 - 新进程调用走新代码。
- 正在跑的进程不会自动切到新代码,它们持有旧模块引用。
- 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 卡死。
十一、测验
GenServer 的 handle_call 默认返回什么?
ETS 表的所有权?
:protected ETS 表允许?
Code.compile_file/2 后必须做什么?
热重载后,正在运行的 GenServer 进程会?
哪种数据适合放 :persistent_term?
IEx 的 r MyApp.Module 等价于?
init/1 返回 {:ok, state, 5000} 表示?
热重载最适合的模块类型是?
用 :code.priv_dir/1 是为了?
下一步:热重载规则可以解决分析师改业务逻辑的问题,但热重载业务数据流还要别的东西——下一课看 Redix + bounded LPUSH 怎么用 Redis 做带背压的队列。