一、从聊天软件的难处说起

咱们平时用微信、QQ这类工具,发一条消息对方几乎瞬间就能收到。这背后其实是海量用户的并发连接在支撑。比如几百万人在线,屏幕前每个动作都可能产生消息,后端系统需要在眨眼之间完成路由、存储、推送。传统编程模型里,一个连接通常要分配一个线程,而线程的创建和切换成本很高,几千个线程就能把机器拖垮。Erlang不一样,它天生就是为这种“大量连接 + 大量消息”的场景设计的。它的解决办法是用轻量级进程替代线程,一个进程在Erlang里就像现实中的一个小员工,你只要分派一条消息给它,它就能自己去处理。单机跑几十万甚至上百万个这样的进程都没有问题。听起来有点神奇,对吧?其实原理并不复杂。

有人可能会问,那别的语言用协程不是也能做到吗?确实可以,但Erlang更彻底的一点是,它把进程、消息、错误处理这些能力全部做进了虚拟机里,你不需要额外引入什么框架,写出来就是这种风格。接下来咱们一层层看它的核心机制。

二、Erlang的核心武器

2.1 进程与并发

Erlang里的进程不是操作系统进程,也不是线程,而是一种由Erlang虚拟机自己管理的轻量级调度单元。每个进程私有一块内存,不共享任何数据,这从根上避免了“加锁”这种麻烦事。虚拟机内部把CPU时间切成很小的片,分给所有进程,你写程序的时候根本不用考虑创建多少个进程,系统会自动帮你调度。这就好像一个大食堂里有很多窗口,每个窗口的师傅只听自己顾客的需求,互不抢菜,然后由一个总调度员决定谁先做,谁后做。

这种模型的优势是:进程数量可以做得非常多。因为每个进程的初始内存只有几KB,创建和切换都非常快。对比一下,操作系统线程动辄几MB的栈空间,你在普通服务器上开几千个线程就够呛了。而在Erlang里,开几万个进程只是家常便饭。这也正是它能扛海量在线连接的根本底气。

2.2 消息传递

进程之间怎么协作?通过消息。一个进程可以给另一个进程发送一条消息,消息放进对方的邮箱,接收方在代码里用 receive 去取。这个过程是异步的,发送方不会卡住。设计思路跟人和人之间发微信一样,发出去了,该干嘛干嘛,等对方回消息再说。这种模型非常简单,但也极其强大。

下面用Erlang演示一个最简单的消息循环。

技术栈:Erlang

% 定义一个模块,命名成 hello
-module(hello).
-export([loop/0]).

% 这个进程会一直等消息,然后根据消息内容做不同事情
loop() ->
    receive
        {greet, From, Name} ->  % 一条带问候信息的消息
            io:format("~p 说: 你好 ~s~n", [From, Name]),
            loop();             % 处理完再回去等
        stop ->
            ok                 % 收到停止指令就退出
    end.

这段代码里的 receive,相当于进程的门卫。它收到 {greet, From, Name} 这个元组后,打印一句话,然后再次进入等待。如果收到 stop,就直接结束。这就是一个典型的最简进程。

2.3 容错与监督树

光有并发还不够,在线系统最怕崩。Erlang的哲学是“让它崩”。进程挂了没关系,由它的上级进程来负责重启。这就像团队里组长盯着组员,组员出错被免职,组长马上再招一个补上。这种上下级关系就组成了“监督树”。在IM里,每个用户连接都可以由一个监督进程看护,连接断了,监督者自动恢复一个新的连接,这对保持海量用户在线非常关键。

OTP就是Erlang里把这些思想固化下来的一套标准框架。它提供了 supervisor、gen_server 等现成组件。gen_server 用于封装业务逻辑,supervisor 用于管理进程生命周期。你可以把OTP想象成施工队里的模板和脚手架,你不需要从零开始盖楼,只需要在框架里填自己的业务。

三、架构设计:支撑海量用户的核心逻辑

3.1 连接层:管理TCP长连接

一个IM系统最底层是连接层。客户端通过TCP(或者WebSocket/QUIC,但底层都是连接)连到服务器。客户端通常不主动断开,所以这个连接必须是长连接。在Erlang里,每个连接就对应一个进程。这个进程负责收发Socket数据,把收到的二进制流解析为消息,再把消息发给路由层。

下面展示一个简单的TCP连接进程骨架。

技术栈:Erlang

-module(conn).
-export([start_conn/1]).

% 入口函数,Sock是已经建立好的Socket
start_conn(Sock) ->
    spawn(fun() -> loop(Sock) end).

% 主循环:不断从Socket读取数据
loop(Sock) ->
    case gen_tcp:recv(Sock, 0) of
        {ok, Data} ->
            handle_data(Sock, Data),  % 解析并处理
            loop(Sock);
        {error, closed} ->
            io:format("连接已关闭~n");
        {error, _Reason} ->
            io:format("网络出错~n")
    end.

% 处理收到的数据
handle_data(Sock, Data) ->
    % 这里假设每条消息以换行符结尾
    [Msg | _] = binary:split(Data, <<"\n">>),
    io:format("收到消息: ~p~n", [Msg]),
    % 把消息发到路由器(此处简化为打印)
    gen_tcp:send(Sock, <<"OK~n">>).

这个例子展示了 recv 阻塞地读数据,拿到数据后解析。真实项目里还会用到更高效的协议解析,比如处理TCP半包粘包问题,但核心结构就是这样:一个连接一个进程,循环读取。

3.2 路由层:用户消息寻址

连接层把消息读出来之后,往哪里送?这里需要一张“用户ID -> 进程ID”的映射表。路由层的关键就是维护这张表,以及负责跨节点转发。

想象一下,用户A要给用户B发消息。A的连接进程把消息发给路由器,路由器查表找到B的进程,然后把消息投递给B的进程。这个查找要非常快。Erlang的进程字典或者ETS表可以派上用场。ETS是Erlang内置的内存表,可以边读边写,并发性能很高。如果你用了OTP的 gen_server 来封装,路由模块大概长这样:

技术栈:Erlang

-module(router).
-behaviour(gen_server).
-export([start_link/0, route/2]).
-export([init/1, handle_cast/2]).

start_link() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

% 对外API:发送消息给目标用户
route(UserId, Msg) ->
    gen_server:cast(?MODULE, {route, UserId, Msg}).

% 初始化:创建一个ETS表保存映射
init([]) ->
    % 命名表 user_map,public 表示允许并发读写
    ets:new(user_map, [named_table, public, set]),
    {ok, []}.

% 异步处理路由
handle_cast({route, UserId, Msg}, State) ->
    case ets:lookup(user_map, UserId) of
        [{_, Pid}] ->  % 找到该用户对应的进程
            Pid ! {deliver, Msg},
            {noreply, State};
        [] ->
            io:format("用户不在线,消息先存离线吧~n"),
            {noreply, State}
    end.

这个小例子里的 route/2 是入口,实际项目里连接进程会调用它。ETS 表保存了 UserId -> Pid 的映射,查表速度极快,而且允许多个进程同时读。消息投递通过 Pid ! Msg 这种原生方式完成,非常直观。

3.3 在线状态管理

用户的上线和下线,就是一个向映射表写入和删除的过程。用户连上时,连接进程把这个用户的ID注册到ETS表里;断开时,再让路由层把它删掉。不过并发环境下要小心,注册和删除的操作需要保证原子性。在Erlang里,最直接的办法是把这些状态变更操作也收进一个 gen_server 串行化。因为所有写操作都经过同一个进程,就不会出现同时写一张表导致的错乱。

这里要补充一个常见的优化:为了支持跨机器在线用户互通,状态映射表还需要一层“前缀”。比如用户ID落在哪个节点上,你可以用分布式进程注册机制,也可以自己维护全局路由。Erlang原生的分布式能力,让两个节点上的进程可以直接互相发消息,前提是节点有相同的 cookie。这为横向扩容省了不少事。

3.4 会话存储与协议处理

除了在线状态,用户聊天记录、离线消息也需要存储。这属于另一个话题,但在架构中必须考虑。Erlang中你可以把消息同步到数据库(比如MySQL或PostgreSQL),也可以使用内存缓存加速。不过Erlang擅长的是处理实时流,持久化它并不擅长,所以常见的做法是让Erlang只负责转发和投递,数据存储交给后端的数据库或者Redis。这里我们不需要展开数据库细节,只需要理解分层架构的意义。

还要提一下TCP半包粘包问题。这是所有即时通讯开发人员躲不开的坑。你可以通过自定义协议头,比如前4字节表示消息长度,收够长度后再解析。在Erlang中,我们可以用二进制模式匹配优雅地处理。因为Erlang的二进制协议解析能力很强,可以一行代码剥离出长度和内容。

技术栈:Erlang

% 假设消息头部4字节是大端整数,表示后续消息体长度
parse(Bin) ->
    <<Len:32, Rest/binary>> = Bin,
    <<Body:Len/binary, Tail/binary>> = Rest,
    {Body, Tail}.

这段代码把二进制拆成“长度 + 内容 + 剩余数据”,再配合循环调用,就完成了粘包与半包处理。咱们再看一个更常见的场景:每条消息包含消息类型、发送者、接收者和内容。可以用模式匹配一次解析出来。

技术栈:Erlang

% 解析一条简单文本消息:0x01 表示单人聊天
parse_message(<<16#01:8, FromId:32, ToId:32, Content/binary>>) ->
    {chat, FromId, ToId, Content};
% 0x02 表示心跳,只有用户ID
parse_message(<<16#02:8, UserId:32>>) ->
    {heartbeat, UserId}.

这个示例看起来很简单,但它真实地体现了Erlang在处理二进制协议时的爽快感。你不需要手工拆字节,直接在参数位置用模式匹配,编译器就帮你算好了。

3.5 一条消息的完整旅程

当用户A发送一条消息到服务器时,连接进程从Socket读到原始字节,通过协议解析得到 {msg, From, To, Content}。然后连接进程把这条消息交给路由器的入口函数。路由器查ETS表,找到用户B的进程ID,并把消息投递给B的进程。B的进程再把它转成二进制,通过B对应的Socket发送出去。整个过程在Erlang里都是靠进程间消息传递完成的,每个步骤只关心自己这一小段,所以逻辑非常清晰。这个流程里,连接进程、路由进程、用户进程各司其职,谁挂了都不影响整条链路,监督树会很快把它拉起来。

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

4.1 典型应用场景

哪些系统真正适合用Erlang?最典型的就是IM、即时游戏服务器、物联网接入网关、以及消息推送服务。这些场景的共同点是:连接数巨大、消息频繁但单条消息数据量不大、需要高可用和低延迟。比如WhatsApp最初就是用Erlang构建的,几百万在线用户依旧稳定。这说明它的设计思路是经过大规模验证的。

4.2 技术优点

Erlang的优点很突出。一是并发能力极强,一个节点可以扛几十万连接,这一点在传统语言里要费很大劲才能做到。二是代码可读性高,因为每个业务逻辑都是相互隔离的进程,没有共享内存,你可以在一个进程里安心地写业务,不用担心其他进程来踩你的变量。三是容错性强,进程崩溃后有监督树自动拉起,系统整体可用性高。四是热更新能力强,在运行中可以替换模块代码,不需要停服。对于IM这种停机敏感的系统,热更新非常实用。

4.3 技术缺点

当然,它不是万能的。Erlang的短板首先是性能天花板,它虽然能扛大量连接,但单条消息的处理速度通常不如C++或Go。CPU密集型的计算,比如大量图片处理、视频转码,绝对不要用Erlang。其次是调试和运维成本高,一些人觉得它的语法和并发模型学习曲线陡峭,出现问题不容易排查。再次是生态相对小众,招人难,很多现成的库没有主流语言丰富。所以实际架构中,很多人会把Erlang当作接入层,把业务逻辑和存储拆到其他语言去。

4.4 注意事项

如果决定用它做IM,有几个坑要避开。第一,别把耗时操作放进进程主循环。比如写数据库、调用外部API,这些操作会阻塞进程,直接影响后面的消息处理。你应该把这类操作交给独立进程,或者使用异步回调。第二,要注意消息并发量对虚拟机内存的影响。如果生成的进程太多,每个进程都有邮箱,内存会涨得很快。要有治理手段,比如限制队列长度、及时丢弃过期消息。第三,不要滥用进程字典。进程字典虽然是每个进程私有的,但会导致代码难以理解和测试,影响定位问题。第四,要处理好网络层的协议设计,至少要把消息结构、编码方式、粘包规则定清楚,不然线上找问题会非常痛苦。

五、总结

Erlang能支撑海量用户在线,靠的不是玄学,而是一套特别适合连接密集型业务的并发模型。每个用户一个进程,进程之间用消息通信,上级进程负责监督和重启,再配合OTP框架,让系统结构变得清晰且强壮。同时我们也看到了它的局限性,它不是银弹,绝不该用在一个纯计算型的服务上。做IM系统时,合理的做法是把Erlang用在最关键的地方:连接管理、消息路由和在线状态保持。正如咱们今天聊的,连接层、路由层、状态管理,这三个环节恰恰是IM最核心的部分。把这三个环节想透,实现一个稳定面对百万级连接的IM架构,就真正摸到了门道。