ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Erlang卡牌游戏服务端实战:从原始码跑通到高并发调优

Erlang卡牌游戏服务端实战:从原始码跑通到高并发调优 简介这是一份面向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_logicyourhost). %% 返回 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 是玩家 IDvalue 是 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但前提是旧代码还在。最后说一个习惯每次改完代码不要只测正常流程一定要测异常流程。断网、重复登录、并发出牌、背包满、货币溢出这些边界才是线上事故的高发区。这套原始码给的是骨架血肉得自己填。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进