Kafka 架構:
以下是一般 Kafka 的架構
多節點多Broker集群
術語
- Broker
Kafka集群包含一個或多個服務器,這種服務器被稱為broker,可以水平擴展,一般broker數量越多,集群吞吐率越高,而且kafka 每個節點可以有多個 broker - Producer
負責發布消息到Kafka broker,可以是web前端產生的page view,或者是服務器日志,系統CPU、memory等 - Consumer
消費消息。每個consumer屬于一個特定的consumer group(可為每個consumer指定group name,若不指定group name則屬于默認的group)。使用consumer high level API時,同一topic的一條消息只能被同一個consumer group內的一個consumer消費,但多個consumer group可同時消費這一消息。 - Zookeeper
通過Zookeeper管理集群配置,選舉leader,以及在consumer group發生變化時進行rebalance - Topic
每條發布到Kafka集群的消息都有一個類別,這個類別被稱為topic。(物理上不同topic的消息分開存儲,邏輯上一個topic的消息雖然保存于一個或多個broker上但用戶只需指定消息的topic即可生產或消費數據而不必關心數據存于何處) - Partition
parition是物理上的概念,每個topic包含一個或多個partition,創建topic時可指定parition數量。每個partition對應于一個文件夾,該文件夾下存儲該partition的數據和索引文件 - Segment
partition物理上由多個segment組成,每一個segment 數據文件都有一個索引文件對應 - Offset
每個partition都由一系列有序的、不可變的消息組成,這些消息被連續的追加到partition中。partition中的每個消息都有一個連續的序列號叫做offset,用于partition唯一標識一條消息.
Push vs. Pull
push模式很難適應消費速率不同的消費者,因為消息發送速率是由broker決定的。push模式的目標是盡可能以最快速度傳遞消息,但是這樣很容易造成consumer來不及處理消息,典型的表現就是拒絕服務以及網絡擁塞。而pull模式則可以根據consumer的消費能力以適當的速率消費消息。
所以我們一般在 Kafka 前面再加一個 Log Server,可以用 LevelDB 緩存,作為一個緩沖,提高峰值處理能力
Topic & Partition
每條消費都必須指定它的topic,為了使得Kafka的吞吐率可以水平擴展,物理上把topic分成一個或多個partition,每個partition在物理上對應一個文件夾,該文件夾下存儲這個partition的所有消息和索引文件。
topic中partition存儲分布
假設實驗環境中Kafka集群只有一個broker,xxx/message-folder為數據文件存儲根目錄,在Kafka broker中server.properties文件配置(參數log.dirs=xxx/message-folder),例如創建2個topic名稱分別為report_push、launch_info, partitions數量都為partitions=4
存儲路徑和目錄規則為:
xxx/message-folder
|--report_push-0
|--report_push-1
|--report_push-2
|--report_push-3
|--launch_info-0
|--launch_info-1
|--launch_info-2
|--launch_info-3
在Kafka文件存儲中,同一個topic下有多個不同partition,每個partition為一個目錄,partiton命名規則為topic名稱+有序序號,第一個partiton序號從0開始,序號最大值為partitions數量減1。如果是多broker分布情況,請參考kafka集群partition分布原理分析
partiton中文件存儲方式
下面示意圖形象說明了partition中文件存儲方式:
每個partion(目錄)相當于一個巨型文件被平均分配到多個大小相等segment(段)數據文件中。但每個段segment file消息數量不一定相等,這種特性方便old segment file快速被刪除。
每個partiton只需要支持順序讀寫就行了,segment文件生命周期由服務端配置參數決定。
這樣做的好處就是能快速刪除無用文件,有效提高磁盤利用率。
segment文件存儲結構
segment file由2大部分組成,分別為index file和data file,此兩個文件一一對應,成對出現,后綴".index"和“.log”分別表示為segment索引文件、數據文件.
segment文件命名規則:
partion全局的第一個segment從0開始,后續每個segment文件名為上一個segment文件最后一條消息的offset值。數值最大為64位long大小,19位數字字符長度,沒有數字用0填充。
下面文件列表是筆者在Kafka broker上做的一個實驗,創建一個topicXXX包含1 partition(方便觀察大小變化),設置每個segment大小為500MB,并啟動producer向Kafka broker寫入大量數據,如下圖所示segment文件列表形象說明了上述2個規則:
以上述圖中一對segment file文件為例,說明segment中
index<—->data file 對應關系物理結構如下:
上述圖中索引文件存儲大量元數據,數據文件存儲大量消息,索引文件中元數據指向對應數據文件中message的物理偏移地址。其中以索引文件中元數據3,497為例,依次在數據文件中表示第3個message(在全局partiton表示第368772個message)、以及該消息的物理偏移地址為497。
從上述圖了解到segment data file由許多message組成,下面詳細說明message物理結構如下:
在partition中如何通過offset查找message
例如讀取offset=368776的message,需要通過下面2個步驟查找:
- 查找segment file
上述圖2為例,其中00000000000000000000.index表示最開始的文件,起始偏移量(offset)為0.第二個文件00000000000000368769.index的消息量起始偏移量為368770 = 368769 + 1.同樣,第三個文件00000000000000737337.index的起始偏移量為737338=737337 + 1,其他后續文件依次類推,以起始偏移量命名并排序這些文件,只要根據offset 二分查找文件列表,就可以快速定位具體文件到
00000000000000368769.index|log - 通過segment file查找message
通過第一步定位到segment file,當offset=368776時,依次定位到00000000000000368769.index的元數據物理位置(368776-368769=7),實際上找到了 第6條消息 進而得到
00000000000000368769.log的物理偏移地址,然后再通過 .log 文件的物理偏移地址,去 .log 文件順序讀取對應message
從上述圖可知這樣做的優點,segment index file采取稀疏索引存儲方式,它減少索引文件大小,通過 mmap 可以直接內存操作,稀疏索引為數據文件的每個對應message設置一個元數據指針,它比稠密索引節省了更多的存儲空間,但查找起來需要消耗更多的時間
Kafka運行時很少有大量讀磁盤的操作,主要是定期批量寫磁盤操作,因此操作磁盤很高效。這跟Kafka文件存儲中讀寫message的設計是息息相關的。Kafka中讀寫message有如下特點:
寫message
- 消息從java堆轉入page cache(即物理內存)。
- 由異步線程刷盤,消息從page cache刷入磁盤。
讀message - 消息直接從page cache轉入socket發送出去。
- 當從page cache沒有找到相應數據時,此時會產生磁盤IO,從磁盤Load消息到page cache,然后直接從socket發出去
message 被分配到 partition 的過程
每一條消息被發送到broker時,會根據paritition規則(有兩種基本的策略,一是采用Key Hash算法,一是采用Round Robin算法)選擇被存儲到哪一個partition。如果partition規則設置的合理,所有消息可以均勻分布到不同的partition里,這樣就實現了水平擴展。(如果一個topic對應一個文件,那這個文件所在的機器I/O將會成為這個topic的性能瓶頸,而partition解決了這個問題)。在創建topic時可以在$KAFKA_HOME/config/server.properties中指定這個partition的數量(如下所示),當然也可以在topic創建之后去修改parition數量。
# The default number of log partitions per topic. More partitions allow greater
# parallelism for consumption, but this will also result in more files across
# the brokers.
num.partitions=3
在發送一條消息時,可以指定這條消息的key,producer根據這個key和partition機制來判斷將這條消息發送到哪個parition。paritition機制可以通過指定producer的paritition. class這一參數來指定,該class必須實現kafka.producer.Partitioner
接口。本例中如果key可以被解析為整數則將對應的整數與partition總數取余,該消息會被發送到該數對應的partition。(每個parition都會有個序號)
import kafka.producer.Partitioner;
import kafka.utils.VerifiableProperties;
public class JasonPartitioner<T> implements Partitioner {
public JasonPartitioner(VerifiableProperties verifiableProperties) {}
@Override
public int partition(Object key, int numPartitions) {
try {
int partitionNum = Integer.parseInt((String) key);
return Math.abs(Integer.parseInt((String) key) % numPartitions);
} catch (Exception e) {
return Math.abs(key.hashCode() % numPartitions);
}
}
}
如果將上例中的class作為partitioner.class,并通過如下代碼發送20條消息(key分別為0,1,2,3)至topic2(包含4個partition)。
public void sendMessage() throws InterruptedException{
for(int i = 1; i <= 5; i++){
List messageList = new ArrayList<KeyedMessage<String, String>>();
for(int j = 0; j < 4; j++){
messageList.add(new KeyedMessage<String, String>("topic2", j+"", "The " + i + " message for key " + j));
}
producer.send(messageList);
}
producer.close();
}
則key相同的消息會被發送并存儲到同一個partition里,而且key的序號正好和partition序號相同。(partition序號從0開始,本例中的key也正好從0開始)。如下圖所示?! ?a target="_blank" rel="nofollow">
message 刪除策略
Kafka集群會保留所有的消息,無論其被消費與否。當然,因為磁盤限制,不可能永久保留所有數據(實際上也沒必要),因此Kafka提供兩種策略去刪除舊數據。一是基于時間,二是基于partition文件大小。例如可以通過配置$KAFKA_HOME/config/server.properties,讓Kafka刪除一周前的數據,也可通過配置讓Kafka在partition文件超過1GB時刪除舊數據
這里要注意,因為Kafka讀取特定消息的時間復雜度為O(1),即與文件大小無關,所以這里刪除文件與Kafka性能無關,選擇怎樣的刪除策略只與磁盤以及具體的需求有關。另外,Kafka會為每一個consumer group保留一些metadata信息–當前消費的消息的position,也即offset。這個offset由consumer控制。正常情況下consumer會在消費完一條消息后線性增加這個offset。當然,consumer也可將offset設成一個較小的值,重新消費一些消息。因為offet由consumer控制,所以Kafka broker是無狀態的,它不需要標記哪些消息被哪些consumer過,不需要通過broker去保證同一個consumer group只有一個consumer能消費某一條消息,因此也就不需要鎖機制,這也為Kafka的高吞吐率提供了有力保障。
Replication
Replication與leader election配合提供了自動的failover機制。replication對Kafka的吞吐率是有一定影響的,但極大的增強了可用性。默認情況下,Kafka的replication數量為1。
每個partition都有一個唯一的leader,所有的讀寫操作都在leader上完成,follower批量從leader上pull數據。一般情況下partition的數量大于等于broker的數量,并且所有partition的leader均勻分布在broker上。follower上的日志和其leader上的完全一樣。
和大部分分布式系統一樣,Kakfa處理失敗需要明確定義一個broker是否alive。對于Kafka而言,Kafka存活包含兩個條件,一是它必須維護與Zookeeper的session(這個通過Zookeeper的heartbeat機制來實現)。二是follower必須能夠及時將leader的writing復制過來,不能“落后太多”。
leader會track“in sync”的node list。如果一個follower宕機,或者落后太多,leader將把它從”in sync” list中移除。這里所描述的“落后太多”指follower復制的消息落后于leader后的條數超過預定值,該值可在$KAFKA_HOME/config/server.properties中配置
#If a replica falls more than this many messages behind the leader, the leader will remove the follower from ISR and treat it as dead
replica.lag.max.messages=4000
#If a follower hasn't sent any fetch requests for this window of time, the leader will remove the follower from ISR (in-sync replicas) and treat it as dead
replica.lag.time.max.ms=10000
從 producer 的角度, 發的數據是否會丟?
需要說明的是,Kafka只解決”fail/recover”,不處理“Byzantine”(“拜占庭”)問題。
一條消息只有被“in sync” list里的所有follower都從leader復制過去才會被認為已commit。這樣就避免了部分數據被寫進了leader,還沒來得及被任何follower復制就宕機了,而造成數據丟失(consumer無法消費這些數據)。而對于producer而言,它可以選擇是否等待消息commit,這可以通過request.required.acks來設置。這種機制確保了只要“in sync” list有一個或以上的flollower,一條被commit的消息就不會丟失:
- acks = 0,發就發了,不需要 ack,無論成功與否 ;
- acks = 1,當寫 leader replica 成功后就返回,其他的 replica 都是通過fetcher去異步更新的,當然這樣會有數據丟失的風險,如果leader的數據沒有來得及同步,leader掛了,那么會丟失數據;
- acks = –1, 要等待所有的replicas都成功后,才能返回;這種純同步寫的延遲會比較高。
所以,一般的情況下,thoughput 優先,設成1,在極端情況下,是有可能丟失數據的; 如果可以接受較長的寫延遲,可以選擇將 acks 設為 –1。
這里的復制機制即不是同步復制,也不是單純的異步復制。事實上,同步復制要求“活著的”follower都復制完,這條消息才會被認為commit,這種復制方式極大的影響了吞吐率(高吞吐率是Kafka非常重要的一個特性)。而異步復制方式下,follower異步的從leader復制數據,數據只要被leader寫入log就被認為已經commit,這種情況下如果follwer都落后于leader,而leader突然宕機,則會丟失數據。而Kafka的這種使用“in sync” list的方式則很好的均衡了確保數據不丟失以及吞吐率。follower可以批量的從leader復制數據,這樣極大的提高復制性能(批量寫磁盤),極大減少了follower與leader的差距(前文有說到,只要follower落后leader不太遠,則被認為在“in sync” list里)。
Leader election
上文說明了Kafka是如何做replication的,另外一個很重要的問題是當leader宕機了,怎樣在follower中選舉出新的leader。因為follower可能落后許多或者crash了,所以必須確保選擇“最新”的follower作為新的leader。一個基本的原則就是,如果leader不在了,新的leader必須擁有原來的leader commit的所有消息。這就需要作一個折衷,如果leader在標明一條消息被commit前等待更多的follower確認,那在它die之后就有更多的follower可以作為新的leader,但這也會造成吞吐率的下降。
一種非常常用的選舉leader的方式是“majority vote”(“少數服從多數”),但Kafka并未采用這種方式。這種模式下,如果我們有2f+1個replica(包含leader和follower),那在commit之前必須保證有f+1個replica復制完消息,為了保證正確選出新的leader,fail的replica不能超過f個。因為在剩下的任意f+1個replica里,至少有一個replica包含有最新的所有消息。這種方式有個很大的優勢,系統的latency只取決于最快的幾臺server,也就是說,如果replication factor是3,那latency就取決于最快的那個follower而非最慢那個。majority vote也有一些劣勢,為了保證leader election的正常進行,它所能容忍的fail的follower個數比較少。如果要容忍1個follower掛掉,必須要有3個以上的replica,如果要容忍2個follower掛掉,必須要有5個以上的replica。也就是說,在生產環境下為了保證較高的容錯程度,必須要有大量的replica,而大量的replica又會在大數據量下導致性能的急劇下降。這就是這種算法更多用在Zookeeper這種共享集群配置的系統中而很少在需要存儲大量數據的系統中使用的原因。例如HDFS的HA feature是基于majority-vote-based journal,但是它的數據存儲并沒有使用這種expensive的方式。實際上,leader election算法非常多,比如Zookeper的Zab, Raft和Viewstamped Replication。而Kafka所使用的leader election算法更像微軟的PacificA算法?! ?br>
Kafka在Zookeeper中動態維護了一個ISR(in-sync replicas) set,這個set里的所有replica都跟上了leader,只有ISR里的成員才有被選為leader的可能。在這種模式下,對于f+1個replica,一個Kafka topic能在保證不丟失已經ommit的消息的前提下容忍f個replica的失敗。在大多數使用場景中,這種模式是非常有利的。事實上,為了容忍f個replica的失敗,majority vote和ISR在commit前需要等待的replica數量是一樣的,但是ISR需要的總的replica的個數幾乎是majority vote的一半?!?br>
如果當前ISR中有至少一個Replica還幸存,則選擇其中一個作為新Leader,新的ISR則包含當前ISR中所有幸存的Replica(選舉算法的實現類似于微軟的PacificA)。否則選擇該Partition中任意一個幸存的Replica作為新的Leader以及ISR(該場景下可能會有潛在的數據丟失)。如果該Partition的所有Replica都宕機了,則將新的Leader設置為-1。
上文說明了一個parition的replication過程,然爾Kafka集群需要管理成百上千個partition,Kafka通過round-robin的方式來平衡partition從而避免大量partition集中在了少數幾個節點上。同時Kafka也需要平衡leader的分布,盡可能的讓所有partition的leader均勻分布在不同broker上。另一方面,優化leadership election的過程也是很重要的,畢竟這段時間相應的partition處于不可用狀態。一種簡單的實現是暫停宕機的broker上的所有partition,并為之選舉leader。實際上,Kafka選舉一個broker作為controller,這個controller通過watch Zookeeper檢測所有的broker failure,并負責為所有受影響的parition選舉leader,再將相應的leader調整命令發送至受影響的broker。
這樣做的好處是,可以批量的通知leadership的變化,從而使得選舉過程成本更低,尤其對大量的partition而言。如果controller失敗了,幸存的所有broker都會嘗試在Zookeeper中創建/controller->{this broker id},如果創建成功(只可能有一個創建成功),則該broker會成為controller,若創建不成功,則該broker會等待新controller的命令。
Consumer group
Ref: