回压式 负载均衡的思考

转载公众号 写Scala的老王

回压策略

  1. Gateway维护一个ConcurrentLinkedQueue[WorkRequest],其中WorkRequest里面记录Provider的唯一标志。之后LoadBalance从这个队列里面一直Poll工作请求。如果有则选择对应的Provider打过去,如果没有则循环等待(此时称Provider端对Gateway产生了回压);
  2. Provider端注册Listener的时候,告知Gateway其最大线程数能力maxThreads;然后Gateway那端的Listener接收到信息后,发送maxThreads数量大小的WorkRequest到 1 中的队列;每次请求完成之后,将WorkRequest还给队列。
    如此,整个系统就围绕队列开展开了。 相关整体的实现可以直接看《反应式设计模式》的16章。

最大剩余能力路由

Little’s Law讲的是这样的一件事情。如果一个处理者处理一件事情的耗费的时间是W,假设其处理的速率为R, 那么在他面前排队的工作的数量Q可以如下表示:

Q = R * W
然后,我们可以根据Q的大小和Provider的耗时,判断出处理速率可能最高的Provider,然后将请求打过去。而每个Provider的Q的初始大小,就是其最大线程数maxThreads。

这个时候,因为统计W的代码写起来比较麻烦。所以习惯性偷懒的我先写了一版本的直接假定所有Provider的W是一样的,然后按照剩余最大Q发送过去的代码,逻辑如下:

对每个Provider在Gateway端维护一个ProviderStatus。其中各自都有一个类型为LinkedBlockingQueue[WorkRequest]的workRequestQueue。仍然按照前面说的,注册的时候每个队列都发maxThreads数量的WorkRequest,直到完成初始化。
LoadBalancer选择的时候,比较每个Provider的workRequestQueue的大小,size最大的把请求发过去。
请求处理完成后,发还workRequest给对应的ProviderStatus的workRequestQueue.

改善回压

思考了一下为什么回压的性能不太行,主要在于Provider端的响应时间随机性太大。虽然平均值是确定的,但是相邻两个请求,可能一个是1ms,另外一个就100ms了。所以没有办法按照同质化的worker那样较好地路由过去。所以我就想能不能根据RTT在队列的数据结构上做文章。

我做的第一个尝试是用多个队列来接收WorkRequest。每个队列的优先级不一样,优先级最高的队列只接受上一次请求的延迟在40ms之内的WorkRequest。然后一个队列是40到 60ms之间,最后一个是大于60ms的。但是实践测试下来效果并不明显。因为Rtt响应时间比较久的WorkRequest会到队列后面去,所以一旦一个Provider出现了较大的延迟,则其参与到整个过程的权重就会减少。而出现大请求的可能性还是比较高的,所以一旦出现大的延迟,这个Provider的权重就会永久性下降。因为前面的workRequest根本就用不完。所以这个方案的成绩比纯粹的回压更差。

然后我想到了优先队列。每次都取响应时间最小的Provider发过去,这样只要Provider的响应更快,则其拿到的请求就会更多。但是这么做还是会遇到和前面相同的问题。一旦一个Provider出现了一个大的请求,则其权重可能就永久性下降了,而且无法恢复。因为优先队列无法把最后面的workRequest拿出来。

之后就考察了一下双端队列。既可以拿前面,也可以拿后面。但是这个时候应该按照什么样的规律把后面的拿出来呢?又应该按照什么样的规律把workRequest送到前面去呢?想了又想,发现这个判断要写下来逻辑蛮复杂的。不如找个又像优先队列,又能从前、从后拿workRequest请求的数据结构好了。

于是ConcurrentSkipListSet[WorkRequest]进入了眼帘。

基于跳跃表的回压解决方案
跳表有这样的一个特性,它可以按照元素的Comparator对象来排序元素。也就是说我可以按照请求延迟的大小,对workRequest做一个排序。而其插入和删除的复杂度都是O(logN)。所以其构建和获取的性能都很好,并且可以自动排序延迟。当我们觉得延迟偶然较大的WorkRequest饥饿很久的时候,我们可以从最后将其取出,并重新放入队列进行排序。

在Java中直接就有ConcurrentSkipListSet拿来用。只不过用的时候需要注意一下,其是一个Set。也就是说,如果有两个请求,其延迟相同,Provider标志相同,则在Set内部就会被合并成一个。

在开始的时候,我的WorkRequest的设置是这样的:

public class WorkRequest {
    final int port;
    final Long latency;

    public WorkRequest(int port, long latency) {
        this.port = port;
        this.latency = latency;
    }

    static class Compartor implements Comparator<WorkRequest> {

        @Override
        public int compare(WorkRequest o1, WorkRequest o2) {
            return o1.latency.compareTo(o2.latency);
        }
    }

}

这就导致WorkRequest在latency冲突的情况下越变越少,最后性能尽然比直接一个队列都还差。

后续发现了原因所在,就将WorkRequest的定义改成这样的了:

public class WorkRequest {
    final int port;
    final Double latency;

    public WorkRequest(int port, long latency) {
        this.port = port;
        this.latency = latency + ThreadLocalRandom.current().nextDouble();
    }

    static class Compartor implements Comparator<WorkRequest> {
        @Override
        public int compare(WorkRequest o1, WorkRequest o2) {
            return o1.latency.compareTo(o2.latency);
        }
    }
}

因为后面的ThreadLocalRandom.current().nextDouble()基本不会冲突,所以WorkRequest的数量和初始数量会大致保持一致。

这时候,跳表就成为了一个智能的回压队列。我们一直pollFirst,也就是选择最小响应延迟的provider发出去。这个时候就是完全优先Rtt的过程了。

当然,如果不做特殊处理的话,它还是会出现我前面说的,大请求的饥饿问题。大请求一直得不到调度,provider端的加权情况就一直没有变化。这个时候在负载变化的时候,后端的加权也不是稳定变化,而是根据Provider的随机响应情况而变化。于是就得想办法解决这个问题。

那么,如何解决呢?

TCP的拥塞探测机制。

TCP在探测发送窗口的时候,会有一个慢启动的过程。在启动的过程中,它会发送探测包到接收方。如果接收方很快地响应了这个包,说明接收方并未排队,于是发送方逐渐扩大发送窗口;如果接收方没有很快响应这个包,则发送方就逐渐减小发送窗口。所以,如果我们能和Provider端建立这样的探测机制的话,那么,我们也可以逐渐扩大发送给Provider的容量。

但是我们没有办法在题目限制的基础上完成这个事情。因为我们没有办法在Provider端建立一个快速连接。然后我想到的办法是,根据Provider端的随机响应性。因为其范围比较大,只要不排队,在一定时间内,肯定会出现响应时间小于3ms的请求。一旦碰到这个请求的时候,我们就从跳表队列的最后位置拉出来workRequest,并将其重新设置为该Provider的workRequest(实际上应该选一个不是该Provider的来替换。但是这点我偷懒了没有做)。

于是,在探测到latency的时候,将workRequest写回去的代码就变成了这样:

private void recordLatency(long latency) {
    if (latency != -1) {
        if (latency < 3) {
            for (int i = 0; i < 1; i++) {
                requestQueue.pollLast();
                requestQueue.add(new WorkRequest(port, latency));
            }
        }
        request(50);
    }
}

注意最后的request(50)。该函数的定义如下:

void request(long latency) {
    requestQueue.add(new WorkRequest(port, latency));
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值