2016-01-25
几个月前,我们目睹了Phoenix团队在单个服务器上建立了200万个并发连接。在此过程中,他们还发现并消除了一些瓶颈。整个过程记录在这篇优秀的文章里。这个成就绝对是伟大的,但阅读的过程中我想到了一个问题:我们真的需要一堆昂贵的服务器来研究我们的系统在负载下的行为吗?
在我看来,许多问题可以在开发人员的机器上发现和处理,在这篇文章中,我将解释如何完成。特别是,我将讨论如何以编程方式“驱动”一个Phoenix套接字,谈谈传输层,然后在我的开发机器上创建一个五十万连接的Phoenix套接字, 并探索进程休眠对内存使用的影响。
目标
主要思想相当简单。我将开发一个作为 helper 的 SocketDriver
模块,这将允许我在一个单独的 Erlang 进程中创建一个Phoenix套接字,然后通过向它发送通道专属的消息来控制它。
假设我们有一个具有一个套接字( socket )和一个通道( channel )的Phoenix应用程序,我们将能够在一个单独的进程中创建一个套接字:
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"}}
最后,我还可以将消息推送到套接字并验证传出的消息:
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
- 一个不编码/解码消息的串行器。这使得事情保持简单,但测试中也就没有了编码/解码的工作。
创建500k套接字和通道
使用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.
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。