为什么 Websockets 难以扩展?
封面照片由fabio 拍摄
编辑:该系列的第二部分已发布 由 ChatGPT 实现的可扩展 Websocket 服务器检查一下,它有一个功能齐全的可扩展 websocket 服务器存储库!
Websockets 提供了一个重要的功能:双向通信。它允许服务器将事件推送给客户端,而无需客户端发出请求。
WebSocket 的双向特性既是优点也是缺点!尽管它为 WebSocket 提供了大量的用例,但与 HTTP 服务器相比,实现可扩展的 WebSocket 服务器却困难得多。
厚颜无耻地自我推销一下:我认为 WebSocket 是 Web 的重要组成部分,它需要在软件开发领域得到更多认可。我计划发布更多关于 WebSocket 的文章,如果你不想错过,可以访问https://nooptoday.com/并订阅我的邮件列表!
Websockets 有何独特之处?
Websocket 是一种应用层协议,就像 HTTP 是另一种应用层协议一样。这两种协议都是通过 TCP 连接实现的。但它们的特性各不相同,在通信领域代表着两个不同的国家——*如果这说得通的话 :) *
HTTP带有基于请求-响应的通信模型的标志,而Websocket带有双向通信模型的标志。
附注:为了更清晰地了解 Websocket,您将在整篇文章中看到 HTTP 与 Websocket 的比较。但这并不意味着它们是相互竞争的协议,相反,它们都有各自的用例。
Websocket的特点:
- 双向通信
- 长寿命 TCP 连接
- 状态协议
HTTP 的特点:
- 基于请求响应的通信
- 短暂的 TCP 连接
- 无状态协议
有状态协议与无状态协议
我相信你一定看过一些关于创建无状态、可无限扩展的后端服务器的文章。它们会告诉你如何使用 JWT 令牌进行无状态身份验证,以及如何在无状态应用程序中使用 lambda 函数等等。
- 他们谈论的这种状态是什么?为什么在扩展服务器应用程序时它如此重要?
状态是指应用程序必须记住才能正常运行的所有信息。例如,应用程序应该记住已登录的用户。99% 的应用程序都会这样做(来源:相信我),这被称为会话管理。
- 好吧,状态真是个好东西!为什么人们讨厌它,总是试图开发无状态应用程序呢?
你需要将状态存储在某个地方,而这个位置通常是服务器的内存。但是你的应用服务器的内存无法被其他服务器访问,问题就来了。
想象一个场景:
- 用户 A向服务器 1发出请求,服务器 1对用户 A进行身份验证,并将其Session A保存到内存中。
- 用户 A向服务器 2发出第二个请求。服务器 2搜索已保存的会话,但找不到会话 A,因为它存储在服务器 1中。
为了使服务器具备可扩展性,您需要在应用程序外部管理状态。例如,您可以将会话保存到 Redis 实例中。这使得应用程序状态可以通过 Redis 供所有服务器使用,并且服务器 2可以从 Redis读取会话 A。
有状态的 Websocket:打开 Websocket 连接就像客户端和服务器之间的婚礼:连接保持打开状态,直到其中一方关闭它(或者由于网络状况而欺骗它)。
无状态 HTTP:另一方面,HTTP 是一个令人心碎的协议,它希望尽快结束一切。一旦 HTTP 连接建立,客户端就会发送请求,而一旦服务器响应,连接就会关闭。
好吧,我的玩笑就此打住,但请记住,Websocket 连接通常持续时间较长,而 HTTP 连接则应该尽快结束。一旦将 Websocket 引入应用程序,它就变成了有状态的。
如果你想知道
尽管 HTTP 和 Websocket 都构建于 TCP 之上,但其中一个可以是无状态的,而另一个则是有状态的。为了简单起见,我不想用 TCP 的细节来混淆你。但请记住,即使在 HTTP 中,底层 TCP 连接也可以是长连接。这超出了本文的讨论范围,但你可以在这里了解更多信息。
我不能只使用 Redis 实例来存储套接字吗?
在前面关于会话的例子中,解决方案很简单。使用外部服务来存储会话,这样其他所有服务器都可以从那里读取会话(Redis 实例)。
WebSocket 的情况则不同,因为你的状态不仅仅是关于 Socket 的数据,你不可避免地会在服务器中存储连接。每个 WebSocket 连接都绑定到单个服务器,其他服务器无法向该连接发送数据。
现在,第二个问题来了:你必须找到一种方法,让其他服务器能够向该 WebSocket 连接发送消息。为此,你需要一种在服务器之间发送消息的方法。幸运的是,现在已经有了消息代理 (Message Broker)。你甚至可以使用 Redis 的发布/订阅机制在服务器之间发送消息。
让我们总结一下到目前为止讨论的内容:
- Websocket 连接是有状态的
- WebSocket 服务器自动成为有状态应用程序
- 为了使有状态应用程序能够扩展,您需要有一个外部状态存储(例如:Redis)
- Websocket 连接绑定到单个服务器
- 服务器需要连接到消息代理才能将消息发送到其他服务器中的 websocket
就这样吗?在我的堆栈中添加一个 Redis 实例就能解决所有 Websockets 的扩展问题吗?
很遗憾,不行。可扩展的 WebSocket 架构还有另一个问题:负载均衡
负载平衡 Websockets
负载均衡是一种确保所有服务器分担相同负载的技术。在普通的 HTTP 服务器中,可以使用轮询 (Round Robin) 等简单算法来实现。但对于 Websocket 服务器来说,这并不理想。
假设你有一个自动扩展的服务器组。这意味着,随着负载的增加,会部署新的实例;而随着负载的减少,会关闭一些实例。
由于 HTTP 请求的寿命很短,因此即使添加/删除服务器,所有实例之间的负载平衡也会比较均匀。
Websocket 连接是长寿命(持久的),这意味着新服务器不会分担旧服务器的负载。因为旧服务器仍然会持久化它们的 Websocket 连接。例如,假设服务器 1有 1000 个打开的 Websocket 连接。理想情况下,当添加新服务器服务器2 时,您希望将 500 个 Websocket 连接从服务器 1迁移到服务器 2。但传统的负载均衡器无法做到这一点。
您可以断开所有 websocket 连接,并期望客户端重新连接。这样,您的服务器上就可以分配 500 / 500 个 websocket 连接,但这不是一个好的解决方案,因为:
- 服务器将受到重连请求的轰炸,服务器负载将大幅波动
- 如果服务器频繁扩展,客户端将频繁重新连接,这可能会对用户体验产生负面影响
- 这不是一个优雅的解决方案——我知道你们很关心这个问题!
这个问题最优雅的解决方案是:一致性哈希
负载均衡算法:一致性哈希
目前市面上有各种各样的负载均衡算法,但一致性哈希却截然不同。 一致性哈希负载均衡的基本思想是:
- 使用某些属性对传入连接进行哈希处理,比如说userId => hashValue
- 然后您可以使用hashValue来确定该用户应该连接哪个服务器
这假设您的哈希函数将userId均匀分配给hashValue。
但是,总有一个但是,不是吗……现在你在添加/删除服务器时仍然会遇到这个问题。解决方案是在添加或删除新服务器时断开连接。
等等,什么!你刚才说那是个坏主意?现在这怎么能算个解决办法呢?
这个解决方案的优点在于,使用一致性哈希,你不必丢弃所有连接,而只需丢弃部分连接即可。实际上,你丢弃的连接数量恰好是你所需要的。让我用一个场景来解释一下:
- 最初,服务器 1有 1000 个连接
- 服务器 2已添加
- 一旦添加服务器 2 ,服务器 1就会运行重新平衡算法
- 重新平衡算法检测需要丢弃哪些 websocket 连接,如果我们的哈希函数检测到大约 500 个需要转到服务器 2 的连接
- 服务器 1向这 500 个客户端发出重新连接消息,然后它们连接到服务器 2
这是 ByteByteGo 制作的精彩视频,以直观的方式解释了这一概念。
更简单、更高效的解决方案
Discord 管理着大量的 Websocket 连接。他们是如何解决负载均衡问题的?
如果您研究有关如何建立 websocket 连接的开发人员文档,就会发现他们是这样操作的:
- 向端点发送 HTTP GET 请求
/gateway
,接收可用的 Websocket 服务器 URL。 - 连接到 Websocket 服务器。
此解决方案的神奇之处在于,您可以控制新客户端应该连接哪个服务器。如果您添加新服务器,您可以将所有新连接定向到新服务器。如果您想将 500 个连接从服务器 1移动到服务器 2,只需从服务器 1删除这 500 个连接,并从端点提供服务器 2 的地址即可。/gateway
/gateway
端点需要了解所有服务器的负载分布,并据此做出决策。它可以简单地返回负载最小的服务器的 URL。
与一致性哈希相比,此解决方案有效且简单得多。但是,一致性哈希方法不需要了解所有服务器的负载分布,也不需要事先发出 HTTP 请求。因此,客户端可以更快地连接,但这通常不是一个重要的考虑因素。此外,实现一致性哈希算法可能比较棘手。因此,我计划撰写一篇关于实现用于负载平衡 Websocket 的一致性哈希的后续文章。
希望您能从这篇文章中学到一些新东西,请在评论区告诉我您的想法。如果您不想错过新文章,可以订阅邮件列表!
文章来源:https://dev.to/nooptoday/why-websockets-are-hard-to-scale-1267