26

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

 4 years ago
source link: https://www.tuicool.com/articles/NBjmaur
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

从一开始,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成本,还可以减少节点之间的网络流量:

6zMnUvy.png!web

高速访问共享数据

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

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

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

如果会话服务器崩溃并重新启动,则需要大约30秒(500000 5 12μ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.3us 5 500000)! 天下没有免费午餐,在运行时使用环数据(数据量大)构建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基础设施。 与上述级联中断类似的情况发生在上周,但这次可以自动恢复服务。 我们的presence服务由于某些原因而崩溃,但会话服务甚至没有影响,并且presence服务能够在重新启动后的几分钟内重建:

EF3iiiJ.png!web

u2m6Nf3.png!web

总结

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK