1 前言
相信各位小伙伴之前或多或少接觸過消息隊列,比較知名的包含 Rocket MQ 和 Kafka,在京東內(nèi)部使用的是自研的消息中間件 JMQ,從 JMQ2 升級到 JMQ4 的也是帶來了性能上的明顯提升,并且 JMQ4 的底層也是參考 Kafka 去做的設(shè)計。在這里我會給大家展示 Kafka 它的高性能是如何設(shè)計的,大家也可以學(xué)習(xí)相關(guān)方法論將其利用在實際項目中,也許下一個頂級項目就在各位的代碼中產(chǎn)生了。
2 如何理解高性能設(shè)計
2.1 高性能設(shè)計的” 秘籍”
先拋開 kafka,咱們先來談?wù)撘幌赂咝阅茉O(shè)計的本質(zhì),在這里借用一下網(wǎng)上的一張總結(jié)高性能的思維導(dǎo)圖:
從中可以看到,高性能設(shè)計的手段還是非常多,從” 微觀設(shè)計” 上的無鎖化、序列化,到” 宏觀設(shè)計” 上的緩存、存儲等,可以說是五花八門,令人眼花繚亂。但是在我看來本質(zhì)就兩點:計算和 IO。下面將從這兩點來淺析一下我認(rèn)為的高性能的” 道”。
2.2 高性能設(shè)計的” 道法”
2.2.1 計算上的” 道”
計算上的優(yōu)化手段無外乎兩種方式:1. 減少計算量 2. 加快單位時間的計算量
減少計算量:比如用索引來取代全局掃描、用同步代替異步、通過限流來減少請求處理量、采用更高效的數(shù)據(jù)結(jié)構(gòu)和算法等。(舉例:mysql 的 BTree,redis 的跳表等)
加快單位時間的計算量:可以利用 CPU 多核的特性,比如用多線程代替單線程、用集群代替單機(jī)等。(舉例:多線程編程、分治計算等)
2.2.2 IO 上的” 道”
IO 上的優(yōu)化手段也可以從兩個方面來體現(xiàn):1. 減少 IO 次數(shù)或者 IO 數(shù)據(jù)量 2. 加快 IO 速度
減少 IO 次數(shù)或者 IO 數(shù)據(jù)量:比如借助系統(tǒng)緩存或者外部緩存、通過零拷貝技術(shù)減少 IO 復(fù)制次數(shù)、批量讀寫、數(shù)據(jù)壓縮等。
加快 IO 速度:比如用磁盤順序?qū)懘骐S機(jī)寫、用 NIO 代替 BIO、用性能更好的 SSD 代替機(jī)械硬盤等。
3 kafka 高性能設(shè)計
理解了高性能設(shè)計的手段和本質(zhì)之后,我們再來看看 kafka 里面使用到的性能優(yōu)化方法。各類消息中間件的本質(zhì)都是一個生產(chǎn)者 - 消費者模型,生產(chǎn)者發(fā)送消息給服務(wù)端進(jìn)行暫存,消費者從服務(wù)端獲取消息進(jìn)行消費。也就是說 kafka 分為三個部分:生產(chǎn)者 - 服務(wù)端 - 消費者,我們可以按照這三個來分別歸納一下其關(guān)于性能優(yōu)化的手段,這些手段也會涵蓋在我們之前梳理的腦圖里面。
3.1 生產(chǎn)者的高性能設(shè)計
3.1.1 批量發(fā)送消息
之前在上面說過,高性能的” 道” 在于計算和 IO 上,咱們先來看看在 IO 上 kafka 是如何做設(shè)計的。 IO 上的優(yōu)化
kafka 是一個消息中間件,數(shù)據(jù)的載體就是消息,如何將消息高效的進(jìn)行傳遞和持久化是 kafka 高性能設(shè)計的一個重點。基于此分析 kafka 肯定是 IO 密集型應(yīng)用,producer 需要通過網(wǎng)絡(luò) IO 將消息傳遞給 broker,broker 需要通過磁盤 IO 將消息持久化,consumer 需要通過網(wǎng)絡(luò) IO 將消息從 broker 上拉取消費。
網(wǎng)絡(luò) IO 上的優(yōu)化:producer->broker 發(fā)送消息不是一條一條發(fā)送的,kafka 模式會有個消息發(fā)送延遲機(jī)制,會將一批消息進(jìn)行聚合,一口氣打包發(fā)送給 broker,這樣就成功減少了 IO 的次數(shù)。除了傳輸消息本身以外,還要傳輸非常多的網(wǎng)絡(luò)協(xié)議本身的一些內(nèi)容(稱為 Overhead),所以將多條消息合并到一起傳輸,可有效減少網(wǎng)絡(luò)傳輸?shù)?Overhead,進(jìn)而提高了傳輸效率。
磁盤 IO 上的優(yōu)化:大家知道磁盤和內(nèi)存的存儲速度是不同的,在磁盤上操作的速度是遠(yuǎn)低于內(nèi)存,但是在成本上內(nèi)存是高于磁盤。kafka 是面向大數(shù)據(jù)量的消息中間件,也就是說需要將大批量的數(shù)據(jù)持久化,這些數(shù)據(jù)放在內(nèi)存上也是不現(xiàn)實。那 kafka 是怎么在磁盤 IO 上進(jìn)行優(yōu)化的呢?在這里我先直接給出方法,具體細(xì)節(jié)在后文中解釋(它是借助于一種磁盤順序?qū)懙臋C(jī)制來提升寫入速度)。
3.1.2 負(fù)載均衡
1.kafka 負(fù)載均衡設(shè)計
Kafka 有主題(Topic)概念,他是承載真實數(shù)據(jù)的邏輯容器,主題之下還分為若干個分區(qū),Kafka 消息組織方式實際上是三級結(jié)構(gòu):主題 - 分區(qū) - 消息。主題下的每條消息只會在某一個分區(qū)中,而不會在多個分區(qū)中被保存多份。
Kafka 這樣設(shè)計,使用分區(qū)的作用就是提供負(fù)載均衡的能力,對數(shù)據(jù)進(jìn)行分區(qū)的主要目的就是為了實現(xiàn)系統(tǒng)的高伸縮性(Scalability)。
不同的分區(qū)能夠放在不同的節(jié)點的機(jī)器上,而數(shù)據(jù)的讀寫操作也都是針對分區(qū)這個粒度進(jìn)行的,每個節(jié)點的機(jī)器都能獨立地執(zhí)行各自分區(qū)讀寫請求。我們還可以通過增加節(jié)點來提升整體系統(tǒng)的吞吐量。Kafka 的分區(qū)設(shè)計,還可以實現(xiàn)業(yè)務(wù)級別的消息順序的問題。
2. 具體分區(qū)策略
所謂的分區(qū)策略是指決定生產(chǎn)者將消息發(fā)送到那個分區(qū)的算法。Kafka 提供了默認(rèn)的分區(qū)策略是輪詢,同時 kafka 也支持用戶自己制定。
輪詢策略:也稱為 Round-robin 策略,即順序分配。輪詢的優(yōu)點是有著優(yōu)秀的負(fù)載均衡的表現(xiàn)。
隨機(jī)策略:雖然也是追求負(fù)載均衡,但總體表現(xiàn)差于輪詢。
消息鍵劃分策略:還要一種是為每條消息配置一個 key,按消息的 key 來存。Kafka 允許為每條消息指定一個 key。一旦指定了 key ,那么會對 key 進(jìn)行 hash 計算,將相同的 key 存入相同的分區(qū)中,而且每個分區(qū)下的消息都是有序的。key 的作用很大,可以是一個有著明確業(yè)務(wù)含義的字符串,也可以是用來表征消息的元數(shù)據(jù)。
其他的分區(qū)策略:基于地理位置的分區(qū)??梢詮乃蟹謪^(qū)中找出那些 Leader 副本在某個地理位置所有分區(qū),然后隨機(jī)挑選一個進(jìn)行消息發(fā)送。
3.1.3 異步發(fā)送
1. 線程模型
之前已經(jīng)說了 kafka 是選擇批量發(fā)送消息來提升整體的 IO 性能,具體流程是 kafka 生產(chǎn)者使用批處理試圖在內(nèi)存中積累數(shù)據(jù),主線程將多條消息通過一個 ProduceRequest 請求批量發(fā)送出去,發(fā)送的消息暫存在一個隊列 (RecordAccumulator) 中,再由 sender 線程去獲取一批數(shù)據(jù)或者不超過某個延遲時間內(nèi)的數(shù)據(jù)發(fā)送給 broker 進(jìn)行持久化。
優(yōu)點:
可以提升 kafka 整體的吞吐量,減少網(wǎng)絡(luò) IO 的次數(shù);
提高數(shù)據(jù)壓縮效率 (一般壓縮算法都是數(shù)據(jù)量越大越能接近預(yù)期的壓縮效果);
缺點:
數(shù)據(jù)發(fā)送有一定延遲,但是這個延遲可以由業(yè)務(wù)因素來自行設(shè)置。
3.1.4 高效序列化
1. 序列化的優(yōu)勢
Kafka 消息中的 Key 和 Value,都支持自定義類型,只需要提供相應(yīng)的序列化和反序列化器即可。因此,用戶可以根據(jù)實際情況選用快速且緊湊的序列化方式(比如 ProtoBuf、Avro)來減少實際的網(wǎng)絡(luò)傳輸量以及磁盤存儲量,進(jìn)一步提高吞吐量。
2. 內(nèi)置的序列化器
org.apache.kafka.common.serialization.StringSerializer;
org.apache.kafka.common.serialization.LongSerializer;
org.apache.kafka.common.serialization.IntegerSerializer;
org.apache.kafka.common.serialization.ShortSerializer;
org.apache.kafka.common.serialization.FloatSerializer;
org.apache.kafka.common.serialization.DoubleSerializer;
org.apache.kafka.common.serialization.BytesSerializer;
org.apache.kafka.common.serialization.ByteBufferSerializer;
org.apache.kafka.common.serialization.ByteArraySerializer;
3.1.5 消息壓縮
1. 壓縮的目的
壓縮秉承了用時間換空間的經(jīng)典 trade-off 思想,即用 CPU 的時間去換取磁盤空間或網(wǎng)絡(luò) I/O 傳輸量,Kafka 的壓縮算法也是出于這種目的。并且通常是:數(shù)據(jù)量越大,壓縮效果才會越好。
因為有了批量發(fā)送這個前期,從而使得 Kafka 的消息壓縮機(jī)制能真正發(fā)揮出它的威力(壓縮的本質(zhì)取決于多消息的重復(fù)性)。對比壓縮單條消息,同時對多條消息進(jìn)行壓縮,能大幅減少數(shù)據(jù)量,從而更大程度提高網(wǎng)絡(luò)傳輸率。 2. 壓縮的
方法
想了解 kafka 消息壓縮的設(shè)計,就需要先了解 kafka 消息的格式:
Kafka 的消息層次分為:消息集合(message set)和消息(message);一個消息集合中包含若干條日志項(record item),而日志項才是真正封裝消息的地方。
Kafka 底層的消息日志由一系列消息集合 - 日志項組成。Kafka 通常不會直接操作具體的一條條消息,他總是在消息集合這個層面上進(jìn)行寫入操作。
每條消息都含有自己的元數(shù)據(jù)信息,kafka 會將一批消息相同的元數(shù)據(jù)信息給提升到外層的消息集合里面,然后再對整個消息集合來進(jìn)行壓縮。批量消息在持久化到 Broker 中的磁盤時,仍然保持的是壓縮狀態(tài),最終是在 Consumer 端做了解壓縮操作。
壓縮算法效率對比
Kafka 共支持四種主要的壓縮類型:Gzip、Snappy、Lz4 和 Zstd,具體效率對比如下:
3.2 服務(wù)端的高性能設(shè)計
3.2.1 Reactor 網(wǎng)絡(luò)通信模型
kafka 相比其他消息中間件最出彩的地方在于他的高吞吐量,那么對于服務(wù)端來說每秒的請求壓力將會巨大,需要有一個優(yōu)秀的網(wǎng)絡(luò)通信機(jī)制來處理海量的請求。如果 IO 有所研究的同學(xué),應(yīng)該清楚:Reactor 模式正是采用了很經(jīng)典的 IO 多路復(fù)用技術(shù),它可以復(fù)用一個線程去處理大量的 Socket 連接,從而保證高性能。Netty 和 Redis 為什么能做到十萬甚至百萬并發(fā)?它們其實都采用了 Reactor 網(wǎng)絡(luò)通信模型。
1.kafka 網(wǎng)絡(luò)通信層架構(gòu)
從圖中可以看出,SocketServer 和 KafkaRequestHandlerPool 是其中最重要的兩個組件:
SocketServer:主要實現(xiàn)了 Reactor 模式,用于處理外部多個 Clients(這里的 Clients 指的是廣義的 Clients,可能包含 Producer、Consumer 或其他 Broker)的并發(fā)請求,并負(fù)責(zé)將處理結(jié)果封裝進(jìn) Response 中,返還給 Clients
KafkaRequestHandlerPool:Reactor 模式中的 Worker 線程池,里面定義了多個工作線程,用于處理實際的 I/O 請求邏輯。
2. 請求流程
Clients 或其他 Broker 通過 Selector 機(jī)制發(fā)起創(chuàng)建連接請求。(NIO 的機(jī)制,使用 epoll)
Processor 線程接收請求,并將其轉(zhuǎn)換成可處理的 Request 對象。
Processor 線程將 Request 對象放入共享的 RequestChannel 的 Request 隊列。
KafkaRequestHandler 線程從 Request 隊列中取出待處理請求,并進(jìn)行處理。
KafkaRequestHandler 線程將 Response 放回到對應(yīng) Processor 線程的 Response 隊列。
Processor 線程發(fā)送 Response 給 Request 發(fā)送方。
3.2.2 Kafka 的底層日志結(jié)構(gòu)
基本結(jié)構(gòu)的展示
Kafka 是一個 Pub-Sub 的消息系統(tǒng),無論是發(fā)布還是訂閱,都須指定 Topic。Topic 只是一個邏輯的概念。每個 Topic 都包含一個或多個 Partition,不同 Partition 可位于不同節(jié)點。同時 Partition 在物理上對應(yīng)一個本地文件夾 (也就是個日志對象 Log),每個 Partition 包含一個或多個 Segment,每個 Segment 包含一個數(shù)據(jù)文件和多個與之對應(yīng)的索引文件。在邏輯上,可以把一個 Partition 當(dāng)作一個非常長的數(shù)組,可通過這個 “數(shù)組” 的索引(offset)去訪問其數(shù)據(jù)。 2.Partition 的并行處理能力
一方面,topic 是由多個 partion 組成,Producer 發(fā)送消息到 topic 是有個負(fù)載均衡機(jī)制,基本上會將消息平均分配到每個 partion 里面,同時 consumer 里面會有個 consumer group 的概念,也就是說它會以組為單位來消費一個 topic 內(nèi)的消息,一個 consumer group 內(nèi)包含多個 consumer,每個 consumer 消費 topic 內(nèi)不同的 partion,這樣通過多 partion 提高了消息的接收和處理能力
另一方面,由于不同 Partition 可位于不同機(jī)器,因此可以充分利用集群優(yōu)勢,實現(xiàn)機(jī)器間的并行處理。并且 Partition 在物理上對應(yīng)一個文件夾,即使多個 Partition 位于同一個節(jié)點,也可通過配置讓同一節(jié)點上的不同 Partition 置于不同的 disk drive 上,從而實現(xiàn)磁盤間的并行處理,充分發(fā)揮多磁盤的優(yōu)勢。
3. 過期消息的清除
Kafka 的整個設(shè)計中,Partition 相當(dāng)于一個非常長的數(shù)組,而 Broker 接收到的所有消息順序?qū)懭脒@個大數(shù)組中。同時 Consumer 通過 Offset 順序消費這些數(shù)據(jù),并且不刪除已經(jīng)消費的數(shù)據(jù),從而避免了隨機(jī)寫磁盤的過程。
由于磁盤有限,不可能保存所有數(shù)據(jù),實際上作為消息系統(tǒng) Kafka 也沒必要保存所有數(shù)據(jù),需要刪除舊的數(shù)據(jù)。而這個刪除過程,并非通過使用 “讀 - 寫” 模式去修改文件,而是將 Partition 分為多個 Segment,每個 Segment 對應(yīng)一個物理文件,通過刪除整個文件的方式去刪除 Partition 內(nèi)的數(shù)據(jù)。這種方式清除舊數(shù)據(jù)的方式,也避免了對文件的隨機(jī)寫操作。
3.2.3 樸實高效的索引
1. 稀疏索引
可以從上面看到,一個 segment 包含一個.log 后綴的文件和多個 index 后綴的文件。那么這些文件具體作用是干啥的呢?并且這些文件除了后綴不同文件名都是相同,為什么這么設(shè)計?
.log 文件:具體存儲消息的日志文件
.index 文件:位移索引文件,可根據(jù)消息的位移值快速地從查詢到消息的物理文件位置
.timeindex 文件:時間戳索引文件,可根據(jù)時間戳查找到對應(yīng)的位移信息
.txnindex 文件:已中止事物索引文件
除了.log 是實際存儲消息的文件以外,其他的幾個文件都是索引文件。索引本身設(shè)計的原來是一種空間換時間的概念,在這里 kafka 是為了加速查詢所使用。kafka 索引不會為每一條消息建立索引關(guān)系,這個也很好理解,畢竟對一條消息建立索引的成本還是比較大的,所以它是一種稀疏索引的概念,就好比我們常見的跳表,都是一種稀疏索引。
kafka 日志的文件名一般都是該 segment 寫入的第一條消息的起始位移值 baseOffset,比如 000000000123.log,這里面的 123 就是 baseOffset,具體索引文件里面紀(jì)錄的數(shù)據(jù)是相對于起始位移的相對位移值 relativeOffset,baseOffset 與 relativeOffse 的加和即為實際消息的索引值。假設(shè)一個索引文件為:00000000000000000100.index,那么起始位移值即 100,當(dāng)存儲位移為 150 的消息索引時,在索引文件中的相對位移則為 150 - 100 = 50,這么做的好處是使用 4 字節(jié)保存位移即可,可以節(jié)省非常多的磁盤空間。(ps:kafka 真的是極致的壓縮了數(shù)據(jù)存儲的空間)
2. 優(yōu)化的二分查找算法
kafka 沒有使用我們熟知的跳表或者 B+Tree 結(jié)構(gòu)來設(shè)計索引,而是使用了一種更為簡單且高效的查找算法:二分查找。但是相對于傳統(tǒng)的二分查找,kafka 將其進(jìn)行了部分優(yōu)化,個人覺得設(shè)計的非常巧妙,在這里我會進(jìn)行詳述。
在這之前,我先補(bǔ)充一下 kafka 索引文件的構(gòu)成:每個索引文件包含若干條索引項。不同索引文件的索引項的大小不同,比如 offsetIndex 索引項大小是 8B,timeIndex 索引項的大小是 12B。
這里以 offsetIndex 為例子來詳述 kafka 的二分查找算法:
1)普通二分查找
offsetIndex 每個索引項大小是 8B,但操作系統(tǒng)訪問內(nèi)存時的最小單元是頁,一般是 4KB,即 4096B,會包含了 512 個索引項。而找出在索引中的指定偏移量,對于操作系統(tǒng)訪問內(nèi)存時則變成了找出指定偏移量所在的頁。假設(shè)索引的大小有 13 個頁,如下圖所示:
由于 Kafka 讀取消息,一般都是讀取最新的偏移量,所以要查詢的頁就集中在尾部,即第 12 號頁上。根據(jù)二分查找,將依次訪問 6、9、11、12 號頁。
當(dāng)隨著 Kafka 接收消息的增加,索引文件也會增加至第 13 號頁,這時根據(jù)二分查找,將依次訪問 7、10、12、13 號頁。
可以看出訪問的頁和上一次的頁完全不同。之前在只有 12 號頁的時候,Kafak 讀取索引時會頻繁訪問 6、9、11、12 號頁,而由于 Kafka 使用了mmap來提高速度,即讀寫操作都將通過操作系統(tǒng)的 page cache,所以 6、9、11、12 號頁會被緩存到 page cache 中,避免磁盤加載。但是當(dāng)增至 13 號頁時,則需要訪問 7、10、12、13 號頁,而由于 7、10 號頁長時間沒有被訪問(現(xiàn)代操作系統(tǒng)都是使用 LRU 或其變體來管理 page cache),很可能已經(jīng)不在 page cache 中了,那么就會造成缺頁中斷(線程被阻塞等待從磁盤加載沒有被緩存到 page cache 的數(shù)據(jù))。在 Kafka 的官方測試中,這種情況會造成幾毫秒至 1 秒的延遲。 2)kafka 優(yōu)化的二分查找
Kafka 對二分查找進(jìn)行了改進(jìn)。既然一般讀取數(shù)據(jù)集中在索引的尾部。那么將索引中最后的 8192B(8KB)劃分為 “熱區(qū)”(剛好緩存兩頁數(shù)據(jù)),其余部分劃分為 “冷區(qū)”,分別進(jìn)行二分查找。這樣做的好處是,在頻繁查詢尾部的情況下,尾部的頁基本都能在 page cahce 中,從而避免缺頁中斷。
下面我們還是用之前的例子來看下。由于每個頁最多包含 512 個索引項,而最后的 1024 個索引項所在頁會被認(rèn)為是熱區(qū)。那么當(dāng) 12 號頁未滿時,則 10、11、12 會被判定是熱區(qū);而當(dāng) 12 號頁剛好滿了的時候,則 11、12 被判定為熱區(qū);當(dāng)增至 13 號頁且未滿時,11、12、13 被判定為熱區(qū)。假設(shè)我們讀取的是最新的消息,則在熱區(qū)中進(jìn)行二分查找的情況如下:
當(dāng) 12 號頁未滿時,依次訪問 11、12 號頁,當(dāng) 12 號頁滿時,訪問頁的情況相同。當(dāng) 13 號頁出現(xiàn)的時候,依次訪問 12、13 號頁,不會出現(xiàn)訪問長時間未訪問的頁,則能有效避免缺頁中斷。
3.mmap 的使用
利用稀疏索引,已經(jīng)基本解決了高效查詢的問題,但是這個過程中仍然有進(jìn)一步的優(yōu)化空間,那便是通過 mmap(memory mapped files) 讀寫上面提到的稀疏索引文件,進(jìn)一步提高查詢消息的速度。
究竟如何理解 mmap?前面提到,常規(guī)的文件操作為了提高讀寫性能,使用了 Page Cache 機(jī)制,但是由于頁緩存處在內(nèi)核空間中,不能被用戶進(jìn)程直接尋址,所以讀文件時還需要通過系統(tǒng)調(diào)用,將頁緩存中的數(shù)據(jù)再次拷貝到用戶空間中。
1)常規(guī)文件讀寫
app 拿著 inode 查找讀取文件
address_space 中存儲了 inode 和該文件對應(yīng)頁面緩存的映射關(guān)系
頁面緩存缺失,引發(fā)缺頁異常
通過 inode 找到磁盤地址,將文件信息讀取并填充到頁面緩存
頁面緩存處于內(nèi)核態(tài),無法直接被 app 讀取到,因此要先拷貝到用戶空間緩沖區(qū),此處發(fā)生內(nèi)核態(tài)和用戶態(tài)的切換
tips:這一過程實際上發(fā)生了四次數(shù)據(jù)拷貝。首先通過系統(tǒng)調(diào)用將文件數(shù)據(jù)讀入到內(nèi)核態(tài) Buffer(DMA 拷貝),然后應(yīng)用程序?qū)?nèi)存態(tài) Buffer 數(shù)據(jù)讀入到用戶態(tài) Buffer(CPU 拷貝),接著用戶程序通過 Socket 發(fā)送數(shù)據(jù)時將用戶態(tài) Buffer 數(shù)據(jù)拷貝到內(nèi)核態(tài) Buffer(CPU 拷貝),最后通過 DMA 拷貝將數(shù)據(jù)拷貝到 NIC Buffer。同時,還伴隨著四次上下文切換。
2)mmap 讀寫模式
調(diào)用內(nèi)核函數(shù) mmap (),在頁表 (類比虛擬內(nèi)存 PTE) 中建立了文件地址和虛擬地址空間中用戶空間的映射關(guān)系
讀操作引發(fā)缺頁異常,通過 inode 找到磁盤地址,將文件內(nèi)容拷貝到用戶空間,此處不涉及內(nèi)核態(tài)和用戶態(tài)的切換
tips:采用 mmap 后,它將磁盤文件與進(jìn)程虛擬地址做了映射,并不會招致系統(tǒng)調(diào)用,以及額外的內(nèi)存 copy 開銷,從而提高了文件讀取效率。具體到 Kafka 的源碼層面,就是基于 JDK nio 包下的 MappedByteBuffer 的 map 函數(shù),將磁盤文件映射到內(nèi)存中。只有索引文件的讀寫才用到了 mmap。
3.2.4 消息存儲 - 磁盤順序?qū)?/p>
對于我們常用的機(jī)械硬盤,其讀取數(shù)據(jù)分 3 步:
尋道;
尋找扇區(qū);
讀取數(shù)據(jù);
前兩個,即尋找數(shù)據(jù)位置的過程為機(jī)械運動。我們常說硬盤比內(nèi)存慢,主要原因是這兩個過程在拖后腿。不過,硬盤比內(nèi)存慢是絕對的嗎?其實不然,如果我們能通過順序讀寫減少尋找數(shù)據(jù)位置時讀寫磁頭的移動距離,硬盤的速度還是相當(dāng)可觀的。一般來講,IO 速度層面,內(nèi)存順序 IO > 磁盤順序 IO > 內(nèi)存隨機(jī) IO > 磁盤隨機(jī) IO。這里用一張網(wǎng)上的圖來對比一下相關(guān) IO 性能:
Kafka 在順序 IO 上的設(shè)計分兩方面看:
LogSegment 創(chuàng)建時,一口氣申請 LogSegment 最大 size 的磁盤空間,這樣一個文件內(nèi)部盡可能分布在一個連續(xù)的磁盤空間內(nèi);
.log 文件也好,.index 和.timeindex 也罷,在設(shè)計上都是只追加寫入,不做更新操作,這樣避免了隨機(jī) IO 的場景;
3.2.5 Page Cache 的使用
為了優(yōu)化讀寫性能,Kafka 利用了操作系統(tǒng)本身的 Page Cache,就是利用操作系統(tǒng)自身的內(nèi)存而不是 JVM 空間內(nèi)存。這樣做的好處有:
避免 Object 消耗:如果是使用 Java 堆,Java 對象的內(nèi)存消耗比較大,通常是所存儲數(shù)據(jù)的兩倍甚至更多。
避免 GC 問題:隨著 JVM 中數(shù)據(jù)不斷增多,垃圾回收將會變得復(fù)雜與緩慢,使用系統(tǒng)緩存就不會存在 GC 問題
相比于使用 JVM 或 in-memory cache 等數(shù)據(jù)結(jié)構(gòu),利用操作系統(tǒng)的 Page Cache 更加簡單可靠。
首先,操作系統(tǒng)層面的緩存利用率會更高,因為存儲的都是緊湊的字節(jié)結(jié)構(gòu)而不是獨立的對象。
其次,操作系統(tǒng)本身也對于 Page Cache 做了大量優(yōu)化,提供了 write-behind、read-ahead 以及 flush 等多種機(jī)制。
再者,即使服務(wù)進(jìn)程重啟,JVM 內(nèi)的 Cache 會失效,Page Cache 依然可用,避免了 in-process cache 重建緩存的過程。
通過操作系統(tǒng)的 Page Cache,Kafka 的讀寫操作基本上是基于內(nèi)存的,讀寫速度得到了極大的提升。
3.3 消費端的高性能設(shè)計
3.3.1 批量消費
生產(chǎn)者是批量發(fā)送消息,消息者也是批量拉取消息的,每次拉取一個消息 batch,從而大大減少了網(wǎng)絡(luò)傳輸?shù)?overhead。在這里 kafka 是通過 fetch.min.bytes 參數(shù)來控制每次拉取的數(shù)據(jù)大小。默認(rèn)是 1 字節(jié),表示只要 Kafka Broker 端積攢了 1 字節(jié)的數(shù)據(jù),就可以返回給 Consumer 端,這實在是太小了。我們還是讓 Broker 端一次性多返回點數(shù)據(jù)吧。
并且,在生產(chǎn)者高性能設(shè)計目錄里面也說過,生產(chǎn)者其實在 Client 端對批量消息進(jìn)行了壓縮,這批消息持久化到 Broker 時,仍然保持的是壓縮狀態(tài),最終在 Consumer 端再做解壓縮操作。
3.3.2 零拷貝 - 磁盤消息文件的讀取
1.zero-copy 定義
零拷貝并不是不需要拷貝,而是減少不必要的拷貝次數(shù)。通常是說在 IO 讀寫過程中。
零拷貝字面上的意思包括兩個,“零” 和 “拷貝”:
“拷貝”:就是指數(shù)據(jù)從一個存儲區(qū)域轉(zhuǎn)移到另一個存儲區(qū)域。
“零” :表示次數(shù)為 0,它表示拷貝數(shù)據(jù)的次數(shù)為 0。
實際上,零拷貝是有廣義和狹義之分,目前我們通常聽到的零拷貝,包括上面這個定義減少不必要的拷貝次數(shù)都是廣義上的零拷貝。其實了解到這點就足夠了。
我們知道,減少不必要的拷貝次數(shù),就是為了提高效率。那零拷貝之前,是怎樣的呢?
2. 傳統(tǒng) IO 的流程
做服務(wù)端開發(fā)的小伙伴,文件下載功能應(yīng)該實現(xiàn)過不少了吧。如果你實現(xiàn)的是一個 web 程序 ,前端請求過來,服務(wù)端的任務(wù)就是:將服務(wù)端主機(jī)磁盤中的文件從已連接的 socket 發(fā)出去。關(guān)鍵實現(xiàn)代碼如下:
while((n = read(diskfd, buf, BUF_SIZE)) > 0) write(sockfd, buf , n); 傳統(tǒng)的 IO 流程,包括 read 和 write 的過程。
read:把數(shù)據(jù)從磁盤讀取到內(nèi)核緩沖區(qū),再拷貝到用戶緩沖區(qū)
write:先把數(shù)據(jù)寫入到 socket 緩沖區(qū),最后寫入網(wǎng)卡設(shè)備 流程圖如下:
用戶應(yīng)用進(jìn)程調(diào)用 read 函數(shù),向操作系統(tǒng)發(fā)起 IO 調(diào)用,上下文從用戶態(tài)轉(zhuǎn)為內(nèi)核態(tài)(切換 1)
DMA 控制器把數(shù)據(jù)從磁盤中,讀取到內(nèi)核緩沖區(qū)。
CPU 把內(nèi)核緩沖區(qū)數(shù)據(jù),拷貝到用戶應(yīng)用緩沖區(qū),上下文從內(nèi)核態(tài)轉(zhuǎn)為用戶態(tài)(切換 2) ,read 函數(shù)返回
用戶應(yīng)用進(jìn)程通過 write 函數(shù),發(fā)起 IO 調(diào)用,上下文從用戶態(tài)轉(zhuǎn)為內(nèi)核態(tài)(切換 3)
CPU 將用戶緩沖區(qū)中的數(shù)據(jù),拷貝到 socket 緩沖區(qū)
DMA 控制器把數(shù)據(jù)從 socket 緩沖區(qū),拷貝到網(wǎng)卡設(shè)備,上下文從內(nèi)核態(tài)切換回用戶態(tài)(切換 4) ,write 函數(shù)返回
從流程圖可以看出,傳統(tǒng) IO 的讀寫流程 ,包括了 4 次上下文切換(4 次用戶態(tài)和內(nèi)核態(tài)的切換),4 次數(shù)據(jù)拷貝(兩次 CPU 拷貝以及兩次的 DMA 拷貝 ),什么是 DMA 拷貝呢?我們一起來回顧下,零拷貝涉及的操作系統(tǒng)知識點。
3. 零拷貝相關(guān)知識點
1)內(nèi)核空間和用戶空間
操作系統(tǒng)為每個進(jìn)程都分配了內(nèi)存空間,一部分是用戶空間,一部分是內(nèi)核空間。內(nèi)核空間是操作系統(tǒng)內(nèi)核訪問的區(qū)域,是受保護(hù)的內(nèi)存空間,而用戶空間是用戶應(yīng)用程序訪問的內(nèi)存區(qū)域。以 32 位操作系統(tǒng)為例,它會為每一個進(jìn)程都分配了 4G (2 的 32 次方) 的內(nèi)存空間。
內(nèi)核空間:主要提供進(jìn)程調(diào)度、內(nèi)存分配、連接硬件資源等功能
用戶空間:提供給各個程序進(jìn)程的空間,它不具有訪問內(nèi)核空間資源的權(quán)限,如果應(yīng)用程序需要使用到內(nèi)核空間的資源,則需要通過系統(tǒng)調(diào)用來完成。進(jìn)程從用戶空間切換到內(nèi)核空間,完成相關(guān)操作后,再從內(nèi)核空間切換回用戶空間。
2)用戶態(tài) & 內(nèi)核態(tài)
如果進(jìn)程運行于內(nèi)核空間,被稱為進(jìn)程的內(nèi)核態(tài)
如果進(jìn)程運行于用戶空間,被稱為進(jìn)程的用戶態(tài)。
3)上下文切換 cpu 上下文
CPU 寄存器,是 CPU 內(nèi)置的容量小、但速度極快的內(nèi)存。而程序計數(shù)器,則是用來存儲 CPU 正在執(zhí)行的指令位置、或者即將執(zhí)行的下一條指令位置。它們都是 CPU 在運行任何任務(wù)前,必須的依賴環(huán)境,因此叫做 CPU 上下文。
cpu 上下文切換
它是指,先把前一個任務(wù)的 CPU 上下文(也就是 CPU 寄存器和程序計數(shù)器)保存起來,然后加載新任務(wù)的上下文到這些寄存器和程序計數(shù)器,最后再跳轉(zhuǎn)到程序計數(shù)器所指的新位置,運行新任務(wù)。
一般我們說的上下文切換 ,就是指內(nèi)核(操作系統(tǒng)的核心)在 CPU 上對進(jìn)程或者線程進(jìn)行切換。進(jìn)程從用戶態(tài)到內(nèi)核態(tài)的轉(zhuǎn)變,需要通過系統(tǒng)調(diào)用 來完成。系統(tǒng)調(diào)用的過程,會發(fā)生 CPU 上下文的切換 。 4)DMA 技術(shù)
DMA,英文全稱是 Direct Memory Access ,即直接內(nèi)存訪問。DMA 本質(zhì)上是一塊主板上獨立的芯片,允許外設(shè)設(shè)備和內(nèi)存存儲器之間直接進(jìn)行 IO 數(shù)據(jù)傳輸,其過程不需要 CPU 的參與 。
我們一起來看下 IO 流程,DMA 幫忙做了什么事情。
可以發(fā)現(xiàn),DMA 做的事情很清晰啦,它主要就是幫忙 CPU 轉(zhuǎn)發(fā)一下 IO 請求,以及拷貝數(shù)據(jù) 。 之所以需要 DMA,主要就是效率,它幫忙 CPU 做事情,這時候,CPU 就可以閑下來去做別的事情,提高了 CPU 的利用效率。 4.kafka 消費的 zero-copy
1)實現(xiàn)原理
零拷貝并不是沒有拷貝數(shù)據(jù),而是減少用戶態(tài) / 內(nèi)核態(tài)的切換次數(shù)以及 CPU 拷貝的次數(shù)。零拷貝實現(xiàn)有多種方式,分別是
mmap+write
sendfile
在服務(wù)端那里,我們已經(jīng)知道了 kafka 索引文件使用的 mmap 來進(jìn)行零拷貝優(yōu)化的,現(xiàn)在告訴你 kafka 消費者在讀取消息的時候使用的是 sendfile 來進(jìn)行零拷貝優(yōu)化。
linux 2.4 版本之后,對 sendfile 做了優(yōu)化升級,引入 SG-DMA 技術(shù),其實就是對 DMA 拷貝加入了 scatter/gather 操作,它可以直接從內(nèi)核空間緩沖區(qū)中將數(shù)據(jù)讀取到網(wǎng)卡。使用這個特點搞零拷貝,即還可以多省去一次 CPU 拷貝 。
sendfile+DMA scatter/gather 實現(xiàn)的零拷貝流程如下:
用戶進(jìn)程發(fā)起 sendfile 系統(tǒng)調(diào)用,上下文(切換 1)從用戶態(tài)轉(zhuǎn)向內(nèi)核態(tài)。
DMA 控制器,把數(shù)據(jù)從硬盤中拷貝到內(nèi)核緩沖區(qū)。
CPU 把內(nèi)核緩沖區(qū)中的文件描述符信息 (包括內(nèi)核緩沖區(qū)的內(nèi)存地址和偏移量)發(fā)送到 socket 緩沖區(qū)
DMA 控制器根據(jù)文件描述符信息,直接把數(shù)據(jù)從內(nèi)核緩沖區(qū)拷貝到網(wǎng)卡
上下文(切換 2)從內(nèi)核態(tài)切換回用戶態(tài) ,sendfile 調(diào)用返回。
可以發(fā)現(xiàn),sendfile+DMA scatter/gather 實現(xiàn)的零拷貝,I/O 發(fā)生了 2 次用戶空間與內(nèi)核空間的上下文切換,以及 2 次數(shù)據(jù)拷貝。其中 2 次數(shù)據(jù)拷貝都是包 DMA 拷貝 。這就是真正的 零拷貝(Zero-copy) 技術(shù),全程都沒有通過 CPU 來搬運數(shù)據(jù),所有的數(shù)據(jù)都是通過 DMA 來進(jìn)行傳輸?shù)摹?br />
2)底層實現(xiàn) Kafka 數(shù)據(jù)傳輸通過 TransportLayer 來完成,其子類 PlaintextTransportLayer 通過 Java NIO 的 FileChannel 的 transferTo 和 transferFrom 方法實現(xiàn)零拷貝。底層就是 sendfile。消費者從 broker 讀取數(shù)據(jù),就是由此實現(xiàn)。
@Override public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException { return fileChannel.transferTo(position, count, socketChannel); } tips:transferTo 和 transferFrom 并不保證一定能使用零拷貝。實際上是否能使用零拷貝與操作系統(tǒng)相關(guān),如果操作系統(tǒng)提供 sendfile 這樣的零拷貝系統(tǒng)調(diào)用,則這兩個方法會通過這樣的系統(tǒng)調(diào)用充分利用零拷貝的優(yōu)勢,否則并不能通過這兩個方法本身實現(xiàn)零拷貝。
4 總結(jié)
文章第一部分為大家講解了高性能常見的優(yōu)化手段,從” 秘籍” 和” 道法” 兩個方面來詮釋高性能設(shè)計之路該如何走,并引申出計算和 IO 兩個優(yōu)化方向。 文章第二部分是 kafka 內(nèi)部高性能的具體設(shè)計 —— 分別從生產(chǎn)者、服務(wù)端、消費者來進(jìn)行全方位講解,包括其設(shè)計、使用及相關(guān)原理。 希望通過這篇文章,能夠使大家不僅學(xué)習(xí)到相關(guān)方法論,也能明白其方法論具體的落地方案,一起學(xué)習(xí),一起成長。
審核編輯:劉清
-
多路復(fù)用器
+關(guān)注
關(guān)注
9文章
868瀏覽量
65263 -
網(wǎng)絡(luò)通信
+關(guān)注
關(guān)注
4文章
781瀏覽量
29744 -
Hash算法
+關(guān)注
關(guān)注
0文章
43瀏覽量
7379 -
負(fù)載均衡器
+關(guān)注
關(guān)注
0文章
18瀏覽量
2573
原文標(biāo)題:從Kafka中學(xué)習(xí)高性能系統(tǒng)如何設(shè)計
文章出處:【微信號:OSC開源社區(qū),微信公眾號:OSC開源社區(qū)】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
相關(guān)推薦
評論