一、开篇:为什么要聊 gen_server?

很多做后端开发的朋友,不管是刚入门还是有几年经验,大概率都碰过这么个场景:要做一个能长期跑、还能“按规矩办事”的服务。比如给用户存临时的登录状态、定时清理过期数据、处理一串需要按顺序来的请求。要是你用的是 Erlang/Elixir 生态,那 gen_server 绝对是绕不开的核心工具。

但不少人一开始用 gen_server,总觉得它“别扭”——要么状态存错地方,要么超时逻辑写得乱七八糟,回调函数写得像坨乱麻。其实只要抓住三个核心:状态管理、超时处理、回调函数设计,就能把 gen_server 用得既顺手又靠谱。今天就用最接地气的方式,带大家把这三个点啃透。

二、状态管理:别让状态“乱跑”

状态是什么?简单说就是服务当前的“记忆”。比如一个统计用户在线数的服务,当前在线人数就是它的状态;一个处理订单的服务,未完成的订单列表也是状态。gen_server 最基础的功能,就是帮你把状态“锁”在一个地方,不会乱改、不会丢。

2.1 状态管理的核心逻辑

gen_server 的状态管理有个死规矩:所有对状态的修改,都得在回调函数里完成,而且只能通过回调函数的返回值更新。你不能在外面随便改,也不能在回调里直接把状态扔到全局变量里——这么做会破坏 gen_server 的“隔离性”,很容易出问题。

举个例子,我们做一个“简易用户计数器”,用来记录每个用户的登录次数。先明确技术栈:Erlang(因为 gen_server 是 Erlang 标准库的工具,最原汁原味)。

% 定义一个状态结构体,用来规范状态的格式,避免乱存
-record(user_counter_state, {
    user_counts = #{} :: map(),  % 键是用户ID,值是登录次数
    max_count = 100 :: integer() % 比如限制每个用户最多登录100次,超过就报警
}).

% 启动服务的入口函数
start_link() ->
    % 调用 gen_server:start_link 启动服务,第一个参数是回调模块,第二个是回调参数(这里是初始状态)
    gen_server:start_link({local, ?MODULE}, ?MODULE, #user_counter_state{}, []).

% 回调函数:初始化状态
init(InitialState) ->
    % 这里的返回值 {ok, 状态} 就是告诉 gen_server,把初始状态存下来
    {ok, InitialState}.

% 回调函数:处理普通请求(比如查询某个用户的登录次数)
handle_call({get_count, UserId}, _From, State = #user_counter_state{user_counts = Counts}) ->
    % 从状态里查值,不会直接修改状态
    Count = maps:get(UserId, Counts, 0),
    % 返回值 {reply, 结果, 新状态} —— 这里新状态和旧状态一样,因为没改
    {reply, Count, State};

% 回调函数:处理需要修改状态的请求(比如给某个用户加登录次数)
handle_call({add_count, UserId}, _From, State = #user_counter_state{user_counts = Counts, max_count = Max}) ->
    OldCount = maps:get(UserId, Counts, 0),
    NewCount = OldCount + 1,
    % 生成新的状态结构体,不能直接改旧的 State
    NewCounts = maps:put(UserId, NewCount, Counts),
    NewState = State#user_counter_state{user_counts = NewCounts},
    % 检查是否超过最大限制,超过的话可以加个日志(这里简化处理)
    if
        NewCount > Max -> io:format("用户~p登录次数超过上限~p~n", [UserId, Max]);
        true -> ok
    end,
    % 返回新状态,gen_server 会自动把状态更新成 NewState
    {reply, NewCount, NewState}.

这个例子里,状态的管理非常清晰:所有状态都存在 #user_counter_state 这个结构体里,只有 handle_call 这类回调函数能修改状态,而且每次修改都要生成新的状态对象,再通过返回值告诉 gen_server 替换旧的。

2.2 状态管理的注意事项

第一,状态要“轻”。别把太大的东西存在状态里,比如几GB的文件内容、几千条复杂的对象。gen_server 是单进程模型,状态太大的话,回调函数处理起来会很慢,甚至导致进程阻塞。 第二,状态要“纯”。别在状态里存临时变量、全局引用,比如数据库连接、文件句柄。这些东西应该在 gen_server 启动的时候初始化,要是连接断了,还要有重连逻辑,不能直接存在状态里。 第三,状态要“可恢复”。如果 gen_server 进程挂了,状态会丢,所以重要的状态要定期持久化到数据库或者文件里。比如上面的用户计数器,每隔5分钟把 Counts 存到 Redis 里,下次进程重启的时候,从 Redis 读出来当初始状态。

三、超时处理:别让服务“卡壳”

超时是 gen_server 里最容易踩坑的部分。很多人写 gen_server,要么超时逻辑和状态混在一起,要么超时触发了却不知道怎么处理,甚至超时会导致进程挂掉。其实 gen_server 的超时分两种:请求超时和状态超时,搞清楚这两种的区别,就能把超时写好。

3.1 两种超时的区别

第一种是请求超时:就是你给 gen_server 发一个请求(比如 handle_call),如果 gen_server 处理得太慢,或者一直没处理,调用方不想等了,就触发超时。这种超时是调用方控制的,和 gen_server 本身的逻辑没关系。 第二种是状态超时:就是 gen_server 自己的状态如果在一段时间内没变化,就自动触发一个回调。比如你做一个“临时会话服务”,某个用户的会话如果10分钟没动静,就自动清理掉。这种超时是 gen_server 自己控制的,需要在回调函数里主动设置。

3.2 状态超时的实现(重点)

状态超时的核心是在回调函数的返回值里加一个超时时间。比如在 handle_call、handle_cast 或者 init 函数的返回值里,加上 {ok, State, Timeout},这里的 Timeout 就是超时时间(单位是毫秒)。如果在这个时间内,gen_server 没收到任何请求,就会自动触发 handle_info 回调,参数是 timeout。

我们还是用刚才的用户计数器,加一个超时逻辑:如果某个用户的登录次数10秒内没更新,就把这个用户的计数重置为0。

% 先加一个常量,方便修改超时时间
-define(USER_TIMEOUT, 10000). % 10秒,单位毫秒

% 回调函数:初始化状态的时候,设置初始超时
init(InitialState) ->
    % 初始状态的超时设为无限大(infinity),因为一开始还没有用户需要超时
    {ok, InitialState, infinity};

% 处理加计数的请求,这次要加超时
handle_call({add_count, UserId}, _From, State = #user_counter_state{user_counts = Counts, max_count = Max}) ->
    OldCount = maps:get(UserId, Counts, 0),
    NewCount = OldCount + 1,
    NewCounts = maps:put(UserId, NewCount, Counts),
    NewState = State#user_counter_state{user_counts = NewCounts},
    if
        NewCount > Max -> io:format("用户~p登录次数超过上限~p~n", [UserId, Max]);
        true -> ok
    end,
    % 重点:返回值里加超时时间,10秒后触发超时回调
    {reply, NewCount, NewState, ?USER_TIMEOUT};

% 处理超时的回调函数,所有超时都会触发这个
handle_info(timeout, State = #user_counter_state{user_counts = Counts}) ->
    % 先简单处理:把所有用户的计数都重置为0(实际场景可以更灵活,比如只重置10秒没更新的)
    NewCounts = maps:map(fun(_UserId, _Count) -> 0 end, Counts),
    NewState = State#user_counter_state{user_counts = NewCounts},
    io:format("超时触发,重置所有用户计数~n"),
    % 超时处理完后,要不要继续设置超时?根据需求来,这里继续设10秒
    {noreply, NewState, ?USER_TIMEOUT};

% 处理其他普通消息的回调(比如外部发过来的消息)
handle_info(_Msg, State) ->
    % 如果收到普通消息,重置超时时间(不然消息来了也会触发超时)
    {noreply, State, ?USER_TIMEOUT}.

这个例子里,超时逻辑非常清晰:每次加计数的时候,就给 gen_server 设置10秒的超时;如果10秒内没加计数,就触发 handle_info(timeout),重置计数;如果10秒内有加计数,就重新设置10秒的超时。

3.3 超时处理的注意事项

第一,超时时间要合理。别设得太短,比如1毫秒,那服务会一直触发超时,反而影响性能;也别设得太长,比如1小时,那超时就没意义了。要根据业务场景来,比如临时会话的超时可以设10分钟,心跳检测的超时可以设30秒。 第二,超时要重置。如果 gen_server 收到了新的请求,不管是加计数还是其他消息,都要重新设置超时时间,不然新消息来了也会触发超时。比如上面的 handle_info 函数,收到普通消息后,就把超时时间重新设为10秒。 第三,超时要能取消。如果业务上不需要超时了,比如用户主动退出登录,就可以把超时时间设为 infinity,取消超时。比如加一个 handle_call({cancel_timeout}, _From, State) 回调,返回 {reply, ok, State, infinity}。

四、回调函数设计:让逻辑“好懂”

gen_server 的回调函数有好几个:init、handle_call、handle_cast、handle_info、terminate、code_change。很多人写回调函数,要么把所有逻辑都塞在一个 handle_call 里,要么回调函数之间的逻辑乱跳,自己都看不懂。其实回调函数的设计有个原则:“各司其职”,每个回调函数只做自己该做的事。

4.1 各回调函数的职责

init:只做初始化,比如读初始状态、连接数据库、启动子进程。别在 init 里写业务逻辑,比如别在 init 里处理请求,不然启动会很慢。 handle_call:处理“需要返回结果”的请求,比如查询用户计数、修改计数。handle_call 是同步的,调用方会等 gen_server 处理完再拿到结果。 handle_cast:处理“不需要返回结果”的请求,比如给用户发通知、记录日志。handle_cast 是异步的,调用方发完请求就走,不管 gen_server 有没有处理完。 handle_info:处理“超时”和“普通消息”,比如超时触发、外部进程发过来的消息。别在 handle_info 里处理业务请求,不然逻辑会乱。 terminate:处理“退出”的逻辑,比如关闭数据库连接、持久化状态、清理临时文件。gen_server 退出的时候会自动调用 terminate。 code_change:处理“代码升级”的逻辑,比如旧版本的状态格式和新版本不一样,升级的时候要把旧状态转换成新状态。这个一般用得少,但重要的服务一定要写。

4.2 回调函数的设计示例

我们把刚才的用户计数器的回调函数重新整理一下,让每个回调函数都各司其职:

% 常量定义,方便维护
-define(USER_TIMEOUT, 10000).
-define(MAX_COUNT, 100).

% 状态结构体,规范格式
-record(user_counter_state, {
    user_counts = #{} :: map(),
    db_conn = undefined :: term() % 数据库连接,只在init里初始化
}).

% 启动入口
start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

% 1. init:只做初始化
init([]) ->
    % 第一步:连接数据库(模拟)
    DbConn = connect_db(),
    % 第二步:从数据库读初始状态
    InitialCounts = load_counts_from_db(DbConn),
    % 第三步:生成初始状态
    InitialState = #user_counter_state{
        user_counts = InitialCounts,
        db_conn = DbConn
    },
    % 初始超时设为无限大
    {ok, InitialState, infinity}.

% 2. handle_call:只处理需要返回结果的请求
% 2.1 查询用户计数(同步请求)
handle_call({get_count, UserId}, _From, State = #user_counter_state{user_counts = Counts}) ->
    Count = maps:get(UserId, Counts, 0),
    {reply, Count, State};

% 2.2 增加用户计数(同步请求)
handle_call({add_count, UserId}, _From, State = #user_counter_state{user_counts = Counts, db_conn = DbConn}) ->
    OldCount = maps:get(UserId, Counts, 0),
    NewCount = OldCount + 1,
    NewCounts = maps:put(UserId, NewCount, Counts),
    NewState = State#user_counter_state{user_counts = NewCounts},
    % 检查是否超过上限
    if
        NewCount > ?MAX_COUNT ->
            % 发异步通知(用handle_cast,因为不需要返回结果)
            gen_server:cast(self(), {notify_limit, UserId, NewCount});
        true -> ok
    end,
    % 超时时间重置
    {reply, NewCount, NewState, ?USER_TIMEOUT};

% 2.3 取消超时(同步请求)
handle_call(cancel_timeout, _From, State) ->
    {reply, ok, State, infinity}.

% 3. handle_cast:只处理不需要返回结果的请求
% 3.1 通知用户超过上限(异步请求)
handle_cast({notify_limit, UserId, Count}, State = #user_counter_state{db_conn = DbConn}) ->
    % 模拟发通知(比如发邮件、短信)
    send_notification(UserId, Count),
    % 把通知记录到数据库
    save_notification_to_db(DbConn, UserId, Count),
    % 不需要返回结果,超时重置
    {noreply, State, ?USER_TIMEOUT};

% 3.2 重置所有计数(异步请求)
handle_cast(reset_all, State = #user_counter_state{db_conn = DbConn}) ->
    NewCounts = maps:map(fun(_UserId, _Count) -> 0 end, maps:get(user_counts, State)),
    NewState = State#user_counter_state{user_counts = NewCounts},
    % 把重置记录到数据库
    save_reset_log_to_db(DbConn, NewCounts),
    {noreply, NewState, ?USER_TIMEOUT}.

% 4. handle_info:只处理超时和普通消息
% 4.1 超时触发
handle_info(timeout, State = #user_counter_state{db_conn = DbConn}) ->
    % 把超时触发的记录到数据库
    save_timeout_log_to_db(DbConn),
    % 重置所有计数
    gen_server:cast(self(), reset_all),
    % 超时时间重置
    {noreply, State, ?USER_TIMEOUT};

% 4.2 其他普通消息(比如外部进程发过来的)
handle_info(_Msg, State) ->
    % 重置超时
    {noreply, State, ?USER_TIMEOUT}.

% 5. terminate:只处理退出逻辑
terminate(_Reason, State = #user_counter_state{db_conn = DbConn}) ->
    % 关闭数据库连接
    close_db(DbConn),
    % 持久化状态
    save_counts_to_db(DbConn, maps:get(user_counts, State)),
    ok.

% 6. code_change:处理代码升级
code_change(_OldVsn, OldState, _Extra) ->
    % 假设旧版本的状态是{user_counts, Counts},新版本是#user_counter_state{}
    case OldState of
        {user_counts, OldCounts} ->
            NewState = #user_counter_state{user_counts = OldCounts, db_conn = undefined},
            {ok, NewState};
        _ ->
            {ok, OldState}
    end.

% 辅助函数(模拟)
connect_db() -> ok.
load_counts_from_db(_) -> #{}.
send_notification(_, _) -> ok.
save_notification_to_db(_, _, _) -> ok.
save_reset_log_to_db(_, _) -> ok.
save_timeout_log_to_db(_) -> ok.
close_db(_) -> ok.
save_counts_to_db(_, _) -> ok.

这个例子里,每个回调函数的职责都非常清晰:init 只做初始化,handle_call 只处理同步请求,handle_cast 只处理异步请求,handle_info 只处理超时和普通消息,terminate 只处理退出逻辑。这样写出来的代码,自己和别人都能看懂,改起来也方便。

4.3 回调函数设计的注意事项

第一,别在回调函数里写太复杂的逻辑。如果一个回调函数的逻辑超过20行,就要考虑把它拆成辅助函数,或者拆到其他回调函数里。比如上面的通知逻辑,就拆成了 handle_cast 里的 {notify_limit, ...},这样 handle_call 里的逻辑就很简洁。 第二,别在回调函数里做耗时操作。比如别在 handle_call 里写一个需要10秒才能完成的逻辑,不然会导致进程阻塞,其他请求都处理不了。耗时操作要拆成异步的,比如用 handle_cast,或者启动一个子进程来处理。 第三,回调函数的返回值要规范。每个回调函数的返回值都有固定的格式,比如 init 的返回值是 {ok, State} 或者 {ok, State, Timeout},别乱改格式,不然 gen_server 会报错。

五、应用场景、优缺点与注意事项

5.1 应用场景

gen_server 适合做“有状态、需要长期运行、逻辑相对固定”的服务,比如:

  • 状态管理服务:比如用户会话管理、配置管理、缓存服务;
  • 定时任务服务:比如定时清理过期数据、定时统计数据、定时发送通知;
  • 顺序处理服务:比如订单处理、消息队列、日志处理(需要按顺序处理的场景);
  • 代理服务:比如数据库代理、API代理、负载均衡服务。

5.2 技术优缺点

优点:

  • 隔离性好:每个 gen_server 是一个独立的进程,一个进程挂了不会影响其他进程;
  • 状态管理简单:gen_server 自带状态管理,不需要自己写复杂的状态同步逻辑;
  • 超时处理灵活:支持请求超时和状态超时,能满足大部分业务场景的需求;
  • 回调函数清晰:回调函数的职责固定,代码结构清晰,容易维护。

缺点:

  • 单进程性能瓶颈:gen_server 是单进程模型,同一时间只能处理一个请求,高并发场景下性能会有瓶颈;
  • 状态丢失风险:如果 gen_server 进程挂了,状态会丢,需要自己做持久化;
  • 学习成本:对于新手来说,gen_server 的回调函数、超时逻辑、状态管理都需要时间理解。

5.3 注意事项

  • 高并发场景下,要考虑用多个 gen_server 进程来分担压力,比如用 gen_server_pool 或者自己写进程池;
  • 重要的状态要定期持久化,比如每隔5分钟存到数据库或者 Redis 里;
  • 回调函数里别写耗时操作,不然会导致进程阻塞;
  • 超时时间要合理设置,别设得太短或者太长;
  • 代码升级的时候,要写 code_change 回调函数,避免状态丢失。

六、总结

gen_server 是 Erlang/Elixir 生态里非常核心的工具,只要抓住三个核心:状态管理、超时处理、回调函数设计,就能把它用得既顺手又靠谱。状态管理要“轻、纯、可恢复”,超时处理要分清楚两种超时,回调函数要“各司其职”。

不管你是刚入门的新手,还是有几年经验的开发者,只要把这三个点啃透,再结合实际业务场景多练几次,就能轻松驾驭 gen_server,写出既靠谱又好维护的服务。