Bytro:改造一个遗留多人游戏后端
主导 Bytro PHP 游戏后端向 event-driven microservices + CQRS 的现代化迁移,在真实玩家不间断对局的条件下将 latency 削减约 35%,同时协调跨三个 squad 的并行工程工作。
Bytro 做实时策略游戏。Supremacy 1914,Conflict of Nations: WW3。基于浏览器、实时、大规模多人。玩家在几天或几周内协调全球战争战役。对局有数百名玩家,状态—单位位置、资源计数、外交协议、战斗结果—必须在任何时候对每个参与者保持一致且可见。
这就是我被要求去改造的系统。
「遗留」实际上意味着什么
原始后端是 PHP。不是有现代异步处理和类型良好的合同的 PHP 8—是遗留 PHP,跑了好多年,积累了一个代码库的决策,这个代码库忠实地服务了玩家,但已经到了每个新功能都是和技术债谈判的程度。
问题不难发现。游戏事件的状态变更—一个单位移动、一场战斗结算、一条外交消息被发送—通过一堆所有权不明确的同步调用发生。当多个事件并发到达时(在有数百名玩家的实时游戏里,这永远是「总是」而不是「有时候」),系统的一致性保证取决于负载。服务器空闲时能撑住,负载重时有竞争条件。
读负载也是问题。游戏状态查询—每个玩家刷新地图、每个客户端轮询更新—打的是和写入相同的数据库路径。没有任何缓存策略能干净地撑过对局峰值。
p99 的 latency 已经在明显影响玩家体验。在 Bytro 的规模下,那是留存问题,不只是工程不便。
架构赌注:CQRS + event sourcing + Kafka
核心决策是在架构层面分离命令处理和查询处理,不只是在代码组织上。
命令—「单位从省份 A 移动到省份 B」、「玩家对派系 X 宣战」、「资源交易被执行」—通过命令处理器走,发布域事件到 Kafka。事件是真实的记录。命令处理器不直接写应用状态。
状态从事件派生。读模型—玩家看地图时查询的物化投影—由更新 PostgreSQL 读副本和 Redis 缓存的事件消费者构建。读请求永远不碰命令路径。写永远不碰读路径。它们独立扩展。
Event sourcing 意味着任何时间点的游戏状态都可以从事件日志重建。这不只是架构上的美感—这是「我的单位发生了什么」争议的答案,而这在玩家非常在意决策的游戏里是真实的客服工单类别。
在迁移过程中让真实玩家活着
这是事后看起来显而易见、活在里面时很痛苦的部分。
Supremacy 不能为了迁移周末下线。玩家正在对局进行中,有些对局跑了好几周。你不能说「迁移截止日前开始的对局用旧系统,之后开始的用新系统」—进行中的游戏状态数量让这在操作上不可能,除非有一个根本不存在的专职迁移团队。
迁移策略是 event-driven strangler fig:新功能从第一天起就作为产生事件的服务实现。遗留代码路径保持活跃并有权威性。过渡期间跑双写—新事件发布到 Kafka,遗留状态仍然同步更新—让我们在切换读流量之前验证 event 派生的读模型与遗留真实来源是一致的。
双写期间是你找到遗留系统所有没写在代码里的假设的地方。每一个隐式的排序保证,每一个遗留系统单线程执行意外防止的竞争条件。找到它们不好玩,不在生产里找到它们才是重点。
约 35% 的 latency 减少
这个数字来自读模型迁移前后的 p95 和 p99 游戏状态查询 latency。访问 Redis 物化投影的读路径和访问同时吸收写入的有竞争的 PostgreSQL 表的读路径,不是同一类操作。这不令人意外,令人意外的是如果它没有改善的话。
更有意思的数字是写路径 latency,它改善得更少—Kafka 发布 latency 是真实的,峰值时的事件消费者 lag 是真实的,命令路径现在是异步的,而它之前是同步的。习惯了点击后立即看到单位移动的玩家,现在看到了短暂的异步延迟。这是一个需要仔细处理的 UX 权衡—客户端侧乐观更新模式覆盖了大部分,但在事件消费者临时落后的情况下校准超时-协调行为花了很多迭代。
多 squad 协调
Bytro 迁移涉及多个 squad:处理基础设施(Kafka、Kubernetes、部署流水线)的平台 squad,处理单个游戏系统(战斗、外交、经济)的域 squad,以及处理前端状态同步变更的客户端 squad。
跨这些 squad 的首席开发者意味着管理它们之间的合同。事件 schema 就是合同。当战斗 squad 需要给战斗结算事件添加一个字段,那是一个客户端 squad 需要处理、分析流水线需要处理、读模型消费者需要处理的 schema 迁移—全部不需要一个「旗帜日」。我们对事件做版本控制。这听起来显而易见,在从没做过它的代码库里实现是三周没人想做但每个人都庆幸你做了的工作。
Kubernetes:对的工具,谨慎应用
在对局峰值期间在 Kubernetes 上自动扩展事件消费者—锦标赛事件、重大更新、如果你不盯着就总会抓到你的周末峰值—是正确的决定。它也是这个代码库第一次在 Kubernetes 上运行,这意味着 Kubernetes 要求你显式区分的有状态/无状态差异,必须被追溯应用到一个隐式且不一致地做出这个区分的代码库上。
存储在单个实例本地内存里的 PHP session 不是 Kubernetes 原生的。我们早就知道这点,把遗留代码做过这个假设的每个地方都梳理一遍,是让其他所有东西都能工作的无聊前提条件。
我负责的事
- CQRS/event sourcing 模型和 Kafka event 拓扑的架构决策
- 面向真实游戏状态的迁移排序和双写策略
- 跨 squad 的事件 schema 设计和版本控制合同
- 读模型设计(PostgreSQL 投影、Redis 缓存层)
- 多 squad 协调:平台、域和客户端工程
- 无状态事件消费者的 Kubernetes 部署设计
游戏的负载可变性是大多数企业软件没有的。周六下午 2 点的锦标赛公告不在你的容量规划表格里。构建一个能吸收那种峰值而不让玩家盯着加载圈的后端,是和处理可预测 B2B 请求曲线完全不同的问题。我在 Bytro 学到的关于事件消费者背压和 lag 告警的东西,在我此后设计的每个分布式系统里都在用。
Conflict of Nations: WW3 还在跑。Supremacy 1914 还在跑。为它们提供服务的后端和我接手的那个已经有了实质性的不同。这就是结果。