热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

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

从一开始,Discord就是Elixir的早期使用者。ErlangVM是我们打算构建的高并发、实时系统的完美候选者。我们用Elixir开发了Discord的原型,这成为我们现在的基

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

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

消息发布

虽然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来处理每次发布。但是这个方案无法应对以下两种情况:(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 CTO 谈如何构建500W并发用户的Elixir应用》

高速访问共享数据

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基础设施。 与上述级联服务中断类似的情况发生在上星期,但这次可以自动恢复服务。 我们的在线服务(管理长连的服务)由于某些原因而崩溃,但会话服务甚至没有影响,并且在线服务能够在重新启动后的几分钟内重建:

在线服务中的实时在线状态:
《Discord CTO 谈如何构建500W并发用户的Elixir应用》

session服务的cpu使用情况:
《Discord CTO 谈如何构建500W并发用户的Elixir应用》

总结

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


推荐阅读
  • Android中高级面试必知必会,积累总结
    本文介绍了Android中高级面试的必知必会内容,并总结了相关经验。文章指出,如今的Android市场对开发人员的要求更高,需要更专业的人才。同时,文章还给出了针对Android岗位的职责和要求,并提供了简历突出的建议。 ... [详细]
  • 图解redis的持久化存储机制RDB和AOF的原理和优缺点
    本文通过图解的方式介绍了redis的持久化存储机制RDB和AOF的原理和优缺点。RDB是将redis内存中的数据保存为快照文件,恢复速度较快但不支持拉链式快照。AOF是将操作日志保存到磁盘,实时存储数据但恢复速度较慢。文章详细分析了两种机制的优缺点,帮助读者更好地理解redis的持久化存储策略。 ... [详细]
  • [大整数乘法] java代码实现
    本文介绍了使用java代码实现大整数乘法的过程,同时也涉及到大整数加法和大整数减法的计算方法。通过分治算法来提高计算效率,并对算法的时间复杂度进行了研究。详细代码实现请参考文章链接。 ... [详细]
  • WebSocket与Socket.io的理解
    WebSocketprotocol是HTML5一种新的协议。它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送 ... [详细]
  • Nginx使用(server参数配置)
    本文介绍了Nginx的使用,重点讲解了server参数配置,包括端口号、主机名、根目录等内容。同时,还介绍了Nginx的反向代理功能。 ... [详细]
  • 本文分享了一个关于在C#中使用异步代码的问题,作者在控制台中运行时代码正常工作,但在Windows窗体中却无法正常工作。作者尝试搜索局域网上的主机,但在窗体中计数器没有减少。文章提供了相关的代码和解决思路。 ... [详细]
  • t-io 2.0.0发布-法网天眼第一版的回顾和更新说明
    本文回顾了t-io 1.x版本的工程结构和性能数据,并介绍了t-io在码云上的成绩和用户反馈。同时,还提到了@openSeLi同学发布的t-io 30W长连接并发压力测试报告。最后,详细介绍了t-io 2.0.0版本的更新内容,包括更简洁的使用方式和内置的httpsession功能。 ... [详细]
  • Spring特性实现接口多类的动态调用详解
    本文详细介绍了如何使用Spring特性实现接口多类的动态调用。通过对Spring IoC容器的基础类BeanFactory和ApplicationContext的介绍,以及getBeansOfType方法的应用,解决了在实际工作中遇到的接口及多个实现类的问题。同时,文章还提到了SPI使用的不便之处,并介绍了借助ApplicationContext实现需求的方法。阅读本文,你将了解到Spring特性的实现原理和实际应用方式。 ... [详细]
  • http:my.oschina.netleejun2005blog136820刚看到群里又有同学在说HTTP协议下的Get请求参数长度是有大小限制的,最大不能超过XX ... [详细]
  • 个人学习使用:谨慎参考1Client类importcom.thoughtworks.gauge.Step;importcom.thoughtworks.gauge.T ... [详细]
  • 本文详细介绍了Java中vector的使用方法和相关知识,包括vector类的功能、构造方法和使用注意事项。通过使用vector类,可以方便地实现动态数组的功能,并且可以随意插入不同类型的对象,进行查找、插入和删除操作。这篇文章对于需要频繁进行查找、插入和删除操作的情况下,使用vector类是一个很好的选择。 ... [详细]
  • 解决nginx启动报错epoll_wait() reported that client prematurely closed connection的方法
    本文介绍了解决nginx启动报错epoll_wait() reported that client prematurely closed connection的方法,包括检查location配置是否正确、pass_proxy是否需要加“/”等。同时,还介绍了修改nginx的error.log日志级别为debug,以便查看详细日志信息。 ... [详细]
  • Spring常用注解(绝对经典),全靠这份Java知识点PDF大全
    本文介绍了Spring常用注解和注入bean的注解,包括@Bean、@Autowired、@Inject等,同时提供了一个Java知识点PDF大全的资源链接。其中详细介绍了ColorFactoryBean的使用,以及@Autowired和@Inject的区别和用法。此外,还提到了@Required属性的配置和使用。 ... [详细]
  • python限制递归次数(python最大公约数递归)
    本文目录一览:1、python为什么要进行递归限制 ... [详细]
  • GreenDAO快速入门
    前言之前在自己做项目的时候,用到了GreenDAO数据库,其实对于数据库辅助工具库从OrmLite,到litePal再到GreenDAO,总是在不停的切换,但是没有真正去了解他们的 ... [详细]
author-avatar
穿越时空lily
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有