從Kafka中學習高效能系統如何設計

2023-06-28 15:00:22

1 前言

相信各位小夥伴之前或多或少接觸過訊息佇列,比較知名的包含Rocket MQ和Kafka,在京東內部使用的是自研的訊息中介軟體JMQ,從JMQ2升級到JMQ4的也是帶來了效能上的明顯提升,並且JMQ4的底層也是參考Kafka去做的設計。在這裡我會給大家展示Kafka它的高效能是如何設計的,大家也可以學習相關方法論將其利用在實際專案中,也許下一個頂級專案就在各位的程式碼中產生了。

2 如何理解高效能設計

2.1 高效能設計的」祕籍」

先拋開kafka,咱們先來談論一下高效能設計的本質,在這裡借用一下網上的一張總結高效能的思維導圖:

從中可以看到,高效能設計的手段還是非常多,從」微觀設計」上的無鎖化、序列化,到」宏觀設計」上的快取、儲存等,可以說是五花八門,令人眼花繚亂。但是在我看來本質就兩點:計算和IO。下面將從這兩點來淺析一下我認為的高效能的」道」。

2.2 高效能設計的」道法」

2.2.1 計算上的」道」

計算上的優化手段無外乎兩種方式:1.減少計算量 2.加快單位時間的計算量

  • 減少計算量:比如用索引來取代全域性掃描、用同步代替非同步、通過限流來減少請求處理量、採用更高效的資料結構和演演算法等。(舉例:mysql的BTree,redis的跳錶等)
  • 加快單位時間的計算量:可以利用CPU多核的特性,比如用多執行緒代替單執行緒、用叢集代替單機等。(舉例:多執行緒程式設計、分治計算等)

2.2.2 IO上的」道」

IO上的優化手段也可以從兩個方面來體現:1.減少IO次數或者IO資料量 2.加快IO速度

  • 減少IO次數或者IO資料量:比如藉助系統快取或者外部快取、通過零拷貝技術減少 IO 複製次數、批次讀寫、資料壓縮等。
  • 加快IO速度:比如用磁碟順序寫代替隨機寫、用 NIO 代替 BIO、用效能更好的 SSD 代替機械硬碟等。

3 kafka高效能設計

理解了高效能設計的手段和本質之後,我們再來看看kafka裡面使用到的效能優化方法。各類訊息中介軟體的本質都是一個生產者-消費者模型,生產者傳送訊息給伺服器端進行暫存,消費者從伺服器端獲取訊息進行消費。也就是說kafka分為三個部分:生產者-伺服器端-消費者,我們可以按照這三個來分別歸納一下其關於效能優化的手段,這些手段也會涵蓋在我們之前梳理的腦圖裡面。

3.1 生產者的高效能設計

3.1.1 批次傳送訊息

之前在上面說過,高效能的」道」在於計算和IO上,咱們先來看看在IO上kafka是如何做設計的。

IO上的優化
kafka是一個訊息中介軟體,資料的載體就是訊息,如何將訊息高效的進行傳遞和持久化是kafka高效能設計的一個重點。基於此分析kafka肯定是IO密集型應用,producer需要通過網路IO將訊息傳遞給broker,broker需要通過磁碟IO將訊息持久化,consumer需要通過網路IO將訊息從broker上拉取消費。

  • 網路IO上的優化:producer->broker傳送訊息不是一條一條傳送的,kafka模式會有個訊息傳送延遲機制,會將一批訊息進行聚合,一口氣打包傳送給broker,這樣就成功減少了IO的次數。除了傳輸訊息本身以外,還要傳輸非常多的網路協定本身的一些內容(稱為Overhead),所以將多條訊息合併到一起傳輸,可有效減少網路傳輸的Overhead,進而提高了傳輸效率。
  • 磁碟IO上的優化:大家知道磁碟和記憶體的儲存速度是不同的,在磁碟上操作的速度是遠低於記憶體,但是在成本上記憶體是高於磁碟。kafka是面向巨量資料量的訊息中介軟體,也就是說需要將大批次的資料持久化,這些資料放在記憶體上也是不現實。那kafka是怎麼在磁碟IO上進行優化的呢?在這裡我先直接給出方法,具體細節在後文中解釋(它是藉助於一種磁碟順序寫的機制來提升寫入速度)。

3.1.2 負載均衡

1.kafka負載均衡設計

Kafka有主題(Topic)概念,他是承載真實資料的邏輯容器,主題之下還分為若干個分割區,Kafka訊息組織方式實際上是三級結構:主題-分割區-訊息。主題下的每條訊息只會在某一個分割區中,而不會在多個分割區中被儲存多份。
Kafka這樣設計,使用分割區的作用就是提供負載均衡的能力,對資料進行分割區的主要目的就是為了實現系統的高伸縮性(Scalability)。不同的分割區能夠放在不同的節點的機器上,而資料的讀寫操作也都是針對分割區這個粒度進行的,每個節點的機器都能獨立地執行各自分割區讀寫請求。我們還可以通過增加節點來提升整體系統的吞吐量。Kafka的分割區設計,還可以實現業務級別的訊息順序的問題。

2.具體分割區策略

  • 所謂的分割區策略是指決定生產者將訊息傳送到那個分割區的演演算法。Kafka提供了預設的分割區策略是輪詢,同時kafka也支援使用者自己制定。
  • 輪詢策略:也稱為Round-robin策略,即順序分配。輪詢的優點是有著優秀的負載均衡的表現。
  • 隨機策略:雖然也是追求負載均衡,但總體表現差於輪詢。
  • 訊息鍵劃分策略:還要一種是為每條訊息設定一個key,按訊息的key來存。Kafka允許為每條訊息指定一個key。一旦指定了key ,那麼會對key進行hash計算,將相同的key存入相同的分割區中,而且每個分割區下的訊息都是有序的。key的作用很大,可以是一個有著明確業務含義的字串,也可以是用來表徵訊息的後設資料。
  • 其他的分割區策略:基於地理位置的分割區。可以從所有分割區中找出那些 Leader 副本在某個地理位置所有分割區,然後隨機挑選一個進行訊息傳送。

3.1.3 非同步傳送

1.執行緒模型

之前已經說了kafka是選擇批次傳送訊息來提升整體的IO效能,具體流程是kafka生產者使用批次處理試圖在記憶體中積累資料,主執行緒將多條訊息通過一個ProduceRequest請求批次傳送出去,傳送的訊息暫存在一個佇列(RecordAccumulator)中,再由sender執行緒去獲取一批資料或者不超過某個延遲時間內的資料傳送給broker進行持久化。

優點:

  • 可以提升kafka整體的吞吐量,減少網路IO的次數;
  • 提高資料壓縮效率(一般壓縮演演算法都是資料量越大越能接近預期的壓縮效果);

缺點:

  • 資料傳送有一定延遲,但是這個延遲可以由業務因素來自行設定。

3.1.4 高效序列化

1.序列化的優勢
Kafka 訊息中的 Key 和 Value,都支援自定義型別,只需要提供相應的序列化和反序列化器即可。因此,使用者可以根據實際情況選用快速且緊湊的序列化方式(比如 ProtoBuf、Avro)來減少實際的網路傳輸量以及磁碟儲存量,進一步提高吞吐量。

2.內建的序列化器

  • 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.壓縮的目的
壓縮秉承了用時間換空間的經典trade-off思想,即用CPU的時間去換取磁碟空間或網路I/O傳輸量,Kafka的壓縮演演算法也是出於這種目的。並且通常是:資料量越大,壓縮效果才會越好。
因為有了批次傳送這個前期,從而使得 Kafka 的訊息壓縮機制能真正發揮出它的威力(壓縮的本質取決於多訊息的重複性)。對比壓縮單條訊息,同時對多條訊息進行壓縮,能大幅減少資料量,從而更大程度提高網路傳輸率。

2.壓縮的方法
想了解kafka訊息壓縮的設計,就需要先了解kafka訊息的格式:

  • Kafka的訊息層次分為:訊息集合(message set)和訊息(message);一個訊息集合中包含若干條紀錄檔項(record item),而紀錄檔項才是真正封裝訊息的地方。
  • Kafka底層的訊息紀錄檔由一系列訊息集合-紀錄檔項組成。Kafka通常不會直接操作具體的一條條訊息,他總是在訊息集合這個層面上進行寫入操作。

每條訊息都含有自己的後設資料資訊,kafka會將一批訊息相同的後設資料資訊給提升到外層的訊息集合裡面,然後再對整個訊息集合來進行壓縮。批次訊息在持久化到 Broker 中的磁碟時,仍然保持的是壓縮狀態,最終是在 Consumer 端做了解壓縮操作。
壓縮演演算法效率對比
Kafka 共支援四種主要的壓縮型別:Gzip、Snappy、Lz4 和 Zstd,具體效率對比如下:

3.2 伺服器端的高效能設計

3.2.1 Reactor網路通訊模型

kafka相比其他訊息中介軟體最出彩的地方在於他的高吞吐量,那麼對於伺服器端來說每秒的請求壓力將會巨大,需要有一個優秀的網路通訊機制來處理海量的請求。如果 IO 有所研究的同學,應該清楚:Reactor 模式正是採用了很經典的 IO 多路複用技術,它可以複用一個執行緒去處理大量的 Socket 連線,從而保證高效能。Netty 和 Redis 為什麼能做到十萬甚至百萬並行?它們其實都採用了 Reactor 網路通訊模型。

1.kafka網路通訊層架構

從圖中可以看出,SocketServer和KafkaRequestHandlerPool是其中最重要的兩個元件:

  • SocketServer:主要實現了 Reactor 模式,用於處理外部多個 Clients(這裡的 Clients 指的是廣義的 Clients,可能包含 Producer、Consumer 或其他 Broker)的並行請求,並負責將處理結果封裝進 Response 中,返還給 Clients
  • KafkaRequestHandlerPool:Reactor模式中的Worker執行緒池,裡面定義了多個工作執行緒,用於處理實際的I/O請求邏輯。

2.請求流程

  • Clients 或其他 Broker 通過 Selector 機制發起建立連線請求。(NIO的機制,使用epoll)
  • Processor 執行緒接收請求,並將其轉換成可處理的 Request 物件。
  • Processor 執行緒將 Request 物件放入共用的RequestChannel的 Request 佇列。
  • KafkaRequestHandler 執行緒從 Request 佇列中取出待處理請求,並進行處理。
  • KafkaRequestHandler 執行緒將 Response 放回到對應 Processor 執行緒的 Response 佇列。
  • Processor 執行緒傳送 Response 給 Request 傳送方。

3.2.2 Kafka的底層紀錄檔結構

基本結構的展示

Kafka是一個Pub-Sub的訊息系統,無論是釋出還是訂閱,都須指定Topic。Topic只是一個邏輯的概念。每個Topic都包含一個或多個Partition,不同Partition可位於不同節點。同時Partition在物理上對應一個本地資料夾(也就是個紀錄檔物件Log),每個Partition包含一個或多個Segment,每個Segment包含一個資料檔案和多個與之對應的索引檔案。在邏輯上,可以把一個Partition當作一個非常長的陣列,可通過這個「陣列」的索引(offset)去存取其資料。

2.Partition的並行處理能力

  • 一方面,topic是由多個partion組成,Producer傳送訊息到topic是有個負載均衡機制,基本上會將訊息平均分配到每個partion裡面,同時consumer裡面會有個consumer group的概念,也就是說它會以組為單位來消費一個topic內的訊息,一個consumer group內包含多個consumer,每個consumer消費topic內不同的partion,這樣通過多partion提高了訊息的接收和處理能力
  • 另一方面,由於不同Partition可位於不同機器,因此可以充分利用叢集優勢,實現機器間的並行處理。並且Partition在物理上對應一個資料夾,即使多個Partition位於同一個節點,也可通過設定讓同一節點上的不同Partition置於不同的disk drive上,從而實現磁碟間的並行處理,充分發揮多磁碟的優勢。

3.過期訊息的清除

  • Kafka的整個設計中,Partition相當於一個非常長的陣列,而Broker接收到的所有訊息順序寫入這個大陣列中。同時Consumer通過Offset順序消費這些資料,並且不刪除已經消費的資料,從而避免了隨機寫磁碟的過程。
  • 由於磁碟有限,不可能儲存所有資料,實際上作為訊息系統Kafka也沒必要儲存所有資料,需要刪除舊的資料。而這個刪除過程,並非通過使用「讀-寫」模式去修改檔案,而是將Partition分為多個Segment,每個Segment對應一個物理檔案,通過刪除整個檔案的方式去刪除Partition內的資料。這種方式清除舊資料的方式,也避免了對檔案的隨機寫操作。

3.2.3 樸實高效的索引

1.稀疏索引

可以從上面看到,一個segment包含一個.log字尾的檔案和多個index字尾的檔案。那麼這些檔案具體作用是幹啥的呢?並且這些檔案除了字尾不同檔名都是相同,為什麼這麼設計?

  • .log檔案:具體儲存訊息的紀錄檔檔案
  • .index檔案:位移索引檔案,可根據訊息的位移值快速地從查詢到訊息的物理檔案位置
  • .timeindex檔案:時間戳索引檔案,可根據時間戳查詢到對應的位移資訊
  • .txnindex檔案:已中止事物索引檔案
    除了.log是實際儲存訊息的檔案以外,其他的幾個檔案都是索引檔案。索引本身設計的原來是一種空間換時間的概念,在這裡kafka是為了加速查詢所使用。kafka索引不會為每一條訊息建立索引關係,這個也很好理解,畢竟對一條訊息建立索引的成本還是比較大的,所以它是一種稀疏索引的概念,就好比我們常見的跳錶,都是一種稀疏索引。
    kafka紀錄檔的檔名一般都是該segment寫入的第一條訊息的起始位移值baseOffset,比如000000000123.log,這裡面的123就是baseOffset,具體索引檔案裡面紀錄的資料是相對於起始位移的相對位移值relativeOffset,baseOffset與relativeOffse的加和即為實際訊息的索引值。假設一個索引檔案為:00000000000000000100.index,那麼起始位移值即 100,當儲存位移為 150 的訊息索引時,在索引檔案中的相對位移則為 150 - 100 = 50,這麼做的好處是使用 4 位元組儲存位移即可,可以節省非常多的磁碟空間。(ps:kafka真的是極致的壓縮了資料儲存的空間)

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)常規檔案讀寫

  • app拿著inode查詢讀取檔案
  • address_space中儲存了inode和該檔案對應頁面快取的對映關係
  • 頁面快取缺失,引發缺頁異常
  • 通過inode找到磁碟地址,將檔案資訊讀取並填充到頁面快取
  • 頁面快取處於核心態,無法直接被app讀取到,因此要先拷貝到使用者空間緩衝區,此處發生核心態和使用者態的切換

tips:這一過程實際上發生了四次資料拷貝。首先通過系統呼叫將檔案資料讀入到核心態Buffer(DMA拷貝),然後應用程式將記憶體態Buffer資料讀入到使用者態Buffer(CPU拷貝),接著使用者程式通過Socket傳送資料時將使用者態Buffer資料拷貝到核心態Buffer(CPU拷貝),最後通過DMA拷貝將資料拷貝到NIC Buffer。同時,還伴隨著四次上下文切換。

2)mmap讀寫模式

  • 呼叫核心函數mmap(),在頁表(類比虛擬記憶體PTE)中建立了檔案地址和虛擬地址空間中使用者空間的對映關係
  • 讀操作引發缺頁異常,通過inode找到磁碟地址,將檔案內容拷貝到使用者空間,此處不涉及核心態和使用者態的切換

tips:採用 mmap 後,它將磁碟檔案與程序虛擬地址做了對映,並不會招致系統呼叫,以及額外的記憶體 copy 開銷,從而提高了檔案讀取效率。具體到 Kafka 的原始碼層面,就是基於 JDK nio 包下的 MappedByteBuffer 的 map 函數,將磁碟檔案對映到記憶體中。只有索引檔案的讀寫才用到了 mmap。

3.2.4 訊息儲存-磁碟順序寫

對於我們常用的機械硬碟,其讀取資料分3步:

  1. 尋道;
  2. 尋找磁區;
  3. 讀取資料;

前兩個,即尋找資料位置的過程為機械運動。我們常說硬碟比記憶體慢,主要原因是這兩個過程在拖後腿。不過,硬碟比記憶體慢是絕對的嗎?其實不然,如果我們能通過順序讀寫減少尋找資料位置時讀寫磁頭的移動距離,硬碟的速度還是相當可觀的。一般來講,IO速度層面,記憶體順序IO > 磁碟順序IO > 記憶體隨機IO > 磁碟隨機IO。這裡用一張網上的圖來對比一下相關IO效能:

Kafka在順序IO上的設計分兩方面看:

  1. LogSegment建立時,一口氣申請LogSegment最大size的磁碟空間,這樣一個檔案內部儘可能分佈在一個連續的磁碟空間內;
  2. .log檔案也好,.index和.timeindex也罷,在設計上都是隻追加寫入,不做更新操作,這樣避免了隨機IO的場景;

3.2.5 Page Cache的使用

為了優化讀寫效能,Kafka利用了作業系統本身的Page Cache,就是利用作業系統自身的記憶體而不是JVM空間記憶體。這樣做的好處有:

  • 避免Object消耗:如果是使用 Java 堆,Java物件的記憶體消耗比較大,通常是所儲存資料的兩倍甚至更多。
  • 避免GC問題:隨著JVM中資料不斷增多,垃圾回收將會變得複雜與緩慢,使用系統快取就不會存在GC問題

相比於使用JVM或in-memory cache等資料結構,利用作業系統的Page Cache更加簡單可靠。

  • 首先,作業系統層面的快取利用率會更高,因為儲存的都是緊湊的位元組結構而不是獨立的物件。
  • 其次,作業系統本身也對於Page Cache做了大量優化,提供了 write-behind、read-ahead以及flush等多種機制。
  • 再者,即使服務程序重啟,JVM內的Cache會失效,Page Cache依然可用,避免了in-process cache重建快取的過程。

通過作業系統的Page Cache,Kafka的讀寫操作基本上是基於記憶體的,讀寫速度得到了極大的提升。

3.3 消費端的高效能設計

3.3.1 批次消費

生產者是批次傳送訊息,訊息者也是批次拉取訊息的,每次拉取一個訊息batch,從而大大減少了網路傳輸的 overhead。在這裡kafka是通過fetch.min.bytes引數來控制每次拉取的資料大小。預設是 1 位元組,表示只要 Kafka Broker 端積攢了 1 位元組的資料,就可以返回給 Consumer 端,這實在是太小了。我們還是讓 Broker 端一次性多返回點資料吧。
並且,在生產者高效能設計目錄裡面也說過,生產者其實在 Client 端對批次訊息進行了壓縮,這批訊息持久化到 Broker 時,仍然保持的是壓縮狀態,最終在 Consumer 端再做解壓縮操作。

3.3.2 零拷貝-磁碟訊息檔案的讀取

1.zero-copy定義
零拷貝並不是不需要拷貝,而是減少不必要的拷貝次數。通常是說在IO讀寫過程中。
零拷貝字面上的意思包括兩個,「零」和「拷貝」:

  • 「拷貝」:就是指資料從一個儲存區域轉移到另一個儲存區域。
  • 「零」 :表示次數為0,它表示拷貝資料的次數為0。

實際上,零拷貝是有廣義和狹義之分,目前我們通常聽到的零拷貝,包括上面這個定義減少不必要的拷貝次數都是廣義上的零拷貝。其實瞭解到這點就足夠了。
我們知道,減少不必要的拷貝次數,就是為了提高效率。那零拷貝之前,是怎樣的呢?

2.傳統IO的流程
做伺服器端開發的小夥伴,檔案下載功能應該實現過不少了吧。如果你實現的是一個web程式 ,前端請求過來,伺服器端的任務就是:將伺服器端主機磁碟中的檔案從已連線的socket發出去。關鍵實現程式碼如下:

while((n = read(diskfd, buf, BUF_SIZE)) > 0)
    write(sockfd, buf , n);

傳統的IO流程,包括read和write的過程。

  • read:把資料從磁碟讀取到核心緩衝區,再拷貝到使用者緩衝區
  • write:先把資料寫入到socket緩衝區,最後寫入網路卡裝置
    流程圖如下:

  • 使用者應用程序呼叫read函數,向作業系統發起IO呼叫,上下文從使用者態轉為核心態(切換1)
  • DMA控制器把資料從磁碟中,讀取到核心緩衝區。
  • CPU把核心緩衝區資料,拷貝到使用者應用緩衝區,上下文從核心態轉為使用者態(切換2) ,read函數返回
  • 使用者應用程序通過write函數,發起IO呼叫,上下文從使用者態轉為核心態(切換3)
  • CPU將使用者緩衝區中的資料,拷貝到socket緩衝區
  • DMA控制器把資料從socket緩衝區,拷貝到網路卡裝置,上下文從核心態切換回使用者態(切換4) ,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拷貝的次數。零拷貝實現有多種方式,分別是

  • mmap+write
  • sendfile

在伺服器端那裡,我們已經知道了kafka索引檔案使用的mmap來進行零拷貝優化的,現在告訴你kafka消費者在讀取訊息的時候使用的是sendfile來進行零拷貝優化。

linux 2.4版本之後,對sendfile做了優化升級,引入SG-DMA技術,其實就是對DMA拷貝加入了scatter/gather操作,它可以直接從核心空間緩衝區中將資料讀取到網路卡。使用這個特點搞零拷貝,即還可以多省去一次CPU拷貝 。
sendfile+DMA scatter/gather實現的零拷貝流程如下:

  • 使用者程序發起sendfile系統呼叫,上下文(切換1)從使用者態轉向核心態。
  • DMA控制器,把資料從硬碟中拷貝到核心緩衝區。
  • CPU把核心緩衝區中的檔案描述符資訊 (包括核心緩衝區的記憶體地址和偏移量)傳送到socket緩衝區
  • DMA控制器根據檔案描述符資訊,直接把資料從核心緩衝區拷貝到網路卡
  • 上下文(切換2)從核心態切換回使用者態 ,sendfile呼叫返回。

可以發現,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 這樣的零拷貝系統呼叫,則這兩個方法會通過這樣的系統呼叫充分利用零拷貝的優勢,否則並不能通過這兩個方法本身實現零拷貝。

4 總結

文章第一部分為大家講解了高效能常見的優化手段,從」祕籍」和」道法」兩個方面來詮釋高效能設計之路該如何走,並引申出計算和IO兩個優化方向。

文章第二部分是kafka內部高效能的具體設計——分別從生產者、伺服器端、消費者來進行全方位講解,包括其設計、使用及相關原理。

希望通過這篇文章,能夠使大家不僅學習到相關方法論,也能明白其方法論具體的落地方案,一起學習,一起成長。

作者:京東物流 李鵬

來源:京東雲開發者社群