2026西湖龙井茶官网DTC发售:茶农直供,政府溯源防伪到农户家
我们实际要解决的问题
在《海特尔》(Hytale)中,寻宝活动不仅仅是生成战利品。它们关乎在保持世界状态一致的同时,为成千上万的玩家生成同步的战利品。我们最初的假设是事件为无状态通知:寻宝开始,我们触发一个事件,客户端做出反应。当只有 200 名并发玩家时,该模型运行良好。但当玩家数量达到 2,000 人时,事件总线变成了每秒 40 MB 的 JSON 数据流洪流。每次掉落战利品都需要序列化整个区块的状态——包括方块、实体和元数据——以便客户端能够实时渲染掉落物。Java 虚拟机的 G1 垃圾收集器无法处理如此高的分配速率。每隔 47 分钟,一次垃圾收集周期会导致 4.2 秒的停顿,区块缓存会出现碎片化,服务器会在 net.minecraft.server.MinecraftServer#processQueue 中因内存溢出错误而严重崩溃。
真正的问题不在于寻宝逻辑,而在于架构上的惰性:将事件视为一种包罗万象的粘合层,而不是具有明确接口的边界层。
我们最初的尝试(以及失败原因)
我们曾尝试使用卡夫卡(Kafka)作为事件总线。计划是按区域对寻宝活动进行分片,并将战利品掉落作为压缩主题进行流式传输。第一次运行持续了大约 6 小时,随后压缩主题开始膨胀。每次寻宝活动每次掉落都会生成 700 KB 的序列化区块状态。以每次寻宝每分钟 30 次掉落计算,每次寻宝每分钟产生 21 MB 的数据。在有 400 个活跃寻宝活动的情况下,消息代理无法跟上处理速度。延迟增长到 12 秒,客户端开始出现“橡皮筋”效应(位置回弹),我们收到了大量来自迪斯科德(Discord)的报告:“你弄沉了我的船!”事件流现在成了瓶颈,而非事件源。
接下来,我们尝试使用雷迪斯(Redis)流配合 Lua 脚本来聚合每个区块的战利品掉落。在 30 分钟内,我们就达到了 4 GB 的最大内存限制,因为 Lua 脚本在等待下一批处理时,将掉落的物品堆积在内存中。该脚本虽然优雅——每次掉落的时间复杂度为 O(1)——但其内存占用使其在生产环境中无法使用。
最后,我们尝试了一种边车服务:一个小型的 Go 语言进程,监听事件总线,聚合每个区块的掉落物,并每秒发布一次增量更新。这将总线负载降低了 92%,但引入了一个新问题:客户端数据过时。在寻宝活动中途加入的玩家在下一次增量更新之前看不到任何战利品。我们收到报告称,玩家在战利品出现前对着空位置挖掘了 18 分钟。事件总线现在保持一致了,但用户体验却遭到了破坏。
架构决策
我们在寻宝引擎周围划定了一个严格的服务边界。在边界内部,寻宝活动是具有独立内存堆的状态对象。在边界外部,我们仅暴露三个操作:
- 开始寻宝(玩家ID, 区域ID) → 寻宝ID
- 领取战利品(玩家ID, 寻宝ID, 位置) → 战利品结果
- 结束寻宝(寻宝ID) → 定时元数据
每个寻宝对象都维护自己的区块缓存以处理战利品掉落。当玩家掉落战利品时,寻宝引擎仅序列化增量数据:方块变化、实体生成和战利品 ID。该增量数据大小为 4 KB,而非 700 KB。
为了在不淹没总线的情况下保持客户端同步,我们引入了双层广播机制:
第一层:为距离寻宝活动 32 个方块内的玩家提供实时增量更新。我们使用直接连接到寻宝引擎的 WebSocket 连接。这对每个玩家而言时间复杂度为 O(1),并将延迟保持在 150 毫秒以下。
第二层:为所有其他玩家每 2 秒提供一次聚合快照。该快照是一个扁平化的协议缓冲区(Protobuf)消息,仅包含活跃的寻宝 ID 和战利品位置。客户端将此快照合并到其本地状态中。这使得即使在 5,000 名玩家的情况下,总线负载也保持在每秒 2 MB。
我们还从 G1 垃圾收集器切换到了 Z 垃圾收集器,并将每个寻宝引擎实例的最大堆内存设置为 16 MB。分配速率从每秒 40 MB 降至每秒 2.3 MB,垃圾收集停顿时间降至 1 毫秒以下。
免责声明:本文内容来自互联网,该文观点不代表本站观点。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,请到页面底部单击反馈,一经查实,本站将立刻删除。