SupportMail 是一个 Discord 机器人——具体来说是 modmail 机器人——管理的用户数量之多已经无法再运行在单一的 gateway 连接中了。像所有达到这一规模的机器人一样,它必须进行分片:将服务器分散到多个连接上,并在达到一定程度后分散到多个进程上。长期以来,这一机制是由一个独立的 Node/Bun 扩展库实现的,该库负责生成和管理机器人周围的集群子进程。在过去几周,我用一个专门用 Go 编写的监督程序、一个重写的集群框架,以及将机器人的 REST API 和机器人本身正确分离的方案,替换了它。

这篇文章就是关于这次重写的:为什么旧的设置不够理想,我构建了什么,以及现在实际有哪些改进。

注意:开发者对 "Discord 服务器"的称呼是 "guild"。

什么是分片,以及 SupportMail 之前使用的方案

如果你不是深入研究 Discord 机器人内部机制的人,你可能听说过 "分片"这个词,但并不真正了解它的含义。值得解释一下,因为这篇文章的其余部分都会用到它。

一个 shard 是一个到 Discord 的 gateway 连接,负责一部分 guilds。Discord 不允许你把机器人所在的所有 guild 都放在一个 WebSocket 连接上——当规模达到一定程度时,一个连接无法跟上事件量,Discord 会强制要求在机器人 guild 数量超过阈值时进行分片。哪个 guild 属于哪个 shard 不是任意的:它是 (guildId >> 22) % totalShards,一个确定性哈希,因此系统的任何部分都可以无需询问他人就计算出某个 guild 属于哪个 shard。

这只是简单部分。更难的部分是,"更多 shards" 不是免费的——你不能只从一个进程打开五百个 gateway 连接就完事。Shards 被分组到 clusters 中,每个 cluster 是一个单独的进程,持有少量 shards。而一旦你有了多个进程,你就需要一个更上层的组件来决定运行多少个 clusters、哪些 shards 分配到哪个 cluster、集群崩溃时重启它,并为外部世界(API、仪表板)提供一个单一的查询点来询问 "guild X 的 shard 现在还活着吗?" 这个组件就是 supervisor。

SupportMail 旧的 supervisor 是 galactic.ts,来自另一个机器人的分片管理器,使用 node 的 child_process 模块。我的 fork 使用 Bun.spawn() 从一个 StandaloneInstance 中 fork 出集群子进程,所有这些都运行在同一个 Bun 进程中,该进程还在 3000 端口运行机器人的 REST API,在 4000 端口运行 WebSocket 服务器。所有内容——gateway 连接、HTTP、sockets、supervisor 逻辑本身——都是一个 Bun 进程树。

如果 "为什么分片" 没有讲明白,那么接下来的几节也不会讲明白——将结构操作与业务操作分离、基于 generation 的部署,同样的想法。只是更小的多个进程和一个协调器。

为什么选择替换而不是修补

引发这次重写的具体痛点是:Bun.spawnchild_process 和 worker threads 在机器人进程内部使用时破坏了 Sentry 检测。主要进程仍然是 Bun。发布到 node 的 diagnostics_channel 上的错误事件根本不起作用,一旦集群子进程介入就会被静默丢失。这是一个 "我不能信任自己的错误报告" 的 bug。

这本身就很难修补,因为 Bun 的发布计划很奇怪,而且那个未合并的 PR 看起来短期内不会合并——最新的 Bun 版本也是两个多月前发布的,没有新版本的迹象。更不值得修补的原因是它背后的东西:galactic.tsInstance/Cluster 类与父进程是 Bun 的假设耦合在一起。选择是在原地修补并保持 Bun 原生,或者完全丢弃它并用不同语言实现外部方案。中间没有别的路可走。一旦你超越 galactic.ts 本身,它管理的大部分内容一开始就不难:discord.js 已经原生支持在一个进程中运行多个 shards(内部分片)——galactic.ts 处理的是围绕它的编排,而不是分片本身。

公平地说旧代码——无停机重聚类逻辑、进程间的 IPC 通信,以及我在 fork 中添加的清理工作并不是累赘。它们对我最终构建的 generation/quiesce 交接是有用的参考材料——值得保留的部分被保留为参考,结构上不合适的部分被替换了。

考虑过的替代方案

上一节解释了为什么在原地修补 galactic.ts 不在选项范围内。即使抛开这一点,留在进程内也不是正确的选择:SupportMail 不是我运行的唯一机器人——我还有 Ticketon,它将来可能需要同样的基础设施——而一个绑定到单个机器人进程的管理器如果不进行另一次重写就无法复用。这就决定了采用外部的、与机器人无关的、用不同语言编写的 supervisor。

说到语言,这个问题仍然开放。我最初考虑过 Rust,主要是因为我想找个借口多学学它。不过我最终选择了 Go,原因有两个:

  1. 并发模型直接符合问题形态。 "监督 N 个集群进程并通过 socket 将它们的 IPC 扇入" 几乎正是 goroutines 和 channels 的用武之地。Rust 的 async 故事——选择运行时、Pin、贯穿一切的 Send/Sync 边界——引入了复杂性,而 Go 用几行代码就能解决这个问题。
  2. 构建速度。 Go 的标准库(netexecnet/http)足以构建 supervisor、IPC 中心和状态 WebSocket,几乎没有第三方依赖。Rust 则意味着在编写任何实际的 supervisor 逻辑之前,先选择并学习一个 async 运行时并引入 crates。

这并不意味着 Rust 更差。这是一个 "交付目标 vs 学习目标" 的权衡,对于如此接近生产关键的基础设施来说,交付获胜。

核心设计决策:结构操作 vs 业务操作

这是核心路由设计。

管理器(sm-manager)只理解五种操作:REGISTERHEARTBEATQUIESCEQUIESCEDDEPLOY。这些是结构性的——它们是关于进程生命周期的,而不是关于机器人做什么的。流经管理器的所有其他内容都是一个不透明的字符串,由 target 寻址,管理器在不查看其内容的情况下将其路由到正确的集群。

为什么这很重要:管理器从不解析业务负载。它不知道 "close ticket" 操作是什么样子的,也不知道 "sync guild config" 操作是什么样子的,或者机器人关心的任何其他操作——它只知道把它发到哪里。这意味着管理器保持完全与机器人无关。让第二个机器人使用相同的基础设施,只需使用不同的配置文件,这意味着我们不必 fork 管理器代码库。(具体来说,这就是 Ticketon 将来能够共享 sm-manager 的原因,而管理器不需要知道 Ticketon 的存在。)

另一种选择——一个理解每个操作的管理器——隐含地摆在桌面上,因为那基本上是单体 supervisor 的样子。它耦合度更高:每个需要跨集群协调的新机器人功能/功能变更都意味着管理器代码变更,而管理器实际上是永久性的单机器人,即使理论上没有什么能阻止你附加第二个机器人。

大致来说,路由看起来是这样的:

client → sm-manager:

{ op: "DEPLOY", ... } → 结构性的,管理器直接处理

{ op: "guild.sync", target: clusterId, payload: <opaque> }

→ 业务性的,管理器原样转发给 target

管理器根据 op 是否是五种结构代码之一进行分支。如果是,它就执行操作。如果不是,它查看 target,找到拥有该集群的连接,并转发整个消息负载。

下方的拓扑图展示了所有内容的上下文,包括管理器、集群、机器人和 API。

Infrastructure diagram

一个术语说明:所有这些之下的传输层是 Unix domain socket,而不是 TCP。Go 使用 net.Listen("unix", ...) 监听;Bun 端的消费者(API 和集群框架)使用 Bun.listen/Bun.connect 连接到相同的 socket 路径。我在全文中仍然称其为 "IPC"——IPC 指的是进程间通信,而 Unix socket 是实现这一点的机制之一,与管道或共享内存属于同一家族。

零停机部署:generations

旧的部署路径使用 PM2,而 PM2 的 restart(而不是 reload)意味着整个机器人进程——包括管理器——都会关闭并重新启动。这在每次部署时都会造成 gateway 连接的真正 "缺口"。天真的修复方法,即使用普通的 reload 而不是 restart,有其自身的失败模式:在某一时刻,你会有新旧代码同时附加并同时处理相同的事件,即重复事件处理。

我提出的修复方法是 generation 标记的 shard 范围。每次部署都会启动一个新的 "generation" 集群,持有与旧 generation 相同的 shard 范围,并行运行。管理器等待新 generation 报告就绪,然后才将路由切换到它上面。只有在切换发生后,它才会告诉旧 generation quiesce——停止接受新工作,完成正在进行的操作,然后退出。

顺序就是重点:路由在 "新 generation 就绪" 和 "旧 generation 已 quiesce" 之间切换,绝不会在新 generation 就绪之前(事件监听器可能尚未加载、DB 连接可能缺失等),也不会在旧 generation quiesce 之后很久(你会有一个缺口)。这种顺序保证了没有窗口期让一个 guild 的事件可能同时路由到两个 generations,也没有窗口期让它们路由到任何一个 generation。

我考虑过更简单的选项——完全重启,或者干脆接受旧的停机时间(即使只有几秒钟)——并拒绝了它们,因为在 SupportMail 的规模下,完全重启的缺口意味着同时在所有 guild 上丢弃 gateway 事件,而不仅仅是一个表面上的小故障。基于有界 quiesce 窗口的逐集群滚动部署会带来更多的实现复杂性,但会将实际 "损害" 限制在可测量且小的范围内。

现在更好的地方是:部署是逐集群 rollout,而不是一次性全部 rollout,quiesce 缺口是有界且可观察的,而不是开放式的,并且没有重复事件处理窗口需要担心——与旧世界相比,部署更接近于 "希望那个缺口期间不会发生什么重要的事情。"

拆分 HTTP API

旧的 client-api 直接依赖于机器人内部机制,并且附加在管理器进程上——部署 API 意味着触及与机器人相同的进程树。

我决定也将其外包出去——sm-api 通过与任何其他客户端使用的完全相同的 Unix socket 协议与 sm-manager 通信来解决这个问题——没有特殊的访问路径,没有通过机器人内部机制的捷径。它只是另一个 IPC 客户端。

回报是 sm-api 可以独立于机器人进行重新部署。而且因为它通过上面描述的结构/业务操作分割,它永远不会回到依赖机器人内部形状的位置。但它与机器人进程解耦这一事实本身也是一个好处。

可观测性作为证明点

结构/业务分割在实践中回报的另一个地方:公共 statuspage WebSocket 位于 sm-manager 上,而不是机器人上。Heartbeat 负载基本上原样转发给它,对管理器来说是不透明的,就像任何其他业务负载一样。

这证实了分割在设计之外的场景中也能成立——管理器不需要特殊处理来支持公共状态源,因为 "在不理解的情况下转发不透明负载" 已经是默认行为。

现在更好的地方

  • Sentry 检测正常工作——这次重写的最初原因。
  • 部署接近零停机,quiesce 窗口是有界且可测量的,而不是 "重启需要多长时间"。
  • 管理器是与机器人无关的:第二个机器人只是一个配置添加,而不是管理器 fork。
  • API 完全与机器人进程解耦——没有共享的进程树,没有内部形状依赖。
  • 它已经在生产环境中运行——SupportMail 自切换以来一直在运行这个。不是头条新闻,但切换后内存使用量下降了大约 40%。
  • 管理器和 API 现在都是 systemd 服务,而不是 pm2 管理的服务,因此它们会在主机重启时自动恢复,而不需要进程管理器看管它们。

结语

有几件事是故意延迟的。传输层是 Unix socket,而不是 TCP 或 WebSocket,目前这没问题,因为所有东西都运行在一台主机上——但如果情况发生变化,这将是一个限制。管理器目前还没有高可用性方案;它是一个单进程,如果它死了,是 systemd 重启它,而不是热备用接管。这两个问题今天都不是问题,也不需要在第一天就解决,只是因为理论上存在缺口。对我来说,真正难的是在 "但如果" 和 "现在真的需要吗" 之间找到界限。

sm-manager 可能在某个时候会开源,因为它是与机器人无关的,而不是 SupportMail 特有的。我无法给出一个日期——当那件事发生时,我会更新这篇文章并发布一篇单独的新文章。

祝你有美好的一天,如果你是一名 Discord 机器人开发者,我希望这篇文章能帮助你以新的方式思考分片和进程编排。

PS:非常感谢 galactic.ts 在我之前文章中提到的那次事件中救了我。它是一个游戏规则改变者,也是大多数人最好的分片解决方案之一。