重构即时IM系统3:负载均衡(下)

8 阅读5分钟

上一讲中提到了IM系统中需要拆分出一个接入层网关,这个网关维护了客户端的长链接和用户ID与socket的map关系。拆分完长连接网关层之后我们为了让客户端连接到对应的网关并且使其负载均衡,我们还需要一个负载均衡服务。前面已经说过了这个负载均衡不适合使用nginx反向代理来做,所以我们需要自治一个负载均衡层。

拆分服务发现

做负载均衡的话肯定需要知道集群中有哪些长连接网关,以及这些长连接网关目前的负载。所以首先是服务发现的问题,我们应该怎么做服务发现去寻找所有的网关层服务器地址呢,答案是使用分布式kv数据库存储网关层对应的ip、端口和对应服务器负载相关信息就好了,现在我们需要分析这个分布式kv应该必须是强一致性还是可以选择最终一致性,从而选择使用etcd或者redis实现。

如果选择强一致性,即线性一致性或者顺序一致性来做的话,任何时刻,任何一个客户端读取到的服务器列表绝对是最新的、准确无误的。如果某台接入层宕机,一旦 etcd 确认删除,注册中心会立刻感知并确保所有读请求都不再返回该 IP。看起来很符合业务要求,但是实现强一致性也有性能瓶颈:每次写入或者读取都需要走Raft或者其他共识算法的多数派流程,会有许多RPC调用,造成网络跳数变多。

如果选择最终一致性的话,假设有一台接入层服务器 Gateway-1 刚刚崩溃了,redis主节点确认了shan'c但是由于没有强一致性保证,可能在接下来的 1~3 秒内,负责调度的服务从 Redis 里读取到的列表里依然包含了 Gateway-1 。调度服务可能会把这个已经挂掉的 IP 分配给新的客户端。客户端拿到 Gateway-1 的 IP 后,发起 TCP 连接会失败(超时或被拒绝),连接失败后,客户端会立刻再次请求调度服务,或者尝试列表中的下一个 IP,几秒钟后, Gateway-1 的心跳在 Redis 中过期(TTL),它自然就会从列表中消失。

其实就是在权衡使用redis做的不一致性是否可以接收,etcd做的Raft流程中开销是否可以接收。这里我打算选择使用etcd去实现负载均衡层:因为假设网关机器发生故障,在故障发生的那个时间窗口内(比如 Redis Key 过期的 5-10 秒内),我们依然会把大量用户调度到那台已经挂掉的服务器上。这会导致客户端发起 TCP 连接时,必须傻傻地等待网络超时(Connect Timeout,通常是 3~5 秒),这种“转圈圈”的等待对用户体验的伤害是巨大的。

对于一台刚上线的网关服务器来说,这台服务器会先根据配置文件找到做服务发现的分布式kv的ip地址和端口,发送set命令将自己的ip地址注册进kv系统,然后建立一个租约或者过期时间(前者etcd后者redis),同时服务器在收到注册成功的消息之后就会开启一个心跳定时器,定时给分布式kv发送心跳续约或者刷新过期时间(后面讲到负载均衡的时候会知道网关服务器还需要发送负载相关信息给分布式kv,此时也会刷新租约或者过期时间)。如果在主节点在租约或者过期时间内没有收到对应网关服务器的心跳,那么主节点就会将该节点的kv移除,并且走Raft或者主从复制流程。

我们的负载均衡服务器(ipconfig)依赖下层的分布式kv获取网关服务器的ip和负载相关信息,有客户端要求负载均衡服务器给出一个网关层ip列表,这个列表按照负载和距离用户地理位置相关信息排序,这样客户端就会尝试在网关层建立长连接。然而每来一个请求时都查询网关层太慢了,我们可以进行如下优化:负载均衡器在本地内存中维护一份网关 IP 列表和负载信息的副本当客户端连接进来时,负载均衡器直接从自己的内存里读数据。然而这个要注意一致性问题,通常会在本地缓存中维护较短的过期时间,还有依靠etcd的watch机制或者redis的pub/sub机制。

如果网关服务器宕机,那么kv服务器就收不到心跳,那么对应的租约就会过期,主节点就会进行下线操作。完成raft流程或者异步注册开始之后,就会依靠etcd的watch或者redis的pub/sub机制推送给负载均衡器,这样负载均衡器就知道了对应网关服务器已经宕机,不会再推送对应网关ip给客户端。

拆分负载均衡层(ipconfig)

在前面拆分完网关层和服务发现层之后,负载均衡层就很好实现了,主要思路就是客户端请求网关服务器的ip列表时,负载均衡层从服务发现层获取到目前所有网关的ip地址和负载均衡相关信息,将其进行负载均衡运算之后,将ip列表返回给客户端,这里可以做一下截取,只返回列表中的前4个,这样可以节约网络带宽。

可见,现在的负载均衡层是无状态的了,可以很方便的进行水平拓展,解决了上一节中使用nginx做负载均衡所产生的问题:有状态化、高内聚和更新导致大量长连接断开问题。在下一节中我会给出这三层在我重构IM项目中的代码实现。