简介:这是一份面向Erlang学习者与游戏后端开发者的开源服务器源码,来自卡牌对战网游《萌兽堂》,可用于研究Erlang在真实游戏项目中的工程化落地。资源包共170个文件,约1.03MB,以111个erl源文件与20个hrl头文件为主体,辅以bat、cmd启动脚本、config配置、makefile与emakefile构建文件、sql脚本及少量C源码与NIF相关文件,整体结构贴近典型OTP应用。项目围绕OTP框架组织,涉及并发进程模型、分布式节点通信、热更新与容错、Mnesia数据持久化、TCP/HTTP网络通信及模块化设计等核心主题,玩家、战斗与卡牌逻辑均可对应到独立进程。已有166人学习下载。读者可借此了解游戏服务器的目录划分、进程组织与构建启动方式,对照源码理解高并发场景下的业务拆分与性能调优思路,适合具备一定Erlang基础、希望深入分布式游戏服务端实践的中高级开发者参考。
1. 卡牌游戏服务端为什么还在用 Erlang:从《萌兽堂》这套原始码说起
如果你手里拿到一份卡牌游戏服务端的 Erlang 原始码,第一反应大概率是「这语言现在还有人用?」。但真做过长连接、高并发、带状态同步的卡牌对战后端,你会发现 Erlang 的进程模型和 OTP 监督树,恰好卡在这类业务的甜点区上。卡牌游戏不像 MMO 那样追求极致帧同步,它的核心压力在于:大量玩家同时在线、每个玩家有独立的卡组和背包状态、战斗回合需要顺序推进、掉线要能重连恢复。这些需求翻译成技术语言,就是「海量轻量进程 + 消息驱动 + 状态隔离 + 容错重启」。Erlang 的 actor 模型天生就是干这个的,一个玩家一个进程,进程崩了只影响他自己,监督者按策略拉起来,其他玩家无感。
《萌兽堂》这套服务端原始码的价值不在于它多完美,而在于它是一份完整的、能跑起来的卡牌游戏后端骨架。你能看到登录、角色、卡牌、背包、战斗、排行榜这些模块怎么用 gen_server 拆开,怎么用 ETS 做热数据缓存,怎么用 Mnesia 存持久化状态。对想学 Erlang 实战的人来说,这比看十篇语法教程管用;对想搭卡牌后端的人来说,这是一份可以直接改的底稿。适合谁?适合已经会一点 Erlang 语法、但不知道怎么组织一个真实服务端的人,也适合做游戏后端想对比技术选型的架构师。下面我按「先跑起来、再拆模块、再调参数、最后避坑」的顺序,把这套东西讲透。
2. 把原始码跑起来:环境、编译与第一个可连节点
2.1 环境准备与依赖确认
拿到一份 Erlang 原始码,第一件事不是读代码,是让它编译通过。这套代码常见做法是基于 rebar3 组织,但原始码里也可能只有一堆 .erl 和 .hrl 文件加一个 Emakefile。先确认 Erlang/OTP 版本,卡牌服务端一般用 OTP 23 到 26 之间的版本比较稳,太老的 OTP 20 以下会有语法和库的坑。检查命令很简单:
erl -version # 输出类似 Erlang/OTP 26 [erts-14.x],确认主版本 which rebar3 # 如果没有 rebar3,需要单独装,或者看项目里有没有 Emakefile如果项目根目录有 rebar.config,直接rebar3 compile。如果没有,看有没有 Emakefile,用erl -make编译。编译报错最常见的是头文件路径不对,.hrl 文件通常放在 include 目录,编译时要加-I include。还有一种情况是原始码里用了某个第三方库但没带依赖,比如 mysql 驱动或者 json 库,这时候要么补依赖,要么把相关模块注释掉先跑核心逻辑。
提示:不要一上来就改代码。先原样编译,把报错记下来,很多「跑不起来」其实是环境版本不匹配,不是代码问题。
2.2 启动顺序与节点互联
Erlang 服务端通常以分布式节点方式启动,因为要用到多节点做网关和逻辑分离。启动脚本一般长这样:
#!/bin/bash # 启动逻辑节点,-sname 用于同机多节点,-name 用于跨机 erl -sname game_logic \ -setcookie cardgame_cookie \ -pa ebin \ -pa deps/*/ebin \ -config config/sys.config \ -eval "application:ensure_all_started(game_server)" \ -noshell这里几个参数必须说清楚。-sname是短节点名,同一台机器上多个节点用这个,节点名会变成game_logic@主机名;跨机部署要用-name加完整域名。-setcookie是节点间通信的凭证,所有要互联的节点必须一致,否则连不上,这个坑我见过太多次,现象是net_adm:ping返回 pang,查半天发现 cookie 不一样。-pa把编译产物目录加进代码路径,顺序有讲究,自己的 ebin 要放在依赖前面,否则可能加载到旧版本。-config指定 sys.config,里面是应用的环境变量,比如数据库地址、端口、进程数上限。-eval在启动时执行表达式,application:ensure_all_started会按依赖顺序拉起所有应用。
启动后验证节点活着:
%% 在另一个 erl shell 里执行 net_adm:ping('game_logic@yourhost'). %% 返回 pong 说明通了,pang 就是不通通了之后,用observer:start()可以打开图形化监控,看进程树、ETS 表、内存占用。这是 Erlang 调试的杀手锏,比打日志直观得多。第一次跑起来,重点看监督树结构,哪个是根监督者,下面挂了哪些子进程,这决定了整个服务端的容错边界。
2.3 最小连通性验证
服务端跑起来不等于能玩。要验证客户端能连上,得看它监听什么端口、用什么协议。卡牌游戏常见的是 TCP 长连接加自定义二进制协议,或者 WebSocket。在代码里搜gen_tcp:listen或cowboy相关模块,找到监听端口。然后用 telnet 或写个最小客户端测:
%% 最小 TCP 连接测试 {ok, Socket} = gen_tcp:connect("127.0.0.1", 9001, [binary, {packet, 2}]). %% {packet, 2} 表示 2 字节长度头,这是卡牌游戏最常见的封包方式 gen_tcp:send(Socket, <<1, 0, 0, 0>>). %% 发一个最简单的登录包,具体格式看协议定义 receive {tcp, Socket, Data} -> io:format("recv: ~p~n", [Data]) after 3000 -> io:format("timeout~n") end.{packet, 2}这个参数是重点,它让 Erlang 自动处理粘包,你收到的每个消息就是完整包,不用自己拼缓冲区。如果原始码用的是{packet, raw},那就要自己实现长度解析,这是新手最容易翻车的地方。验证到能收发一个包,环境就算通了,接下来才是读业务逻辑。
3. 拆开看骨架:玩家进程、ETS 与 Mnesia 的分工
3.1 一个玩家一个进程的落地方式
卡牌服务端最核心的设计决策是「玩家状态放哪」。这套原始码的常见做法是每个在线玩家对应一个 gen_server 进程,进程内保存该玩家的会话状态,比如当前卡组、临时 buff、战斗上下文。玩家进程的注册方式一般用{via, gproc, ...}或者自己维护一个 ETS 映射表,key 是玩家 ID,value 是 Pid。登录时先查这个映射,如果已有进程说明重复登录,踢掉旧的或者拒绝新的。
%% 玩家进程启动与注册的典型写法 start_link(PlayerId) -> gen_server:start_link(?MODULE, [PlayerId], []). init([PlayerId]) -> %% 注册到本地映射表,方便按 ID 找进程 ets:insert(player_registry, {PlayerId, self()}), {ok, #player_state{id = PlayerId, cards = [], bag = #{}}}. %% 按 ID 发消息 send_to_player(PlayerId, Msg) -> case ets:lookup(player_registry, PlayerId) of [{_, Pid}] -> Pid ! Msg, ok; [] -> {error, not_online} end.这里的关键是player_registry这张 ETS 表要设置成public且named_table,否则其他进程访问不了。ETS 的并发读写性能很好,但要注意它不持久化,节点重启就没了,所以它只适合放「在线状态」这种可重建的数据。玩家进程的terminate回调里要记得清理注册表,否则会留下僵尸映射,导致玩家重连时以为还在线。
3.2 ETS 做热数据、Mnesia 做持久化的边界
原始码里数据分两层:ETS 存高频读写的热数据,比如卡牌配置表、排行榜快照、在线玩家索引;Mnesia 存需要持久化的玩家数据,比如背包、卡牌收集、货币。这个分工的逻辑是:配置表读多写少,启动时从文件或数据库加载进 ETS,之后只读,性能极高;玩家数据要落盘,用 Mnesia 的事务保证一致性。
%% 从 Mnesia 读玩家背包,带事务 get_bag(PlayerId) -> F = fun() -> case mnesia:read(player, PlayerId) of [P] -> P#player.bag; [] -> #{} end end, {atomic, Bag} = mnesia:transaction(F), Bag. %% 写回时用 dirty_write 还是 transaction 要看一致性要求 save_bag(PlayerId, Bag) -> F = fun() -> [P] = mnesia:read(player, PlayerId), mnesia:write(P#player{bag = Bag}) end, mnesia:transaction(F).mnesia:transaction会加锁,保证并发下数据不错乱,但性能比dirty_write低。卡牌游戏里涉及货币、卡牌增减的操作必须用事务,纯展示性的数据可以用脏写。Mnesia 的表要提前创建,通常在应用启动时用mnesia:create_table建好,指定disc_copies或ram_copies。disc_copies落盘,ram_copies只存内存,后者快但重启丢数据,适合做缓存表。
注意:Mnesia 的 disc_copies 表在节点重启后会自动恢复,但如果节点名变了,表会加载失败。所以生产环境节点名和 cookie 必须固定,不能随便改。
3.3 战斗回合的顺序保证
卡牌战斗是回合制的,两个玩家出牌有先后,服务端必须保证顺序。原始码里常见的做法是给每场战斗起一个独立的 gen_server 进程,战斗内的所有操作都发消息给这个进程,由它串行处理。这样天然避免了并发写战斗状态的问题,因为 gen_server 一次只处理一条消息。
%% 战斗进程处理出牌 handle_call({play_card, PlayerId, CardId}, _From, State) -> case validate_turn(PlayerId, State) of true -> NewState = apply_card_effect(PlayerId, CardId, State), {reply, ok, NewState}; false -> {reply, {error, not_your_turn}, State} end.validate_turn检查是不是该玩家出牌,apply_card_effect结算效果。整个流程在单进程内串行,不需要额外加锁。战斗进程的生命周期和战斗绑定,战斗结束就退出,监督者用temporary策略,不重启。这里有个坑:如果战斗进程卡住,比如某个效果计算死循环,会阻塞整场战斗。所以效果计算里要避免复杂递归,必要时加超时。
4. 参数怎么调:进程数、超时与内存的实战取值
4.1 进程数上限与文件描述符
Erlang 默认进程数上限是 262144,对卡牌游戏通常够用,但如果单节点要扛十万在线,每个玩家一个进程加战斗进程,可能逼近上限。调整方式:
erl +P 1048576 +Q 1048576 ...+P是最大进程数,+Q是最大端口数。端口数容易被忽略,每个 TCP 连接占一个端口,十万连接就要把+Q调到十万以上。同时操作系统的文件描述符限制也要改,ulimit -n 1048576,否则 Erlang 层面调了也没用,连接建立时会报emfile。
4.2 超时参数的取值逻辑
卡牌游戏里超时无处不在:心跳超时、战斗回合超时、登录认证超时。原始码里常见的心跳是 30 秒,服务端 60 秒没收到心跳就断开。这个值不能太短,移动网络下玩家切后台再回来,太短会频繁断线重连;也不能太长,否则死连接占着资源。我一般设心跳 20 秒,服务端判定 45 秒,留一点网络抖动余量。
%% 心跳检测的典型实现 handle_info(check_heartbeat, State) -> Now = erlang:monotonic_time(second), case Now - State#player_state.last_heartbeat > 45 of true -> {stop, heartbeat_timeout, State}; false -> erlang:send_after(10000, self(), check_heartbeat), {noreply, State} end.erlang:monotonic_time比os:timestamp更适合做间隔计算,它不受系统时间调整影响。send_after做周期性检查,间隔 10 秒,比每次心跳都检查更省。战斗回合超时一般设 30 秒,超时自动出牌或判负,防止一个玩家挂机卡死整场。
4.3 内存与 GC 的观察点
Erlang 进程内存是动态的,小进程占几百字节,大进程可能几 MB。玩家进程如果缓存了太多数据,比如整个卡牌库的详细对象,内存会涨得很快。用observer看进程内存排序,找出占用最高的几个。如果某个玩家进程异常大,多半是状态里存了不该存的东西。
%% 查看进程内存占用 [{Pid, Info}] = [{P, process_info(P, memory)} || P <- processes()], %% 按内存排序找 top 10二进制数据超过 64 字节会走引用计数,共享在堆外,这部分不计入进程内存,但会计入 Erlang 总内存。如果总内存高但进程内存都不大,多半是二进制没释放。检查有没有地方持有大 binary 的引用不放,比如把整个协议包存进状态里。
5. 避坑与排查:原始码跑不通时先看这几处
5.1 节点起不来,报「Cannot start kernel」
现象:执行启动脚本后立刻退出,日志里出现Cannot start kernel或init terminating。原因通常是-config指定的 sys.config 路径不对,或者文件里的 Erlang 语法有错,比如少了结尾的点号、括号不匹配。解决:先用erl -config config/sys.config单独测,看具体报错行;确认路径是绝对路径或相对于启动目录的正确相对路径。还有一种情况是 cookie 文件权限不对,~/.erlang.cookie必须是 600 权限,否则 Erlang 拒绝启动。
5.2 客户端连上就断,日志无异常
现象:TCP 能建立,但几秒后断开,服务端日志没有 error。原因多半是心跳逻辑没跑起来,或者{packet, N}配置和客户端不一致。客户端按 2 字节长度头发,服务端用{packet, 4}解析,收到的长度就是错的,解析失败后进程崩溃,监督者重启,连接就断了。解决:抓包看前几个字节,确认长度头是几字节;检查 accept 后的 socket 选项设置,inet:setopts(Socket, [{packet, 2}])要和协议文档一致。
5.3 Mnesia 表创建失败,报「already exists」
现象:应用启动时mnesia:create_table返回{aborted, {already_exists, Table}}。原因:表已经存在,重复创建。这在开发时常见,因为改了表结构但没删旧表。解决:创建前先判断,或者用mnesia:delete_table删掉重建。生产环境不能随便删表,要用mnesia:transform_table做结构迁移。另外,如果节点名变了,旧表文件还在但加载不了,需要手动清理 Mnesia 目录。
5.4 战斗结算数据对不上,偶发卡牌消失
现象:玩家反馈打完一场,某张卡不见了,但日志里没有报错。原因:战斗进程和玩家进程同时写背包,没有走同一个事务。战斗结算时战斗进程直接改了 Mnesia,玩家进程也改了,两边覆盖。解决:所有涉及玩家资产的操作,统一走玩家进程的消息接口,由玩家进程串行处理,战斗进程只发结算消息,不直接写库。这是分布式状态管理的基本原则:一个数据只有一个写入者。
5.5 高在线时延迟飙升,CPU 打满
现象:在线人数上去后,操作延迟从几十毫秒涨到几秒,CPU 单核打满。原因:某个热点 ETS 表被大量并发写,或者某个 gen_server 成了瓶颈。ETS 的write_concurrency选项没开,默认是单写锁。解决:对写多的表加{write_concurrency, true},读多的加{read_concurrency, true}。如果是 gen_server 瓶颈,看是不是所有请求都走同一个进程,考虑按玩家 ID 分片,起多个 worker。
6. 从能跑到能扛:压测、热更新与状态迁移的进阶手法
把原始码跑通只是起点,真正要上线,得回答「能扛多少人」和「怎么不停机更新」。压测这块,Erlang 自带tsung是常见选择,但配置略重,我一般先用简单脚本模拟并发连接,看服务端在多少连接下开始丢包或延迟上涨。写个 escript 批量建连:
#!/usr/bin/env escript %% 模拟 N 个客户端并发连接并保持 main([Host, PortStr, NumStr]) -> Port = list_to_integer(PortStr), Num = list_to_integer(NumStr), [spawn(fun() -> connect_and_hold(Host, Port) end) || _ <- lists:seq(1, Num)], timer:sleep(infinity). connect_and_hold(Host, Port) -> {ok, S} = gen_tcp:connect(Host, Port, [binary, {packet, 2}, {active, false}]), loop(S). loop(S) -> case gen_tcp:recv(S, 0, 60000) of {ok, _} -> loop(S); {error, _} -> ok end.这个脚本能快速告诉你服务端在多少连接下开始拒绝或超时。重点观察observer里的进程数和内存曲线,找到拐点。拐点之前是线性增长,之后如果内存陡增或调度器忙不过来,就是瓶颈所在。
热更新是 Erlang 的强项,靠的是代码加载机制。Erlang 允许同一个模块有两个版本共存,旧版本处理完当前请求后自动卸载。操作方式是编译新 .beam,然后code:purge(Module)和code:load_file(Module)。但有个前提:进程不能跨版本持有旧代码的 fun 或闭包,否则会报badfun。所以热更新前要确认没有长期存活的进程持有旧代码引用。常见做法是在code_change/3回调里做状态转换,gen_server 支持这个回调,升级时自动调用。
%% gen_server 的代码升级回调 code_change(_OldVsn, State, _Extra) -> %% 在这里把旧状态结构转成新结构 NewState = migrate_state(State), {ok, NewState}.状态迁移是热更新里最容易翻车的部分。如果新版本的状态记录多了一个字段,旧进程的状态里没有,直接访问会报badrecord。所以code_change里必须做兼容处理,用element或record_info判断字段数,缺的补默认值。我一般会在升级前先在小范围节点灰度,观察一天再全量,后悔药就是回滚旧 .beam,但前提是旧代码还在。
最后说一个习惯:每次改完代码,不要只测正常流程,一定要测异常流程。断网、重复登录、并发出牌、背包满、货币溢出,这些边界才是线上事故的高发区。这套原始码给的是骨架,血肉得自己填。希望帮到你。
本文还有配套的精品资源,点击获取