2026年6月15日
我如何把一万名玩家放进同一个世界
大多数网络游戏都在掩盖自己的规模——把玩家拆进 20 人的房间,或者分成几百人的分片。为 Helix Empire 我故意定了一个更难的目标:一万名玩家在同一个共享世界里,跑在一台服务器上,在浏览器里实时呈现。这是它如何被一步步建起来的完整故事——你会撞上的四堵墙,为什么真正的瓶颈是流量而不是 CPU,以及一次压测如何证明我那个漂亮数字其实是个谎言。它很长,很技术,每一个论断都以一次测量收尾。这些经验适用于任何高负载系统。
我做的是 Helix Empire——一款实时、基于浏览器的太空策略游戏,它的 核心是遗传学:你通过编辑 DNA 来培育各种生物族群,而这些基因会向外扩散,影响你的经济、你的军队、 你的科技。但这篇文章其实不是讲游戏。它讲的是它底下那个工程问题,那个我觉得真正困难、真正 有意思的问题:把一万名玩家放进同一个共享世界,跑在一台服务器上,在每个浏览器里实时更新。
大多数游戏从不尝试这件事。它们掩盖自己的规模——20 人的房间,或者几百人的「分片」,彼此永远见不到面。 我故意定了更难的目标,因为真正的架构就活在这个约束里,也因为我想证明自己能做到。这是完整的故事, 平实地讲出来,而它的结尾——像所有诚实的工程一样——是测量出来的数字,而不是承诺。哪怕你永远不做 游戏,这个问题的形状也会出现在任何需要把实时更新同时推送给大量人群的系统里。
把它说出口,四堵墙就出现了
「一万名玩家在同一个世界里」是一句话。当你真去构建它的那一刻,四堵墙就出现了,一个幼稚的设计会 直直撞上去。
墙 1:把每个人的情况告诉每个人,是 O(N²)。 如果每个玩家都必须知道其他每个玩家在做什么,那么 一个更新周期就是每个玩家乘以其他每个玩家:
10,000 watchers × 10,000 objects = 100,000,000 pairs — per tick
一亿对,每秒好几次。这不是「慢」,这是物理上不可能。而且它是二次方的:玩家翻倍,工作量就 翻两番。任何向所有人广播所有人的设计,一上线就死了。
墙 2:主导成本是流量,不是 CPU。 这是反直觉的那一条,也是决定一切的那一条。在云主机上, 出站流量——egress——是最贵的资源,比裸金属服务器贵几十倍。如果每个 tick 都给每个玩家发一大堆 字节,那么早在处理器流汗之前,带宽账单就把你拖破产了。所以系统必须围绕 尽量减少出到线上的东西 来设计,而不是围绕原始计算速度。这一个认识就重排了其他所有决策。
墙 3:一个世界想活在一台服务器上。 把单个世界摊到好几台机器上,你就签下了分布式共识——服务器 不断争论谁手里那份世界副本才是真的。慢,而且狠地复杂。所以我选了 single-writer:每个世界 恰好只有一个进程被允许去改它。没有竞争,没有共识。它藏着一个讨厌的陷阱,我后面会讲到。
墙 4:瘦客户端跟不上。 如果服务器算所有东西,而浏览器只负责画像素,那么服务器就成了同时 服务一万人的瓶颈。所以浏览器客户端必须是 厚的——聪明到能从一丁点数据里自己重建出大部分画面。
四堵墙。每一堵都干掉了那个显而易见的方案。架构无非就是对这四堵墙给出的一组答案,而纪律就在于: 让约束——而不是最时髦的技术——来挑工具。
这套技术栈,是针对墙选出来的
人们爱按潮流挑技术。真正的工作正好相反:先把你的约束说清楚,再选出能回答它们的最小一组工具。 下面是这套对应关系,因为这套对应关系 本身 就是思考。
模拟运行在 Rust 上,编译成两种形态:给服务器的原生二进制,和给浏览器的 WebAssembly。 同一份代码,两边都用。这比听起来更重要。因为客户端跑的是完全相同的模拟,它能从紧凑的「种子」里 重建世界,甚至向前预测——这把工作从服务器上挪走了(墙 2 和墙 4)。而 Rust 的内存是可预测的,没有 垃圾回收器的停顿,所以一台服务器能装下更多玩家。
实时帧通过 WebTransport / QUIC 传输——一条跑在 UDP 上的快速二进制流,绕开了 TCP 在丢包时 会遇到的卡顿(墙 2),并带有 WebSocket 兜底。每个世界都是 single-writer 加 event sourcing: 一个进程改它,而它的历史是一串事件流,你可以重放这串事件来重建或审计状态(墙 3)。它托管在 裸金属(Hetzner)上,那里的 egress 比超大规模云厂商便宜 20–40 倍(又是墙 2——注意它出现得有 多频繁)。
这其中的每一项,都服务于同一个结论:瓶颈是 egress 和更新的 fan-out。 把这个判断做对,技术栈 就自然落出来了。做错,你就会深情地优化 CPU,而带宽账单悄悄把你弄死。
一层一层地建,每一层都测量
我不是一鼓作气英雄式地建出来的。我一层一层来,而且 每一步都测量——因为头号规则是:你无法优化 你没有测量过的东西。这里有件让人意外的事:我是从测量它有 多糟 开始的。
基线。 在碰任何东西之前,我先写下那个尴尬的真相:读取游戏状态会飙到 4–10 秒,而推送到 浏览器的更新大约是 每 20 秒一帧。基本上是冻住的。但现在我有了一个要打败的数字,而这是唯一 能把「感觉很慢」变成工程的东西。
干掉 single-writer 陷阱。 还记得我答应过的那个陷阱吗?single-writer 意味着一个进程改世界。 但当这个进程在应用一个 tick——而一个 tick 要写存储,这很慢——的时候,每个读取者都卡在同一把锁上 干等。 玩家打开他的防御界面,挂十秒,不是因为读取很难,而是因为服务器恰好在保存的半途中。
修法是整篇文章里最可迁移的一个想法:把读和写分开。 我在内存里建了无锁缓存——随时可供取用的 世界投影,增量更新,读取者 不用 拿写锁就能命中它们。写入者依然独占所有改动(所以一致性从来 不成问题),但读取者不再等它了。
这种读写分离不是游戏的小把戏。它和到处都在用的读副本、CQRS、物化视图背后是同一个动作——也是几乎 任何在负载下变慢的系统里,我第一个会去伸手够的招。
发差异,不发整个世界。 即便读取已经瞬时,每个 tick 仍然在发 整个 状态——一堆没变的东西被 反复重传。这是墙 2 上的纯粹浪费。所以我换成了 deltas:只发上一帧之后改变的东西。delta 类型 很小,而且经过数学校验——一个函数构造两个状态之间的差异,另一个把它重放回去,一个收敛测试证明: 一长串 delta 被客户端折叠之后,会 精确地 重现服务器的快照。因为客户端和服务器共享同一份 Rust 代码,它们不可能彼此漂移。如果某一帧真的丢了,客户端会察觉到,并请求一份全新的完整快照。
把 O(N²) 砍成一条直线。 这是对墙 1 的回答。一个玩家不需要知道其他全部一万人——只需要知道他在 空间和外交上的邻居。所以每个 delta 都按玩家的 Area of Interest(兴趣区域) 裁剪:你收到的是和 你 相关的改动,而像聊天和交易这样的共享上下文仍然会传过来。这就把一次二次方的广播变成了线性的 ——流量随着 N 乘以你兴趣区域的大小增长,而不是随 N² 增长。没有这一步,一万在纸面上就不可能。有了 它,数学就闭合了。
那个漂亮数字原来是个谎言的瞬间
下面是我最自豪的部分,也是我犯了错的部分。
我有一个压测,它报出在 10k 玩家下漂亮的 320 Mbit/s——舒舒服服地在预算之下。我差点就信了。 然后我看了看这个数字是从哪来的,发现它建立在一个 写死的猜测 上:「假设每个玩家更新 32 字节。」 不是测量。是某人(我)敲进去的一个假设。
所以我接上了真正的二进制编码器,去 测量 一次真实的更新。它是 104 字节,不是 32。为什么? 因为我每次都在发玩家的 整个 档案——十一个资源字段——哪怕只有其中一个变了。104 对 32,是 糟 3.25 倍,这把预测吹到了 10k 下大约 1.04 Gbit/s。那一刻诚实的答案是那个让人难受的: 「不行——在真实的线上格式下,我装不下一万。」
这正是把一名架构师和一个只会交付漂亮 demo 的人分开的瞬间。轻松的路是继续报 320。正确的路是相信 测量,把这数字不好这件事说出口,然后去修真正那个东西。修法是一个 按字段的 delta:不发整个档案, 而是发一个 id、一个标记哪些字段变了的小位掩码,以及仅仅那些字段的值。
player_id (4 bytes) + changed-field bitmask (2 bytes) + only the changed values
# if population, food and science changed:
4 + 2 + 4 + 8 + 8 = 26 bytes (instead of 104)
二十六字节,而不是一百零四。掩码是十六位,每位对应一个字段:位被置上,它的值就跟在线上;没被置上, 这个字段就没变,一点都不占。
证明,不是承诺
没有证明的架构只是一个自信的故事。所以这里是证明,测量出来的。
底下是那些无趣的保证:往返测试(把一个 delta 编码成字节,再解码回来,无损)、收敛测试(客户端 永远不会从服务器漂走)、每条边界上的契约测试,以及 42 个在真实 Chromium 浏览器里跑的端到端测试, 全部走 WebTransport——包括那些对实时对局真正要紧的测试,比如「在一个实时套接字上接收服务器 tick, 不靠轮询」和「同一个世界里的两名玩家实时保持同步」。
还有压轴的:一个一万机器人的蜂群测试,它用 真正的二进制编解码器 来测量 egress——这次没有任何 写死的常量。它在一个世界里拉起一万个机器人客户端,跑一次真正的权威 tick,把每个机器人能看见的 更新通过真正的 Area of Interest 过滤器走一遍,然后把实际测出来的字节加起来。
而且这个数字被 持续 保持诚实:一个叫 check:load 的关卡在每次运行时都重新核验这份报告,预测是
从测量值推导出来的,而不是从一个假设。如果未来某个改动不小心把更新格式撑胖了,这道关卡就会失败,
回退就溜不过去。这道守卫之所以存在,恰恰是因为我已经被一个我没测量的数字骗过一次了。
诚实的边界
成熟不只是拿到一个好数字——而是把这个数字覆盖了什么、没覆盖什么说清楚。所以,直说:
已经被证明的。 在计算和延迟上,把一个 tick 扇出给一万个机器人保持在 200 ms 以内,还有大量 余地。在流量上,10k 下 237.6 Mbit/s——是 1 Gbit/s 预算的 24%——而且这是通过真实编解码器的真实 测量,不是一厢情愿的猜测。
还没被证明的。 这是从 10k 下一个测量出来的 tick 做出的预测,机器人都在同一个进程内——而不是一个 由一万个真实 QUIC 套接字组成、被连续猛打几小时的活集群。在一条多分钟的长流下持续的 CPU 与内存, 以及给厚浏览器客户端的单玩家更新通道,是下一层要做的工作。我宁愿这么说,也不愿夸大它。要紧的是, 最大的风险——egress,那个我自己标过在真实格式下超预算的——已经关闭并被测量了。
哪些能迁移,哪怕你永远不做游戏
剥掉飞船和遗传学,剩下的是一组动作,我几乎会把它们用在任何需要同时服务大量人群的系统上:
- 在优化任何东西之前,先点名真正的瓶颈。 这里它是 egress,不是 CPU。一切都从这一个判断里流出。 最贵的错误,是把不该优化的资源优化得很漂亮。
- 把读和写分开。 一个写入者保证一致性,加上无锁的读投影,干掉了争用,又没放弃正确性。这一招 几乎是通用的。
- 发 diff,不发整个状态——而且如果你能,就让一份代码跨越边界共享,让两边物理上没法不一致。
- 找到那个 O(N²),把它砍成线性的。 几乎每个「一上规模就垮」的故事里,都藏着一个二次方。我的 那个藏在广播里;Area of Interest 就是那把刀。
- 永远别信一个你没测量的漂亮数字。 我那 320 Mbit/s 是一个敲进去的假设,错了 3.25 倍。整个结果 系于能不能抓到它、并且愿不愿意把它说出来。
- 用一道关卡把胜利锁住,让未来的回退去触发警报,而不是默默地发上线。
归根结底
一万名玩家在一个活的世界里,是那种听起来像虚张声势的目标——直到你把它拆成四堵墙,一堵一堵地回答, 一路测量过去。这个引擎在 24% 的流量预算 下做到了,而且我能给你看那个证明它的测试,而不是 请你信我一句话。
这套架构不是什么巧妙的把戏——它是一条决策的链子,每一个决策回答一堵具体的墙,每一个都由一个 数字撑着。 这就是整个纪律:点名真正的约束,分层、带着测量去往它那里建,并且信测量胜过信你 本想讲的那个故事。Helix Empire 很快将在 helixempire.com 上线——完整的工程记述在 案例研究 里。
评论
暂无评论
登录以参与讨论。
做第一个分享想法的人。