目录
一个功能
最近在做一个功能,消息队列的消费端负载均衡,像kafka、RocketMQ等都可以做到全局消费的负载均衡,但是他们都需要一个中心节点来做统一分配与调整。我们做到了不用集中节点的全局负载均衡,并且模仿kafka的StickAssignor做到了粘性分配,即每次变动最少(纯属为了炫耀,与本文无关)。
回归正题,每次对消费端做负载均衡是一个代价很大的操作,因为要停止受影响消费端的消费,收集消费offset,重新分配,所以这个操作触发不能过于频繁。但是触发重新负载均衡的条件有很多,所以我们要对此做限制。
最初的想法,对触发操作增加一个最小时间间隔限制,每次实际执行前,要判断上一次调整距离当前的时间间隔是否大于最小时间间隔。这保证了在最小时间间隔内,不会连续触发负载均衡。
public void rebalanceAll() {
if (lastRebalanceTimeStamp + minRebalanceInterval < System.currentTimeMillis()) {
logger.info("rebalance too frequently . last rebalance time {}, min interval {}", lastRebalanceTimeStamp, minRebalanceInterval);
return;
}
lastRebalanceTimeStamp = System.currentTimeMillis();
executorService.execute(() -> doRebalanceAll());
}
但是,触发操作来源于不同线程,这就会有并发问题。首先想到的就是加锁,对该方法进行加锁,保证了线程安全。当然了,无论是用jvm的synchronized或者JUC的lock,都可以。
public synchronized void rebalanceAll() {
if (lastRebalanceTimeStamp + minRebalanceInterval < System.currentTimeMillis()) {
logger.info("rebalance too frequently . last rebalance time {}, min interval {}", lastRebalanceTimeStamp, minRebalanceInterval);
return;
}
lastRebalanceTimeStamp = System.currentTimeMillis();
executorService.execute(() -> doRebalanceAll());
}
对于这种场景,加锁就可以满足要求,因为该操作不需要考虑性能问题。抛开具体场景,我们还可以继续优化,利用CAS的方式,做到无锁化。我们增加一个原子变量,用来表示当前是否已经有任务在执行,在任务执行结束后,重置该变量。
AtomicBoolean processing = new AtomicBoolean(false);
public void rebalanceAll() {
if (processing.compareAndSet(false,true)) {
if (lastRebalanceTimeStamp + minRebalanceInterval < System.currentTimeMillis()) {
logger.info("rebalance too frequently . last rebalance time {}, min interval {}", lastRebalanceTimeStamp, minRebalanceInterval);
return;
}
lastRebalanceTimeStamp = System.currentTimeMillis();
executorService.execute(() -> doRebalanceAll());
}
}
再扩展一下,如果要支持同一时间内可以运行N个任务,用Boolean的原子变量就无法实现了。我们继续可以用一个int的原子变量来表示当前时间段正在运行的任务数量,当任务数量<N时,可以添加任务,当任务数量>N时,抛弃任务。
AtomicInteger processing = new AtomicInteger(0);
public void rebalanceAll(int serverOffset) {
if (processing.getAndIncrement() > N) {
processing.decrementAndGet();
logger.info("rebalance too frequently . last rebalance time {}, min interval {}", lastRebalanceTimeStamp, minRebalanceInterval);
return;
}
lastRebalanceTimeStamp = System.currentTimeMillis();
executorService.execute(() -> doRebalanceAll(serverOffset));
}
再扩展一下,如果系统同一时间内最多可以支持运行N个任务,如果到达瓶颈N时,为了保护系统,需要等待任务消耗到一定阈值M后,才可以继续执行任务,否则全部抛弃。这是一个很常见的场景,比如系统最多并行处理任务10个,此时系统负载已经跑满,如果消耗掉一个任务后,新任务立即可添加,系统负载又会跑满,会导致系统始终处于一个满负荷状态,并且是否允许添加任务的状态会连续更改。
我们继续修改,再增加一个标记,用来表示当前是否到达了最高阈值,如果是,在恢复到最低阈值前,全部抛弃任务。
int processingCount = 0;
volatile boolean rejectAll = false;
public synchronized void rebalanceAll() {
if (processingCount > N) {
rejectAll = true;
logger.info("rebalance too frequently . last rebalance time {}, min interval {}", lastRebalanceTimeStamp, minRebalanceInterval);
return;
}
if(processingCount < M){
rejectAll = false;
}
if(processingCount > M && rejectAll){
logger.info("rebalance too frequently . last rebalance time {}, min interval {}", lastRebalanceTimeStamp, minRebalanceInterval);
return;
}
processingCount++;
lastRebalanceTimeStamp = System.currentTimeMillis();
executorService.execute(() -> doRebalanceAll());
}
这个小功能算是扩展结束了,有一种很熟悉的感觉,N是不是就叫做高水位,M是不是就叫做低水位。
Netty高低水位
在Netty里我们经常会设置两个参数:
WRITE_BUFFER_HIGH_WATER_MARK:高水位
WRITE_BUFFER_LOW_WATER_MARK:低水位
netty中使用高低水位用来做流控,当你的channel写缓冲区WriteRequestQueue的数据到达了高水位后,就会设置channel为不可写状态,只有当数据堆积量降低到低水位以下后,才会重新设置channel为可写状态。
这种目的其实也是为了防止写缓冲长期处于满负荷状态,保护服务稳定。并且减少频繁更改channel的可写状态。
Java8的Map
在JDK8中,HashMap的hash冲突采用的拉链法,做了一些优化,当链表长度超过一定阈值后,会把链表转换为红黑树,目的就是为了防止链表过长,查找效率降低。当链长度缩减到一定阈值后,会把红黑树重新恢复为链表,目的是在查找效率相近时,降低插入效率。
这就会有一个问题,如果连续的insert---delete---insert---delete操作,并且key都落在一个hash槽上,就会导致连续的链表与红黑树转换。
JDK是怎么解决的呢?
其实很简单,链表转红黑树用了一个阈值N,红黑树恢复链表用了另外一个阈值M,其中N>M。也就是说,当链表长度>=N时,把链表转换为红黑树。当红黑树的节点数量<=M时,把红黑树恢复为链表。其中M与N之间的差值,就是用来做缓冲的,防止连续变化。
调整敏感度
以上的三个例子,其实都是一个问题,即调整敏感度。对于一个状态的设置与恢复,如果过于频繁,可能会影响效率或者影响系统的稳定性,此时就需要降低调整的敏感度。
一般的做法,都是采用高低水位的方式来控制,利用高低水位间的差值来做缓冲。当到达高水位时,设置状态,并且只有在恢复到低水位后,才可以恢复状态。
那么我们可以抽象出这类问题,当某个状态影响了系统的稳定性或者性能,我们减低该状态调整的敏感度,一般方法为高低水位控制法,利用高低水位间的距离来做调整缓冲。
应用
在实际中,其实我们经常会用到。比如上面说的HashMap的链表与红黑树互转,netty的高低水位。像我工作中也有很多地方用到,比如前面说的任务并发度控制、限流控制、缓冲队列长度的内存保护等等。
在生活中也会有这种场景,比如地铁放行,当地铁进站人数到达一定程度之后,管理人员会做人流控制,禁止排队乘客再过安检。只有当地铁进站人数少于一定量后,才会继续放行安检。
个人公众号,期待大家关注: