目錄
序言
kafka在0.11版本後提供了Range、Round Robin、Sticky三種consumer group partition分配策略,其中Range、Round Robin比較簡單,Sticky有些複雜,但Sticky的平衡效果最好。有點需要注意,consumer group partition分配策略是在consumer端完成分配計劃後發送給GroupCoordiantor,最後由GroupCoordinator傳播給其它consumer。這種設計有兩個優點,consumer完成分配計劃,減少了GroupCoordinator的壓力,增加了GroupCoordinator靈活性。由GroupCoordinator傳播給其它Consumer,避免了consumer之間互聯。(注意:本文說的消費均衡是參與消費的單個Consumer消費Partition數量均衡,不是指consumer group 中所有的Consumer進程之間的消費均衡)
Range
Range策略是針對單個Topic設計的。如果Consumer Group只訂閱了單個Topic,那麼消費會很均衡。不論怎麼Rebalance,參與消費的consumer之間的partition數量之差最多爲1,理想情況下可以達到0,我們稱這個爲平衡分數(這個概念下面也會用到)。平衡分數越接近0平衡性越好,0是最完美的。如果Consumer Group訂閱了多個Topic,平衡分數比較大,訂閱的Topic越多,平衡分數越大,平衡效果越差,不建議Range策略使用在此場景下。
算法
//單個Topic的partition數量除以consumer數量,得每個consumer可得partition數量
int numPartitionsPerConsumer = numPartitionsForTopic / consumersForTopic.size();
//單個Topic的partition數量模consumer數量,得餘數
int consumersWithExtraPartition = numPartitionsForTopic % consumersForTopic.size();
List<TopicPartition> partitions = AbstractPartitionAssignor.partitions(topic, numPartitionsForTopic);
for (int i = 0, n = consumersForTopic.size(); i < n; i++) {
//partition按區間分配給consumer
int start = numPartitionsPerConsumer * i + Math.min(i, consumersWithExtraPartition);
//加上餘數,加完爲止
int length = numPartitionsPerConsumer + (i + 1 > consumersWithExtraPartition ? 0 : 1);
assignment.get(consumersForTopic.get(i)).addAll(partitions.subList(start, start + length));
}
示例
1、consumer group 有三個consumer,分別爲C={C0、C1、C2},消費Topic T 有5個partition P={p0,p1,p2,p3,p4}
首初分配結果:
consumer數量不變與上面一致。如果C0掉線,分配結果如下:
2、consumer group 有三個consumer,分別爲C={C0、C1、C2},消費Topic t0有5個partition P={p0,p1,p2,p3,p4},T1有4個partition P={p0,p1,p2,p3}
從上圖可以看出,consumer group 消費多Topic會出現不均衡,隨着Topic變多,均衡性越差。
Round Robin
Round Robin策略在設計中考慮了同一個Consumer Group消費多個Topic,因此消費多個Topic會表現的比較好的平衡性。同時也具有Range策略的優勢。但Consumer Group中各自consumer訂閱的Topic不同時Round Robin平衡性表現會比較差。
算法
1、基於字符順序構建有序consumer集合列表,基於取模特性,達到一個首尾相連有序環效果
2、基於Topic字符順序構建有序partition集合列表
3、遍歷有序partition集合,從有序環中取出consumer,並分配給partition
核心源碼
基於有序列表,達到首尾相連有序環的效果
@Override
public T next() {
T next = list.get(i);
//基於取模特性,下標介於[0,list.size-1]之間
i = (i + 1) % list.size();
return next;
}
示例
1、consumer group 有三個consumer,分別爲C={C0、C1、C2},消費Topic t0有5個partition P={p0,p1,p2,p3,p4},T1有4個partition P={p0,p1,p2,p3}
2、consumer group 有三個consumer,分別爲C={C0、C1、C2}。3個Topic,分別爲T0的P={T0P0},T1的P={T0P0,T1P1},T2的P={T2P0,T2P1,P2P2}。 其中C0訂閱了T0、T1,C1訂閱了T0、T1,C2訂閱了T1、T2。即同一個consumer group下所有consumer訂閱的topic不同,這種情況下平衡性表現不理想。
Sticky
Sticky策略是我們今天重點要講的,Sticky不具有Range、Round Robin兩種策略的缺陷,在單Topic、多Topic、consumer group中Consumer訂閱的Topic不同等複雜情況下都有良好的平衡性。Sticky策略的源碼有些複雜,初看會覺的雜亂無序,而且註釋不多,但多看幾遍發現代碼邏輯挺清晰。
數據結構&算法
數據結構
Sticky策略沒有一個專門的數據結構,而是一個核心數據結構和多個輔助結構。採用Map<String,List<TopicPartition>>結構做爲核心數據結構,記錄分配情況,Key爲consumer,Value爲consumer分配的partition集合。如圖:
要做到上面的分配效果需要幾個輔助集合:
1、有序consumer TreeSet集合,以consumer當前分配的partition數量做升序
2、有序可分配的partition List集合,基於consumer順序,同一個consumer中以partition下標做升序
3、partition可被分配的consumer Map集合,key:partition,value:可分配的consumer集合
4、consumer可訂閱的partition Map集合,key:consumer,vlaue:可訂閱的partition集合
注意:第三個集合大於或者等於第四個集合,因爲第三個集合從Topic視角組裝集合
算法
算法邏輯上有幾個難點:
一、判斷平衡性
1、每個consumer被分配的partition數量相等或者差異爲1,那麼已經是很理想的平衡分數。
二、partition是否需要重新分配Consumer
爲方便說明記:
partition當前consumer訂閱的partition數量爲current.p.count
partition上個consumer訂閱的partition數量爲prev.p.count
partition可分配的consumer訂閱的partition數量爲potential.p.count
1、檢查partition是否發生了generation衝突且current.p.count>prev.p.count+1,需要重新分配Consumer。
2、檢查current.p.count>potential.p+1或者current.p.count+1<potential.p,需要重新分配Consumer
(備註:這裏需要注意下,在源碼裏面沒有current.p.count+1<potential.p判斷,這是源碼巧妙的地方,在performReassignments方法中只有達到平衡後纔會退出,在partitions爲基礎的死循環的過程中current和potential的視角是不斷變化的。例如:p0時,c1是current consumer,但在p1時,c1是potential consumer,所以源碼只做單方向判斷。)
三、粘性處理
粘性處理是Sticky策略最亮眼的地方,在保證最大可能的平衡的情況下,確保partition--->consumer的變化最少。在粘性處理準則與最大可能平衡平衡準則有衝突,以最大可能平衡優先。
邏輯:
1、構建分配策略的時候,優先保持原有的分配
2、記錄Partition最近一 次的移動軌跡,移動軌跡通過src---->dst表示。
假設T0P0最近一次的移動軌跡爲C1---->C2,本次計劃從C2---->C1(稱爲反轉),存在C2--->C1移動記錄,不允許這樣移動。從Partition的Topic移動軌跡中找一個移動軌跡一樣的Partition出來進行分配。
假設T0P0最近一次的移動軌跡爲C1---->C2,本次計劃從C2---->C3,反轉後變爲C3---->C1,不存C3---->C1移動記錄,可以移動。
示例
1、consumer group 有三個consumer,分別爲C={C0、C1、C2}。3個Topic,分別爲T0的P={T0P0},T1的P={T0P0,T1P1},T2的P={T2P0,T2P1,P2P2}。 其中C0訂閱了T0、T1,C1訂閱了T0、T1,C2訂閱了T1、T2。
如果C0被刪除,分配如下
核心方法
代碼步驟
1、構建當前的分配情況
currentAssignment是基於上次訂閱情況構建,結構key:consumer, value:list<partition>,構建過程中有generation衝突的consumer,取generation最大的consumer
2、構建可能的分配組合
從partition角度看,構建partition2AllPotentialConsumers
從consumer角度看,構建consumer2AllPotentialPartitions
3、排序partitions
對所有合法的partition進行排序,確保重分配階段partition在consumer之間移動最小
排序原則:即訂閱partition量最多的cosumer涉及的partition排在前面,因爲這些partition最有可能需要重分配
a. 重平衡且partition2AllPotentialConsumers中值均相等且consumer2AllPotentialPartitions中值均相等
b.
//重分配且P與C呈倍數關係,需要進行sort partition操作
// 操作CurrentAssignment
// 1、過濾掉不存在的Partition集合
// 2、產生Generation衝突的Partition集合與每個Consumer的Partitions做交集處理
// loop
// a. 存在交集:取交集中一個partition
// b. 不存在交集:取Consumer訂閱的一個partition
// end loop
// 3、添加沒有被訂閱的partition
//按照可訂閱Partition的consumer數量做升序排序,並以此順序轉換爲List集合
4、平衡處理
a. 分配未分配的partition
遍歷sortedCurrentSubscriptions,按順序分配可分配的consumer,並對sortedCurrentSubscriptions重新排序
縮小重新分配的範圍
b.過濾掉不需要重新分配的partition和consumer
1、縮小到那些需要重新分配的partition
尋找真正可以被重新分配的partition,一個partition可選擇的consumer小於2,說明是不可以重新分配
2、縮小到那些需要重新分配的consumer
a. consumer已經訂閱的p的數量小於可訂閱的p的數量,說明consumer是可重新分配的
b. 一個partition可選擇的consumer大於等待2,說明consumer是可重新分配的
c. 不符合a、b兩個條件的consumer爲不可重新分配的
c.執行重分配
1、遍歷有序partition
2、檢查當前的分配策略是否均衡,檢查consumer訂閱的partition的數量
a. 有序的consumer集合,比較first consumer與last consumer訂閱的partition數量,如果兩者相等或者差一,說明已經平衡
b. 比較訂閱了同一個主題的兩個consumer所分配的partition數量之差
(可訂閱但沒有訂閱的partition所對應的consumer訂閱的情況)
consumer可訂閱的partition集合L,consumer當前訂閱的partition集合M,
遍歷集合L,判斷L1是否在集合M中,若不在查看p當前的消費者c2,
判斷c1.count<c2.count(這裏的count指訂閱的所有topic),說明不均衡
3、檢查partition是否需要重新分配
a. 檢查partition是否屬於generation衝突且current consumer的partition數量大於prev consumer的partition.size+1,
說明需要重新分配
b. 檢查partition可選擇的consumer訂閱的partition數量加1小於當前consumer訂閱的partition數量
4、遍歷有序consumer(優先找訂閱少的consumer)尋找一個可訂閱當前partition的consumer並分配給它
5、粘性分配
a. 記錄每次分配或者partition遷移
b.如果partition有遷移記錄,C1--->C2且C2----->C1,那麼改變分配的partition
6、對比兩次分配的平衡效果。消費者訂閱partition數量的差異之和爲平衡分數,平衡分數越接近0越平衡。
平衡判斷
private boolean isBalanced(Map<String, List<TopicPartition>> currentAssignment,
TreeSet<String> sortedCurrentSubscriptions,
Map<String, List<TopicPartition>> allSubscriptions) {
//有序consumer的首尾元素的partition數量差異
int min = currentAssignment.get(sortedCurrentSubscriptions.first()).size();
int max = currentAssignment.get(sortedCurrentSubscriptions.last()).size();
//符合條件說明已經很平衡了
if (min >= max - 1)
return true;
// create a mapping from partitions to the consumer assigned to them
final Map<TopicPartition, String> allPartitions = new HashMap<>();
Set<Entry<String, List<TopicPartition>>> assignments = currentAssignment.entrySet();
for (Map.Entry<String, List<TopicPartition>> entry: assignments) {
List<TopicPartition> topicPartitions = entry.getValue();
for (TopicPartition topicPartition: topicPartitions) {
if (allPartitions.containsKey(topicPartition))
log.error("{} is assigned to more than one consumer.", topicPartition);
allPartitions.put(topicPartition, entry.getKey());
}
}
// for each consumer that does not have all the topic partitions it can get make sure none of the topic partitions it
// could but did not get cannot be moved to it (because that would break the balance)
for (String consumer: sortedCurrentSubscriptions) {
List<TopicPartition> consumerPartitions = currentAssignment.get(consumer);
int consumerPartitionCount = consumerPartitions.size();
// skip if this consumer already has all the topic partitions it can get
// 滿足平衡要求
if (consumerPartitionCount == allSubscriptions.get(consumer).size())
continue;
List<TopicPartition> potentialTopicPartitions = allSubscriptions.get(consumer);
for (TopicPartition topicPartition: potentialTopicPartitions) {
if (!currentAssignment.get(consumer).contains(topicPartition)) {
String otherConsumer = allPartitions.get(topicPartition);
int otherConsumerPartitionCount = currentAssignment.get(otherConsumer).size();
//關聯consumer之間比較partition數量,判斷平衡要求
if (consumerPartitionCount < otherConsumerPartitionCount) {
return false;
}
}
}
}
return true;
}
執行分配
private boolean performReassignments(List<TopicPartition> reassignablePartitions,
Map<String, List<TopicPartition>> currentAssignment,
Map<TopicPartition, ConsumerGenerationPair> prevAssignment,
TreeSet<String> sortedCurrentSubscriptions,
Map<String, List<TopicPartition>> consumer2AllPotentialPartitions,
Map<TopicPartition, List<String>> partition2AllPotentialConsumers,
Map<TopicPartition, String> currentPartitionConsumer) {
boolean reassignmentPerformed = false;
boolean modified;
// 執重複執行分配直到平衡爲止
do {
modified = false;
Iterator<TopicPartition> partitionIterator = reassignablePartitions.iterator();
//判斷是否平衡且可分配Partition不爲空
while (partitionIterator.hasNext() && !isBalanced(currentAssignment, sortedCurrentSubscriptions, consumer2AllPotentialPartitions)) {
TopicPartition partition = partitionIterator.next();
if (partition2AllPotentialConsumers.get(partition).size() <= 1)
log.error("Expected more than one potential consumer for partition '{}'", partition);
String consumer = currentPartitionConsumer.get(partition);
if (consumer == null)
log.error("Expected partition '{}' to be assigned to a consumer", partition);
//Generation衝突的Partition
if (prevAssignment.containsKey(partition) &&
currentAssignment.get(consumer).size() > currentAssignment.get(prevAssignment.get(partition).consumer).size() + 1) {
reassignPartition(partition, currentAssignment, sortedCurrentSubscriptions, currentPartitionConsumer, prevAssignment.get(partition).consumer);
reassignmentPerformed = true;
modified = true;
continue;
}
//訂閱同一個Topic的consumer之間partition數量差異比較,判斷partition是否需要分配
for (String otherConsumer: partition2AllPotentialConsumers.get(partition)) {
if (currentAssignment.get(consumer).size() > currentAssignment.get(otherConsumer).size() + 1) {
reassignPartition(partition, currentAssignment, sortedCurrentSubscriptions, currentPartitionConsumer, consumer2AllPotentialPartitions);
reassignmentPerformed = true;
modified = true;
break;
}
}
}
} while (modified);
return reassignmentPerformed;
}