一、热加载是个好东西,但别乱用

Erlang的热加载(Hot Code Loading)就像给行驶中的汽车换发动机——听起来很酷,但搞不好就会翻车。它允许我们在不停止系统的情况下更新代码,这对需要高可用的电信系统或实时游戏服务器简直是救命稻草。但问题是,不是所有代码都适合热加载,有些情况会直接让系统崩溃。

举个例子,假设我们正在运行一个在线聊天室服务,用户模块user_server负责管理用户状态。我们想热更新它的消息处理逻辑:

%% 旧版本代码(技术栈:Erlang/OTP 25+)
-module(user_server).
-export([handle_message/2]).

handle_message(Message, State) ->
    io:format("Old handler: ~p~n", [Message]),
    State#{last_msg => Message}.  % 旧版只记录消息

然后我们热加载一个新版本:

%% 新版本代码
handle_message(Message, State) ->
    io:format("New handler: ~p~n", [Message]),
    case Message of  % 新增消息类型判断
        {text, _} -> State#{last_msg => Message, type => text};
        {image, _} -> State#{last_msg => Message, type => image}
    end.

问题来了:如果旧版State没有type字段,而新版代码依赖它,运行时就会爆出{badmatch, ...}错误。这就是典型的数据结构不兼容问题。

二、这些雷区千万别踩

1. 数据结构变更

就像上面的例子,增加或删除字段时要特别小心。Erlang的record在热加载时更危险:

%% 旧版record定义
-record(user, {id, name}).

%% 新版增加age字段
-record(user, {id, name, age}).  % 热加载后已有实例会丢失age字段!

解决方案:使用map代替record,或者通过版本号控制:

%% 使用map的兼容方案
update_user(User) ->
    case maps:get(version, User, 1) of  % 默认版本为1
        1 -> User#{version => 2, age => guess_age(User)};
        2 -> User  % 已经是新版
    end.

2. 进程状态迁移

假设有个状态机进程正在运行旧版代码:

%% 状态机旧版
state_a(Event, State) ->
    {next_state, state_b, State#{flag => old}}.

%% 热加载后新版
state_a(Event, State) ->
    {next_state, state_c, State#{flag => new}}.  % 状态名变了!

如果热加载时进程正好在state_a,下次触发事件时会找不到state_c的定义。

正确做法:永远保持状态名不变,新增状态通过新函数处理:

%% 向后兼容的修改
state_a(Event, State) ->
    case State of
        #{version := 2} -> {next_state, state_c_v2, State};
        _ -> {next_state, state_b, State}  % 旧版逻辑
    end.

三、那些隐藏的坑

1. 原子耗尽攻击

每次热加载都会产生新版本的模块原子(如user_server_v1user_server_v2)。Erlang原子不会被GC回收,如果频繁热加载:

1> code:load_file(user_server).  % 重复执行会导致原子表膨胀

防御措施

  • 监控原子数量erlang:memory(atom_used)
  • 使用code:purge/1清理旧版本

2. 依赖模块连锁反应

当模块A依赖模块B,而B被热加载时:

%% 模块A
call_b() ->
    B:do_something().  % 如果B的函数签名改了...

%% 模块B旧版
do_something() -> ok.

%% 模块B新版
do_something(Arg) -> {ok, Arg}.  % 参数变了!

这时所有调用B:do_something()的进程都会崩溃。

最佳实践

  • 使用gen_server:call代替直接函数调用
  • 通过版本路由控制:
%% 调用方兼容写法
call_b() ->
    case code:which(B) of
        BeamFile when is_list(BeamFile) -> 
            B:do_something(undefined);  % 适配新版
        _ -> B:do_something()           % 旧版
    end.

四、安全热加载操作指南

1. 灰度发布策略

%% 先在一台节点测试
Nodes = [node()|nodes()],
{Mod, Bin, _} = code:get_object_code(new_user_server),
rpc:call(hd(Nodes), code, load_binary, [Mod, "new_user_server.beam", Bin]).

%% 确认无异常后再全量加载
[begin 
    rpc:call(Node, code, load_file, [new_user_server]),
    rpc:call(Node, code, soft_purge, [user_server])
end || Node <- Nodes].

2. 回滚方案

%% 保存旧版本BEAM文件
{ok, OldBin} = file:read_file("user_server.old"),
code:load_binary(user_server, "user_server.old", OldBin).

%% 强制回滚(慎用)
code:purge(user_server),
code:load_file(user_server).

五、什么时候该用热加载

适用场景

  • 电信交换机升级协议
  • 游戏服务器修复紧急BUG
  • 需要24/7运行的金融交易系统

不适用场景

  • 数据库Schema变更
  • 涉及第三方驱动更新
  • 需要重置状态的重大架构调整

记住:热加载是最后手段,不是常规部署方式。就像医生做心脏手术时换瓣膜——技术很厉害,但能不开胸就别开胸。