Apache Kafka是大量使用磁盤和頁緩存(page cache)的,特別是對page cache的應用被視為是Kafka實現高吞吐量的重要因素之一。實際場景中用戶調整page cache的手段並不太多,更多的還是通過管理好broker端的IO來間接影響page cache從而實現高吞吐量。我們今天就來討論一下broker端的各種IO操作。
開始之前,還是簡單介紹一下page cache:page cache是內核使用的最主要的磁盤緩存(disk cache)之一——實際上Linux中還有其他類型的磁盤緩存,如dentry cache、inode cache等。通常情況下Linux內核在讀寫磁盤時都會訪問page cache。當用戶進程打算讀取磁盤上文件的數據時,內核會首先查看待讀取數據所在的page是否在page cache中,如果存在自然命中page cache,直接返回數據即可,避免了物理磁盤讀操作;反之內核會向page cache添加一個新的page並發起物理磁盤讀操作將數據從磁盤讀取到新加page中,之后再返回給用戶進程。Linux內核總是會將系統中所有的空閑內存全部當做page cache來用,而page cache中的所有page數據將一直保存在page cache中直到內核根據特定的算法替換掉它們中的某些page——一個比較朴素的算法就是LRU。同樣地,在寫入page數據到磁盤之前,內核也會檢查對應的page是否在page cache中,如果不存在則添加新page並將待寫入數據填充到該page中,此時真正的磁盤寫還尚未開始,通常都是隔幾秒之后才真正寫入到底層塊設備上——即這是一個延遲寫入操作。理論上來說,在這幾秒之內的間隔中,用戶進程甚至還允許修改這些待寫入的數據——當然對於Kafka而言,它的寫入操作本質上是append-only的,故沒有這樣的使用場景。
針對Kafka而言,我平時看到對page cache的調優主要集中在下面這3種上:
- 設置合理(主要是偏小)的Java Heap size:很多文章都提到了這種調優方法。正常情況下(如沒有downConvert的情形)Kafka對於JVM堆的需求並不是特別大。設置過大的堆完全是一種浪費甚至是“拖后腿”。業界對該值的設定有比較一致的共識,即6~10GB大小的JVM堆是一個比較合理的數值。鑒於目前服務器的硬件配置都非常好,內存動輒都是32GB甚至是64、128GB的,這樣的JVM設置可以為內核預留出一個非常大的page cache空間。這對於改善broker端的IO性能非常有幫助
- 調節內核的文件預取(prefetch):文件預取是指將數據從磁盤讀取到page cache中,防止出現缺頁中斷(page fault)而阻塞。調節預取的命令是blockdev --setra XXX
- 設置“臟頁”落盤頻率(vm.dirty_ratio):主要控制"臟頁“被沖刷(flush)到磁盤的頻率——當然還有個dirty_background_ratio,大家可以google它們的區別。在我看來,前者類似是一個hard limit,而后者更像個soft limit
除了上面這幾種,我更想討論一下broker端自己的使用場景會對page cache造成什么影響進而反過來影響broker性能。目前broker端的IO主要集中在以下幾種:
- Producer發送的PRODUCE請求
- ISR副本/非滯后consumer發送的FETCH請求
- 滯后consumer發送的FECTH請求
- 老版本consumer發送的FETCH請求
- Broker端的log compaction操作
一、Producer發送的PRODUCE請求
Producer發送消息給broker,broker端寫入到底層物理磁盤,這是Kafka broker端最主要的磁盤寫操作了。真正的寫入操作是異步的,就像之前說的,broker只是將數據直接寫入到page cache中。何時寫回到磁盤由操作系統決定,Kafka不關心。顯然,當prodcuer持續發送數據時,page cache中會不斷緩存當前發送的消息序列。這些數據何時會被訪問呢?有三個可能的時機:
- ISR副本拉取:當leader broker成功地寫入了一條消息后,follower broker會從leader處拉取該條消息,如果是ISR的follower副本,通常能夠很快速地拉取這條新寫入消息,那么此時這條消息依然保存在leader broker頁緩存的概率就會很大,可以保證直接命中。再結合sendfile系統調用提供的Zero Copy特性內核就能直接將該數據從page cache中輸送到Socket buffer上從而快速地發送給follower,避免不必要的數據拷貝
- Broker端compaction:當寫入消息成功一段時間后log cleaner可能會立即開啟工作,故compaction也有可能會觸碰到這條消息。當然這種幾率比較小,因為log cleaner不會對active日志段進行操作,而寫入的消息有較大幾率依然保存在active日志段上
- Consumer讀取:對於非滯后consumer(nonlagging consumer)而言,它們會立即讀取到這條消息。和ISR副本拉取情況相同,這些consumer的性能也會比較好,因為可以直接命中page cache
二、ISR副本/非滯后consumer發送的FETCH請求
Broker端最重要的讀操作! ISR副本和非滯后consumer都幾乎是“實時”地讀取page cache中的數據,而不需要發起緩慢的物理磁盤讀操作。再加上上面說的Zero Copy技術既實現了快速的數據讀取,也避免了對磁盤的訪問,從而將磁盤資源保存下來用於寫入操作。由此可見,這是最理想的情況,在實際使用過程中我們應該盡量讓所有consumer都變成non-lagging的。對於這種Broker IO模式而言,此時的Kafka已經有點類似於Redis了。
三、滯后consumer發送的FECTH請求
真實場景下這種consumer一定存在的,不管是從頭開始讀取的consumer還是長時間追不上producer進度的consumer,它們都屬於這類消費者。它們要讀取的數據有大概率是不在page cache中的,所以broker端所在機器的內核必然要首先從磁盤讀取數據加載到page cache中之后才能將結果返還給consumer。這還不是最重要的,最重要的是這種consumer的存在“污染”了當前broker所在機器的page cache,而且本來可以服務於寫操作的磁盤現在要讀取數據了。平時應用中,我們經常發現我們的consumer無緣無故地性能變差了,除了查找自己應用的問題之外,有時候診斷一下有沒有lagging consumer間接“搗亂”也是必要的。
四、老版本consumer發送的FETCH請求
老版本consumer識別的數據格式與broker端不同,因此和走Zero Copy路徑的consumer不同的是,此時broker不能直接將數據(可能命中page cache也可能從磁盤中讀取)直接返回給consumer,而是必須要先進行數據格式轉換,即所謂的downConvert——一旦需要執行downConvert,此broker就失去了Zero Copy的好處,因為broker需要將數據從磁盤或page cache拷貝到JVM的堆上進行處理。顯然這勢必推高堆占用率從而間接地減少了page cache的可用空間。更糟的是,當broker JVM線程處理完downConvert之后,還需要把處理后的數據拷貝到內核空間(不是page cache。因為失去了Zero Copy,所以必須先拷貝到內核空間然后才能發送給Socket buffer),再一次地壓縮了page cache的空間。如果數據量很大,那么這種場景極易造成JVM堆溢出(OOM)。值得一提的是,KIP-283解決了容易出現OOM的問題,但依然不能使得downConvert場景繼續“享受”Zero Copy。
五、Broker端的log compaction操作
Compaction操作定期處理日志段上的數據,執行基於key的壓實操作。在compact期間,broker需要讀取整個日志段,在JVM堆上構建映射表,因此也會擠占page cache的空間,另外compact會將處理結果寫回到日志段中。Compaction是定時運行的操作,在頻率上並不如上面4個來的頻繁。
綜合比較上面這5種IO,我們希望broker端的磁盤盡量為producer寫入服務,而page cache盡量為non-lagging consumer服務——這應該是能獲取clients端最大吞吐量的必要條件。但在實際應用中,我們的確也觀測到了因為lagging consumer或downConvert甚至是compaction導致其他clients被影響的實例。究其原因就是因為所有IO對page cache的影響是無差別的。producer持續寫入保證了page cache中不斷充滿最新的數據,但只有存在一個auto.offset.reset=earliest的consumer,就有可能瞬間把page cache修改得面目全非,即使這個consumer只是一個一次性的測試consumer。從根本上來說,我們應該區分不同clients或進程對page cache訪問的優先級。
實際上,Linux的open系統調用提供了O_DIRECT的方式來禁用page cache,因此我在想能否為Kafka clients提供這樣的選擇,即可以指定某些clients或Kafka線程以O_DIRECT方式來訪問Linux的VFS。如果支持這個功能的話,那么像這種一次性的auto.offset.reset=earliest的consumer抑或是階段性的compact完全可以采用這種方式從而完全避免對page cache的“污染”。不過令人遺憾的是,Java直到Java 10才加入了對O_DIRECT的支持(https://bugs.openjdk.java.net/browse/JDK-8164900)。也許在未來Kafka不再支持Java 9時這會是一個不錯的KIP提議吧。