這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
概述
訊息系統通常都會由生產者,消費者,Broker三大部分組成,生產者會將訊息寫入到Broker,消費者會從Broker中讀取出訊息,不同的MQ實現的Broker實現會有所不同,不過Broker的本質都是要負責將訊息落地到服務端的儲存系統中。不管是生產者還是消費者對於Broker而言都是用戶端,只不過一個是生產訊息一個是消費訊息。圖2-1中生產者和消費者都是通過用戶端請求的方式發送給服務端去執行儲存訊息或者擷取訊息的流程,在用戶端和服務端這一層都有一個連線物件專門負責發送請求和接收請求,具體步驟如下:
生產者用戶端應用程式產生訊息
用戶端連線物件將訊息封裝到請求中發送到服務端
服務端的入口也有一個連線物件負責接收請求,並將訊息以檔案的形式儲存起來
服務端返迴響應結果給生產者用戶端
消費者用戶端應用程式消費訊息
用戶端連線物件將消費資訊也封裝到請求中發送給服務端
服務端從檔案儲存體系統中取出訊息
服務端返迴響應結果給消費者用戶端
用戶端將響應結果還原成訊息並開始處理訊息
圖2-1 用戶端和服務端互動
Kafka作為一個分布式的訊息儲存系統,生產者用戶端需要將訊息傳給Kafka叢集完成訊息儲存,本章從Kafka的消費者實現為入口,在源碼分析的過程中,思考以下幾個問題是如何?的:
生產者是如何確保將訊息以分布式的方式儲存到Kafka叢集?
生產者用戶端是如何組織訊息,發送訊息,並接收服務端的響應?
用戶端和服務端的通訊機制,如何有效運用執行緒模式更高效地通訊
本章的著重點主要在於用戶端和服務端的網路通訊流程,暫時還沒有涉及到Kafka的服務端具體實現。因為對於任何的分布式系統而言,必須有一套負責不同節點之間資料轉送的網路層通訊機制,這套底層的架構要能夠處理協議的編解碼,用戶端和服務端的請求發送和接收等等。在Java中的網路編程中最早是Socket模式,後來進化出了Selector選取器模式,再結合上隊列模型,緩衝區機制,就可以設計出一套適合自己系統的網路層通訊協定架構。雖然通訊模型和服務端的架構實現上沒有太大的關聯,不過可以在這最底層的架構上添加一些額外的功能比如逾時重試,序列化等功能,那麼服務端就可以更專註地處理主體商務邏輯,而不需要花太多的精力去關注網路層的各種異常情況。
在分布式的系統中,協議是由服務端定製的,用戶端只要遵循這種協議發送請求,服務端就可以確保可以正常地接收並處理用戶端的請求。所以實際上用戶端的實現可以由不同的語言自己去實現,官方的wiki中列出了目前已經支援的絕大多數語言。因為對於不同語言都有自己的網路層編程API,比如Golang使用channel通訊,Akka使用Actor方式傳遞訊息,它們就可以充分利用自己的語言特性去實現不同的用戶端。
Kafka初期使用Scala編寫,因此早期scala版本的producer、consumer和服務端的實現都放在core包下,最新的用戶端使用了Java重新實現,被放在了clients包下。本章我們主要分析如下幾個部分的內容:
新版本的Producer用戶端實現(Java)
舊版本的Producer用戶端實現(Scala)
服務端的網路連接實現(SocketServer)
雙端隊列InFlightRequests
隊列
圖2-32是記錄收集器的batches隊列和NetworkClient的inFlightRequests隊列的對比,記錄收集器雙端隊列中的元素只儲存資料,沒有狀態資訊,所以針對這個隊列的操作只是簡單地追加到隊列最後一個,取出時取的是隊列第一個元素。而inFlightRequests隊列中的元素是用戶端請求對象,它是有狀態的,比如這個請求是否已經發送完成就是一種狀態。請求發送完成並不代表就可以從隊列中移除,不過如果用戶端不需要響應結果發送完成則是可以刪除的。
圖2-32 InFlightRequests雙端隊列
實際上如果用戶端請求添加到隊列尾部也是可以的,2-33隻不過對應的peek和poll的順序都要做出改變:
圖2-33 添加最新元素到雙端隊列的兩種方式
圖2-34中以新請求添加到隊列頭部為例,類比了多個請求是如何排入佇列以及完成時如何從隊列中移除,其中[r1,r2,r3]需要響應結果,而r4不需要響應結果,假設[r1-r4]四個請求都屬於某個節點,所以用戶端會按照順序依次加入到隊列中。不過後一個請求必須要保證前一個請求發送到服務端節點後才可以進入隊列等待發送,當收到響應請求完成時,r4是從隊列頭部被刪除,而其他請求則是從隊列尾部刪除。
圖2-34 雙端隊列操作
用戶端請求的生命週期
用戶端在和服務端某個節點建立串連時,會根據用戶端中目前的請求隊列判斷第一個請求是否已經完成來判斷這個節點是否可以發送更多的請求canSendMore。那麼用戶端請求什麼時候才算是completed?注意雖然隊列中儲存的是ClientRequest,不過在add和peek時都是取出ClientRequest裡的RequestSend對象。RequestSend到Send的繼承體系是RequestSend->NetworkSend->ByteBufferSend->Send。對於ByteBufferSend而言完成的條件是沒有要發送的資料了,即緩衝區中的資料都寫完了。所以這裡請求完成指的是當前發送請求已經成功發送到服務端了,但並不需要等待這個請求收到響應結果。
即使在同一個目標節點的同一個隊列中,多個不同ClientRequest請求也是有順序的,在前面的分析中已經有兩個地方限制了用戶端請求並不是可以隨便添加到隊列中的:
在準備串連時,queue.peekFirst().request().completed()=true
可以串連發送請求後,KafkaChannelsetSend也要確保send!=null,一個KafkaChannel只允許同時運行一個Send
其中第二個條件也將直接影響第一個條件,如果第一個請求沒有發送完畢,它還會存在於KafkaChannel中,此時來了第二個請求,如果不加以限制即使send!=null,也要將第二個請求設定到KafkaChannel中,這樣第一個請求返回的時候卻返回了第二個請求,因為Send已經被第二個請求更新了,所以這是有問題的。
不過ClientRequest.RequestSend完成,並不表示這個ClientRequest在NetworkClient中就完成了,用戶端的請求被發送到服務端,還需要等待收到服務端的響應結果。所以inFlightRequests表示進行中中還沒有完成的請求,下面幾種情境都表示還沒有完成的ClientRequest:ClientRequest請求等待發送,ClientRequest請求正在發送,ClientRequest請求已經發送(這時RequestSend完成),ClientRequest對應的請求還未收到響應結果,圖2-35是ClientRequest在InFlightRequests中的生命週期。
圖2-35 用戶端請求在隊列中的生命週期
用戶端請求發送和接收樣本
我們從發送線程開始,舉例多個請求的發送和接收,以及在隊列中的操作。發送線程第一次運行在準備工作時選擇readyNodes,然後為已經準備好的節點建立串連和用戶端請求ClientRequest,調用NetworkClient.send會將請求先加入請求對應的目標節點的隊列中,然後設定到KafkaChannel中,每個KafkaChannel只有一個進行中中的Send,如果已經存在Send(比如進行中中的用戶端請求沒有被發送完成就不會被重設為空白)則不允許再次調用。當選取器輪詢時會將選擇到的KafkaChannel中的Send通過底層的SocketChannel發送給服務端。圖2-36類比了第一個請求排入佇列後的工作過程。
圖2-36 NetworkClient.send包括入隊列和調用Selector.send
假設第一個請求還沒有發送完成比如還在步驟2/3時,發送線程第二次運行準備發送第二批資料(假設這兩個請求都是要發送到同一個目標節點),由於隊列中的第一個請求還沒有完成,canSendMore返回false,在準備工作時就會將其從readyNodes中移除,這樣就不會為這個節點建立新的ClientRequest,即第二個請求根本就不會被產生。即使沒有canSendMore這一層判斷,假設建立了第二個請求,當準備調用NetworkClient.send時,可是又遇到了第二個攔路虎,因為KafkaChannel.setSend要求send不可為空時才可以設定,而現在send已經被第一個用戶端請求佔著不放,還沒有重設,所以用戶端請求還是無法被成功地設定。這樣就存在一個問題,請求已經被添加到隊列中,但是卻沒辦法設定到KafkaChannel中,只能等下次再調用一次NetworkClient.send,不過這樣請求隊列針對同一個請求就被加入多次了,所以能夠儘早在第一道門框攔下第二個請求就不要在放進來了。所以新的請求被建立的時機必須等到隊列頭部第一個請求已經完成才會建立,而且此時第一個請求在完成的時候就設定了send=null,新建立的請求也可以被成功地設定到KafkaChannel中,所以說如果第一個條件滿足(canSendMore=true)後通常第二個條件也是滿足(send=null),圖2-37是請求[R2,R3]分別在每次允許排入佇列時加入到隊列頭部,圖2-38是不需要響應結果的請求R3從隊列頭部刪除請求,圖2-39是需要響應結果的請求[R1,R2,R4]分別收到響應結果後從隊列尾部刪除請求。
圖2-37 往隊列中添加一個新的請求必須確保上一個請求已經完成
圖2-38 不需要響應結果的請求發送完成從隊列頭部刪除
圖2-39 需要響應結果的請求收到響應後從隊列尾部刪除
排隊的樣本
這裡的雙端隊列和現實世界的排隊方式是類似的,2-40以去銀行辦理業務為例,排隊機給每個人一個號碼錶示ClientRequest請求的順序,只有上一個號碼的人辦理完了業務,下一個人才能辦理。為了和這裡的NetworkClient語義相同,我們稍微修改下排隊規則,假設辦理業務分成三個步驟:告訴業務員要辦理什麼業務,業務員處理業務,業務員完成業務,這些步驟都是可以並存執行的,而且執行完一個小步驟都要回到自己的座位上繼續等待,假設只有一個業務辦理視窗時(不過其實你不用擔心,假設這個業務員只是一個入口而已,他的後台即服務端是開著很多線程在處理的)。第一個人開始辦理業務時首先加入到inFlightRequests,並告訴業務員要取錢,業務員收到指令後,記錄了這個資訊(可以把業務員看做專門負責接收業務指令,但是不辦理具體的業務),第一個人回到自己座位,他還不能離開大廳,因為他只是傳達了這個指定,但是錢還沒取到;因為此時隊列中的第一人已經完成了發送指令請求,第二個人可以辦理了,同樣先加入到inFlightRequests隊列中,然後第二個人說要改密碼,業務員收到指令後同樣不真正執行改密碼的命令,但是如果這個時候第三個人等不住了,還沒等第二個人傳達完指令就想強行插隊,對不起,請稍等下!所以inFlightRequest表示已經發送完請求,或者正在發送請求的,但是他們都還不能離開大廳,因為還沒有收到響應結果。因為每個請求發送給業務員都是有順序的,所以加入到inFlightRequest中的ClientRequest也都是有順序的,這個隊列是個雙端隊列,隊列頭部是最近加入的請求,隊列尾部是最早加入的請求,如果隊列第一個元素的請求還沒有發送完成,不允許下一個請求排入佇列中,所以新排入佇列的元素,在這之前的請求一定都是已經發送完成了,否則他就不可能被排入佇列中了。
圖2-40 銀行辦理業務與隊列
圖2-40中雖然符合新請求添加到隊列頭部(我們把尾部設定為面對業務員),按照排隊的方式理解起來也比較直觀,第一個請求先於第二個請求被處理,不過似乎業務員總是面對著第一個請求。為了更好地理解這個雙端隊列圖2-41中分成兩個隊列,排隊隊列負責接收請求,處理隊列負責處理收到的請求,請求按照發送順序加入排隊隊列,一旦請求發送完畢,業務員就會把收到的請求放入另一個隊列中,這樣兩種隊列其實都滿足了排隊論。不過雙端隊列本來就可以在頭尾同時操作,所以實際上只需要一個隊列即可。
圖2-41 排隊隊列和處理隊列
現在如果從一個業務視窗推到多個視窗,2-42就類似於用戶端可以向多個服務端節點同時發送請求,每個服務端目標節點都有一個雙端隊列,每個隊列的處理方式和上面一個視窗都是類似的,只不過現在每個請求都攜帶了自己將會被排隊到指定視窗。
圖2-42 多個視窗的隊列
假設第一個人的業務被成功受理,並且也成功取到錢了,他就可以拿著錢開開心心地離開銀行大廳了,現在他的業務已經全部辦理好了,就會從inFlightRequests中移除了,因為inFlightRequests中儲存的是發送完或正在發送請求,但是沒有收到響應結果,一旦收到響應結果就不應該繼續在大廳裡呆下去了,畢竟inFlightRequests的容量也是有限制的,如果銀行大廳座位都做滿了,說明請求量太大了,所以取完錢就趕緊回家。
對於需要響應的請求,請求在服務端慢吞吞地處理,返回也是有順序的,也是說服務端是按照用戶端請求的順序處理的,只有第一個請求返回後才會接著返回第二個請求結果,並不會出現第二個請求先於第一個請求返回結果給用戶端。所以對於不需要響應結果的用戶端請求如果在handleCompletedSends中沒有刪除而是等到handleCompletedReceives才刪除顯然是不公平的,因為他本來可以立即返回,但是卻要等到他前面的人都收到結果後才能輪到他。比如超市通常會設定無購物快速通道,如果顧客沒有購買任何東西不需要在購物通道上排隊就可以快速出去。
如果用戶端請求不需要響應就會像上面那樣,在發送完就被清理掉了,這是因為用戶端既然不想要響應結果,那麼就讓請求越快完成越好。通過這種快速清理的方式確保了下一個請求進來之前,儲存在隊列中的一定都是需要響應的:因為上一個請求是不需要響應的,那麼在下一個請求排入佇列頭部之前,上一個請求已經從隊列頭部移除掉了。以超市為例,進入購物通道排隊的人一定知道排隊的人都是有購物的,沒有人那麼傻沒有買任何東西卻還傻傻地在排隊。同樣以銀行辦理業務為例,假設有些人是來諮詢業務的,業務員是立即可以回答的,不需要和背景服務端互動(或者儘管有互動,但是用戶端並不關心這個結果,在你出來結果之後,他可能已經都走了)。這樣的用戶端請求也要排隊,在準備發送請求時排入佇列頭部第一個元素,完成時就可以立即從隊列頭部移除,不需要進入處理隊列了。
現在Java版本的生產者用戶端已經分析完畢,表2-4總結了用戶端發送過程涉及到的主要組件和其用途:
表2-4 Java版本的生產者主要組件
本章總結
本章主要分析了兩種版本的生產者用戶端以及服務端的網路層實現,重點介紹了用戶端的NetworkClient和服務端的SocketServer,Java版本的用戶端和服務端的Processor都使用了Selector選取器模式和KafkaChannel,而Scala版本的用戶端則使用比較原始的BlockingChannel。在用戶端服務端的通訊模型中,通常一個用戶端會串連到多個服務端,一個服務端也會接受多個用戶端的串連,所以使用Selector模式可以使得網路通訊更加高效,在服務端還運用了Reactor模式將IO部分和業務處理部分的線程進行分離。除此之外,用戶端和服務端在很多地方都運用了隊列這種資料結構來對請求或者響應進行排隊,隊列是一種保證資料被有序地處理並且能夠緩衝的結構。表2-8總結了Scala版本的生產者用戶端和服務端中使用隊列的地方,這裡並不包括Java版本的生產者使用更進階的雙端隊列。
在用戶端要向服務端發送訊息時我們會擷取Cluster叢集狀態(Java版本)/叢集中繼資料TopicMetadata(Scala版本),為訊息選擇Partition,選擇Partition的Leader作為目標節點,在服務端SocketServer會接收用戶端發送的請求交給Handler和KafkaApis處理,具體和訊息相關的處理邏輯由KafkaApis以及KafkaServer中的其他組件一起完成。
圖2-57是Kafka服務端的內部元件圖表,網路層包括一個Acceptor線程和多個Processor線程;API層的多個API線程指的是多個KafkaRequestHandler線程,網路層和API層中間有一個RequestChannel,它是請求和響應的資料交換中轉站;API層和日誌子系統有關聯因為API層的請求要讀取或寫入記錄檔,Replication子系統主要的管理類是ReplicaManager,而KafkaApis和它有直接的關聯;一個KafkaBroker和其他Broker以及依賴的ZK也有關聯,這些關聯絡統在後續的章節中都會分析到。
圖2-57 KafkaBroker的內部組件
圖片引自:https://cwiki.apache.org/confluence/display/KAFKA/Index
本章分析的Producer包括後面要分析的Consumer都不是作為Kafka的內建服務,而是一種用戶端(所以它們都在clients包),用戶端可以獨立於Kafka叢集,因此開發用戶端應用程式時只需要提供一個Kafka叢集的地址即可,說明用戶端可以和Kafka叢集獨立開來,圖2-58展示了一種典型的生產者、消費者和Kafka叢集互動方式,其中Kafka叢集還會和ZooKeeper互相通訊。
圖2-58 生產者、消費者、Kafka叢集互動
用戶端有發送和接收請求,服務端同樣也有接收和發送的邏輯,因為對於I/O來說是雙向的:用戶端發送請求,就意味著服務端要接收請求,同樣服務端對請求作出響應並發送響應結果給用戶端,用戶端就要接收響應。接下來我們會分析用戶端發送的請求在服務端是怎麼被KafkaApis處理的。
來源:zqhxuyuan.github.io
原文:http://zqhxuyuan.github.io/2016/05/26/2016-05-13-Kafka-Book-Sample/#%E7%AC%AC%E4%BA%8C%E7%AB%A0_%E7%94%9F%E4%BA%A7%E8%80%85