Kafka原始碼篇 --- 可能是你看過最詳細的RecordAccumulator解讀

說出你的願望吧發表於2020-01-07

前言

我們上一篇的時候說了這篇會把Kafka的網路模型給梳理一下,這個和 NIO 的那篇關係就非常非常大了,所以如果對這塊不瞭解的朋友可以跳轉過去瞧瞧,起碼對你理解起來會有一定的幫助

那我們就接著上一篇的流程繼續,標題中的RecordAccumulator很快就講到

回顧上一講 Kafka 拉取後設資料的流程

上一講我們雖然碼了大概有7600多字,可是其實根本就沒跳出第一步,所以這東西真的工程量挺大的

現在讓我們用一個流程圖去把那7000多字給濃縮一下吧

首先我們的 KafkaProducer 作為主執行緒,它要去傳送訊息給我們的 Kafka 叢集,可是傳送給叢集需要什麼?需要後設資料呀!不知道後設資料,那我就不知道leader partition是哪個了

此時主執行緒就會從 Metadata 那裡去找看有沒有後設資料的快取,也就是是否曾經拉取過了,但是我們假設現在我們是第一次啟動,所以明顯是沒有的,那沒有後設資料的情況

此時我們要拉取一個version的值,並把一個 needUpdate 引數修改為true,然後去喚醒 Sender 執行緒去拉取後設資料,而這需要通過一個網路元件 NetworkClient 和Broker通訊。

在此同時,主執行緒會進行阻塞,等待後設資料的到來。而當後設資料拉取完成後,會通過 notifyAll 喚醒主執行緒,返回阻塞等待的時間。

好的,圖中已經標好了邏輯順序,其實大致的步驟就是這樣,至於更多的細節,那就跳轉到上一篇:Kafka原始碼篇 --- 小白也能看懂的Producer的初始化及後設資料獲取流程,在本地的idea匯入原始碼去跟著走一遍吧?

一、ProducerInterceptors

那我們上一篇其實就是講了 KafkaProducer 這一個東西,此時有小夥伴就要問了,這圖不是中間還畫著個 ProducerInterceptors 嗎,其實它是存在於 KafkaProducer 的初始化流程中的,這個攔截器的作用其實就是過濾掉一些不必要的請求,


但是這個 “不想傳送某些訊息” 的功能我在這個傳送訊息的時候進行判斷也就可以了,所以就顯得比較雞肋,就直接跳過了。

二、Serializer

那序列化的那部分

這個序列化的功能在example包裡面的Producer.java進行了設定

這兩個一個Integer型別,一個String型別的,也是Kafka自己定製好的,且正常情況下,根本就不需要自己去自定義一些序列化格式。

三、Partitioner

回到 KafkaProducer 中獲取到後設資料後的下一句

點進去 partition() 方法

3.1 分割槽方法 partition() 流程

我們可以看到這裡對 “ 是否已經存在分割槽號 record ” 進行了判斷,正常來說訊息過來這裡的時候都是自身並不自帶分割槽號的,那要這個判斷做啥子用?

這也是因為重試機制的問題,如果一條訊息之前已經走過了這裡,那就分配上分割槽號了,可是由於種種原因這條訊息沒傳送成功,但因為Kafka的重試機制重新傳送了,那這時我們就不再給它分配了,直接用上一次的分割槽號即可。

根據我們的場景驅動,現在是第一次進來,所以肯定是要走下方的分支,也就是訊息肯定都還是沒有分割槽號的。所以第一句

Integer partition = record.partition();
複製程式碼

這個結果肯定是null,然後執行

partitioner.partition(record.topic(), record.key(), 
    serializedKeyrecord.value(), serializedValuecluster);
複製程式碼

這個 partitioner 引數就是 Kafka 自帶的分割槽器,點進去可以看到

這個 Partitioner 介面有一個 partition 方法和一個 close 方法,如果真的想自定義分割槽器,也可以直接模仿 DefaultPartitioner ,也就是預設的分割槽器中的程式碼來自行實現

3.2 DefaultPartitioner的分割槽邏輯

DefaultPartitioner的預設處理方式在這裡展開一下

3.2.1 定義一個隨機數

這個實現類的一開始就定義好了一個隨機數,是用於處理不指定key的訊息的,怎麼用後面會提到

3.2.2 主要的 partition 方法

這個方法一開始先把分割槽數獲取過來。然後就是兩個分支,這兩個分支為 “訊息是否指定key” 的不同方案

現在來結合實際的數字說明一下那一通操作到底是怎麼操作的,假設我們剛剛取得的 counter 就是10(此時 counter 隨機到負數也沒關係,toPositive()方法會將負數取絕對值),可用分割槽數也是10,那 nextValue 就是11,那11 % 10 = 1,那我們的第一條訊息就是丟到第一個分割槽下的。然後下一次,nextValue 變為12,對 10 取餘結果為 2 ,那就丟到第二個分割槽下···依次類推,就是 通過一個簡單的輪詢來達到負載均衡的效果 ,因為訊息基本都會按順序地去填到分割槽中。

而如果指定了 key ,就直接對 key 取得一個 hash 值,然後用 hash值 % 可用分割槽總數 取模,這種方法如果 key 相等,那計算出來的一定會是同一個分割槽,所以如果當我們想要把某類訊息都傳送至同一個分割槽時,就可以指定 key 來實現

四、驗證訊息的大小

在分割槽完成後,我們要進行訊息大小的驗證

這裡一條訊息的大小是 Records.LOG_OVERHEAD + 訊息本身的大小,然後進行一個ensure的驗證方法

4.1 Records.LOG_OVERHEAD

訊息字首固定為12個位元組大小,這個可以點進去 Records.java 檢視

這裡可以看到 Records.LOG_OVERHEAD = 4 + 8 = 12 位元組

4.2 ensureValidRecordSize

這裡需要注意,maxRequestSize 是我們在 KafkaProducer 初始化時設定好的引數,如果超過的話會有一個自定義的異常 RecordTooLargeException 。而 totalMemorySize 是指緩衝區的大小,我們知道,訊息的傳送是先送往緩衝區打包好再傳送出去的,一條訊息就超過了32M那就撐爆整個緩衝區了。所以這些訊息,它們不能大於整一個緩衝區的大小。

此步驟過去後會封裝分割槽物件,這個並不重要所以就不展開了。

五、給訊息繫結回撥函式

因為我們現在是使用非同步的方式來傳送訊息,通過回撥函式得知訊息的傳送是否成功

六、重頭戲:RecordAccumulator

最最最重要的 RecordAccumulator ,也就是這塊東西,我們來詳細再詳細地說!

// 訊息放入緩衝區並打包傳送
RecordAccumulator.RecordAppendResult result = 
    accumulator.append(tp, timestamp, serializedKey, 
        serializedValue, interceptCallback, remainingWaitMs);
複製程式碼

這個 RecordAccumulator 裡面是一個怎樣的結構呢?它裡面存在一個個 Batches

畢竟是重頭戲,圖也不省了,直接截了吧

此時我們點進去 append 方法

程式碼非常長,我們一一解讀

6.1 append方法解讀

6.1.1 步驟一:獲取或建立佇列

首先是第一句:Deque dq = getOrCreateDeque(tp);

我們會先根據分割槽找到應該把訊息插入到哪個佇列裡面,如果這個分割槽的對應佇列已經存在,那我們就使用那個已存在的佇列,但是我們現在在模擬的是第一次進來,所以相應的佇列是一定還沒有被建立出來的,所以為什麼這個方法會命名為 getOrCreateDeque ,就是代表,存在即獲取,不在即建立,所以就是get或者create。

6.1.2 步驟二:嘗試往佇列中新增資料

RecordAppendResult appendResult = 
    tryAppend(timestamp, key, value, callback, dq);
複製程式碼

但是這個操作明顯會失敗,因為此時我們第一次進來,雖然佇列是已經建立了,可是佇列裡面是沒有 batch(批次) 的,Kafka中的訊息是會打包成一個個批次傳送的,第一條訊息進來會無法形成一個批次而操作失敗。

6.1.3 步驟三:計算batch大小

我們現在要把訊息封裝在批次裡面,所以現在我們必須要建立批次出來,建立批次那就要先申請記憶體,那申請記憶體就必須知道這個批次的大小,而且此時我們要注意,一條訊息是不能超過你設定的批次的大小限制的,預設16K,而且假如我們的訊息一條就是16K,每個批次都傳送一條訊息出去,那批次存在的意義就沒有了,所以要結合實際情況去調整這些訊息大小和批次大小的限制

補充:批次也不是一定要存滿才傳送的,100毫秒後也會自動傳送,這個在我們之前的文章也提到過這個引數了

Records.LOG_OVERHEAD + Record.recordSize(key, value) 這個東西之前就解釋過了,就是訊息字首加上訊息本體才是一條訊息的大小

6.1.4 步驟四:根據batch的大小分配記憶體

6.1.5 步驟五:根據記憶體大小封裝batch並放入佇列

6.1.6 假設第二次進來append()方法的情況

因為append方法是死迴圈,第二次進來的時候,佇列就已經是直接存在的了,所以此時就不用再create,而是get,批次也已經存在了,第二條訊息就會直接追加到上一次初始化好的批次裡面。

6.1.7 補充6.1.1中getOrCreateDeque的具體實現

可以直接點進去getOrCreateDeque()方法


這裡的batches我們點進去看看

你可以清晰地看到,一個TopicPartition對應一個Deque,也就是一個分割槽對應一個佇列,這個佇列裡面的元素是 RecordBatch,直譯過來就是“訊息批次”。

所以,整一個流程就是如下圖註釋的一樣,按照場景驅動,我們此時就獲取到一個空的佇列了

6.1.8 補充6.1.2中tryAppend的具體實現

注意,我們的append()方法中一共嘗試寫入batch嘗試了3次,其中一次是佇列未建立,一次是batch未建立,還有最後的一次才是成功的

我們現在先來看看,第一次我們執行程式,前兩次寫入失敗的情況。也是直接點進去tryAppend即可,看到如下程式碼

我給大家註釋一下,就很好懂了

如果此時是第二次程式進來,在判斷 if (last != null) 時會直接執行下面的插入,然後在append方法中的第一次嘗試插入會成功。直接return

但是如果此時是第一次執行,那我們必須等到建立好佇列與批次之後的tryAppend才會成功,現在我們點進去這個tryAppend(),也就是這個


此時我們會跳轉到 RecordBatch.java的tryAppend()方法,點進去append

跳轉到 MemoryRecords.java,這個邏輯就不再說明了,知道有這麼回事即可。

6.1.9 為什麼會有一個釋放記憶體的操作

觀察仔細的小夥伴們應該看到了,它中間先是申請了一塊記憶體,然後在嘗試寫入過後立刻又把這個記憶體給釋放掉了

生產者是一個高併發的場景。假設我們現線上程1先進來,它經過以上圖中的所有步驟,申請到了一個佇列並建立了批次,假設執行緒2也是和執行緒1傳送到同一個分割槽的,那麼執行緒2在getOrCreateDeque中獲取到了相應deque後,也會經過計算訊息大小並申請記憶體的步驟,可是此時執行緒1早就已經申請好了響應的記憶體,而執行緒2申請的記憶體就沒用了。所以需要把執行緒2申請的記憶體釋放掉

而且append()方法整一段程式碼採用了分段加鎖的做法,把沒有必要加鎖的地方都沒有加上鎖,append()方法也沒有直接使用synchronized關鍵字修飾,這都是為了效能的考慮。比如申請記憶體,就沒有加鎖。這個做法在 HDFS 的原始碼中也有體現,在 Hadoop原始碼篇 --- 面試常問的Namenode後設資料管理及雙緩衝機制 中的雙緩衝機制也有體現。所以Kafka的原始碼真的非常不錯,它各方面的考慮都是非常周到且合理。

6.1.10 getOrCreateDeque是否執行緒安全?


此時有小夥伴可能要說了,這個 getOrCreateDeque 也沒有加鎖呀,它會不會存線上程安全的問題?

答案是不會的,我們可以點進去看看batches的結構

看到這個 CopyOnWriteMap 沒有,在JUC下面是不是存在著一個CopyOnWriteArrayList這樣的資料結構?它們這名字起得十分類似,我們現在知道CopyOnWriteMap是Kafka專門定義好的一個資料結構,我們點進去看看

--- CopyOnWriteMap


首先這裡定義了一個map


這個map的put方法是執行緒安全的,會將資料插入到一個新的記憶體空間裡面,然後在之後把記憶體空間合併再賦值到map


讀是直接讀map裡面的資料,而寫的時候是寫到新的一個記憶體空間hashMap。而且此時因為map是volatile關鍵字修飾的,所以map的值的改變對於它來說是可見的

這種資料結構的設計非常適合於讀多寫少的應用場景,它為了保證資料安全採用了讀寫分離的做法,每次新增資料都要開闢新的記憶體空間,也就是說寫資料對於它來說是一件又耗時間又耗記憶體的事兒,好處就在於讀資料的時候不需要加鎖,保證了讀的高效能。

而回到我們的batches,它是不是讀多寫少的呢?

batches是存deque所使用的一個儲存單元,而deque的數量和分割槽數是有關的,所以它寫的場景僅存在於,此時不存在這個分割槽對應的deque,需要建立這個分割槽對應的deque。而讀的場景那就再多不過了,每次訊息寫過來,我都需要跑一次 getOrCreateDeque,此時deque已經存在那就是get,也就是讀操作。那我假設有1W條訊息,那就要讀1W次,所以batches明顯就是讀多寫少的

6.1.11 allocate()是如何去申請記憶體的

/ 申請了一塊記憶體
ByteBuffer buffer = free.allocate(size, maxTimeToBlock);
複製程式碼

為了建立批次而申請的記憶體,而當這些批次傳送到服務端以後,是否就要進行釋放記憶體的工作,就是垃圾回收,可是垃圾回收是又導致fullgc的可能的,這會讓程式碼的效能下跌。所以Kafka考慮到這些問題,還專門設計了一個記憶體池。

Java裡面有一個東西叫做連線池,我們的應用程式連線到mysql資料庫,讀取完資料之後就需要釋放連線,但是建立連線和釋放連線對於mysql的效能來說耗費會較大,所以就會有一個連線池,在它裡面存好連線,之後應用程式就通過從連線池裡獲取連線去訪問mysql,使用完成又丟回連線池供下一次使用。這樣就省去了建立和釋放的流程

Kafka的記憶體池裡面就會存放著許多的記憶體塊,當我們要申請記憶體去建立批次的時候,把記憶體從記憶體池申請出來,等待訊息傳送成功後,把記憶體中的資料清空,不釋放這個記憶體而是丟回到記憶體池中,供下一次使用,這樣就會避免了 fullgc 的問題

補充:簡單看看allocate和deallocate的原始碼

1. allocate

直接點進去allocate()方法,會跳轉到 BufferPool.java,程式碼非常長

① 第一次嘗試從記憶體池中獲取記憶體

這裡我們直接看try裡面的程式碼,首先它會先進行一個判斷


我們的場景驅動也是假設我們現在是第一次進來,而記憶體池不像連線池一樣會事先初始化好連線,所以這個return是獲取不到記憶體空間的


這裡的free就是指記憶體空間

② 計算記憶體池的大小

this.availableMemory(可用記憶體) + freeListSize(記憶體池)如果大於我們要申請的記憶體,那我們就判斷就有足夠的空間讓你申請

此時進行記憶體的削減,併成功申請記憶體進行返回

如果不夠,那就採用有多少給你多少的方式,一點一點地分配給你,這一塊的程式碼不難看懂,有興趣可以自己去讀一下

2. deallocate

這裡提醒一句,為了提升複用率,我們一般都會讓申請的記憶體空間就等於我們batch的預設大小。而且我們會讓和batch預設大小的直接回到記憶體池,而不相等的直接等待垃圾回收。

finally

剛剛我們不僅解釋了RecordAccumulator的流程,並把一些設計細節進行了大致展開,這些都是值得我們去學習的地方

經過這一系列的說明,相信你已經差不多get到了這整一個邏輯了。

接下來還有喚醒Sender執行緒傳送資料的流程,我們們下一篇再闡述,如果覺得本文對你有幫助,歡迎關注我的公眾號,一起努力探索技術的本質,謝謝

相關文章