2016-01-25html
几个月前,咱们目击了Phoenix团队在单个服务器上创建了200万个并发链接。在此过程当中,他们还发现并消除了一些瓶颈。整个过程记录在这篇优秀的文章里。这个成就绝对是伟大的,但阅读的过程当中我想到了一个问题:咱们真的须要一堆昂贵的服务器来研究咱们的系统在负载下的行为吗?git
在我看来,许多问题能够在开发人员的机器上发现和处理,在这篇文章中,我将解释如何完成。特别是,我将讨论如何以编程方式“驱动”一个Phoenix套接字,谈谈传输层,而后在个人开发机器上建立一个五十万链接的Phoenix套接字, 并探索进程休眠对内存使用的影响。github
主要思想至关简单。我将开发一个做为 helper 的 SocketDriver
模块,这将容许我在一个单独的 Erlang 进程中建立一个Phoenix套接字,而后经过向它发送通道专属的消息来控制它。web
假设咱们有一个具备一个套接字( socket )和一个通道( channel )的Phoenix应用程序,咱们将可以在一个单独的进程中建立一个套接字:shell
iex(1)> {:ok, socket_pid} = SocketDriver.start_link( SocketDriver.Endpoint, SocketDriver.UserSocket, receiver: self )
receiver: self
语句指定了全部的传出消息(由套接字发送到另外一方的消息)将做为纯Erlang消息发送到调用者进程。编程
如今我能够要求socket进程加入通道:安全
iex(2)> SocketDriver.join(socket_pid, "ping_topic")
而后,我能够验证套接字发回的响应:服务器
iex(3)> flush {:message, %Phoenix.Socket.Reply{payload: %{"response" => "hello"}, ref: #Reference<0.0.4.1584>, status: :ok, topic: "ping_topic"}}
最后,我还能够将消息推送到套接字并验证传出的消息:websocket
iex(4)> SocketDriver.push(socket_pid, "ping_topic", "ping", %{}) iex(5)> flush {:message, %Phoenix.Socket.Message{event: "pong", payload: %{}, ref: nil, topic: "ping_topic"}}
有了这样的驱动程序,我如今能够轻松地从iex shell建立一堆套接字,并与它们一块儿玩。稍后你会看到一个简单的演示,但首先让咱们先来探讨如何开发这样的驱动程序。网络
建立和控制套接字能够在Phoenix.ChannelTest
模块的帮助下轻松完成。使用宏和函数,如connect/2
,subscribe_and_join/4
和push/3
,您能够轻松建立套接字,加入通道和推送消息。毕竟,这些宏就是为了在单元测试中以编程方式驱动套接字而建立的。
这种方法应该在单元测试中能很好地工做,但我不肯定它是适合负载测试。最重要的缘由是这些函数本是在测试进程中调用的。这能完美适用于单元测试,但在负载测试中我想更接近真实的东西。也就是说,我想在一个单独的进程中运行每一个套接字,在这一点上,我须要作的内部操做量增长了,我实际上实现了一个phoenix 套接字传输层(我会在一分钟内解释这意味着什么) 。
此外,Phoenix.ChannelTest
彷佛依赖于套接字和通道的一些内部函数,而且它的函数为每一个链接的客户端建立了一个%Socket{}
结构体,这是目前现有的Phoenix传输层不能完成的。
因此,我将实现SocketDriver
来做为部分的Phoenix传输层,也就是一个能够用于建立和控制套接字的 GenServer
。这将使我更接近现有的传输层。此外,这是一个有趣的进程,了解 phoenix 内部的东西。最后,这种套接字驱动程序能够用于超出负载的测试目的,例如暴露可能存在于 Cowboy 和 Ranch 以外的不一样接入点。
在进一步以前,让咱们来讨论一些术语。
套接字和通道的想法很简单,但很是优雅。套接字是客户端和服务器之间抽象的长时间运行的链接。消息能够经过websocket,长轮询,或几乎任何其余东西来传输层。
一旦套接字创建,客户端和服务器可使用它来进行各类话题下的多人交流。这些对话称为通道,它们共同交换消息和管理每一侧的通道的特定状态。
相应的进程模型是至关合理的。一个进程用于一个套接字,一个进程用于一个通道。若是客户端打开了2个套接字并在每一个套接字上链接了20个话题,咱们将最终有42个进程:2 *(1个套接字进程+ 20个通道进程)。
phoenix套接字传输层是长期运行的链接的驱动。多亏了传输层,咱们能够放心地假设 Phoenix.Socket
,Phoenix.Channel
和你本身的通道,正在稳定的,长期运行的链接上运做,而无论这个链接其实是如何驱动的。
您能够实现本身的传输层,从而向您的客户端公开各类通讯机制。另外一方面,实现传输层有点复杂,由于在这个层混合了各类需求。特别是,一个传输层必须:
管理双向状态性链接
接受传入的消息并将其分派到通道
对通道消息作出反应并分派对客户端的响应
在HashDict
(一般也使用反向映射)中管理话题到通道进程的映射
捕获退出,对通道进程的退出做出反应
提供底层http服务器库的适配器,例如Cowboy
在我看来,捆绑在一块儿的不少职责,使得一个传输层的实现更复杂,引入了一些重复代码,并使传输层不那么灵活。我和Chris和José分享了对这些见解,因此有可能在将来改进它们。
因此,若是你想实现一个传输层,你须要解决上面的点,可能保留一个:若是你的传输层不须要经过http端点暴露,你能够跳过最后一点,例如, 你不须要实现Cowboy(或一些其余Web库)适配器。这意味着你再也不是phoenix传输层了(由于你不能经过终端访问),但你仍然可以建立和控制一个phoenix套接字。这就是我所谓的套接字驱动。
按照上面的列表,SocketDriver
的实现是至关直接的,但有些复杂,因此我将避免逐步解释。你能够在这里找到完整的代码,包括一些基本的意见。
它的要点是,你须要在适当的时刻调用一些Phoenix.Socket.Transport
函数。首先,须要调用connect/6
建立套接字。而后,对于每一个传入消息(即由客户端发送的消息),您须要调用dispatch/3
。在这两种状况下,您都会获得一些必须处理的限定于通道的响应。
此外,您须要对从通道进程和PubSub层发送的消息作出反应。最后,您须要检测通道进程的终止,并从你的内部状态中删除相应的条目。
我应该提到,这SocketDriver
使用一个没有文档的Phoenix.ChannelTest.NoopSerializer
- 一个不编码/解码消息的串行器。这使得事情保持简单,但测试中也就没有了编码/解码的工做。
使用SocketDriver
,咱们如今能够轻松地在本地建立一系列套接字。我将在prod
环境中这样作,以更真实地模仿生产。
一个简单的套接字/通道的基本Phoenix服务器能够在这里找到。我须要在prod(MIX_ENV = prod mix compile
)编译它,而后我就能够启动它:
MIX_ENV=prod PORT=4000 iex --erl “+P 10000000” -S mix phoenix.server
—erl “+ P 10000000”
选项将缺省最大进程数增长到1000万。我计划建立500k套接字,因此我须要一百多万个进程,但为了安全起见,我选择了一个更大的数字。建立套接字如今很简单:
iex(1)> for i <- 1..500_000 do # Start the socket driver process {:ok, socket} = SocketDriver.start_link( SocketDriver.Endpoint, SocketDriver.UserSocket ) # join the channel SocketDriver.join(socket, "ping_topic") end
在个人机器上建立全部这些套接字须要一分钟,而后我能够启动观察者。看看系统选项卡,我能够看到大约一百万的进程正在运行,如预期:

我还应该提到我已经将默认记录器级别设置更改成:warn
在 prod 环境。默认状况下,此设置为:info
, 将把一堆日志转储到控制台。这反过来可能会影响你的负载生成器的吞吐量,因此我提升了这个级别, 静音没必要要的消息。
此外,为了使代码能够开箱即用,我删除了对prod.secret.exs
文件的须要。显然是一个很是糟糕的作法,但这只是一个演示,因此咱们应该没问题。请记住,避免在个人(或你本身的)黑客实验之上开发任何产品:-)
若是你仔细看看上面的图片,你会看到大约6GB的内存使用有点高,虽然我不会称之为过多的, 毕竟建立了这么多的套接字。我不知道Phoenix团队是否作了一些内存优化,因此有可能这个开销在将来的版本可能会减小。
就这样,让咱们看看进程休眠是否能够帮助咱们减小内存开销。注意这是一个初步的实验,因此不要得出任何肯定的结论。这将更像一个简单的演示,咱们能够经过在咱们的开发盒上建立一堆套接字,并在本地浏览各类路由,快速得到一些看法。
首先一点理论。您能够经过使用如下命令来减小进程的内存使用:erlang.hibernate/3。这将触发进程的垃圾回收,收缩堆,截断堆栈,并使进程处于等待状态。该进程将在收到消息时被唤醒。
当谈到GenServer
时,您能够经过在回调函数中添加:hibernate
原子到大多数返回元组来请求休眠。因此例如代替{:ok,state}
或{:reply,response,state}
,你能够从init/1
和handle_call/3
回调中返回{:ok,state,:hibernate}
和{:reply,response,state,:hibernate}
。
休眠能够帮助减小不常常活动的进程的存储器使用。你增长了一些CPU的负载,但你回收了一些内存。像生活中的大多数其余的东西,休眠是一个工具,而不是一个银弹。
所以,让咱们看看咱们是否能够经过休眠套接字和通道进程得到一些东西。首先,我将经过在SocketDriver
中添加:hibernate
到 init
,handle_cast
和handle_info
回调, 来修改SocketDriver
。有了这些更改,我获得如下结果:
这大约减小了40%的内存使用,这彷佛颇有但愿。值得一提的是,这不是一个决定性的测试。我休眠我本身的套接字驱动程序,因此我不知道是否相同的保存将发生在websocket传输层,这不是基于GenServer
。可是,我稍微更肯定休眠可能有助于长轮询,在那里一个套接字由GenServer进程驱动,这相似于SocketDriver
(事实上,我在开发SocketDriver
时查阅了Phoenix不少代码)。
在任何状况下,这些测试应该在实际传输层中重试,这是为何这个实验有点勉强和不肯定的一个缘由。
不管如何,让咱们继续,尝试休眠通道进程。我修改了deps/phoenix/lib/phoenix/channel/server.ex
使通道进程休眠。从新编译deps和建立500k套接字后,我注意到额外的内存节省800MB:

休眠套接字和通道后,内存使用量减小了50%以上。不是太寒酸 :-)
固然,值得重复的是休眠带来的是CPU使用的增长。经过休眠,咱们迫使一些工做当即完成,因此应该仔细使用,并应衡量对性能的影响。
此外,让我再次强调,这是一个很是初步的测试。最多这些结果能够做为一个指示,一个线索是否休眠可能有帮助。就我的而言,我认为这是一个有用的提示。在真实系统中,您的通道状态可能会更复杂,而且可能会执行各类转换。所以,在某些状况下,偶尔的休眠可能带来一些不错的节省。所以,我认为Phoenix应该容许咱们经过回调元组请求咱们的通道进程的休眠。
本文的主要内容是,经过驱动Phoenix套接字,你能够快速得到一些关于你的系统在更重要负载下的行为的看法。你能够启动服务器,启动一些综合加载器,并观察系统的行为。你能够收集反馈,更快地尝试一些备选方案,在过程当中你不须要为大型服务器支付大量资金,也不须要花费大量时间调整操做系统设置以适应大量开放网络套接字。
固然,不要误认为这是一个完整的测试。虽然驱动套接字能够帮助你获得一些看法,但它不描绘整个画面,由于网络I / O被绕过。此外,因为加载器和服务器在同一机器上运行,所以竞争相同的资源,结果可能偏斜。密集加载器可能会影响服务器的性能。
为了获得整个画面,你可能想要在相似于生产的服务器上使用单独的客户端机器运行最终的端到端测试。可是你能够更少地这样作,而且更有信心在处理更复杂的测试阶段以前处理了大多数问题。在个人经验中,许多简单的改进能够经过在本地执行系统来完成。
最后,不要对综合测试过度信任,由于它们不能彻底模拟现实生活的混乱和随机模式。这并不意味着这样的测试是无用的,但它们绝对不是决定性的。正如老话说的:“没有像生产同样的测试!”:-)
Copyright 2014, Saša Jurić. This article is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License.
The article was first published on the old version of the Erlangelist site.
The source of the article can be found here.