ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Erlang卡牌游戏服务器原始码实战:从跑通到热更新与避坑

Erlang卡牌游戏服务器原始码实战:从跑通到热更新与避坑 简介这是一份面向Erlang学习者与游戏后端开发者的开源服务器源码来自卡牌对战网游《萌兽堂》可用于研究Erlang在真实游戏项目中的工程化落地。资源包共170个文件约1.03MB以111个erl源文件与20个hrl头文件为主体辅以bat、cmd启动脚本、config配置、makefile与emakefile构建文件、sql脚本及少量C源码整体呈现典型OTP应用结构。项目围绕OTP框架组织涉及并发进程模型、分布式节点通信、热更新与容错、Mnesia数据持久化、TCP/HTTP网络通信及模块化设计等核心知识点并配有eunit测试与调试相关文件。目前已有166人学习下载适合具备一定Erlang基础、希望理解高并发游戏服务器架构与业务逻辑拆分的开发者参考可从中梳理进程划分、节点同步与构建部署的整体思路。1. 卡牌游戏服务器为什么还在用 Erlang从《萌兽堂》原始码说起如果你手里有一套卡牌游戏的 Erlang 服务端原始码第一反应大概率是「这玩意儿还能跑起来吗」。我最初接触这类代码时也有同样的疑问都什么年代了为什么不用 Go 或者 Java 重写一遍但真正把一套完整的 Erlang 卡牌服务器跑通、读懂、改出第一个功能之后我的判断变了——Erlang 在卡牌游戏这个品类上不是历史包袱而是一个被低估的合理选择。卡牌游戏的服务端有几个硬需求大量玩家同时在线、每个玩家的战斗和背包状态要严格一致、掉线重连不能丢数据、活动开服瞬间的并发峰值要扛得住。Erlang 的轻量进程模型和「让进程崩溃而不是到处 try-catch」的哲学恰好对上这些需求。一套完整的原始码里你能看到进程树怎么划分、玩家状态怎么隔离、跨服战斗怎么通信、热更新怎么做——这些东西比看十篇架构文章都实在。这篇文章面向的是想拿这套原始码真正跑起来、改起来的人不是来看热闹的。2. 先把原始码跑起来环境、依赖与最小启动链路2.1 Erlang/OTP 版本选择与构建工具链拿到原始码第一步不是急着编译而是确认它依赖的 OTP 版本。卡牌游戏服务端这类项目通常写于 OTP 20 到 OTP 24 之间版本跨度带来的 API 差异足以让你在编译阶段就卡住半天。我的习惯是先翻rebar.config和*.app.src文件看它声明了哪些依赖、有没有指定minimum_otp_vsn。# 查看当前 Erlang 版本 erl -eval erlang:display(erlang:system_info(otp_release)), halt(). -noshell # 如果项目用 rebar3 构建先确认 rebar3 版本 rebar3 version # 编译前先拉依赖这一步最容易因为网络或版本问题失败 rebar3 get-deps逻辑说明第一条命令输出当前 OTP 主版本号用来和项目要求比对。第二条确认构建工具版本rebar3 在 3.14 之后对旧项目的兼容性有变化。第三条拉取依赖如果项目用的是老式rebar非 rebar3命令要换成./rebar get-deps。参数说明-noshell表示不进入交互式 shell执行完直接退出适合脚本里用。-eval后面跟的是要执行的 Erlang 表达式。如果你的机器上装了多个 OTP 版本建议用kerl或asdf管理别直接覆盖系统默认版本。一个常见的坑是原始码里用了crypto:hash/2这种在新版本被废弃的函数编译能过但运行时报undef。遇到这种情况要么降 OTP 版本要么在rebar.config里加{erl_opts, [nowarn_deprecated_function]}先压住警告再逐个替换。2.2 目录结构与进程树先看懂再动手Erlang 项目的目录结构本身就是一张架构图。典型的卡牌游戏服务端原始码会长这样目录作用阅读优先级apps/gateway网关负责连接接入和协议解析高apps/login登录认证、角色列表高apps/game核心玩法战斗、背包、任务最高apps/chat聊天与广播中apps/cross跨服战斗与排行榜中config/各环境配置文件高priv/静态数据、配置表中先看apps/game/src/下的*_sup.erl文件监督树的结构决定了进程怎么起、崩了怎么重启。我一般会从game_sup开始顺着start_link往下画一棵进程树标出哪些是worker、哪些是supervisor、哪些用了simple_one_for_one。%% 典型的玩家进程监督策略 init([]) - PlayerSup {player_sup, {player_sup, start_link, []}, permanent, infinity, supervisor, [player_sup]}, {ok, {{one_for_one, 10, 10}, [PlayerSup]}}.逻辑说明这段监督策略表示player_sup是永久进程崩溃后无限重启重启强度是 10 秒内最多重启 10 次超过就整棵子树挂掉。卡牌游戏里玩家进程通常用simple_one_for_one因为玩家数量动态变化每个玩家一个独立进程。参数说明one_for_one表示一个子进程崩了只重启它自己permanent表示进程永远重启infinity是重启间隔上限实际由后面的10, 10控制。改这些参数之前先想清楚你的业务能不能接受「一个玩家进程崩了导致整服重启」。2.3 数据库与配置把外部依赖接上卡牌游戏服务端一般会连 MySQL 存角色数据、Redis 做缓存和排行榜。原始码里通常有config/sys.config和config/vm.args两个文件前者管应用配置后者管 Erlang 虚拟机参数。%% sys.config 片段数据库连接池配置 {game, [ {mysql_pool, [ {host, 127.0.0.1}, {port, 3306}, {user, game_user}, {password, game_pass}, {database, mengshou}, {pool_size, 10} ]}, {redis, [ {host, 127.0.0.1}, {port, 6379}, {pool_size, 5} ]} ]}.逻辑说明这段配置告诉game应用去哪里连数据库。pool_size是连接池大小卡牌游戏里这个值要根据同时在线人数调太小会排队太大会把数据库连接数打满。参数说明vm.args里通常要改的是K true启用内核 poll、A 16异步线程数、P 1048576最大进程数。卡牌游戏玩家进程多P一定要调大默认值在高峰期会直接报system_limit。提示第一次跑通之前先把数据库建好、表结构导入别急着启动服务端。我见过太多人卡在「连不上数据库」这一步结果发现是库根本没建。3. 玩家进程与状态管理卡牌服务器的核心骨架3.1 一个玩家一个进程状态隔离与消息驱动Erlang 卡牌服务器最核心的设计就是「一个玩家一个进程」。玩家的背包、任务进度、战斗状态全部放在这个进程的私有状态里其他进程想改只能发消息。这样做的好处是状态天然隔离不会出现两个请求同时改一个玩家数据导致覆盖的问题。%% 玩家进程主循环 loop(State) - receive {cast, {add_item, ItemId, Count}} - NewState add_item(State, ItemId, Count), loop(NewState); {call, From, {get_bag}} - From ! {bag, State#player.bag}, loop(State); {cast, {battle_result, Result}} - NewState apply_battle_result(State, Result), loop(NewState) end.逻辑说明receive是 Erlang 进程的消息接收原语。cast表示不需要回复的异步消息call表示需要回复的同步消息。所有对玩家状态的修改都在这个循环里串行执行天然避免了并发写冲突。参数说明State是玩家进程的状态记录通常是一个#player{}record。add_item和apply_battle_result是纯函数输入旧状态和参数返回新状态。这种「状态不可变、每次修改产生新状态」的写法是 Erlang 的惯用法也是热更新能平滑进行的基础。实际项目里玩家进程还会挂一个定时器做定期存库避免每次改数据都写数据库。存库频率一般设 30 到 60 秒太频繁会压垮数据库太稀疏掉线时会丢数据。3.2 跨进程通信战斗、聊天、跨服怎么串起来玩家进程之间不能直接调用必须通过消息。卡牌游戏里最常见的跨进程场景是战斗两个玩家匹配上了要在某个进程里跑战斗逻辑然后把结果分别发回两个玩家进程。%% 战斗进程收到匹配结果后通知双方玩家进程 start_battle(PlayerA, PlayerB) - BattlePid spawn(?MODULE, battle_loop, [PlayerA, PlayerB]), PlayerA ! {cast, {battle_start, BattlePid}}, PlayerB ! {cast, {battle_start, BattlePid}}, BattlePid. battle_loop(PlayerA, PlayerB) - %% 战斗逻辑跑完后把结果发回 Result run_battle(PlayerA, PlayerB), PlayerA ! {cast, {battle_result, Result}}, PlayerB ! {cast, {battle_result, Result}}.逻辑说明spawn起一个临时战斗进程把战斗逻辑从玩家进程里剥离出来避免战斗计算阻塞玩家进程的消息处理。战斗结束后进程自然退出不需要手动清理。参数说明spawn的第三个参数是初始参数列表。如果战斗进程需要注册名字或监控改用proc_lib:spawn_link或gen_server:start_link。注意战斗进程崩了不能影响玩家进程所以这里用spawn而不是spawn_link。聊天和跨服也是类似思路聊天走独立的chat进程跨服走cross进程通过gen_server:cast或rpc:call通信。跨服场景下如果两个节点不在同一台机器要用net_kernel做节点互联或者走独立的网关转发。3.3 状态持久化存库时机与掉线恢复玩家状态最终要落到数据库。原始码里通常有一个db_mgr进程负责批量写库玩家进程定期把脏数据发给它。%% 玩家进程定期存库 handle_info(save_timer, State) - db_mgr ! {save_player, State#player.id, State#player.data}, erlang:send_after(30000, self(), save_timer), {noreply, State}. %% db_mgr 批量写库 handle_cast({save_player, PlayerId, Data}, State) - NewQueue queue:in({PlayerId, Data}, State#db.queue), {noreply, State#db{queue NewQueue}}; handle_info(flush_timer, State) - flush_to_mysql(State#db.queue), {noreply, State#db{queue queue:new()}}.逻辑说明玩家进程每 30 秒发一次存库消息db_mgr把请求攒在队列里每 5 秒批量刷一次数据库。这样把随机写变成批量写数据库压力小很多。参数说明30000是玩家存库间隔单位毫秒。flush_timer的间隔一般设 5000。如果服务器崩溃最多丢 30 秒的数据卡牌游戏一般能接受。要更安全就缩短间隔但数据库压力会上升。掉线恢复的逻辑是玩家重新登录时先从数据库加载上次存的状态再把掉线期间可能产生的离线收益补上。原始码里通常有offline_reward模块处理这块注意离线时间上限要设不然玩家离线一年回来直接爆仓。4. 协议、热更新与活动让服务器真正能运营4.1 协议编解码客户端和服务端怎么对齐卡牌游戏的协议一般是自定义二进制格式客户端用 C# 或 Lua服务端用 Erlang。原始码里通常有proto目录里面是协议定义和编解码函数。%% 协议编码示例把玩家信息打包成二进制 encode_player_info(Player) - (Player#player.id):32/little, (Player#player.level):16/little, (Player#player.exp):32/little, (byte_size(NameBin)):16/little, NameBin/binary. %% 解码 decode_player_info(PlayerId:32/little, Level:16/little, Exp:32/little, NameLen:16/little, Rest/binary) - NameBin:NameLen/binary, _/binary Rest, {ok, #player{id PlayerId, level Level, exp Exp, name NameBin}}.逻辑说明Erlang 的二进制模式匹配是处理协议的神器。编码时按字段顺序拼二进制解码时用模式匹配拆出来。little表示小端序要和客户端约定一致不然数字全是错的。参数说明32/little表示 32 位小端整数16/little是 16 位。字符串要先算长度再拼解码时先读长度再读内容。协议字段的顺序、类型、字节序必须和客户端严格对齐差一个字节就全乱。实际项目里协议定义一般会写在一份独立的描述文件里然后用工具生成两端代码。原始码里如果有proto_gen之类的脚本优先用它生成别手写。4.2 热更新不关服改代码的正确姿势Erlang 的热更新能力是它区别于其他语言的核心优势。卡牌游戏运营期间要频繁改活动配置、修 bug能热更就不用停服。%% 热更新一个模块 reload_module(Module) - Code code:which(Module), {ok, Bin} file:read_file(Code), {module, Module} code:load_binary(Module, Code, Bin), ok. %% 更安全的做法先检查新旧版本是否兼容 soft_purge(Module) - code:soft_purge(Module).逻辑说明code:load_binary加载新版本代码旧版本进程继续用旧代码跑完当前请求新请求用新代码。code:soft_purge检查旧版本是否还有进程在用没有才真正清除。参数说明热更新只对gen_server这类「每次请求都重新进入模块」的进程有效。如果进程状态里存了旧版本的数据结构热更后可能匹配失败。所以改数据结构时要在code_change/3回调里做状态转换。注意热更新不是万能的。改了进程状态结构、改了监督树、改了应用配置这些都需要重启对应进程甚至整个节点。我一般把热更分成「安全热更」和「必须重启」两类运营前先分类。4.3 活动系统配置驱动与定时开服卡牌游戏的活动系统通常是配置驱动的策划配一张表服务端定时读表、到点开活动、到点关活动。%% 活动定时器 start_activity(ActivityId, StartTime, EndTime) - Now erlang:system_time(second), case Now StartTime of true - erlang:send_after((StartTime - Now) * 1000, self(), {open_activity, ActivityId}); false - open_activity(ActivityId) end, erlang:send_after((EndTime - Now) * 1000, self(), {close_activity, ActivityId}).逻辑说明根据当前时间和活动开始时间算延迟到点发消息开活动。结束时间同理。活动数据一般存在 ETS 表里玩家进程查 ETS 判断活动是否开启。参数说明erlang:system_time(second)返回 Unix 秒级时间戳。send_after的延迟单位是毫秒。注意send_after在节点重启后会丢失所以活动状态要持久化重启后重新扫描一遍活动表。活动配置表一般用 CSV 或 Excel服务端启动时加载到 ETS。改活动配置不需要改代码策划改表、服务端重读表就行。重读表的命令通常是activity_mgr:reload()这个函数要保证读表失败时不影响正在进行的活动。5. 避坑与排查原始码跑起来之后最容易翻车的几件事5.1 进程数爆了system_limit 报错怎么查现象服务器跑一段时间后日志刷system_limit新玩家进不来。原因Erlang 默认最大进程数是 262144卡牌游戏玩家进程、战斗进程、定时器进程加起来很容易超。尤其是战斗进程如果没及时退出会越积越多。解决在vm.args里加P 1048576把上限调到 104 万。同时检查战斗进程有没有在异常分支里漏掉退出逻辑用erlang:processes()和erlang:process_info/1排查僵尸进程。5.2 消息队列堆积玩家进程卡死的排查路径现象某个玩家操作没反应其他玩家正常。原因玩家进程的消息队列堆积了通常是某个同步调用卡住或者存库进程阻塞导致玩家进程等回复。解决用erlang:process_info(Pid, message_queue_len)看队列长度超过 100 就要警惕。再用erlang:process_info(Pid, current_stacktrace)看卡在哪。常见原因是gen_server:call没设超时或者数据库慢查询把db_mgr堵死了。5.3 热更新后状态不匹配code_change 写错的代价现象热更后玩家进程报function_clause或badrecord。原因改了 record 结构但没写code_change/3旧进程的状态还是旧结构新代码按新结构匹配就崩了。解决改 record 必须同步改code_change/3在里面做旧状态到新状态的转换。测试时先在一个玩家进程上热更确认没问题再全量推。5.4 数据库连接池打满pool_size 不是越大越好现象高峰期大量请求超时日志报连接池取不到连接。原因pool_size设太小或者有慢查询占着连接不释放。解决先看慢查询日志把没走索引的 SQL 优化掉。pool_size一般设 CPU 核数的 2 到 4 倍不是越大越好连接太多数据库本身也会成为瓶颈。5.5 跨服节点掉线net_kernel 的 cookie 和网络分区现象跨服战斗匹配不上日志报nodedown。原因两个节点的 Erlang cookie 不一致或者网络抖动导致节点断开。解决确认所有节点的.erlang.cookie文件内容一致。网络分区用net_kernel:monitor_nodes(true)监控掉线后做重连和状态同步。跨服数据要设计成最终一致别依赖强同步。6. 从能跑到能改读懂原始码的三个进阶习惯第一个习惯是「先看测试再改代码」。原始码里如果有test/目录先跑一遍rebar3 eunit看哪些用例是过的、哪些是挂的。挂掉的用例往往指向环境依赖或者已知 bug比你自己瞎摸快得多。没有测试就自己补一个最小用例比如起一个玩家进程、加一件道具、断言背包里多了一件这个用例能跑通说明核心链路是活的。第二个习惯是「用 observer 看运行时」。Erlang 自带observer:start()能看进程树、ETS 表、内存分布、消息队列。我改代码之前会先开 observer对着进程树找到要改的模块对应的进程看它的状态和消息。这比读代码猜运行状态靠谱得多。尤其是排查内存泄漏时observer 的 memory 面板能直接告诉你哪个进程在涨。第三个习惯是「改配置不改代码」。原始码里能通过配置解决的问题别动代码。活动时间、掉落概率、战斗数值这些尽量走配置表。代码只改逻辑不改数值。这样策划自己能调你不用每次改个概率就热更一次。%% 一个最小验证脚本起玩家进程、加道具、查背包 -module(verify_player). -export([run/0]). run() - {ok, Pid} player:start_link(1001), player:add_item(Pid, 2001, 5), timer:sleep(100), Bag player:get_bag(Pid), io:format(bag: ~p~n, [Bag]), case lists:keyfind(2001, 1, Bag) of {2001, 5} - io:format(verify ok~n); _ - io:format(verify failed~n) end.逻辑说明这个脚本起一个玩家进程加 5 个 ID 为 2001 的道具等 100 毫秒让消息处理完然后查背包断言。跑通它说明玩家进程、消息处理、背包逻辑这条链路是好的。参数说明timer:sleep(100)是给消息处理留时间实际项目里应该用同步调用gen_server:call代替 sleep避免时序问题。lists:keyfind在背包列表里找道具背包结构不同的话这里要改。最后说一个我自己的教训刚接触这类原始码时我总想先把它重构成「现代架构」结果改了两周发现连原来的功能都跑不起来了。后来学乖了先跑通、先加日志、先写验证脚本等对系统有手感了再动结构。原始码的价值不在于它写得多好而在于它是一个完整跑过的系统你能从里面看到真实项目怎么处理边界、怎么容错、怎么运营。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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