Discord CTO 谈如何构建500W并发用户的Elixir应用

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

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

消息发布

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

当用户上线时,他们会链接到公会,而且公会会向全部链接的会话发布该用户的在线状态。公会在幕后有不少其余逻辑,但这是一个简化的例子:git

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的消息消费没法跟上消息产生的速度。在某个时刻,咱们必须手动干预并关闭产生消息的功能以应对高负载。在达到超负载以前,咱们必须弄清楚问题所在。github

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

咱们必须以某种方式均匀地分发发布消息的工做。因为Erlang中建立process很廉价,咱们的第一个猜想就是建立另外一个process来处理每次发布。可是这个方案没法应对如下两种状况:(1)每次发布的消息的schedule(例如:发布1个小时后的消息)不一样,Discord客户端依赖于事件的原子一致性(linearizability);(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秒(500000X5X12μ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,一个利用Erlang VM功能的module:若是Erlang VM发现一个老是返回相同常量的函数,它会将该数据放入一个只读的共享堆中,process能够访问而无需复制。 mochiglobal的实现原理是经过在运行时建立一个带有一个函数的Erlang module并对其进行编译。 因为数据永远不会被copy,查询成本下降到0.3us,总时间缩短到750ms(0.3usX5X500000)! 天下没有免费午饭,在运行时使用环数据结构(数据量大)构建module的时间可能须要一秒钟。 好消息是咱们不多改变环数据结构,因此这是咱们愿意接受的惩罚。

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

极限并发

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

咱们须要使会话process更加智能。理想状况下,若是调用失败是不可避免的,他们甚至不会尝试对公会注册表进行调用。 咱们不想使用断路器(circuit breaker),由于咱们不但愿超时致使不可用状态。 咱们知道如何用其余编程语言解决这个问题,但咱们如何在Elixir中解决它?

在大多数其余编程语言中,若是失败数量太高,咱们可使用原子计数器来跟踪未完成的请求并提早释放,事实上就是实现信号量(semaphore)。 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使用状况:
session服务的cpu使用状况

总结

选择使用和熟悉Erlang和Elixir已被证实是一种很棒的体验。 若是咱们不得不从新开始,咱们确定会作出相同的选择。 咱们但愿分享咱们的经验和工具,而且能帮助其余Elixir和Erlang开发人员。但愿在咱们的旅程中继续分享、解决问题并在此过程当中学习。

相关文章
相关标签/搜索