重构即时IM系统2:负载均衡(上)

5 阅读9分钟

数据量或者并发量大到单机承受不住时,就进行多机器部署,此时为了分摊负载压力,我们就需要进行负载均衡和服务发现的处理,下面开始针对即时IM项目进行相关分析,得出重构方案。

一说到负载均衡我们就可以想到nginx,那么我们的IM即时通讯项目是否可以使用nginx做负载均衡呢,下面我们进行分析。

nginx分析

首先对nginx的负载均衡流程进行分析,nginx是基于反向代理实现的负载均衡,具体流程是客户端发起请求,用户通过浏览器访问域名(如 www.example.com),请求首先到达nginx。然后nginx根据预设的负载均衡算法,从后端的服务器池(Upstream Pool)中挑选出一台合适的服务器,将请求转发给这个服务器。后端服务器处理完业务逻辑后,将结果返回给nginxnginx再将接收到的响应转发给最终的用户。nginx中还可以实现多种调度算法来控制负载均衡的策略,比如说轮询、加权轮询、ip hash和最少连接数优先。

有一个问题就是nginx为什么选择这种反向代理的方式做负载均衡,每次请求都先经过nginx转发,这不是多消耗了网络跳数吗。其实用户直连后端服务器的话,如果后端服务器崩溃了或者压力突增,用户使用就会受到影响,使用nginx这种反向代理方式的话就可以实现无损故障转移:如果某台后端服务器挂了,nginx能瞬间感知并把流量切到健康的机器上,用户无感知。而且通常在机房内部网络开销不是很大,甚至可以和后端服务部署在同一台服务器上直接使用socket通信。

而且由于客户访问后端服务之前会先经过nginx做转发,我们还可以依靠nginx做一些特殊的处理。

比如说客户端直连后端服务器的话,后端服务直接暴露在公网,不仅容易遭受恶意攻击,而且后端程序(如 Go 程序)既要处理复杂的业务逻辑,还要关心SSL握手和解码密文,我们有了nginx这一中间层就可以在nginx中先做处理,使后端服务器专注于自己的业务逻辑,将资源放在业务上。下面给出AI关于nginx可先行处理请求相关的回答:

  • 我们可以配置 Nginx 拦截所有静态资源请求(如 /static/ 、 .jpg 、 .css ),直接从磁盘读取并返回给用户,速度极快。这样后端服务只需要专注于处理 API 接口和动态逻辑,大幅降低 CPU 和内存消耗,减少磁盘io。
  • 将 SSL 证书配置在 Nginx 上。客户端与 Nginx 之间是 HTTPS 加密通信,而 Nginx 与后端服务器之间走内网 HTTP 明文通信,把繁重的加解密工作交给性能强悍的 Nginx ,后端服务完全不需要感知证书的存在,代码更简单。
  • Nginx还可以做安全防护,比如说限制同一个IP在每段时间内只可以访问特定次数的接口,防止恶意请求,还可以做黑白名单直接从网关层拒绝请求。

这就是nginx为什么选择使用这种反向代理方式。

nginx适用于即时IM系统吗

在IM中,连接不仅是socket收发通道,还绑定了用户ID、设备ID等等,其中用户ID用于业务逻辑中确定收发消息的用户在哪个连接,设备ID表示一个用户可能有多个设备登录,比如说我发送消息给别人,别人可以在他的手机和电脑上面都收到。

所以我如果选择使用nginx做反向代理的话,通常情况下 nginx只是作为无状态的管道进行盲转 。但如果我想在 nginx 层做一些业务优化(比如后面的拦截“已读信令”),那么 nginx 就 被迫 需要维护 Socket 与用户 ID、设备 ID 的映射关系。这意味着 Nginx 从一个纯粹的流量转发层,变质为了一个 重业务逻辑的接入层 。

此时,Nginx 变得极其臃肿:它既要维护海量长连接的物理状态,又要处理业务逻辑(解析信令、维护 UserID 映射),还要负责 SSL 解密。前者消耗内存,后者消耗 CPU,导致 Nginx 成为系统的性能瓶颈。

这样的话就无法做到水平拓展,因为 Nginx 内存里存储了“用户状态(UserID -> Socket)”,用户的请求必须“粘滞”在同一台 Nginx 上。如果这台Nginx挂了,内存中的状态瞬间丢失,业务直接中断

再者,我如果打算变更业务逻辑的话,比如说拓展一个消息已读请求,那么这个请求要走完客户端->nginx->后端->基础设施层->后端->nginx->客户端这个完整链路吗,显然是不必要的,因为我们之前都在nginx中维护了各个socket和用户ID之间的映射,如果可以在当前nginx中找到对应用户ID的socket的话直接发送已读信令不就好了,这个只是一条控制信令,没必要走完整的业务信令的请求链路,只需要客户端可以识别这个信令然后显示已读就好了,那么此时就会有什么问题。

我这个会更改nginx层逻辑是吧,因为要新加业务字段,我在nginx解码得知是一个已读控制信令就可以走上面的业务逻辑,那我就需要更改nginx层代码,这样的话重启nginx层服务是不是就会造成旧服务上面的长连接全部断开,然后大量连接请求全部打到修改后的nginx上,这样显然是不妥的。这部分可以使用热重启技术进行优化,重构IM的后面也会进行代码实现,原理可以参考这篇博客

Nginx 的设计初衷是无状态,追求极致的平庸——即不对任何特定的业务逻辑产生依赖。无论是 HTTPS 的加解密,还是 RESTful 请求的转发,Nginx 都将其视为一次孤立的事件。因为他只负责把HTTP请求转发给后端服务器,而且每一个 HTTPS 请求都是自包含的。它带着自己的 Token、自己的参数。Nginx 只需要解析出这个请求要去哪,然后转发。它不需要记住上一个请求发生了什么。

相比之下,IM 系统需要的有状态(如 UserID 与 Socket 的绑定),本质上是业务寻址需求。如果强行把这种状态塞进 Nginx,就对nginx产生了强依赖,nginx会成为维持长链接收发消息的瓶颈,然而nginx还需要进行负载均衡和解密相关CPU耗时操作,显然这里不符合领域专一的思想,我们需要进行服务的拆分。

解决方案

拆分网关接入层

由于不可以直接使用nginx做负载均衡,但是维护长连接又是必要的,因为这是即时IM的核心,我们可以将这两个服务拆分开来,拆成接入层和负载均衡层。其中负载均衡层(opconfig)是为客户端寻找一个接入服务器,客户端在这个接入服务器上建立持久的长连接。但是现在又有一个问题,这样的话客户端不就对这个接入层服务器的崩溃有感知了吗?

没错,客户端一定会感知到连接断开。但这在长连接架构中是不可避免的,也是必须面对的。与无状态的 HTTP 请求(请求 A 失败了,网关自动重试请求 B,用户无感知)不同,长连接本质上就是点对点的物理通道,长连接请求必须在建立连接的服务器的socket中被处理,因为只有建立连接的服务器中才会存储例如Socket_101 = 用户张三 这样的map信息,A服务器挂掉之后nginx将客户端请求转发到服务器B也没作用,因为服务器B上面没有维护相关用户和socket的对应关系。

就算使用Nginx做反向代理也是一样的 ,因为nginx最终还是要把长连接固定到某一台后端服务器上。一旦这台后端服务器崩溃,Nginx维护的连接映射也会失效,客户端依然会断线。所以,无论中间加不加 Nginx,都无法在物理层面隔离用户对服务器崩溃的感知。

所以我们将服务拆分为接入层,这一层只负责维持长连接和基础的网络交互。这一层的职责是处理 TCP/WebSocket 的握手、心跳保活(Heartbeat)、断线检测。这一层维护了用户ID到对应socket的映射,只负责将消息转发给后端服务器,接入层剥离了复杂的业务逻辑,它的代码变更频率极低,运行极其稳定。这就意味着它很少需要重启,长连接也就更不容易断,用户体验更流畅。

而且这样也实现了状态的隔离,接入层是有状态的(存了 UserID -> Socket),而业务逻辑层变成了彻底的无状态。如果业务逻辑需要变更(比如修改聊天气泡样式、增加红包功能),我们只需要重启后端的“业务逻辑层”。由于接入层没动,用户的长连接就不会断,用户完全感知不到后台在进行业务升级。具体实现等到推进到网关层架构时在博客中详解。