发布于 2026-01-06 1 阅读
0

从构建 WebSocket 服务器中吸取的经验教训

从构建 WebSocket 服务器中吸取的经验教训

Appwrite是一个开源的、自托管的后端即服务 (Backend-as-a-Service),旨在通过提供各种编程语言的 SDK 来简化应用程序开发。

在 0.10.0 版本实时 API 发布之前,应用程序只能与我们的 REST API 通信。

我们为什么要构建实时API?

REST API 过去一直是数据传输的热门架构。那么,为什么现在我们需要实时 API 呢?

我们的 REST API 运行良好且非常简单,但为了提供更大的灵活性,并允许开发人员创建新的用例,例如游戏开发和响应式应用程序,我们需要添加一个新的 API 层来实现实时交互。

API 客户端不再像以前那样只能在下次查询时获取新数据,而是会立即收到推送的新数据。如果开发者已经通过轮询 REST API 来获取数据变更,这不仅意味着他们希望更快地访问数据,更强烈表明他们确实需要一个实时 API。

实时 API 能够显著降低应用程序的处理开销和代码复杂度,从而为开发者带来更愉悦的体验。数据实时传输到系统后,开发者便能专注于为产品创造价值。

建筑学

由于实时服务是基于现有的 REST API 实现的,因此所有通过实时服务发送的消息都由 HTTP 服务器触发。这意味着,如果创建或更新了资源,WebSocket 服务器将被触发,并将此操作发送给其订阅者。

REST 和 WebSocket 之间数据交换的核心是Redis实例。我们使用单个发布/订阅通道,它是 WebSocket 服务器的唯一数据源。如果通过 REST API 添加新资源,HTTP 服务器会将有效负载及其元数据发布到此通道中。WebSocket 服务器订阅此通道,处理消息,决定哪个客户端可以接收该消息,并将其发送给目标客户端。

建筑学

数据流

在 Appwrite 中,REST API 的资源按项目划分,通过权限进行保护,事件则按频道进行分类。当客户端与实时服务器建立连接时,会发送一个项目标识符,以及用于验证连接的用户信息和客户端接收消息的频道。下面,我们以“汽车”资源为例,指示 WebSocket 服务器订阅“汽车”频道。

WebSocket 服务器现在将用户、项目和通道的所有角色分配给客户端的唯一连接标识符。

如果Car资源通过 REST API 更新,HTTP 服务器会将此事件及其有效负载发布到 Redis 通道。WebSocket 服务器随后会接收到此事件,并开始检查事件的接收者是谁。

客户需满足以下条件:

  • 项目 ID 必须相同。
  • 资源的权限必须符合用户的角色。
  • 必须订阅该频道。

然后,WebSocket 服务器会将资源的有效负载发送给所有符合条件的客户端。

数据结构

对于构建需要实时更新的应用程序而言,速度至关重要。我们的数据结构需要尽快处理,以确定哪个客户端应该接收事件。为此,我们在内存中维护了两个哈希表。一个存储所有订阅,另一个存储所有连接

订阅

观察前面的条件,我们可以看到这棵树所反映的模式。您可能会意识到这种结构存在一个缺点,即连接 ID 存在许多重复的数据条目。然而,这种缺点是有意为之,并且有其特定原因——速度。

在 WebSocket 服务器中,内存与速度之间的权衡至关重要。这种架构允许我们在订阅者数量庞大的情况下,快速识别他们并将消息转发给他们,尽管这可能会占用更多内存。

下面是一个我们的实现示例,它将订阅者均匀地分布在 20 个不同的频道中,然后使用一个事件来收集该事件的所有订阅者。

订阅 所用时间 内存使用情况
10,000 0.022毫秒 11MB
100,000 0.238毫秒 90MB
500,000 1.525毫秒 427MB
1,000,000 3.678毫秒 852MB
5,000,000 19.334毫秒 4,289MB

这些速度对于日常应用来说绰绰有余,尤其考虑到单个 WebSocket 服务器不太可能同时维护超过一百万个连接。由于 WebSocket 服务器是无状态的,并且只管理自身的订阅,因此它可以轻松地进行横向扩展并均衡工作负载。

现在我们来看下一个数据结构,以及我们首先需要它的原因。

假设一个客户端连接到我们的 WebSocket 服务器并订阅了一些频道。一段时间后,客户端断开连接,我们需要清理该客户端留下的痕迹,并将其连接从所有频道中移除。

连接

为了避免无休止地识别每个遗留问题,我们维护了一个辅助数据表,其中包含每个连接的项目和角色信息,方便我们随时访问。利用这些数据,我们可以快速删除订阅者的所有信息,而无需进行大量搜索。

绊脚石

当然,我们不可能第一次就把所有事情都做对。每次我们遇到并解决一个难题时,下一个难题就已经在那里等着我们了。

权限变更

我们遇到的第一个难题是:如果用户在连接期间权限发生变化会发生什么?如果用户被停用但连接仍然保持打开状态会发生什么?

WebSocket 服务器不会察觉到这一变化,仍会继续发送用户在连接建立之初被允许接收的所有消息。这将导致资源暴露给未经授权的人员。

为了防止这种情况发生,我们在发送给 WebSocket 服务器的消息中添加了一个标志,用于指示特定用户的权限是否发生了更改。当 WebSocket 服务器收到此消息时,它会检查该用户当前是否已连接,并将其角色与后端中的角色进行匹配。

操作系统

Linux 的网络协议栈为许多工作负载提供了合理的默认设置,但它并未针对超过 100 万个并发连接进行优化。我们预料到会遇到某种形式的C10k 问题,因此我们提前对系统进行了准备[1] [2] [3]

  • 增加了系统的默认 TCP 缓冲区大小。
  • 增加了默认 IPv4 端口范围
  • 提高了打开文件数和文件句柄数的限制。

尽管进行了这样的调整,我们还是遇到了大约 26 万个连接的限制——超过这个数量后,HTTP 服务器就停止响应客户端请求。我们观察到服务器无法完成TCP 三次握手:它会收到来自客户端的 SYN 数据包(通过tcpdump观察到),但没有回复 ACK 数据包。

经过数小时徒劳的调试后,我们请其他维护人员帮忙查看问题。凭借开源协作的力量,我们仅用了几分钟就找到了罪魁祸首:

$ cat /proc/sys/net/netfilter/nf_conntrack_max
262144
Enter fullscreen mode Exit fullscreen mode

由于 WebSocket 连接的生命周期很长,我们需要提高网络协议栈中的连接跟踪限制。提高限制后,我们轻松地达到了 100 万个连接。

异步交付

当我们检查消息发送性能时,一切都很顺利,直到我们运行更大规模的测试,结果却出乎意料地糟糕。罪魁祸首是我们串行发送了每条消息,而不是并行发送。

幸运的是,解决方案只需要几行代码就能搞定。

使用 Cookie 进行身份验证

WebSocket 服务器的第一个实现版本仅支持单向通信,即向客户端发送更新。事后看来,这存在问题,因为我们当前的实现版本使用了仅限 HTTP 的 cookie,该 cookie 会在握手过程中随消息一起发送给 WebSocket 服务器。

后来,在开发演示应用程序时,我们注意到在某些情况下不会发送此 cookie,例如,当客户端和服务器位于不同的域时。

经过一番研究,我们发现握手本身并非设计用于身份验证的方法。Chrome WebSocket 实现的维护者之一解释了原因(链接在此)。我们通过 WebSocket 协议发送消息进行额外身份验证解决了这个问题。如果用户未通过 cookie 验证,我们决定回退到通过消息进行身份验证,并将 cookie 中的令牌发送到 WebSocket 服务器。

所以,仅仅依靠握手进行身份验证显然是个糟糕的主意。

外卖

当然,上述方法可能并不适用于所有用例——但就目前而言,它们对我们适用。正如唐纳德·克努特在他的著作《计算机程序设计艺术》中所说

“真正的问题在于,程序员们在错误的地方和错误的时间花费了太多时间来担心效率;过早优化是编程中一切罪恶的根源(或者至少是大部分罪恶的根源)。”

我们可以对数据结构进行微调,以便在拥有更多订阅者的情况下获得更好的效果。然而,更简便的方法是在其后方添加另一个 WebSocket 服务器实例,从而实现横向扩展。

只要这对我们有效,我们就会听从唐纳德的建议。

鸣谢

感谢您的关注,希望您喜欢这篇文章!

以下是一些关于 Appwrite 的实用链接,可供您了解更多信息:

文章来源:https://dev.to/appwrite/lessons-learned-from-building-a-websocket-server-4i04