Discord-CTO-谈如何支撑500W并发用户的应用Programming-in-Elixir

33次阅读

共计 4396 个字符,预计需要花费 11 分钟才能阅读完成。

从一开始,Discord 就是 Elixir 的早期使用者。Erlang VM 是我们打算构建的高并发、实时系统的完美候选者。我们用 Elixir 开发了 Discord 的原型,这成为我们现在的基础设施的基础。Elixir 的承诺很简单:通过更加现代化和用户友好的语言和工具集,使用 Erlang VM 的强大功能。

两年多的发展,我们的系统有近 500 万并发用户和每秒数百万个事件。虽然我们对选择的基础设施没有任何遗憾,但我们需要做大量的研究和实验才能达到这种程序。Elixir 是一个全新的生态系统,Erlang 的生态系统缺乏在生产环境中的使用信息(尽管 erlang in anger 非常棒)。我们为 Discord 工作的过程中吸取了一系列的经验教训和创造了一系列的 libs。

消息发布

虽然 Discord 功能丰富,但大多数功能都归结为发布 / 订阅。用户连接 WebSocket 并启动一个会话 process(一个 GenServer),然后会话 process 与包含公会 process(内部称为“Discord Server”,也是一个 GenServer)的远程 Erlang 节点进行通信。当公会中发布任何内容时,它会被展示到每个与其相关的会话中。

当用户上线时,他们会连接到公会,并且公会会向所有连接的会话发布状态。公会在幕后有很多其他逻辑,但这是一个简化的例子:

def handle_call({:publish, message}, _from, %{sessions: sessions}=state) do
  Enum.each(sessions, &send(&1.pid, message))
  {:reply, :ok, state}
end

我们找到一个好方法。我们最初将 Discord 构建成只能创建少于 25 成员的公会。当人们开始将 Discord 用于大型公会时,我们很幸运能够出现“问题”。最终,用户创建了许多像守望先锋这样的 Discord 服公会,最多可以有 30,000 个并发用户。在高峰时段,我们开始看到这些 process 无法跟上其消息队列。在某个时刻,我们必须手动干预并关闭生成消息的功能以应对高负载。在达到超负载之前,我们必须弄清楚问题所在。

我们首先在公会 process 中对热门路径进行基准测试,并迅速发现了一个明显的问题。在 Erlang process 之间发送消息并不像我们预期的那么高效,并且 reduction(用于进程调度的 Erlang 工作单元)也非常高。我们发现单次 send/2 调用的运行时间可能在 30μs 到 70us 之间。这意味着在高峰时段,从大型公会 (3W 人) 发布活动可能需要 900 毫秒到 2.1 秒!Erlang process 实际上是单线程的,并行工作的唯一方法是对它们进行分片。这本来是一项艰巨的任务。

我们必须以某种方式分发发布消息的工作。由于 Erlang 中创建 process 很廉价,我们的第一个猜测就是创建另一个 process 来处理每次发布。但是,每次发布的时间安排不同,Discord 客户端依赖于事件的原子一致性(linearizability)。该解决方案也不能很好地扩展,因为公会服务本身的压力并没有减轻。

受一篇关于提高节点之间消息传递性能的博客文章的启发,Manifold 诞生了。Manifold 将消息的发送工作分配给 PID 的远程节点(Erlang 进程标识符),这保证了发送进程最多只调用 send / 2 等于所涉及的远程节点的数量。Manifold 通过首先按其远程节点对 PID 进行分组,然后在每个节点上发送给 Manifold.Partitioner 来实现此目的。然后,分区程序使用:erlang.phash2 / 2 一致地散列 PID,按核心数分组,并将它们发送给子工作者。最后,这些工作人员将消息发送到实际进程。这可以确保分区器不会过载,并且仍然提供 send / 2 保证的线性化。这个解决方案实际上是 send / 2 的替代品:

受到一篇《Boost message passing between Erlang nodes》博客文章的启发,Manifold 诞生了。Manifold 将消息的发送工作分配给的远程分区节点(一系列 PID),这保证了发送 process 调用 send/ 2 的次数最多等于远程分区节点的数量。Manifold 首先对会话 process PID 进行分组,然后发送给每个远程分区节点的 Manifold.Partitioner。然后 Partitioner 使用 erlang.phash2/2 对会话 process PID 进行一致性哈希,分成 N 组,并将消息发送给子 workers(process)。最后,这些子 workers 将消息发送到会话 process。这可以确保 Partitioner 不会过载,并且通过 send/2 保证原子一致性。这个解决方案实际上是 send/2 的替代品:

Manifold.send([self(), self()], :hello)

Manifold 的作用是不仅可以分散消息发布的 CPU 成本,还可以减少节点之间的网络流量:

高速访问共享数据

Discord 是通过一致性哈希实现的分布式系统。使用此方法需要我们创建可用于查找特定实体的节点的环数据结构。我们希望这很高效,所以我们使用 Erlang C port(负责与 C 代码连接的 process)并选择了 Chris Moos 写的 lib。它对我们很有用,但随着 Discord 的发展壮大,当我们有大量用户重连时,我们开始发现性能问题。负责处理环数据的 Erlang 进程将开始变得繁忙以至于处理跟不上请求,并且整个系统将变得过载。最初的解决方案似乎很明显:运行多个 process 处理环数据,以更好地利用 cpu 的所有核来响应请求。但是,我们注意到这是一条热门路径。我们可以做得更好吗?

让我们分解这条热门道路的消耗:

  • 用户可以加入任意数量的公会,但普通用户是 5 个。
  • 负责会话的 Erlang VM 最多可以有 500,000 个实时会话。
  • 当会话连接时,必须为它加入的每个公会查找远程节点。
  • 使用 request/reply 与另一个 Erlang 进程通信的成本约为 12μs。

如果会话服务器崩溃并重新启动,则需要大约 30 秒 (500000512μs) 的时间来查找环数据。这甚至没有计算 Erlang 为其他 process 工作而取消环数据 process 调度的时间。我们可以取消这笔花销吗?

当他们想要加速数据访问时,人们在 Elixir 中做的第一件事就是引入 ETS。ETS 是一个用 C 实现的快速、可变的字典; 我们不能马上将环数据搬进 ETS,因为我们使用 C port 来控制环数据,所以我们将代码转换为纯 Elixir。在 Elixir 实现中,我们会有一个 process,其工作是持有环数据并不断将其 copy 到 ETS 中,以便其他 process 可以直接从 ETS 读取。这显著改善了性能,ETS 读取时间约为 7μs(很快),但我们仍然花费 17.5 秒来查找环中的值。环数据结构实际上相当大,并且将其 copy 进和 copy 出 ETS 是很大的花费。我们很失望,在任何其他编程语言中,我们可以轻松地拥有一个可以安全读的共享值。在 Erlang 中必须造轮子!

在做了一些研究后,我们找到了 mochiglobal,一个利用 VM 功能的 module:如果 Erlang 发现一个总是返回相同常量的函数,它会将该数据放入一个只读的共享堆,process 可以访问而无需复制。mochiglobal 的实现原理是通过在运行时创建一个带有一个函数的 Erlang module 并对其进行编译。由于数据永远不会被 copy,查询成本降低到 0.3us,总时间缩短到 750ms(0.3us5500000)!天下没有免费午餐,在运行时使用环数据(数据量大) 构建 module 的时间可能需要一秒钟。好消息是我们很少改变环数据,所以这是我们愿意接受的惩罚。

我们决定将 mochiglobal 移植到 Elixir 并添加一些功能以避免创建 atoms。我们的版本名为 FastGlobal。

极限并发

在解决了节点查找热路径的性能之后,我们注意到负责处理公会节点上的 guild_pid 查找的 process 变慢了。先前的节点查找很慢时,保护了这些 process,新问题是近 5,000,000 个会话 process 试图冲击 10 个 process(每个公会节点上有一个 process)。使这条路径跑得更快并不能解决问题,潜在的问题是会话 process 对公会注册表的 request 可能会 超时 并将请求留在公会注册表的 queue 中。然后 request 会在退避后重试,但会永久堆积 request 并最终进入不可恢复状态。会话将阻塞在这些 request 直到接收到来自其他服务的消息时引发超时,最终导致会话撑爆消息队列并 OOM,最终整个 Erlang VM 级联服务中断。

我们需要使会话 process 更加智能; 理想情况下,如果调用失败是不可避免的,他们甚至不会尝试对公会注册表进行调用。我们不想使用断路器(circuit breaker),因为我们不希望超时导致暂时状态。我们知道如何用其他编程语言解决这个问题,但我们如何在 Elixir 中解决它?

在大多数其他编程语言中,如果失败数量过高,我们可以使用原子计数器来跟踪未完成的请求并提前释放,事实上就是实现信号量。Erlang VM 是围绕协调 process 之间通信而构建的,但是我们知道我们不想超载负责进行协调的 process。经过一些研究,我们偶然发现:ets.update_counter/4,它的功能是对 ETS 的键值执行原子递增操作。其实我们也可以在 write_concurrency 模式下运行 ETS,但是 ets.update_counter/4 会返回更新结果值,为我们创建 semaphore 库 提供了基础。它非常易于使用,并且在高吞吐量下表现非常出色:

semaphore_name = :my_sempahore
semaphore_max = 10
case Semaphore.call(semaphore_name, semaphore_max, fn -> :ok end) do
  :ok ->
    IO.puts "success"
  {:error, :max} ->
    IO.puts "too many callers"
end

事实证明,该库有助于保护我们的 Elixir 基础设施。与上述级联中断类似的情况发生在上周,但这次可以自动恢复服务。我们的在线服务 (管理长连的服务) 由于某些原因而崩溃,但会话服务甚至没有影响,并且在线服务能够在重新启动后的几分钟内重建:

在线服务中的实时在线状态

session 服务的 cpu 使用情况

总结

选择使用和熟悉 Erlang 和 Elixir 已被证明是一种很棒的体验。如果我们不得不重新开始,我们肯定会做出相同的选择。我们希望分享我们的经验和工具,并且能帮助其他 Elixir 和 Erlang 开发人员。希望在我们的旅程中继续分享、解决问题并在此过程中学习。

正文完
 0