再看过载保护

过载保护初探

距离上一篇过载保护的文章已经快一年了

上一篇文章是上一个项目分布式文件系统使用的,这个项目因为一些架构上的缺陷,并没有让最大的云盘服务接入。

没有那么大的量级,因此思考的过载保护方案也没有全部去实现。

新的项目有比较大的量级,在实现新项目的过载保护机制时会更全面。

新的认识

之前举例是从100qps到1000qps导致过载来举例的

实际上应该以任务队列的长度作为过载的基准点。

对一个对外服务来说,负载的提升是因为并发量的提升,qps是结果,而不是原因

对一个内部服务来说,则是任务队列长度的提升(pipeline下只有较少连接)

回顾一下之前的关键点结合说明下新的体悟:

1 过载原因:

超时请求处理的无用功依然占用资源

失败造成的重试,使得负载激增,导致过载

真的存在这么大的访问量,负载激增,导致过载

三个问题其实是一个根本问题,任务队列过载

2 处理策略:

内部系统上游客户端:熔断,动态回馈负载均衡策略

内部系统下游服务端:限流(上游有过载保护就没必要再做)

接入层服务端:限流(由于客户端不受控制,所以必须做)

处理策略其实都是在控制节点的任务队列长度

本篇就根据已经实现的内部上游客户端和接入层过载保护来进行说明

负载定理

下面我实现的策略都是基于一个定理去实现的:

负载和任务队列成线性正比,也和响应时间成线性正比。

可以写一个简单demo来验证

这个demo是基于grpc来实现的,server端如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var gResp *pb.HelloReply = &pb.HelloReply{Message: "Hello"}
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
for i := 0; i < 1000; i++ {
c := make(chan struct{})
wait(c)
<-c
}
return gResp, nil
}
func wait(c chan<- struct{}) {
go func() {
for i := 0; i < 10000; i++ {
_ = i
}
close(c)
}()
}

我一开始是用很长的for循环来实现的,后来发现go没法很好的调度协程,会导致有的协程饥饿(不会完全饥饿,毕竟for循环也是有可能被中断的)

wait函数是模拟一些异步调用使得协程更有机会去调度切换,使响应时间更均衡

不断的加大并发值,如下输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
qps_client:
name:grpc c:1 qps:120 delay:8.269263ms okQps: 120 okDelay: 8.269263ms
name:grpc c:10 qps:274 delay:36.490219ms okQps: 274 okDelay: 36.490219ms
name:grpc c:100 qps:306 delay:328.115535ms okQps: 306 okDelay: 328.115535ms
name:grpc c:1000 qps:354 delay:2.846736166s okQps: 354 okDelay: 2.846736166s
name:grpc c:5000 qps:348 delay:15.320079274s okQps: 348 okDelay: 15.320079274s
name:grpc c:10000 qps:379 delay:29.898999296s okQps: 379 okDelay: 29.898999296s
name:grpc c:100000 qps:907 delay:4m1.892444684s okQps: 907 okDelay: 4m1.892444684s

delay_client:
name:grpc c:5 qps:587 delay:8.509949ms okQps: 587 okDelay: 8.509949ms
name:grpc c:5 qps:161 delay:31.03484ms okQps: 161 okDelay: 31.03484ms
name:grpc c:5 qps:24 delay:206.264145ms okQps: 24 okDelay: 206.264145ms
name:grpc c:5 qps:9 delay:558.61258ms okQps: 9 okDelay: 558.61258ms
name:grpc c:5 qps:9 delay:522.283114ms okQps: 9 okDelay: 522.283114ms
name:grpc c:5 qps:6 delay:745.789694ms okQps: 6 okDelay: 745.789694ms
name:grpc c:5 qps:5 delay:1.005063016s okQps: 5 okDelay: 1.005063016s

qps_client主要参考的是qps,因为随着并发的增加本身客户端协程的调度使得响应时间失真

delay_client主要参考的是响应时间,只启动很少的协程保证响应时间真实

可以观察到,随着并发的提高,qps不断升高,到一定值就不再提升。

当qps_client 过5000时,客户端已经过载,此时响应时间不再有意义。

在5000内时,并发和响应时间成近似线性关系

测试代码地址

根本原因是并发和任务队列成正比,随着任务队列的增加,资源被平均分配给更多的任务,使得响应时间也增加

内部上游客户端

服务发现

服务发现是客户端实现过载保护功能的基石,一般的服务治理框架例如Spring Cloud,Dubbo都有这个基本功能

实现了服务发现,客户端才能抉择对下游服务端负载均衡以及熔断。

这部分内容不在本篇讨论范围内不进行展开了

动态回馈负载均衡策略

在之前的系统中也实现了这个策略,但是效果不是很好,当时采集了cpu,内存,网络,磁盘的负载情况。

(lvs的动态回馈负载均衡)[http://www.linuxvirtualserver.org/zh/lvs4.html]

参考的lvs进行实现,但是怎么对负载进行权重调整,就很懵逼了。

比如磁盘密集型的和cpu密集型的,磁盘和cpu的权重很明显不能一样。但是如果要每次自己去配置就显得很笨拙了。

这次吸取了这部分的教训,根据上面推断的定理,从任务队列和响应时间作为负载指标,做负载均衡策略

下游响应时间负载均衡

每隔一段时间,客户端对每个下游服务端进行平均响应时间的统计,来更新权重

具体的算法是借鉴的Spring Cloud中Ribbon的ResponseTimeWeightedRule

ResponseTimeWeightedRule源码

在计算权重时比较简单粗暴,将总平均响应时间-各个服务器的平均响应时间就是最终的权重

这种计算方式不太容易出现分配特别不均衡的情况,导致部分良好节点过载

算法上依然存在优化空间(待续)

优缺点分析:

优点是不需要下游服务端支持,对db服务都生效

缺点是如果客户端自身的资源调度不均衡,会导致失真。例如饥饿协程,gc等待

下游任务队列负载均衡

任务队列反应了当前节点的真实处理能力

下游起一个任务队列长度的server,上游会调用这个server获取到任务队列

根据任务队列来设置权重,可以做到很精准

优缺点分析:

优点是比起响应时间更精准可信

缺点是实现需要下游服务端支持,如果下游是第三方服务,就无法很好的均衡

熔断

熔断是我上一篇文章完全没有调研的内容,内部系统客户端实现了熔断以后,下游服务端的限流机制就没有必要去做了。

相当于把限流的位置提前了,少了很多无用开销。

熔断器实现上参考的是Spring Cloud的Hystrix

Netflix Hystrix 工作流程浅析 && HystrixCircuitBreaker源码分析

http://7xkkgd.com1.z0.glb.clouddn.com/hystrix-command-flow-chart.png

以及微软的实现文章

断路器模式

实现上基本都是一致的,写一下自己的体悟:

熔断器是过载保护的最后一层屏障。

当下游的所有节点全部过载时,负载均衡策略显然已经没有意义。

此时就需要熔断来保证系统不会因为部分链路的失败而全部超时雪崩了

因此如何设计熔断阀值就很重要了,拿最简单的连续错误数来说,设高了对错误不敏感,设低了容易误报熔断。

目前我使用的是超过5次连续错误进行熔断(待续)

结合实现

上面都是单独的策略,具体实际编写框架时的结合还有一些微调

框架的底层实现是一个basic balance,普通balance和哈希balance(不进行讨论)继承这个抽象接口类

以下是普通balance的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Enable和Disable接口,用于熔断器的节点闭路开路通知

被调用时对节点设置一个熔断状态,重新生成权值分布表时disable状态会被忽略

Update接口用于etcd,zookeeper的节点通知

被调用时以新的节点为准,复制老的节点的权重,熔断状态,来重新生成权值分布表

UpdateWeight接口用于动态反馈模块的权值调整通知

被调用时以已有节点为准,进行权值调整,不新增删除节点,来重新生成权值分布表

Get接口用于返回节点地址

被调用时生成一个总权值范围内的随机数,根据权重分布表来选择节点返回

动态负载均衡和熔断器以桥接模式的方式集成在普通balance上

接入层服务端

限流降级

最简单的限流降级上一篇文章讨论过了,一般是设定某个阀值进行限制。

本篇重点讨论的则是动态限流策略。

在最初实现的时候,我是按照每隔一段时间采集平均处理时间,达到一定阀值则直接调用降级逻辑

这样就会出现一段时间过载很高,过了一段时间又突然完全没有负载,这种情况了。

这种调整策略的响应时间是连续的波峰波谷,一段时间内的平均处理时间并不能很好的反应负载情况。

必须使用任务队列长度来进行限流。

设我们需要的响应时间阀值为limitRespTime

目前使用的算法是,每隔很小的一段时间记录avgRespTime(平均响应时间)和avgJobLength(平均任务队列长度),分别写入queue(队列)。

每隔较长时间,例如10秒,分段统计queue中avgRespTime出现最多的段,以1,10,100,1000,10000依次类推进行分段。

这是为了排除一些阻塞导致的数据噪点,然后统计最多的这个段内avgRespTime的平均值mostAvgRespTime。

最后得到的mostAvgRespTime如果大于limitRespTime,那么最终的期望队列阀值为mostAvgJobLength/(mostAvgRespTime/limitRespTime)

但是阀值的调整不能一次到位,每次只调整期望队列阀值和已有队列阀值的1/2

例如得到的mostAvgRespTime为1000ms,limitRespTime为100ms,目前的队列长度为1000,期望队列长度就是100

最终调整结果是(1000-100)/2 + 100 = 550,多次调整后就会逼近理想值100

这种算法还有很大的优化空间,但是基本可以比较智能的调整限流

优缺点分析

优点:不需要每次修改一点点代码都去跑性能测试来确定限流值

缺点:算法粗糙

总结

过载保护的细节太多

然而总体离不开控制任务处理量这个关键点

对外服务端做限流,对内客户端做熔断和动态反馈负载均衡在我目前接触的项目中就能较好的保护系统。

这一篇博客比起上一篇更加落地,也提出了一些自己的想法。

后续的优化会围绕的我实际开发的微服务框架sheep来进行

一个基于grpc的微服务框架sheep

PS: 实现过载保护离不开后台的定时统计

然而golang的后台统计协程在负载较大时不能被很好的调度。

这也和golang本身的缺陷有关,因为完全无抢占无法保证核心协程被准确定时调度,导致统计失真影响过载保护结果