相信各位小夥伴之前或多或少接觸過訊息佇列,比較知名的包含Rocket MQ和Kafka,在京東內部使用的是自研的訊息中介軟體JMQ,從JMQ2升級到JMQ4的也是帶來了效能上的明顯提升,並且JMQ4的底層也是參考Kafka去做的設計。在這裡我會給大家展示Kafka它的高效能是如何設計的,大家也可以學習相關方法論將其利用在實際專案中,也許下一個頂級專案就在各位的程式碼中產生了。
先拋開kafka,咱們先來談論一下高效能設計的本質,在這裡借用一下網上的一張總結高效能的思維導圖:
從中可以看到,高效能設計的手段還是非常多,從」微觀設計」上的無鎖化、序列化,到」宏觀設計」上的快取、儲存等,可以說是五花八門,令人眼花繚亂。但是在我看來本質就兩點:計算和IO。下面將從這兩點來淺析一下我認為的高效能的」道」。
計算上的優化手段無外乎兩種方式:1.減少計算量 2.加快單位時間的計算量
IO上的優化手段也可以從兩個方面來體現:1.減少IO次數或者IO資料量 2.加快IO速度
理解了高效能設計的手段和本質之後,我們再來看看kafka裡面使用到的效能優化方法。各類訊息中介軟體的本質都是一個生產者-消費者模型,生產者傳送訊息給伺服器端進行暫存,消費者從伺服器端獲取訊息進行消費。也就是說kafka分為三個部分:生產者-伺服器端-消費者,我們可以按照這三個來分別歸納一下其關於效能優化的手段,這些手段也會涵蓋在我們之前梳理的腦圖裡面。
之前在上面說過,高效能的」道」在於計算和IO上,咱們先來看看在IO上kafka是如何做設計的。
IO上的優化
kafka是一個訊息中介軟體,資料的載體就是訊息,如何將訊息高效的進行傳遞和持久化是kafka高效能設計的一個重點。基於此分析kafka肯定是IO密集型應用,producer需要通過網路IO將訊息傳遞給broker,broker需要通過磁碟IO將訊息持久化,consumer需要通過網路IO將訊息從broker上拉取消費。
1.kafka負載均衡設計
Kafka有主題(Topic)概念,他是承載真實資料的邏輯容器,主題之下還分為若干個分割區,Kafka訊息組織方式實際上是三級結構:主題-分割區-訊息。主題下的每條訊息只會在某一個分割區中,而不會在多個分割區中被儲存多份。
Kafka這樣設計,使用分割區的作用就是提供負載均衡的能力,對資料進行分割區的主要目的就是為了實現系統的高伸縮性(Scalability)。不同的分割區能夠放在不同的節點的機器上,而資料的讀寫操作也都是針對分割區這個粒度進行的,每個節點的機器都能獨立地執行各自分割區讀寫請求。我們還可以通過增加節點來提升整體系統的吞吐量。Kafka的分割區設計,還可以實現業務級別的訊息順序的問題。
2.具體分割區策略
1.執行緒模型
之前已經說了kafka是選擇批次傳送訊息來提升整體的IO效能,具體流程是kafka生產者使用批次處理試圖在記憶體中積累資料,主執行緒將多條訊息通過一個ProduceRequest請求批次傳送出去,傳送的訊息暫存在一個佇列(RecordAccumulator)中,再由sender執行緒去獲取一批資料或者不超過某個延遲時間內的資料傳送給broker進行持久化。
優點:
缺點:
1.序列化的優勢
Kafka 訊息中的 Key 和 Value,都支援自定義型別,只需要提供相應的序列化和反序列化器即可。因此,使用者可以根據實際情況選用快速且緊湊的序列化方式(比如 ProtoBuf、Avro)來減少實際的網路傳輸量以及磁碟儲存量,進一步提高吞吐量。
2.內建的序列化器
1.壓縮的目的
壓縮秉承了用時間換空間的經典trade-off思想,即用CPU的時間去換取磁碟空間或網路I/O傳輸量,Kafka的壓縮演演算法也是出於這種目的。並且通常是:資料量越大,壓縮效果才會越好。
因為有了批次傳送這個前期,從而使得 Kafka 的訊息壓縮機制能真正發揮出它的威力(壓縮的本質取決於多訊息的重複性)。對比壓縮單條訊息,同時對多條訊息進行壓縮,能大幅減少資料量,從而更大程度提高網路傳輸率。
2.壓縮的方法
想了解kafka訊息壓縮的設計,就需要先了解kafka訊息的格式:
每條訊息都含有自己的後設資料資訊,kafka會將一批訊息相同的後設資料資訊給提升到外層的訊息集合裡面,然後再對整個訊息集合來進行壓縮。批次訊息在持久化到 Broker 中的磁碟時,仍然保持的是壓縮狀態,最終是在 Consumer 端做了解壓縮操作。
壓縮演演算法效率對比
Kafka 共支援四種主要的壓縮型別:Gzip、Snappy、Lz4 和 Zstd,具體效率對比如下:
kafka相比其他訊息中介軟體最出彩的地方在於他的高吞吐量,那麼對於伺服器端來說每秒的請求壓力將會巨大,需要有一個優秀的網路通訊機制來處理海量的請求。如果 IO 有所研究的同學,應該清楚:Reactor 模式正是採用了很經典的 IO 多路複用技術,它可以複用一個執行緒去處理大量的 Socket 連線,從而保證高效能。Netty 和 Redis 為什麼能做到十萬甚至百萬並行?它們其實都採用了 Reactor 網路通訊模型。
1.kafka網路通訊層架構
從圖中可以看出,SocketServer和KafkaRequestHandlerPool是其中最重要的兩個元件:
2.請求流程
基本結構的展示
Kafka是一個Pub-Sub的訊息系統,無論是釋出還是訂閱,都須指定Topic。Topic只是一個邏輯的概念。每個Topic都包含一個或多個Partition,不同Partition可位於不同節點。同時Partition在物理上對應一個本地資料夾(也就是個紀錄檔物件Log),每個Partition包含一個或多個Segment,每個Segment包含一個資料檔案和多個與之對應的索引檔案。在邏輯上,可以把一個Partition當作一個非常長的陣列,可通過這個「陣列」的索引(offset)去存取其資料。
2.Partition的並行處理能力
3.過期訊息的清除
1.稀疏索引
可以從上面看到,一個segment包含一個.log字尾的檔案和多個index字尾的檔案。那麼這些檔案具體作用是幹啥的呢?並且這些檔案除了字尾不同檔名都是相同,為什麼這麼設計?
2.優化的二分查詢演演算法
kafka沒有使用我們熟知的跳錶或者B+Tree結構來設計索引,而是使用了一種更為簡單且高效的查詢演演算法:二分查詢。但是相對於傳統的二分查詢,kafka將其進行了部分優化,個人覺得設計的非常巧妙,在這裡我會進行詳述。
在這之前,我先補充一下kafka索引檔案的構成:每個索引檔案包含若干條索引項。不同索引檔案的索引項的大小不同,比如offsetIndex索引項大小是8B,timeIndex索引項的大小是12B。
這裡以offsetIndex為例子來詳述kafka的二分查詢演演算法:
1)普通二分查詢
offsetIndex每個索引項大小是8B,但作業系統存取記憶體時的最小單元是頁,一般是4KB,即4096B,會包含了512個索引項。而找出在索引中的指定偏移量,對於作業系統存取記憶體時則變成了找出指定偏移量所在的頁。假設索引的大小有13個頁,如下圖所示:
由於Kafka讀取訊息,一般都是讀取最新的偏移量,所以要查詢的頁就集中在尾部,即第12號頁上。根據二分查詢,將依次存取6、9、11、12號頁。
當隨著Kafka接收訊息的增加,索引檔案也會增加至第13號頁,這時根據二分查詢,將依次存取7、10、12、13號頁。
可以看出存取的頁和上一次的頁完全不同。之前在只有12號頁的時候,Kafak讀取索引時會頻繁存取6、9、11、12號頁,而由於Kafka使用了mmap來提高速度,即讀寫操作都將通過作業系統的page cache,所以6、9、11、12號頁會被快取到page cache中,避免磁碟載入。但是當增至13號頁時,則需要存取7、10、12、13號頁,而由於7、10號頁長時間沒有被存取(現代作業系統都是使用LRU或其變體來管理page cache),很可能已經不在page cache中了,那麼就會造成缺頁中斷(執行緒被阻塞等待從磁碟載入沒有被快取到page cache的資料)。在Kafka的官方測試中,這種情況會造成幾毫秒至1秒的延遲。
2)kafka優化的二分查詢
Kafka對二分查詢進行了改進。既然一般讀取資料集中在索引的尾部。那麼將索引中最後的8192B(8KB)劃分為「熱區」(剛好快取兩頁資料),其餘部分劃分為「冷區」,分別進行二分查詢。這樣做的好處是,在頻繁查詢尾部的情況下,尾部的頁基本都能在page cahce中,從而避免缺頁中斷。
下面我們還是用之前的例子來看下。由於每個頁最多包含512個索引項,而最後的1024個索引項所在頁會被認為是熱區。那麼當12號頁未滿時,則10、11、12會被判定是熱區;而當12號頁剛好滿了的時候,則11、12被判定為熱區;當增至13號頁且未滿時,11、12、13被判定為熱區。假設我們讀取的是最新的訊息,則在熱區中進行二分查詢的情況如下:
當12號頁未滿時,依次存取11、12號頁,當12號頁滿時,存取頁的情況相同。當13號頁出現的時候,依次存取12、13號頁,不會出現存取長時間未存取的頁,則能有效避免缺頁中斷。
3.mmap的使用
利用稀疏索引,已經基本解決了高效查詢的問題,但是這個過程中仍然有進一步的優化空間,那便是通過 mmap(memory mapped files) 讀寫上面提到的稀疏索引檔案,進一步提高查詢訊息的速度。
究竟如何理解 mmap?前面提到,常規的檔案操作為了提高讀寫效能,使用了 Page Cache 機制,但是由於頁快取處在核心空間中,不能被使用者程序直接定址,所以讀檔案時還需要通過系統呼叫,將頁快取中的資料再次拷貝到使用者空間中。
1)常規檔案讀寫
tips:這一過程實際上發生了四次資料拷貝。首先通過系統呼叫將檔案資料讀入到核心態Buffer(DMA拷貝),然後應用程式將記憶體態Buffer資料讀入到使用者態Buffer(CPU拷貝),接著使用者程式通過Socket傳送資料時將使用者態Buffer資料拷貝到核心態Buffer(CPU拷貝),最後通過DMA拷貝將資料拷貝到NIC Buffer。同時,還伴隨著四次上下文切換。
2)mmap讀寫模式
tips:採用 mmap 後,它將磁碟檔案與程序虛擬地址做了對映,並不會招致系統呼叫,以及額外的記憶體 copy 開銷,從而提高了檔案讀取效率。具體到 Kafka 的原始碼層面,就是基於 JDK nio 包下的 MappedByteBuffer 的 map 函數,將磁碟檔案對映到記憶體中。只有索引檔案的讀寫才用到了 mmap。
對於我們常用的機械硬碟,其讀取資料分3步:
前兩個,即尋找資料位置的過程為機械運動。我們常說硬碟比記憶體慢,主要原因是這兩個過程在拖後腿。不過,硬碟比記憶體慢是絕對的嗎?其實不然,如果我們能通過順序讀寫減少尋找資料位置時讀寫磁頭的移動距離,硬碟的速度還是相當可觀的。一般來講,IO速度層面,記憶體順序IO > 磁碟順序IO > 記憶體隨機IO > 磁碟隨機IO。這裡用一張網上的圖來對比一下相關IO效能:
Kafka在順序IO上的設計分兩方面看:
為了優化讀寫效能,Kafka利用了作業系統本身的Page Cache,就是利用作業系統自身的記憶體而不是JVM空間記憶體。這樣做的好處有:
相比於使用JVM或in-memory cache等資料結構,利用作業系統的Page Cache更加簡單可靠。
通過作業系統的Page Cache,Kafka的讀寫操作基本上是基於記憶體的,讀寫速度得到了極大的提升。
生產者是批次傳送訊息,訊息者也是批次拉取訊息的,每次拉取一個訊息batch,從而大大減少了網路傳輸的 overhead。在這裡kafka是通過fetch.min.bytes引數來控制每次拉取的資料大小。預設是 1 位元組,表示只要 Kafka Broker 端積攢了 1 位元組的資料,就可以返回給 Consumer 端,這實在是太小了。我們還是讓 Broker 端一次性多返回點資料吧。
並且,在生產者高效能設計目錄裡面也說過,生產者其實在 Client 端對批次訊息進行了壓縮,這批訊息持久化到 Broker 時,仍然保持的是壓縮狀態,最終在 Consumer 端再做解壓縮操作。
1.zero-copy定義
零拷貝並不是不需要拷貝,而是減少不必要的拷貝次數。通常是說在IO讀寫過程中。
零拷貝字面上的意思包括兩個,「零」和「拷貝」:
實際上,零拷貝是有廣義和狹義之分,目前我們通常聽到的零拷貝,包括上面這個定義減少不必要的拷貝次數都是廣義上的零拷貝。其實瞭解到這點就足夠了。
我們知道,減少不必要的拷貝次數,就是為了提高效率。那零拷貝之前,是怎樣的呢?
2.傳統IO的流程
做伺服器端開發的小夥伴,檔案下載功能應該實現過不少了吧。如果你實現的是一個web程式 ,前端請求過來,伺服器端的任務就是:將伺服器端主機磁碟中的檔案從已連線的socket發出去。關鍵實現程式碼如下:
while((n = read(diskfd, buf, BUF_SIZE)) > 0)
write(sockfd, buf , n);
傳統的IO流程,包括read和write的過程。
從流程圖可以看出,傳統IO的讀寫流程 ,包括了4次上下文切換(4次使用者態和核心態的切換),4次資料拷貝(兩次CPU拷貝以及兩次的DMA拷貝 ),什麼是DMA拷貝呢?我們一起來回顧下,零拷貝涉及的作業系統知識點。
3.零拷貝相關知識點
1)核心空間和使用者空間
作業系統為每個程序都分配了記憶體空間,一部分是使用者空間,一部分是核心空間。核心空間是作業系統核心存取的區域,是受保護的記憶體空間,而使用者空間是使用者應用程式存取的記憶體區域。 以32位元作業系統為例,它會為每一個程序都分配了4G (2的32次方)的記憶體空間。
2)使用者態&核心態
3)上下文切換
cpu上下文
CPU 暫存器,是CPU內建的容量小、但速度極快的記憶體。而程式計數器,則是用來儲存 CPU 正在執行的指令位置、或者即將執行的下一條指令位置。它們都是 CPU 在執行任何任務前,必須的依賴環境,因此叫做CPU上下文。
cpu上下文切換
它是指,先把前一個任務的CPU上下文(也就是CPU暫存器和程式計數器)儲存起來,然後載入新任務的上下文到這些暫存器和程式計數器,最後再跳轉到程式計數器所指的新位置,執行新任務。
一般我們說的上下文切換 ,就是指核心(作業系統的核心)在CPU上對程序或者執行緒進行切換。程序從使用者態到核心態的轉變,需要通過系統呼叫 來完成。系統呼叫的過程,會發生CPU上下文的切換 。
4)DMA技術
DMA,英文全稱是Direct Memory Access ,即直接記憶體存取。DMA 本質上是一塊主機板上獨立的晶片,允許外設裝置和記憶體記憶體之間直接進行IO資料傳輸,其過程不需要CPU的參與 。
我們一起來看下IO流程,DMA幫忙做了什麼事情。
可以發現,DMA做的事情很清晰啦,它主要就是幫忙CPU轉發一下IO請求,以及拷貝資料 。
之所以需要DMA,主要就是效率,它幫忙CPU做事情,這時候,CPU就可以閒下來去做別的事情,提高了CPU的利用效率。
4.kafka消費的zero-copy
1)實現原理
零拷貝並不是沒有拷貝資料,而是減少使用者態/核心態的切換次數以及CPU拷貝的次數。零拷貝實現有多種方式,分別是
在伺服器端那裡,我們已經知道了kafka索引檔案使用的mmap來進行零拷貝優化的,現在告訴你kafka消費者在讀取訊息的時候使用的是sendfile來進行零拷貝優化。
linux 2.4版本之後,對sendfile做了優化升級,引入SG-DMA技術,其實就是對DMA拷貝加入了scatter/gather操作,它可以直接從核心空間緩衝區中將資料讀取到網路卡。使用這個特點搞零拷貝,即還可以多省去一次CPU拷貝 。
sendfile+DMA scatter/gather實現的零拷貝流程如下:
可以發現,sendfile+DMA scatter/gather實現的零拷貝,I/O發生了2 次使用者空間與核心空間的上下文切換,以及2次資料拷貝。其中2次資料拷貝都是包DMA拷貝 。這就是真正的 零拷貝(Zero-copy) 技術,全程都沒有通過CPU來搬運資料,所有的資料都是通過DMA來進行傳輸的。
2)底層實現
Kafka資料傳輸通過 TransportLayer 來完成,其子類 PlaintextTransportLayer 通過Java NIO 的 FileChannel 的 transferTo 和 transferFrom 方法實現零拷貝。底層就是sendfile。消費者從broker讀取資料,就是由此實現。
@Override
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
return fileChannel.transferTo(position, count, socketChannel);
}
tips: transferTo 和 transferFrom 並不保證一定能使用零拷貝。實際上是否能使用零拷貝與作業系統相關,如果作業系統提供 sendfile 這樣的零拷貝系統呼叫,則這兩個方法會通過這樣的系統呼叫充分利用零拷貝的優勢,否則並不能通過這兩個方法本身實現零拷貝。
文章第一部分為大家講解了高效能常見的優化手段,從」祕籍」和」道法」兩個方面來詮釋高效能設計之路該如何走,並引申出計算和IO兩個優化方向。
文章第二部分是kafka內部高效能的具體設計——分別從生產者、伺服器端、消費者來進行全方位講解,包括其設計、使用及相關原理。
希望通過這篇文章,能夠使大家不僅學習到相關方法論,也能明白其方法論具體的落地方案,一起學習,一起成長。
作者:京東物流 李鵬
來源:京東雲開發者社群