vlambda博客
学习文章列表

TCP长连接下,流量负载均衡的做法

有很多种方式,实现负载均衡。可以通过DNS实现、硬件实现和软件实现。一般我们谈到反向代理和负载均衡时,首先想到的应用场景可能就是网站了,比如使用Nginx作为反向代理服务器,实现HTTP请求的负载均衡。负载均衡的策略可以有轮询、权重、哈希等方式,这种是比较常见的做法。

今天主要讨论的是,在特定场景下,通过软件实现应用层面的「负载均衡」。为什么要加括号呢,因为我觉得它已经不算是通用的负载均衡了, 而是基于特定场景下实现的,其中还会包含一小部分业务逻辑。

1 背景

为了更好地理解该场景,我们以短信群发平台为例。

CMPP(China Mobile Peer to Peer, 中国移动通信互联网短信网关接口协议)是国内短信平台最常用的协议,没有之一。和HTTP协议一样,CMPP也是运行在TCP/IP协议之上。实现CMPP协议时,可以采用长连接,也可以采用短连接。但为了提高效率,实际更多的是采用长连接。

Client先经三次握手与Server连接成功,然后通过发送类型为CMPP_CONNECT的数据包登录Server,Server进行IP鉴权、帐号鉴权后,回复类型为CMPP_CONNECT_RESP的数据包。之后,Client与Server方可进行数据传输,即可以开始愉快地发送短信了。在这之后,除非某一方主动地断开连接,或者由于网络因素导致连接被动断开,连接一直保持,即使没有短信在发送,也会有链路检测CMPP_ACTIVE_TEST数据包在定时传输。

2 存在的问题

2.1 连接被均分,流量无法被均分

Client与Server之间采用的是长连接,每条连接在不同时间段,流量也不一样。使用Nginx的stream模块,只能均分连接,却无法均分流量。

如下图,4个客户端的4条长连接被均分到两个App,但是Client1和Client3流量较大,都被集中到App1,导致App1所在服务器负载较高,而App1会在内存中保存相关数据,所以还会导致App1占用的物理内存暴增,如果没有流控机制,结果就比较悲剧了,而此时App2还处于比较悠闲的状态。

所以,如果采用Nginx的stream模块,当某一时刻,流量较大的连接都分布在同一个App上,就可能会造成内存增加,响应延迟等问题,同时也无法在业务高峰期,高效地利用所有服务器资源。

2.2 长短信问题

同时,采用上述方式,还有另外一个题外话:长短信的处理问题。按惯例,一条中文短信,如果超过70个字,就会按照67字拆分成多个分片,分别提交上来。一条长短信通常最多拆分成10个分片。

正常来讲,一条长短信的10个分片,客户端应该保证都由同一条连接提交到服务器。但实际场景下,有些客户端因量比较大,一个帐号会与服务器建立多条连接,并且,一条长短信的不同分片,是经由不同连接提交上来的。

所以你就会发现,App1收到了长短信的第一个分片,App2收到了长短信的第二个分片,App1和App2还部署在不同的服务器上。这就导致了还必须得有进程间通信机制或者缓存机制来保证长短信分片的合并问题。客户端的这种方式无形中给服务端增加很多业务逻辑和复杂性。

如果Nginx使用按照IP HASH的方式分配客户端连接呢?也不行,因为这会导致一个IP的所有连接都被分配到1个后端App上,存在单点故障、流量不均等问题,而且,万一,一条长短信的不同分片是客户端经由不同IP提交上来的呢。永远不要猜测客户端的行为,因为猜不到。

2.3 IP鉴权问题

在反向代理场景下,Nginx可以通过增加HTTP头域字段X-Real-IP将客户端原始IP透传给后端App,后端App可以进行IP鉴权。而在上述场景下,后端App获取客户端的IP,只能得到Nginx的IP,无法获取客户端原始IP,因为Nginx只能透传客户端的原始数据流。

如果将客户端的IP,配置到Nginx的配置文件中,那么又会面临一个问题:需要reload才会生效。因为Nginx支持平滑升级,在短连接通信场景下,reload不会有任何问题。执行reload指令时,nginx启动新的进程加载新的配置,旧的进程处理完当前请求便退出。对于长连接场景,问题在于,由于连接一直保持着,而且还有数据在传输,旧的nginx会一直处理is shutting down状态,无法退出,虽然有worker_shutdown_timeout参数可以控制,但属于强制结束,对客户端连接不友好。

当然也有其他方式解决这个问题,不过不在本文讨论范围内,这里简单提一下。proxy protocol是HAProxy的作者Willy Tarreau于2010年开发和设计的一个Internet协议,通过为tcp添加一个很小的头信息,来方便的传递客户端信息(协议栈、源IP、目的IP、源端口、目的端口等),在网络情况复杂又需要获取用户真实IP时非常有用。

2.4 限速问题

Nginx的配置项limit_ate来用来限制客户端每秒传输的字节数,但在短信平台实际场景下,速度控制的概念,主要是判断每秒能提交多少条短信,所以无法使用该配置项。

另外,如果一个账号开启多条连接,通过Nginx,这些连接被均分到不同的后端应用,那就只能通过进程间通信机制来计算同一个账号的总速度,会导致一些性能损失。

3 定制自己的服务器负载均衡

问题不在于Nginx,也不在于stream模块,而是因为这种场景与业务高度绑定,使用Nginx已经不太适合了。若要解决上述问题,定制自己的服务器负载均衡,可能是性价比较高的方式,否则就只能针对每个问题各个击破。

如下图,多个客户端,或者一个客户端的多个连接,对接到LB之后,LB可将流量均分到所有的后端App,以实现完全的流量负载均衡。后端App不需要再关注连接层面的问题,比如IP鉴权和短信下发速度控制,也不需要处理棘手的长短信合并问题,这些都很好地被规避掉了。

3.1 解决单点故障

至于负载均衡的单点故障问题,与使用Nginx时一样,可以采用keepalived+VIP方式实现双击热备,正常情况下,虚IP指向主用负载均衡所在服务器,当主用负载均衡宕机时,虚IP漂移至备用负载均衡,方案比较成熟,具体操作方式这里就不再展开讨论了。

3.2 支持平滑升级

虽然大部分情况下,不需要升级负载均衡,但总有需要升级的时刻。如果采用直接重启的方式,虚IP会飘移至备用负载均衡,所以你需要先升级备用负载均衡,否则所有客户都会断连2次(先升级主用时,会断连一次,所有长连接均迁移至备用,再升级备用时,连接又断一次)。

直接重启比较粗暴,可以学习Nginx的方式,升级时,先关闭监听端口,再启动新的负载均衡,此时,新旧负载均衡同时存在。然后旧负载均衡再根据策略,逐步断开所有连接,再退出。

CMPP协议有CMPP_TERMINATE命令,该命令请求拆除双方之间的连接,负载均衡可以先发送该请求,等待客户端主动断开连接。若客户端仍然一直保持连接,那么可以在一段时间之后,强制断开连接。

这种方式需要注意兼容性,比如负载均衡与后端App之间的协议是私有的协议,而此次升级,对该协议做了更改,新增了字段new_field,那么肯定需要先升级后端应用,使其同时支持有new_field和没有new_field字段,因为新旧负载均衡会同时运行一段时间。

3.3 序列号问题

负载均衡主要起到了将数据流重新路由到后端App的作用,这其中,少不了交换相关数据。比如,CMPP协议分为Header和Body两个部分,其中Header包括三个字段中:TotalLength(数据包整体长度,包括Header和Body)、CommondId(消息ID)、SequenceId(消息序列号)。

若负载均衡与每一个后端App保持一个连接,那么当客户端1和客户端2的某一个消息都使用了相同的SequenceId,且都需要转发到后端App1,就会存在SequenceId重复的情况。

有两种方法解决:

  1. 需要在负载均衡内存中缓存一些数据。负载均衡负责生成新的SequenceId,并将原始连接Id和原始SequenceId,与新的SequenceId做Hash映射。当应答回来之后,根据新的SequenceId,找到原始SequenceId和原始连接Id。

  2. 需要修改CMPP协议。将客户端的原始连接Id和原始SequenceId,添加到Header中,后端App回复应答时,需要将这两个字段在Header中返回。这样负载均衡可以直接找到原始连接Id,使用原始SequenceId回复客户端。

两种方法各有优缺点。方法一处理起来比方法二麻烦一点,但是对后端App比较友好,后端App不需要改动。方法一处理起来比较直观,而且也不需要在内存缓存数据,但是需要后端App配合修改。

4 总结

由此可见,对于后端分流来说,并不是所有的场景,都适合直接上Nginx,特别是业务比较特殊的情况下,盲目使用Nginx可能会带来很多其他的隐性开发成本,而这些问题,其实都可以避免。