大家好!我是 Sergey Kamardin,是 http://Mail.Ru 的一名工程师。
本文主要介绍如何使用 Go 开发高负载的 WebSocket 服务。
如果你熟悉 WebSockets,但对 Go 了解不多,仍希望你对这篇文章的想法和性能优化方面感兴趣。
1. 简介
为了定义本文的讨论范围,有必要说明我们为什么需要这个服务。
http://Mail.Ru 有很多有状态系统。用户的电子邮件存储就是其中之一。我们有几种方法可以跟踪该系统的状态变化以及系统事件,主要是通过定期系统轮询或者状态变化时的系统通知来实现。
两种方式各有利弊。但是对于邮件而言,用户收到新邮件的速度越快越好。
邮件轮询大约每秒 50,000 个 HTTP 查询,其中 60% 返回 304 状态,这意味着邮箱中没有任何更改。
因此,为了减少服务器的负载并加快向用户发送邮件的速度,我们决定通过用发布 - 订阅服务(也称为消息总线,消息代理或事件管道)的模式来造一个轮子。一端接收有关状态更改的通知,另一端订阅此类通知。
之前的架构:
现在的架构:
第一个方案是之前的架构。浏览器定期轮询 API 并查询存储(邮箱服务)是否有更改。
第二种方案是现在的架构。浏览器与通知 API 建立了 WebSocket 连接,通知 API 是总线服务的消费者。一旦接收到新邮件后,Storage 会将有关它的通知发送到总线(1),总线将其发送给订阅者(2)。 API 通过连接发送这个收到的通知,将其发送到用户的浏览器(3)。
所以现在我们将讨论这个 API 或者这个 WebSocket 服务。展望一下未来,我们的服务将来可能会有 300 万个在线连接。
2. 常用的方式
我们来看看如何在没有任何优化的情况下使用 Go 实现服务器的某些部分。
net/httpChannel2.1 Channel 结构体
readerwriterChannelch.send2.2 I/O goroutines
readerbufio.Readerread()bufbufwriterc.send2.3 HTTP
Channel注意:如果你不知道 WebSocket 的运行原理,需要记住客户端会通过名为 Upgrade 的特殊 HTTP 机制转换到 WebSocket 协议。在成功处理 Upgrade 请求后,服务端和客户端将使用 TCP 连接来传输二进制的 WebSocket 帧。是连接的内部结构的说明。
http.ResponseWriterbufio.Readerbufio.Writer*http.RequestresponseWriter.Hijack()go:linknamenet/http.putBufio {Reader, Writer}net/httpsync.Pool因此,我们还需要 24 GB 的内存用于 300 万个连接。
那么,现在为了一个什么功能都没有的应用程序,一共需要消耗 72 GB 的内存!
3. 优化
ping/pong连接的生命周期可能持续几秒到几天。
Channel.reader()Channel.writer()现在我们对哪些地方可以做优化应该比较清晰了。
3.1 Netpoll
Channel.reader()bufio.Reader.Read()conn.Read()如果我们查看 ,将会在其中看到 :
Go 在非阻塞模式下使用套接字。 EAGAIN 表示套接字中没有数据,并且读取空套接字时不会被锁定,操作系统将返回控制权给我们。(译者注:EAGAIN 表示目前没有可用数据,请稍后再试)
read()如果,我们将看到 netpoll 在 Linux 中是使用 实现的,而在 BSD 中是使用 实现的。为什么不对连接使用相同的方法?我们可以分配一个 read 缓冲区并仅在真正需要时启动 read goroutine:当套接字中有可读的数据时。
3.2 去除 goroutines 的内存消耗
Channel.reader()Channel.writer()write()EAGAINreader()ch.send完美!我们通过去除两个运行的 goroutine 中的内存消耗和 I/O 缓冲区的内存消耗节省了 48 GB。
3.3 资源控制
大量连接不仅仅涉及到内存消耗高的问题。在开发服务时,我们遇到了反复出现的竞态条件和 self-DDoS 造成的死锁。
ping/pong被锁或超载的服务器停止服务,如果它之前的负载均衡器(例如,nginx)将请求传递给下一个服务器实例,这将是不错的。
此外,无论服务器负载如何,如果所有客户端突然(可能是由于错误原因)向我们发送数据包,之前的 48 GB 内存的消耗将不可避免,因为需要为每个连接分配 goroutine 和缓冲区。
Goroutine 池
上面的情况,我们可以使用 goroutine 池限制同时处理的数据包数量。下面是这种池的简单实现:
现在我们的 netpoll 代码如下:
现在我们不仅在套接字中有可读数据时读取,而且还可以占用池中的空闲的 goroutine。
Send()go ch.writer()NNN + 1N + 1Accept()Upgrade()3.4 upgrade 零拷贝
如前所述,客户端使用 HTTP Upgrade 切换到 WebSocket 协议。这就是 WebSocket 协议的样子:
net/httphttp.RequestWebSocket 的实现
net/http如果有一个这种 API 的库,我们可以按下面的方式从连接中读取数据包(数据包的写入也一样):
简单来说,我们需要自己的 WebSocket 库。
wsnet/httpwsws.Upgrade()io.ReadWriternet.Connnet.Listen()ln.Accept()ws.Upgrade()Cookienet/httpnet.Listen()wsnet/http3.5 摘要
我们总结一下这些优化。
net/http服务的代码看起来如下所示:
总结
过早优化是编程中所有邪恶(或至少大部分)的根源。 -- Donald Knuth
当然,上述优化是和需求相关的,但并非所有情况下都是如此。例如,如果空闲资源(内存,CPU)和线上连接数之间的比率比较高,则优化可能没有意义。但是,通过了解优化的位置和内容,我们会受益匪浅。
感谢你的关注!
引用
本文由 原创编译, 荣誉推出