RPC框架编写实践--自动负载均衡

本文总阅读量

前记

在业务早期,业务比较简单, 流量也比较少,单台机器就可以抗下所有请求流量, 但随着业务的增长, 一些中间件会单独占有一台机器, 业务代码也会逐渐拆分, 整个架构会慢慢的从单机的单体架构变为多机的微服务架构。而对于用户来说, 他不知道自己的请求会分流到哪个机器, 但返回的结果必定要一致的, 其中承担用户流量分流到不同机器的技术组件会称为负载均衡。

1.不同网络层的负载均衡组件

现在后端开发常见的负载均衡组件是Nginx, 即使没用过也有听过, 基本上所有的服务都会与Nginx交互。 此外还有另外一个负载均衡, 基本上所有请求都会接触它–DNS, 因为用户访问目标域名时, 会由DNS解析域名, 并由用户所在地区, 后端服务器情况来得到对应的最佳ip, 然后用户的客户端会通过该ip请求到对应的服务器, 所以DNS也是属于一种负载均衡技术, 可见一般用户的请求都会经历DNSNginx两次负载均衡。
不过真正的大型系统中负载均衡往往是多级的, 比如用户流量会先访问域名, 然后DNS开始解析, 把用户的请求分发到最近的机器A, 接着又会被机器A的四层负载均衡会把流量分到对应的机器B, 最后由机器B上的Nginx把流量分发到对应的web应用服务。

除了DNSCDN这种大厂才有的负载均衡服务外, 我们常见的负载均衡可以分为四层负载均衡和七层负载均衡, 这里的四层和七层指的是经典OSI七层模式(见计算机网络相关书籍)中的第4层传输层和第7层应用层。 需要注意的是“四层”是说这些工作模式的共同特点是维持同一个TCP连接,而不是说它只工作在第四层, 事实上,这些模式还包括了第二层数据链路层(改写MAC地址)和第三层网络层(改写IP地址)。
第四层负载均衡主要只做转发流量, 流量都是在单独一个TCP链接中流动,而第七层则是代理流量, 客户端,负载均衡组件, 服务端三者之间通过两条TCP连接传输着流量。
其中第四层性能比第七层高, 因为流量经过机器时, 机器不需要解过多的包, 只要识别MAC地址之类的就可以进行负载均衡转发, 同时这种模式适用于’单臂模式’, 流量从目标机器返回时,可以不直接经过负载均衡机器直接返回到请求机器中, 少走一个节点, 如下图:
image
这种方式可以使流量不用跨越太多的机器, 增加传输性能, 但是这种方案也带来了一定的限制, 比如负载均衡机器与节点可能要处于同一个子网, 无法跨VLAN。

可以看到第四层主要注重于性能以及简单的转发, 可定制性和功能也没有第七层的强和多, 适用场景少, 同时由于TCP协议的缺陷, 第四层负载均衡容易收到攻击, 所以一般的负载均衡实现方案都是四层负载和七层负载一起用, 并把第四层负载均衡放置在第七层负载均衡前面。

2.七层负载均衡功能

第七层负载均衡的主要职能是选择哪个后台应用来处理网络请求, 由于后台应用比较多, 负载均衡必须确保每个请求都能去到最优的服务器, 让用户得到最好的网络响应, 为此出现了很多负载均衡策略, 常见的有:

  • 轮训均衡负载: 每一次来自网络的请求轮流分配给内部的服务器,从1至N然后重新开始, 讲究的是最均衡的分配, 每个机器最终处理的请求数量一致。
  • 权重轮训负载均衡: 与轮训负载均衡很像, 但可以配置权重, 把请求更有倾向性的分配到不同的机器, 一般用于后端有多个性能不同的机器。
  • 随机负载: 每次请求都随机负载到某个机器, 在数据量大时, 每个机器处理的请求数很相近
  • 一致性哈希均衡: 根据请求的特征(token, ip)等等作为特征进行计算, 再根据计算结果把请求分配到某个机器上, 一般用于让某个用户只能访问一个机器的情况。
  • 响应速度均衡: 根据过去一段时间内每个机器的响应速度进行计算, 优先把请求转发给响应时间短的机器
  • 最少连接均衡:根据过去一段时间内每个机器的连接数进行计算, 优先把请求转发给处理请求数少的机器。

为了上述的负载均衡策略得以实现, 负载均衡组件通常还必须拥有健康检查功能, 检查每个服务是否可用,同时也需要服务发现功能, 在节点变多后, 通过服务发现来动态修改后端节点。

此外, 常用的负载均衡组件, 如Nginx除了代理流量外, 还会承担一些静态文件请求, 以及缓存, 内容分发, 以及带有一些流量复制, 流量监控等等的功能。

3.微服务的负载均衡组件

一般在一开始接触Nginx时, 我们都会以一台Nginx来处理所有流量, 这种方法叫集中式的负载均衡, 这种负载均衡方法很致命的问题就是单点依赖问题, 在单点问题场景中, 当单点组件挂的时候, 后台所有服务都无法处理客户端的请求。
可见单点依赖问题很严重, 我们应当尽量避免这个情况, 在流量从客户端到后台应用的链路中, 所有组件应尽可能的避免使用单点组件。 由于微服务基本上都处于在一个内网之中, 可以不用考虑有个网关之类的服务来过滤非法请求, 也不怕被外网攻击, 同时在微服务设计中, 所有的服务都尽可能的拆成自己单一的服务, 此时若还依赖Nginx来做单点负载均衡, 则很容易使所有微服务由于Nginx出现问题而无法处理请求, 同时所有流量都需要经过一层Nginx进行转发, 会浪费机器资源, 所以微服务最好不去使用这种负载均衡模式, 那微服务的负载均衡该怎么做呢?

由于微服务有个明显的特征, 就是它处理的请求都是在内部集群中流转, 不像传统负载均衡一样需要处理外部的请求, 从微服务客户端请求的数据基本是可信的(代码是自己写的, 在发起请求前就能自动过滤掉非法的请求), 那么可以采用称为客户端负载均衡模式的分布式负载均衡, 这种模式是每个微服务都内置了自己的一个负载均衡实现, 把一个单一的负载均衡变为分布式的负载均衡, 这种方式十分灵活,每个服务都可以单独设置单种负载均衡方式,以及附加的功能, 同时可以避免单一组件的危险, 减少流量要在集群内部绕圈圈的局面。 但是这种方案也是有局限性的,它跟服务是同一个进程, 意味着它的实现必须与服务同一个语言,负载均衡的稳定性也会影响到同一个进程的CPU,内存等资源(所以就出现了服务网格与边车代理, 但不是本文的相关内容)。不过,这种模式只负责做负责均衡, 没办法自动发现有多少个可用节点, 还是会依赖一个单一组件–配置中心, 它需要通过配置中心来了解这个服务对应的集群ip是多少, 以及服务是否上下线。

4.自动负载均衡的实现

上面说了很多了, 那负载均衡该如何实现呢, 首先要明确自己的要求是什么:

  • 1.客户端能够自动感应服务端节点的上下线。
  • 2.在没人工干预的情况下, 能自动隔离故障的节点, 并在节点恢复的时候自动恢复。
  • 3.每次选择节点时,选取到的节点都是距离当前服务最近的,响应尽可能快, 负载尽可能小的。
  • 4.不会因为负载均衡造成羊群效应, 一切的变动都是非常及时的。

对于第一个要求, 则需要依赖于注册/配置中心, 通过注册中心来自动获取/感知对应服务的上下线, 通过配置中心来更改权重值等配置, 这会在注册中心的文章进行介绍。

对于第二个要求, 则采用健康检查的思路, 客户端会每隔一个时间后发送一个ping信息到对应的目标服务, 根据ping的成功率来决定是否要自动隔离目标节点以及恢复, 比如最近3次都ping失败就可以判断对应的节点已经故障, 可用性为0, 不再分发请求到该节点, 直到最近3次Ping都成功为止。

而第三种和第四种, 就是最复杂, 也是最重要的了, 是自动负载均衡的核心部分, 我的RPC务框架的客户端实现一定程度参考了kratos框架的1.x版本。 这个实现简单的说就是从连接池中挑选对应的多个连接, 通过打分的形式综合自己当前的连接状态和服务端的情况进行打分, 然后判断哪个连接的分数比较高, 并选取分数最高的连接作为本次请求的连接,其中涉及到的名词有从多个连接选取最想要的一个–p2c, 反映近期数值变化的加权平均数–EWMA和主观批评方法–mos。接下来将从离自动负载均衡最远到近一个一个参数进行介绍。

4.1.mos

首先是mos, mos是衡量服务端当前压力的值, 这是我在RTCP通话质量分析里面学到的词, 这个值只会在0-5波动, 0代表不可用, 5代表最佳。 为了减少资源消耗, 服务端会实时记录请求数据指标, 通过滑动窗口每隔一段时间计算一次mos值, 并保存在内存中, 或者更新内存中的值(详见服务治理的基石)。
然后在客户端通过ping-pong请求发送ping到服务端时, 服务端会把位于内存的mos值放在响应体中返回给客户端, 客户端并不需要知道这个值是怎么来的, 是依赖哪些指标生成的, 只需要知道在自动负载均衡中对应的服务的mos值越高, 就更应该连接到这个服务。

NOTE: Kratos框架会在每次请求都返回最新的服务端压力指标, 如cpu使用率,并发数等等,然后客户端收到指标数据后会实时的根据这些指标进行计算。 我在实践的时候感觉这样做作用不大,同时又很废网络流量,故而只通过ping-pong的请求来获取指标,减少客户端的计算量和网络传输资源的占用。

那服务端依赖哪些指标来生成mos值呢, 前面说道, 这类型的值主要作用是用来衡量服务端当前的压力, 比如Linux系统有一个叫load的变量, 来衡量机器的负载, 而对于服务端来说, 可以衡量的值比较多, 具体需要根据场景来选用, 常用的有:

  • QPS-每秒请求数, 意味着每秒收到的请求数量。此外还有一个叫预估峰值QPS, 原理是每天80%的访问集中在20%的时间里,这20%时间叫做峰值时间,而峰值QPS的计算是(每天的请求数80%)/(每天秒数20%),假设每天的请求数为100W,那么峰值QPS为46。
  • TPS-每秒处理的事务数,系统整体处理能力取决于处理能力最低模块的TPS值。
  • RT-系统对请求作出响应的时间, 这个值对于用户来说是最直观的, 所以该值也是非常的重要。
  • 并发数-该机器同一时刻的处理量, 该值越高,机器压力越大。
  • CPU使用率-CPU的使用量, 对于CPU密集型的应用来说, CPU使用率是一个很重要的指标。
  • 请求错误数-一般来说,当服务出现错误时,可能会伴随这其他系统资源的占用并且没来得及释放, 当请求错误数变多时,可以假想机器的压力变大。

我在为框架写一个通用的mos计算时, 去掉了一些业务相关的压力指标依赖, 选择了一些通用的压力指标如cpu使用率,请求数,响应数,错误数,当前正在处理请求数, 当前channel数量, 这些指标经过计算最后只会在0到1直接波动, 通过这些指标与5进行计算, 最后取值范围在0-5之间,具体代码见:https://github.com/so1n/rap/blob/master/rap/server/plugin/processor/mos.py

需要注意的是, 光靠上面是数值会很容易的陷入到数值陷阱中, 因为随着服务的变化, 这些数值的影响力也会发生变化, 我们需要过段时间就更改数值。 此外,在真正应用到生产环境时, mos的计算方法还是要根据对应的工作环境来制定, 因为每个服务的要求都是不同的, 有些服务的最高成本请求会比最低成本请求多消耗1000倍的资源, 如果没办法区分, 负载均衡策略就会造成误判。 同时还有一些比较特别的服务, 他们可能会强依赖单一的组件, 比如MySQL, 当MySQL挂的时候, 整个服务是不可能的, 应当快速的把mos设置为0。

4.2.EWMA

客户端在获取了服务端的mos值后,客户端可以把mos与自己的指标进行计算去得出一个负载均衡的评分, 但是服务端的mos并不是实时获取出来的, 通常都会有一段延时, 这样算出来的评分可能没办法反映真实的情况, 需要一个算法来适当的修正, 而EWMA的修正则是可以让这个mos值变得更加的真实。

EWMA全称为Exponentially Weighted Averages,中文意思为指数移动加权平均, 它体现的是一段时间内的平均值,此算法是对观察值分别给予不同的权数,按不同权数求得移动平均值,并以最后的移动平均值为基础,确定预测值的方法。 采用加权移动平均法,是因为观察期的近期观察值对预测值有较大影响,它更能反映近期变化的趋势, 同时它不需要保存过去的所有数值, 计算量也不大, 适合经常更新数据的服务, 同时对于网络抖动比较敏感。

EWMA的公式如下:
$$V_t=w*V_{t-1} + (1-w)*T_{t}$$

这个公式中,Vt代表第t次请求时的EWMA值,Vt-1代表第t-1次请求的EWMA值,Tt代表第t次请求的实际耗时, 先把EWMA相关的值忽略当为1可以发现, 公式中参数w是跟请求频率相关的, 我们可以通过控制这个值来迅速的监控到网络毛刺, 比如当请求频繁时,说明节点负载变高了,我们就需要相对的调小该值,如果请求不频繁, 则可以调大该值, 这样计算出来的EWMA就越接近平均值。 在经过一番查找后, 发现可以运用牛顿冷却定律的衰减函数模型(见基于牛顿冷却定律的时间衰减函数模型)来进行计算, 从而生成符合该条件的w值, 而牛顿冷却定律的时间衰减公式为:w=1/e^(k*△t) , 其中可以把△t认为是两次请求的间隔, 而e, k为常变量, 通常一个是1,另外一个需要自己根据响应时间的平均时长来进行计算。

了解完EWMA后就可以开始实现了, 代码如下(具体见:https://github.com/so1n/rap/blob/master/rap/client/transport/transport.py#L193):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
async def ping(self, conn: Connection, deadline: Deadline) -> None:
start_time: float = time.time()
# 初始化mos和延迟rtt
mos: int = 5
rtt: float = 0

async def _ping() -> None:
# 发送ping请求, 并处理ping的响应体, 从而得出响应时长和服务端返回的mos
nonlocal mos
nonlocal rtt
response: Response = await self._base_request(Request.from_event(self.app, event.PingEvent({})), conn)
rtt += time.time() - start_time
mos += response.body.get("mos", 5)

# 发送3次ping请求
with deadline.inherit():
await asyncio.gather(*[_ping(), _ping(), _ping()])

# 算出这3次请求的平均值
mos = mos // 3
rtt = rtt / 3

# 获取初始值与上次请求的值
now_time: float = time.time()
old_last_ping_timestamp: float = conn.last_ping_timestamp
old_rtt: float = conn.rtt
old_mos: int = conn.mos

# 计算两次请求间隔, 从而计算出w, 由于我的ping是随机间隔1-3秒, 所以self._decay_time设定在600左右
td: float = now_time - old_last_ping_timestamp
w: float = math.exp(-td / self._decay_time)

if rtt < 0:
rtt = 0
if old_rtt <= 0:
w = 0

# 通过EWMA公式来计算rtt和mos
conn.rtt = old_rtt * w + rtt * (1 - w)
conn.mos = int(old_mos * w + mos * (1 - w))
conn.last_ping_timestamp = now_time
# 通过本次rtt, mos以及用户指定的权重值进行计算打分, 得出对应的分数
conn.score = (conn.weight * mos) / conn.rtt

4.3.选择最佳节点

一般来说, 为了增加服务的可用性, 同一个服务会由多个服务端来提供, 这样对于客户端来说会有很多可选节点, 如果客户端每次请求都从所有节点进行比较, 再选出最合适的连接来进行请求则会比较消耗计算资源与时间,这是没必要的。 因为大部分节点在大部分时间内的状态都是正常的, 只有在一些特殊情况才会发生异常状况, 而我们要做到的就是在有异常情况的时候尽量的不要去选择有问题的连接, 这时就需要P2C。 P2C很容易理解, 就是从多个节点中随机选择两个节点, 通过随机选取的方式, 可以在占用极小的计算资源的情况下选取合适的连接, 同时尽量的避免在异常情况下选到不可用的连接(都选到的不可用的连接的概率非常的小, 即使选到了, 也可以通过其他服务治理来规避问题), 除了随机外, 也支可以持其他方式(当然, 这种方式需要尽量的简单), 类似于2里面说的负载均衡,具体实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def picker(self, cnt: Optional[int] = None) -> Picker:
"""
自动算出需要一次获取多少个连接数, P2C默认是获取2个, 我这里则是默认获取3次, 因为获取的连接有可能是不可用的
"""

if not cnt:
if self._connected_cnt <= 3:
cnt = self._connected_cnt
else:
cnt = 3
if cnt <= 0:
cnt = 1

conn_list: List[Connection] = self._pick_conn(cnt)
return Picker(conn_list)

def _pick_conn(self, cnt: int) -> List[Connection]:
"""默认的获取连接方法, 通过启动配置可以配置成下面对应的获取连接方法"""
pass

def _random_pick_conn(self, cnt: int) -> List[Connection]:
"""随机获取连接的方法, 一般来说获取的连接都是无规律的"""
cnt = min(cnt, len(self._conn_dict))
key_list: List[tuple] = list(self._conn_dict.keys())
if not key_list:
raise ConnectionError("Endpoint Can not found available conn")
conn_list: List[Connection] = []
for _ in range(cnt):
key: tuple = random.choice(key_list)
conn: Connection = self._conn_dict[key]
if conn.available:
conn_list.append(conn)

return conn_list

def _round_robin_pick_conn(self, cnt: int) -> List[Connection]:
"""轮训获取连接的方法, 不加锁可能导致获取的连接是不连续的, 但是影响不大"""
conn_list: List[Connection] = []
key_list: List[tuple] = list(self._conn_dict.keys())
if not key_list:
raise ConnectionError("Endpoint Can not found available conn")
for _ in range(cnt):
self._round_robin_index += 1
index = self._round_robin_index % (len(self._conn_dict))
conn: Connection = self._conn_dict[key_list[index]]
if conn.available:
conn_list.append(conn)
return conn_list

def _pick_faster_conn(self, cnt: int) -> List[Connection]:
"""根据响应时间来获取连接, 通常来说会经常使用响应速度最快的连接, 不太推荐"""
return sorted([i for i in self._conn_dict.values() if i.available], key=lambda c: c.rtt)[:cnt]

选择完了连接后, 就需要依赖打分机制来决定最终需要选谁了, 上面说过,客户端可以通过ping-pong机制获取到服务对应的mos值, 并进行打分, 但是ping-pong的间隔是随机的1-3秒, 所以客户端更新对应服务的分数的间隔时间也是随机的1-3秒, 这样的更新频率并不快, 如果直接使用该值做自动化负载均衡的话, 那就会造成短时间内流量都跑到该更新时间周期内压力最少的机器上面, 从而产生羊群效应,造成这些机器短时间内产生极大的压力, 整个内部网络环境的流量也是极不均衡的, 所以还需要客户端依赖自己的一些连接数据来生成最后的打分情况。
这个值就是客户端与每个服务的正在请求数, 通过连接数可以衡量当前客户端会把流量发送到哪里, 如果发现发送的量不均衡, 那么正在请求的数会变得不均衡, 在rap这个框架中, 负责这个功能的类叫Picker, 它除了管理获取最佳连接外, 还通过信号量来管理每个连接的使用情况, 包括当前连接有多少个请求, 是否超过使用量等等, 具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Picker(object):

def __init__(self, conn_list: List[Connection]):
"""该类是由上面picker函数创建的, 所以这些值也就是那个阶段中确定了"""
self._conn: Connection = self._pick(conn_list)
self._start_time: float = time.time()

@staticmethod
def _pick(conn_list: List[Connection]) -> Connection:
"""pick by score"""
pick_conn: Optional[Connection] = None
conn_len: int = len(conn_list)
if conn_len == 1:
# 如果只有一个可用的连接, 则不用进行对比, 直接采用
pick_conn = conn_list[0]
elif conn_len > 1:
# 如果可用连接多于一个, 就开始进入对比阶段
score: float = 0.0
for conn in conn_list:
# 通过信号量获取当前正在使用连接的量
conn_inflight: float = conn.semaphore.inflight
# 服务端返回的值计算出的评分
_score: float = conn.score
if conn_inflight:
# 通过正在使用连接的量计算正真的评分
_score = _score / conn_inflight
logging.debug("conn:%s available:%s rtt:%s score:%s", conn.peer_tuple, conn.available, conn.rtt, _score)
if _score > score:
score = _score
pick_conn = conn
# 如果没有可用连接,则报错
if not pick_conn:
raise ValueError("Can not found available conn")
return pick_conn

# 通过__aenter__和__aexit__限制了调用端只能通过async with pick来获取可用的连接, 以此方便通过信号量来管理连接的使用量
async def __aenter__(self) -> Connection:
await self._conn.semaphore.acquire()
return self._conn

async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
self._conn.semaphore.release()
return None

5.总结

第一次调差自动负载均衡的资料时, 理解了很久, 但在经过实践后, 发现只要理解了EWMA的方法并去理解它同时自动化负载均衡的原理十分简单, 就是在正常的情况下尽可能的平均的去请求到服务端, 同时保证在某个服务端压力变大时, 尽量的不去选取它即可。 但是上面实现起来有一个弊端, 就是自动化负载均衡跟客户端是在同一个进程内的, 所以跨语言的话就得重新开发一个, 负载均衡的实现好坏也会影响到客户端对应的性能, 所以更好的方法是采用边车代理的方式来实现自动负载均衡。

查看评论