第6章海量推送系统——6.2 海量推送系统设计

第6章海量推送系统——6.2 海量推送系统设计
John Yaml6.2 海量推送系统设计
在6.1节中我们对推送系统涉及的关键技术做了基本介绍,本节将介绍推送系统的整体架构设计。
6.2.1 整体架构设计
推送系统的整体架构如图6-3所示,其中:
Nginx作为客户端设备与推送系统交互的代理,负责客户端建立连接的负载均衡与消息的最终下发;
每个pusher节点都负责与不同的客户端建立长连接,是消息的核心推送者;
ZooKeeper/etcd负责Nginx请求pusher集群时的服务发现,为Nginx转发连接请求时给出当前可用的pusher节点列表,进而帮助客户端连接到合适的pusher节点;
- Redis集群负责保存用户设备与pusher节点的关联关系(如果采用Round-Robin等无数据规则的路由算法);
- Push-gateway作为下行消息到pusher集群的代理网关,负责后端应用服务与推送系统之间的交互,包括按照单点消息、多点消息以及全局消息的方式进行消息的路由转发,是后端应用服务与推送系统之间的唯一代理;
- 消息队列负责消息的异步发送。
6.2.2 长连接的建立过程
在客户端与推送系统之间建立长连接的过程如下所述,如图6-4所示。
- 当客户端进行登录/启动时,客户端长连接通信SDK使用WebSocket与后端尝试建立连接,在建立连接时可以携带若干客户端基本信息,如用户ID、设备device_id,设备版本号等。
- Nginx收到客户端的建立连接请求,使用所设定的路由算法,选择一个pusher节点与其建立长连接。
- 如果推送系统采用了Round-Robin等无数据规则的路由算法,则需要同时将device id与pusher节点地址的关联关系以Key-Value的形式保存到Redis集群中。存储数据结构类型可以采用Key-Value的形式,其中Key是用户设备device id,Value是对应的pusher节点的IP:port地址。
- pusher节点与客户端建立长连接后,将device_id与连接Socket文件描述符fd的关联关系保存到本地内存中。
- 客户端周期性地与pusher节点在长连接上确认心跳,如果pusher节点在所设定的时间内没有收到心跳,则认为客户端长连接已断开,关闭其连接。
6.2.3 消息格式设计
pusher节点发送到客户端的消息格式设计如表6-1所示 (这里仅列出了最重要的消息字段,其他字段可以根据具体需求进行添加)。
下面对表6-1中的字段进行详细说明。
- uuid:每条消息都有自己独有的唯一ID,客户端可以使用这个唯一ID判断消息是否是重复下发的,如果是则丢弃此消息。此外,唯一ID也可以协助服务端和客户端的工程师进行消息触达的测试与调试。
- target did:消息的目标device_id,客户端可以将目标device_id与本地device_id进行对比,如果不一致则丢弃消息。这是为了防止推送系统在遇到某些异常时将消息错发到其他设备。
- unique_name:用于告知客户端此消息中的payload数据实际上是哪段业务逻辑所需要的,以便客户端能把消息交给对应的handler来处理。
- payload:真正的消息体,不同业务场景的推送数据有不同的格式,将推送数据序列化到payload。
- encode type:指明payload的编码方式,用于告知客户端应该使用何种方式对payload进行解析。
我们可以通过一个例子来加深对上述后三个字段的理解。假设某个用户对你的文章发表了评论,产品经理希望系统可以在客户端的屏幕顶部弹出通知告诉你:A对你的文章B发表了评论:xxx,那么负责文章评论模块的服务端开发人员和客户端人员共同约定了如下通知格式(假设采用了protobuf协议 ):
1 | message CommentNotify { |
在服务端,会使用protobuf将这个通知序列化保存到推送系统的消息结构PushMessage的payload字段,可以将unique_name字段设置为CommentNotify,将encode_type字段设置为 PROTO_BUF。
当客户端收到这条推送的消息时,首先根据unique name识别出这是评论通知,准备好CommentNotify结构处理解析结果;然后根据encode_type得知消息体是protobuf格式的,于是使用protobuf格式将payload内容解析到CommentNotify结构;最后客户端完整地获取到服务端下发的通知。
6.2.4 消息推送接口
推送系统暴露给后端应用服务的消息推送接口设计如下:
1 | enum PushType { // 消息类型 |
业务服务端需要先构造推送消息请求结构PushMessageReq:使用PushType字段指定消息类型是单点消息、多点消息还是全局消息;使用TargetDevicelds字段指定具体要发送的目标device_id列表。如果是单点消息,那么这个列表仅有一个device_id;如果是多点消息,那么这个列表有若干device_id;如果是全局消息,那么这个列表为空即可。请求格式中其他字段的说明与PushMessage结构相同。
业务服务端构造完推送消息请求结构后,调用推送系统的PushMessage接口进行消息推送。如果消息推送成功,则接口会返回PushMessageResp告知推送成功。如果不成功,则会额外携带错误信息。
6.2.5 单点消息推送的细节
将一条单点消息从服务端推送到客户端的完整过程如下所述,如图6-5所示。
- 单点消息携带推送的消息体payload、推送的目标device_id,将推送消息请求发送到Push-gateway。
- Push-gateway从Redis集群中查询消息目标device id对应的pusher节点地址。
- 如果无须访问Redis,则Push-gateway使用与建立长连按时相同的路由算法,计算出消息目标device_id对应的pusher节点地址。
- Push-gateway将消息转发到计算出的pusher节点。
- pusher节点在本地内存中查询长连接文件描述符fd。
- pusher将消息发送到fd上,下行消息推送完成。
6.2.6 全局消息推送的细节
对于要发送给全部在线用户的推送的消息,我们可以让Push-gateway将推送的消息广播到所有pusher节点,由每个pusher节点将消息数据写入本地每个连接中。
这里涉及两个“写放大”,如图6-6所示,其中:
- Push-gateway需要轮询所有M个pusher节点并进行消息的转发,写放大M倍 ;
- 假设一个pusher节点本地维护N个长连接,当它收到一条全局的推送的消息时,需要把消息发送到所有长连接的发送缓冲区,网络带宽会瞬间增大,写放大N倍。
通常而言,一个被部署在商业级服务器上的长连接推送服务节点经过高性能设计,可以同时承载十多万个乃至数十万个长连接。也就是说,仅1000个pusher节点就可以管理承载上亿个在线用户的长连接。
如果有条件,部署pusher节点的服务器性能较高、网卡读/写快,那么由于pusher节点的单机长连接数量可以很大,pusher节点不会很多,所以MxN倍的写放大压力很容易被接受。
但是如果条件有限,网卡的性能一般,甚至云服务器的性能较差,该怎么办?
我们可以换一种思路来解决这个问题。全局消息一般是系统推送的消息,这种消息对于同时到达全部用户的诉求不高,所以可以让一部分用户先收到消息,另一部分用户稍后收到消息。于是,我们可以尝试依次分批发送的思路。
广播的批次M可以根据服务器的性能而定,假设批次为100。
- 在Push-gateway上通过mod操作将发往设备X的消息分别归到批次Y,即将device_id%100作为批次顺序。
- Push-gateway与每个pusher节点都建立一个广播消息队列通道,并在消息体PushMessage结构中将新增字段hash_id作为批次顺序ID;Push-gateway收到全局消息后,将消息分为100次向消息队列投递:
- 第1次全局消息投递,设置hash_id=0,发送到消息队列 ;
- 第2次全局消息投递,设置hash_id=1,发送到消息队列;
- ……
- 第 100次全局消息投递,设置hash_id=99,发送到消息队列。
- 每个pusher节点收到消息后,都根据hash_id向本地维护的满足device_id%100的长连接发送这条消息。
这样一来,通过将全局消息分M次分批逐步发送,可以使得原来的全局消息推送开销下降到1/M。
6.2.7 多点消息推送的细节
为了支持多点消息推送,一条推送的消息的目标可以是多个device_id。推送系统在接收到多点消息后,需要找到每个目标device_id对应的pusher节点地址,然后向这些pusher节点转发消息。这里的重点是如何高效地找到所有device_id对应的pusher节点列表。
- 如果pusher节点与客户端建立连接时使用的是比如一致性Hash等有数据规则的路由算法,则Push-gateway在处理多点消息时,无论有多少个目标device_id,都可以在本地内存中很快计算出相关的pusher节点地址,然后进行消息的转发。
- 如果pusher节点与客户端建立连接时使用的是比如Round-Robin等无数据规则的路由算法,此时device_id与pusher节点地址的对应关系被存放在Redis中,那么Redis的MGET命令可以支持我们一次性获取所有device id对应的pusher节点地址。
不过,Redis MGET是一个危险的命令。如果目标device id较少,比如少于50个,那么使用MGET没有太大问题;但是当目标device_id较多时,MGET会为Redis存储系统带来较大的风险,例如:
- 一次性获取过多的Key,Redis服务器需要较大的内存来存放响应数据,有较大的内存溢出风险;
- 如今大部分互联网公司的Redis架构都是类似于Codis的方式,由 Proxy对Redis进行集群分片管理。为了满足MGET的需要,Proxy不得不启动多个线程向各个相关的Redis分片分发MGET请求。一次性获取过多的Key,可能造成Proxy启动大量线程,占用较多的系统资源,最终影响Redis存储服务的质量。
所以说,如果多点消息涉及的目标device_id较多,则不宜直接从Redis中获取pusher节点地址。让我们换一个思路:既然目标device_id较多,那么涉及的pusher节点大概率也较多,我们不如像全局消息那样将多点消息直接广播到各个pusher节点。如果pusher节点有相关的device_id长连接,则进行消息的推送,否则丢弃消息即可。
总的来说,多点消息的处理逻辑取决于长连接的路由算法。
- Hash类路由算法:无论目标device_id有多少,Push-gateway都可以直接计算出 pusher节点地址,然后进行消息的转发。
- Round-Robin类路由算法:进一步检查目标device_id的数量是否达到阈值(如50个)。
- 如果未达到阈值,则Push-gateway通过Redis MGET获取相关的pusher节点地址列表,然后进行消息的转发。
- 如果超过阈值,则Push-gateway向全部pusher节点广播此消息,由pusher节点自己判断是推送消息还是丢弃消息。
6.2.8 pusher平滑升级的问题
pusher服务器本身也是一个服务,也有系统开发、迭代升级或Bug修复,这就避免不了让原pusher进程退出和启动新的pusher进程。但是由于pusher服务器是一台长连接服务器,pusher进程退出会导致所有长连接被关闭。假设一个pusher节点保持了10万个客户端连接,在对pusher节点进行服务更新时,这10万个长连接就都断掉了,此时对应的5万个客户端设备感知到长连接被重置,于是几乎在同一时间开始重新建立连接,推送系统会收到10万QPS的瞬时激增流量。
瞬时激增的10万个连接请求可能会直接击垮推送系统,导致整个系统无法创建长连接,这是一个极大的风险。针对这种情况,我们可以为建立连接的入口Nginx增加限流来保护推送系统。那么,我们是否有更好的方式,比如提供一种更加优雅的Pusher节点升级方式,使得已建立的长连接得到保持?
其实,这是一个老生常谈的话题,它有成熟的解决方案:长连接服务平滑重启,业界高性能服务器如Nginx、Service Mesh的MOSN Sidecar都有平滑升级功能。根据笔者的研究 ,这些支持平滑升级的TCP服务器普遍采用了如下关键技术。
- 信号技术:使用自定义信号如SIGUSR2作为进程升级事件,而非直接杀死进程。
- fork+exec技术:父进程调用fork()创建子进程,子进程调用exec()载入最新程序二进制文件,原父进程的文件打开信息会被自动继承下来。
- UnixSocket协议:支持在进程间传输文件描述符。
平滑升级的原理可以被描述为:
- 原服务进程以收到的SIGUSR2信号作为进程升级事件并监听此信号。
- 当系统发起升级操作时,系统向父进程发送SIGUSR2信号。
- 父进程收到此信号,开始调用fork()创建子进程,子进程调用exec()载入最新程序一进制文件。
- 然后父进程将本进程内全部连接的Socket文件描述符都通过UnixSocket协议调用sendmsg()发送到子进程,同时停止本进程内一切最新数据的处理操作。
- 子进程收到Socket文件描述符并接管此Socket后续数据的读/写处理,并通知父进程终止。
平滑升级的流程如下。
- 系统准备升级服务,向父进程发送SIGUSR2信号。
- 父进程收到SIGUSR2信号,准备更新服务。
- SIGUSR2信号处理函数会调用fork()创建一个子进程,子进程调用exec()导入最新的待升级二进制文件,并携带一个约定参数表示新进程是通过重启加载的。
- 在父进程内不再对listen_fd读取新连接,且不再对各客户端连接读取数据,即停止对一切Socket的处理工作。
- 父进程启动一个UnixSocket,并尝试调用sendmsg()向其发送listen_fd和全部客户端连接fd,直到全部成功才停止。
- 使用exec()启动子进程后,子进程通过运行参数判断自己是否是通过重启启动的。如果是,则子进程不主动创建listen_fd,而是创建与父进程同路径的UnixSocket,用于接收父进程传输的信息。
- 子进程收到listen_fd,直接调用accept。继续接收新的连接请求,此时子进程服务器正式可以对外提供服务了。
- 子进程持续收到全部客户端连接fd,创建每个连接的handler继续处理连接的读/写请求,此时子进程服务器正式接管了父进程存量的客户端连接。
- 子进程通知父进程终止,最终新进程替换了旧进程,整个升级过程平滑完成。
完整实现上述流程的C++程序如下(为了简洁、直观,程序代码采用了一个连接对应一个线程的服务器模型,且并未考虑并发问题,仅为展示平滑升级的主流程):
6.2.9 pusher扩容的问题
pusher服务不仅与正常的业务服务一样涉及服务升级,而且为了应对请求量的逐渐增长,同样会有服务扩容的需求,以避免服务性能下滑。但是,由于长连接服务是一个典型的有状态服务,且长连接不是实体数据,无法直接迁移,所以其扩容行为会有各种各样的问题需要解决。
当向pusher集群中新增一个pusher节点时,我们希望这个pusher节点能尽快分担在工作的pusher节点的压力。
但是,如果我们采用的路由算法是Random算法、Round-Robin算法等,那么在将一个新的pusher节点加入集群后,这个pusher节点并不能很有效地分担在工作的pusher节点的连接压力。下面以Round-Robin算法为例进行介绍。
假设有5个pusher节点p1~p5,每个节点都承载了100个长连接,现在系统压力很大,于是我们对推送系统进行扩容,新增pusher节点p6。此时这6个pusher节点的长连接数量分别为100、100、100、100、100、0,如图6-7所示。
接下来有60个新客户端连接到来,pusher节点的长连接数量分别为110、110、110、 110、110、10,如图6-8所示。
我们可以发现,虽然已建立的连接可以继续保持,但是原来的5个pusher节点的压力还在以近似于扩容前的速率逐渐增长。这个例子说明在集群扩容时,Round-Robin算法并不能满足我们想要尽快均摊新连接的要求。
解决方法很简单,我们可以采用加权式Round-Robin算法:
- 为每个pusher节点都引入权重,权重高的pusher节点优先对外建立连接。
- 同时,对于新加入的pusher节点,在其开始工作的最初一段时间内为其设置较高的权重,当新的pusher节点达到与其他pusher 节点相似的容量时,再将其权重下调到平均水平。
- 这样一来,新的pusher节点在加入集群后 ,就能快速分担接下来的新连接压力,并能较快地与集群中的其他节点在长连接数量上持平。
而如果采用一致性Hash算法,则会遇到另一个问题:连接失效。当将新的pusher节点加入一致性哈希环中时,会使得环上相邻pusher节点的大量(约一半)长连接重新指向新的pusher节点,这些连接在原pusher节点上会全部失效,需要全部断开重新连接。
通过6.2.8节的介绍,我们知道此时会有大量的客户端同时重新连接。为了防止出现这种情况,可以采用两种方法:
- 一种可行的做法是可以使用限流的方式来保护推送系统。
- 另一种可行的做法是当新的pusher节点加入时,相邻pusher节点的受影响的长连接并不全部同时断开重新连接,而是逐步断开重新连接,拉长重新连接的时间线,减少瞬时请求量。
本章小结
本章主要讲解了应该如何设计一个推送系统,以及在设计推送系统时需要注意的一些技术细节。
- 首先,客户端与pusher节点建立连接的路由算法非常重要,它直接决定了下行消息的转发路由逻辑与系统的可扩展性;
- 其次,处理单点消息、多点消息、全局消息的推送,往往有不同的逻辑;
- 最后,注意如何对pusher服务进行平滑升级和扩容。
总结
推送系统的整体架构?
Nginx作为客户端设备与推送系统交互的代理,负责客户端建立连接的负载均衡与消息的最终下发;
每个pusher节点都负责与不同的客户端建立长连接,是消息的核心推送者;
ZooKeeper/etcd负责Nginx请求pusher集群时的服务发现,为Nginx转发连接请求时给出当前可用的pusher节点列表,进而帮助客户端连接到合适的pusher节点;
- Redis集群负责保存用户设备与pusher节点的关联关系(如果采用Round-Robin等无数据规则的路由算法);
- Push-gateway作为下行消息到pusher集群的代理网关,负责后端应用服务与推送系统之间的交互,包括按照单点消息、多点消息以及全局消息的方式进行消息的路由转发,是后端应用服务与推送系统之间的唯一代理;
- 消息队列负责消息的异步发送。
在客户端与推送系统之间建立长连接的过程?
- 当客户端进行登录/启动时,客户端长连接通信SDK使用WebSocket与后端尝试建立连接,在建立连接时可以携带若干客户端基本信息,如用户ID、设备device_id,设备版本号等。
- Nginx收到客户端的建立连接请求,使用所设定的路由算法,选择一个pusher节点与其建立长连接。
- 如果推送系统采用了Round-Robin等无数据规则的路由算法,则需要同时将device id与pusher节点地址的关联关系以Key-Value的形式保存到Redis集群中。存储数据结构类型可以采用Key-Value的形式,其中Key是用户设备device id,Value是对应的pusher节点的IP:port地址。
- pusher节点与客户端建立长连接后,将device_id与连接Socket文件描述符fd的关联关系保存到本地内存中。
- 客户端周期性地与pusher节点在长连接上确认心跳,如果pusher节点在所设定的时间内没有收到心跳,则认为客户端长连接已断开,关闭其连接。
pusher节点发送到客户端的消息格式设计(这里仅列出了最重要的消息字段,其他字段可以根据具体需求进行添加)。
- uuid:每条消息都有自己独有的唯一ID,客户端可以使用这个唯一ID判断消息是否是重复下发的,如果是则丢弃此消息。此外,唯一ID也可以协助服务端和客户端的工程师进行消息触达的测试与调试。
- target did:消息的目标device_id,客户端可以将目标device_id与本地device_id进行对比,如果不一致则丢弃消息。这是为了防止推送系统在遇到某些异常时将消息错发到其他设备。
- unique_name:用于告知客户端此消息中的payload数据实际上是哪段业务逻辑所需要的,以便客户端能把消息交给对应的handler来处理。
- payload:真正的消息体,不同业务场景的推送数据有不同的格式,将推送数据序列化到payload。
- encode type:指明payload的编码方式,用于告知客户端应该使用何种方式对payload进行解析。
将一条单点消息从服务端推送到客户端的完整过程?
- 单点消息携带推送的消息体payload、推送的目标device_id,将推送消息请求发送到Push-gateway。
- Push-gateway从Redis集群中查询消息目标device id对应的pusher节点地址。
- 如果无须访问Redis,则Push-gateway使用与建立长连按时相同的路由算法,计算出消息目标device_id对应的pusher节点地址。
- Push-gateway将消息转发到计算出的pusher节点。
- pusher节点在本地内存中查询长连接文件描述符fd。
- pusher将消息发送到fd上,下行消息推送完成。
将全局消息从服务端推送到客户端的完整过程?
我们可以换一种思路来解决这个问题。全局消息一般是系统推送的消息,这种消息对于同时到达全部用户的诉求不高,所以可以让一部分用户先收到消息,另一部分用户稍后收到消息。于是,我们可以尝试依次分批发送的思路。
广播的批次M可以根据服务器的性能而定,假设批次为100。
- 在Push-gateway上通过mod操作将发往设备X的消息分别归到批次Y,即将device_id%100作为批次顺序。
- Push-gateway与每个pusher节点都建立一个广播消息队列通道,并在消息体PushMessage结构中将新增字段hash_id作为批次顺序ID;Push-gateway收到全局消息后,将消息分为100次向消息队列投递:
- 第1次全局消息投递,设置hash_id=0,发送到消息队列 ;
- 第2次全局消息投递,设置hash_id=1,发送到消息队列;
- ……
- 第 100次全局消息投递,设置hash_id=99,发送到消息队列。
- 每个pusher节点收到消息后,都根据hash_id向本地维护的满足device_id%100的长连接发送这条消息。
这样一来,通过将全局消息分M次分批逐步发送,可以使得原来的全局消息推送开销下降到1/M。
将多点消息从服务端推送到客户端的完整过程?
为了支持多点消息推送,一条推送的消息的目标可以是多个device_id。推送系统在接收到多点消息后,需要找到每个目标device_id对应的pusher节点地址,然后向这些pusher节点转发消息。这里的重点是如何高效地找到所有device_id对应的pusher节点列表。
总的来说,多点消息的处理逻辑取决于长连接的路由算法。
- Hash类路由算法:无论目标device_id有多少,Push-gateway都可以直接计算出 pusher节点地址,然后进行消息的转发。
- Round-Robin类路由算法:进一步检查目标device_id的数量是否达到阈值(如50个)。
- 如果未达到阈值,则Push-gateway通过Redis MGET获取相关的pusher节点地址列表,然后进行消息的转发。
- 如果超过阈值,则Push-gateway向全部pusher节点广播此消息,由pusher节点自己判断是推送消息还是丢弃消息。
支持长连接服务平滑重启的高性能服务器有哪些?
- Nginx
- Service Mesh的MOSN Sidecar
这些支持平滑升级的TCP服务器普遍采用了如下关键技术?
- 信号技术:使用自定义信号如SIGUSR2作为进程升级事件,而非直接杀死进程。
- fork+exec技术:父进程调用fork()创建子进程,子进程调用exec()载入最新程序二进制文件,原父进程的文件打开信息会被自动继承下来。
- UnixSocket协议:支持在进程间传输文件描述符。
平滑升级的原理?
- 原服务进程以收到的SIGUSR2信号作为进程升级事件并监听此信号。
- 当系统发起升级操作时,系统向父进程发送SIGUSR2信号。
- 父进程收到此信号,开始调用fork()创建子进程,子进程调用exec()载入最新程序一进制文件。
- 然后父进程将本进程内全部连接的Socket文件描述符都通过UnixSocket协议调用sendmsg()发送到子进程,同时停止本进程内一切最新数据的处理操作。
- 子进程收到Socket文件描述符并接管此Socket后续数据的读/写处理,并通知父进程终止。
平滑升级的流程如下?
- 系统准备升级服务,向父进程发送SIGUSR2信号。
- 父进程收到SIGUSR2信号,准备更新服务。
- SIGUSR2信号处理函数会调用fork()创建一个子进程,子进程调用exec()导入最新的待升级二进制文件,并携带一个约定参数表示新进程是通过重启加载的。
- 在父进程内不再对listen_fd读取新连接,且不再对各客户端连接读取数据,即停止对一切Socket的处理工作。
- 父进程启动一个UnixSocket,并尝试调用sendmsg()向其发送listen_fd和全部客户端连接fd,直到全部成功才停止。
- 使用exec()启动子进程后,子进程通过运行参数判断自己是否是通过重启启动的。如果是,则子进程不主动创建listen_fd,而是创建与父进程同路径的UnixSocket,用于接收父进程传输的信息。
- 子进程收到listen_fd,直接调用accept。继续接收新的连接请求,此时子进程服务器正式可以对外提供服务了。
- 子进程持续收到全部客户端连接fd,创建每个连接的handler继续处理连接的读/写请求,此时子进程服务器正式接管了父进程存量的客户端连接。
- 子进程通知父进程终止,最终新进程替换了旧进程,整个升级过程平滑完成。
pusher如何扩容?
pusher服务不仅与正常的业务服务一样涉及服务升级,而且为了应对请求量的逐渐增长,同样会有服务扩容的需求,以避免服务性能下滑。但是,由于长连接服务是一个典型的有状态服务,且长连接不是实体数据,无法直接迁移,所以其扩容行为会有各种各样的问题需要解决。当向pusher集群中新增一个pusher节点时,我们希望这个pusher节点能尽快分担在工作的pusher节点的压力。
但是,如果我们采用的路由算法是Random算法、Round-Robin算法等,那么在将一个新的pusher节点加入集群后,这个pusher节点并不能很有效地分担在工作的pusher节点的连接压力。
对于采用Round-Robin算法的话,解决方法是可以采用加权式Round-Robin算法:
- 为每个pusher节点都引入权重,权重高的pusher节点优先对外建立连接。
- 同时,对于新加入的pusher节点,在其开始工作的最初一段时间内为其设置较高的权重,当新的pusher节点达到与其他pusher 节点相似的容量时,再将其权重下调到平均水平。
- 这样一来,新的pusher节点在加入集群后 ,就能快速分担接下来的新连接压力,并能较快地与集群中的其他节点在长连接数量上持平。
对于采用一致性Hash算法,则会遇到另一个问题:连接失效。当将新的pusher节点加入一致性哈希环中时,会使得环上相邻pusher节点的大量(约一半)长连接重新指向新的pusher节点,这些连接在原pusher节点上会全部失效,需要全部断开重新连接。
通过6.2.8节的介绍,我们知道此时会有大量的客户端同时重新连接。为了防止出现这种情况,可以采用两种方法:
- 一种可行的做法是可以使用限流的方式来保护推送系统。
- 另一种可行的做法是当新的pusher节点加入时,相邻pusher节点的受影响的长连接并不全部同时断开重新连接,而是逐步断开重新连接,拉长重新连接的时间线,减少瞬时请求量。