從Kafka中學(xué)習(xí)高性能系統(tǒng)如何設(shè)計(jì)
1 前言
相信各位小伙伴之前或多或少接觸過消息隊(duì)列,比較知名的包含Rocket MQ和Kafka,在京東內(nèi)部使用的是自研的消息中間件JMQ,從JMQ2升級(jí)到JMQ4的也是帶來了性能上的明顯提升,并且JMQ4的底層也是參考Kafka去做的設(shè)計(jì)。在這里我會(huì)給大家展示Kafka它的高性能是如何設(shè)計(jì)的,大家也可以學(xué)習(xí)相關(guān)方法論將其利用在實(shí)際項(xiàng)目中,也許下一個(gè)頂級(jí)項(xiàng)目就在各位的代碼中產(chǎn)生了。
2 如何理解高性能設(shè)計(jì)
2.1 高性能設(shè)計(jì)的”秘籍”
先拋開kafka,咱們先來談?wù)撘幌赂咝阅茉O(shè)計(jì)的本質(zhì),在這里借用一下網(wǎng)上的一張總結(jié)高性能的思維導(dǎo)圖:

從中可以看到,高性能設(shè)計(jì)的手段還是非常多,從”微觀設(shè)計(jì)”上的無鎖化、序列化,到”宏觀設(shè)計(jì)”上的緩存、存儲(chǔ)等,可以說是五花八門,令人眼花繚亂。但是在我看來本質(zhì)就兩點(diǎn):計(jì)算和IO。下面將從這兩點(diǎn)來淺析一下我認(rèn)為的高性能的”道”。
2.2 高性能設(shè)計(jì)的”道法”
2.2.1 計(jì)算上的”道”
計(jì)算上的優(yōu)化手段無外乎兩種方式:1.減少計(jì)算量 2.加快單位時(shí)間的計(jì)算量
減少計(jì)算量:比如用索引來取代全局掃描、用同步代替異步、通過限流來減少請(qǐng)求處理量、采用更高效的數(shù)據(jù)結(jié)構(gòu)和算法等。(舉例:mysql的BTree,redis的跳表等)
加快單位時(shí)間的計(jì)算量:可以利用CPU多核的特性,比如用多線程代替單線程、用集群代替單機(jī)等。(舉例:多線程編程、分治計(jì)算等)
2.2.2 IO上的”道”
IO上的優(yōu)化手段也可以從兩個(gè)方面來體現(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è)計(jì)
理解了高性能設(shè)計(jì)的手段和本質(zhì)之后,我們?cè)賮砜纯磌afka里面使用到的性能優(yōu)化方法。各類消息中間件的本質(zhì)都是一個(gè)生產(chǎn)者-消費(fèi)者模型,生產(chǎn)者發(fā)送消息給服務(wù)端進(jìn)行暫存,消費(fèi)者從服務(wù)端獲取消息進(jìn)行消費(fèi)。也就是說kafka分為三個(gè)部分:生產(chǎn)者-服務(wù)端-消費(fèi)者,我們可以按照這三個(gè)來分別歸納一下其關(guān)于性能優(yōu)化的手段,這些手段也會(huì)涵蓋在我們之前梳理的腦圖里面。
3.1 生產(chǎn)者的高性能設(shè)計(jì)
3.1.1 批量發(fā)送消息
之前在上面說過,高性能的”道”在于計(jì)算和IO上,咱們先來看看在IO上kafka是如何做設(shè)計(jì)的。
IO上的優(yōu)化
kafka是一個(gè)消息中間件,數(shù)據(jù)的載體就是消息,如何將消息高效的進(jìn)行傳遞和持久化是kafka高性能設(shè)計(jì)的一個(gè)重點(diǎn)?;诖朔治鰇afka肯定是IO密集型應(yīng)用,producer需要通過網(wǎng)絡(luò)IO將消息傳遞給broker,broker需要通過磁盤IO將消息持久化,consumer需要通過網(wǎng)絡(luò)IO將消息從broker上拉取消費(fèi)。
網(wǎng)絡(luò)IO上的優(yōu)化:producer->broker發(fā)送消息不是一條一條發(fā)送的,kafka模式會(huì)有個(gè)消息發(fā)送延遲機(jī)制,會(huì)將一批消息進(jìn)行聚合,一口氣打包發(fā)送給broker,這樣就成功減少了IO的次數(shù)。除了傳輸消息本身以外,還要傳輸非常多的網(wǎng)絡(luò)協(xié)議本身的一些內(nèi)容(稱為Overhead),所以將多條消息合并到一起傳輸,可有效減少網(wǎng)絡(luò)傳輸?shù)腛verhead,進(jìn)而提高了傳輸效率。
磁盤IO上的優(yōu)化:大家知道磁盤和內(nèi)存的存儲(chǔ)速度是不同的,在磁盤上操作的速度是遠(yuǎn)低于內(nèi)存,但是在成本上內(nèi)存是高于磁盤。kafka是面向大數(shù)據(jù)量的消息中間件,也就是說需要將大批量的數(shù)據(jù)持久化,這些數(shù)據(jù)放在內(nèi)存上也是不現(xiàn)實(shí)。那kafka是怎么在磁盤IO上進(jìn)行優(yōu)化的呢?在這里我先直接給出方法,具體細(xì)節(jié)在后文中解釋(它是借助于一種磁盤順序?qū)懙臋C(jī)制來提升寫入速度)。
3.1.2 負(fù)載均衡
1.kafka負(fù)載均衡設(shè)計(jì)

Kafka有主題(Topic)概念,他是承載真實(shí)數(shù)據(jù)的邏輯容器,主題之下還分為若干個(gè)分區(qū),Kafka消息組織方式實(shí)際上是三級(jí)結(jié)構(gòu):主題-分區(qū)-消息。主題下的每條消息只會(huì)在某一個(gè)分區(qū)中,而不會(huì)在多個(gè)分區(qū)中被保存多份。
Kafka這樣設(shè)計(jì),使用分區(qū)的作用就是提供負(fù)載均衡的能力,對(duì)數(shù)據(jù)進(jìn)行分區(qū)的主要目的就是為了實(shí)現(xiàn)系統(tǒng)的高伸縮性(Scalability)。不同的分區(qū)能夠放在不同的節(jié)點(diǎn)的機(jī)器上,而數(shù)據(jù)的讀寫操作也都是針對(duì)分區(qū)這個(gè)粒度進(jìn)行的,每個(gè)節(jié)點(diǎn)的機(jī)器都能獨(dú)立地執(zhí)行各自分區(qū)讀寫請(qǐng)求。我們還可以通過增加節(jié)點(diǎn)來提升整體系統(tǒng)的吞吐量。Kafka的分區(qū)設(shè)計(jì),還可以實(shí)現(xiàn)業(yè)務(wù)級(jí)別的消息順序的問題。
2.具體分區(qū)策略
所謂的分區(qū)策略是指決定生產(chǎn)者將消息發(fā)送到那個(gè)分區(qū)的算法。Kafka提供了默認(rèn)的分區(qū)策略是輪詢,同時(shí)kafka也支持用戶自己制定。
輪詢策略:也稱為Round-robin策略,即順序分配。輪詢的優(yōu)點(diǎn)是有著優(yōu)秀的負(fù)載均衡的表現(xiàn)。
隨機(jī)策略:雖然也是追求負(fù)載均衡,但總體表現(xiàn)差于輪詢。
消息鍵劃分策略:還要一種是為每條消息配置一個(gè)key,按消息的key來存。Kafka允許為每條消息指定一個(gè)key。一旦指定了key ,那么會(huì)對(duì)key進(jìn)行hash計(jì)算,將相同的key存入相同的分區(qū)中,而且每個(gè)分區(qū)下的消息都是有序的。key的作用很大,可以是一個(gè)有著明確業(yè)務(wù)含義的字符串,也可以是用來表征消息的元數(shù)據(jù)。
其他的分區(qū)策略:基于地理位置的分區(qū)??梢詮乃蟹謪^(qū)中找出那些 Leader 副本在某個(gè)地理位置所有分區(qū),然后隨機(jī)挑選一個(gè)進(jìn)行消息發(fā)送。
3.1.3 異步發(fā)送
1.線程模型

之前已經(jīng)說了kafka是選擇批量發(fā)送消息來提升整體的IO性能,具體流程是kafka生產(chǎn)者使用批處理試圖在內(nèi)存中積累數(shù)據(jù),主線程將多條消息通過一個(gè)ProduceRequest請(qǐng)求批量發(fā)送出去,發(fā)送的消息暫存在一個(gè)隊(duì)列(RecordAccumulator)中,再由sender線程去獲取一批數(shù)據(jù)或者不超過某個(gè)延遲時(shí)間內(nèi)的數(shù)據(jù)發(fā)送給broker進(jìn)行持久化。
優(yōu)點(diǎn):
可以提升kafka整體的吞吐量,減少網(wǎng)絡(luò)IO的次數(shù);
提高數(shù)據(jù)壓縮效率(一般壓縮算法都是數(shù)據(jù)量越大越能接近預(yù)期的壓縮效果);
缺點(diǎn):
數(shù)據(jù)發(fā)送有一定延遲,但是這個(gè)延遲可以由業(yè)務(wù)因素來自行設(shè)置。
3.1.4 高效序列化
1.序列化的優(yōu)勢(shì)
Kafka 消息中的 Key 和 Value,都支持自定義類型,只需要提供相應(yīng)的序列化和反序列化器即可。因此,用戶可以根據(jù)實(shí)際情況選用快速且緊湊的序列化方式(比如 ProtoBuf、Avro)來減少實(shí)際的網(wǎng)絡(luò)傳輸量以及磁盤存儲(chǔ)量,進(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.壓縮的目的
壓縮秉承了用時(shí)間換空間的經(jīng)典trade-off思想,即用CPU的時(shí)間去換取磁盤空間或網(wǎng)絡(luò)I/O傳輸量,Kafka的壓縮算法也是出于這種目的。并且通常是:數(shù)據(jù)量越大,壓縮效果才會(huì)越好。
因?yàn)橛辛伺堪l(fā)送這個(gè)前期,從而使得 Kafka 的消息壓縮機(jī)制能真正發(fā)揮出它的威力(壓縮的本質(zhì)取決于多消息的重復(fù)性)。對(duì)比壓縮單條消息,同時(shí)對(duì)多條消息進(jìn)行壓縮,能大幅減少數(shù)據(jù)量,從而更大程度提高網(wǎng)絡(luò)傳輸率。
2.壓縮的方法
想了解kafka消息壓縮的設(shè)計(jì),就需要先了解kafka消息的格式:
Kafka的消息層次分為:消息集合(message set)和消息(message);一個(gè)消息集合中包含若干條日志項(xiàng)(record item),而日志項(xiàng)才是真正封裝消息的地方。
Kafka底層的消息日志由一系列消息集合-日志項(xiàng)組成。Kafka通常不會(huì)直接操作具體的一條條消息,他總是在消息集合這個(gè)層面上進(jìn)行寫入操作。
每條消息都含有自己的元數(shù)據(jù)信息,kafka會(huì)將一批消息相同的元數(shù)據(jù)信息給提升到外層的消息集合里面,然后再對(duì)整個(gè)消息集合來進(jìn)行壓縮。批量消息在持久化到 Broker 中的磁盤時(shí),仍然保持的是壓縮狀態(tài),最終是在 Consumer 端做了解壓縮操作。
壓縮算法效率對(duì)比
Kafka 共支持四種主要的壓縮類型:Gzip、Snappy、Lz4 和 Zstd,具體效率對(duì)比如下:

3.2 服務(wù)端的高性能設(shè)計(jì)
3.2.1 Reactor網(wǎng)絡(luò)通信模型
kafka相比其他消息中間件最出彩的地方在于他的高吞吐量,那么對(duì)于服務(wù)端來說每秒的請(qǐng)求壓力將會(huì)巨大,需要有一個(gè)優(yōu)秀的網(wǎng)絡(luò)通信機(jī)制來處理海量的請(qǐng)求。如果 IO 有所研究的同學(xué),應(yīng)該清楚:Reactor 模式正是采用了很經(jīng)典的 IO 多路復(fù)用技術(shù),它可以復(fù)用一個(gè)線程去處理大量的 Socket 連接,從而保證高性能。Netty 和 Redis 為什么能做到十萬甚至百萬并發(fā)?它們其實(shí)都采用了 Reactor 網(wǎng)絡(luò)通信模型。
1.kafka網(wǎng)絡(luò)通信層架構(gòu)

從圖中可以看出,SocketServer和KafkaRequestHandlerPool是其中最重要的兩個(gè)組件:
SocketServer:主要實(shí)現(xiàn)了 Reactor 模式,用于處理外部多個(gè) Clients(這里的 Clients 指的是廣義的 Clients,可能包含 Producer、Consumer 或其他 Broker)的并發(fā)請(qǐng)求,并負(fù)責(zé)將處理結(jié)果封裝進(jìn) Response 中,返還給 Clients
KafkaRequestHandlerPool:Reactor模式中的Worker線程池,里面定義了多個(gè)工作線程,用于處理實(shí)際的I/O請(qǐng)求邏輯。
2.請(qǐng)求流程
Clients 或其他 Broker 通過 Selector 機(jī)制發(fā)起創(chuàng)建連接請(qǐng)求。(NIO的機(jī)制,使用epoll)
Processor 線程接收請(qǐng)求,并將其轉(zhuǎn)換成可處理的 Request 對(duì)象。
Processor 線程將 Request 對(duì)象放入共享的RequestChannel的 Request 隊(duì)列。
KafkaRequestHandler 線程從 Request 隊(duì)列中取出待處理請(qǐng)求,并進(jìn)行處理。
KafkaRequestHandler 線程將 Response 放回到對(duì)應(yīng) Processor 線程的 Response 隊(duì)列。
Processor 線程發(fā)送 Response 給 Request 發(fā)送方。
3.2.2 Kafka的底層日志結(jié)構(gòu)
基本結(jié)構(gòu)的展示

Kafka是一個(gè)Pub-Sub的消息系統(tǒng),無論是發(fā)布還是訂閱,都須指定Topic。Topic只是一個(gè)邏輯的概念。每個(gè)Topic都包含一個(gè)或多個(gè)Partition,不同Partition可位于不同節(jié)點(diǎn)。同時(shí)Partition在物理上對(duì)應(yīng)一個(gè)本地文件夾(也就是個(gè)日志對(duì)象Log),每個(gè)Partition包含一個(gè)或多個(gè)Segment,每個(gè)Segment包含一個(gè)數(shù)據(jù)文件和多個(gè)與之對(duì)應(yīng)的索引文件。在邏輯上,可以把一個(gè)Partition當(dāng)作一個(gè)非常長(zhǎng)的數(shù)組,可通過這個(gè)“數(shù)組”的索引(offset)去訪問其數(shù)據(jù)。
2.Partition的并行處理能力
一方面,topic是由多個(gè)partion組成,Producer發(fā)送消息到topic是有個(gè)負(fù)載均衡機(jī)制,基本上會(huì)將消息平均分配到每個(gè)partion里面,同時(shí)consumer里面會(huì)有個(gè)consumer group的概念,也就是說它會(huì)以組為單位來消費(fèi)一個(gè)topic內(nèi)的消息,一個(gè)consumer group內(nèi)包含多個(gè)consumer,每個(gè)consumer消費(fèi)topic內(nèi)不同的partion,這樣通過多partion提高了消息的接收和處理能力
另一方面,由于不同Partition可位于不同機(jī)器,因此可以充分利用集群優(yōu)勢(shì),實(shí)現(xiàn)機(jī)器間的并行處理。并且Partition在物理上對(duì)應(yīng)一個(gè)文件夾,即使多個(gè)Partition位于同一個(gè)節(jié)點(diǎn),也可通過配置讓同一節(jié)點(diǎn)上的不同Partition置于不同的disk drive上,從而實(shí)現(xiàn)磁盤間的并行處理,充分發(fā)揮多磁盤的優(yōu)勢(shì)。
3.過期消息的清除
Kafka的整個(gè)設(shè)計(jì)中,Partition相當(dāng)于一個(gè)非常長(zhǎng)的數(shù)組,而Broker接收到的所有消息順序?qū)懭脒@個(gè)大數(shù)組中。同時(shí)Consumer通過Offset順序消費(fèi)這些數(shù)據(jù),并且不刪除已經(jīng)消費(fèi)的數(shù)據(jù),從而避免了隨機(jī)寫磁盤的過程。
由于磁盤有限,不可能保存所有數(shù)據(jù),實(shí)際上作為消息系統(tǒng)Kafka也沒必要保存所有數(shù)據(jù),需要?jiǎng)h除舊的數(shù)據(jù)。而這個(gè)刪除過程,并非通過使用“讀-寫”模式去修改文件,而是將Partition分為多個(gè)Segment,每個(gè)Segment對(duì)應(yīng)一個(gè)物理文件,通過刪除整個(gè)文件的方式去刪除Partition內(nèi)的數(shù)據(jù)。這種方式清除舊數(shù)據(jù)的方式,也避免了對(duì)文件的隨機(jī)寫操作。
3.2.3 樸實(shí)高效的索引
1.稀疏索引

可以從上面看到,一個(gè)segment包含一個(gè).log后綴的文件和多個(gè)index后綴的文件。那么這些文件具體作用是干啥的呢?并且這些文件除了后綴不同文件名都是相同,為什么這么設(shè)計(jì)?
.log文件:具體存儲(chǔ)消息的日志文件
.index文件:位移索引文件,可根據(jù)消息的位移值快速地從查詢到消息的物理文件位置
.timeindex文件:時(shí)間戳索引文件,可根據(jù)時(shí)間戳查找到對(duì)應(yīng)的位移信息
.txnindex文件:已中止事物索引文件
除了.log是實(shí)際存儲(chǔ)消息的文件以外,其他的幾個(gè)文件都是索引文件。索引本身設(shè)計(jì)的原來是一種空間換時(shí)間的概念,在這里kafka是為了加速查詢所使用。kafka索引不會(huì)為每一條消息建立索引關(guān)系,這個(gè)也很好理解,畢竟對(duì)一條消息建立索引的成本還是比較大的,所以它是一種稀疏索引的概念,就好比我們常見的跳表,都是一種稀疏索引。
kafka日志的文件名一般都是該segment寫入的第一條消息的起始位移值baseOffset,比如000000000123.log,這里面的123就是baseOffset,具體索引文件里面紀(jì)錄的數(shù)據(jù)是相對(duì)于起始位移的相對(duì)位移值relativeOffset,baseOffset與relativeOffse的加和即為實(shí)際消息的索引值。假設(shè)一個(gè)索引文件為:00000000000000000100.index,那么起始位移值即 100,當(dāng)存儲(chǔ)位移為 150 的消息索引時(shí),在索引文件中的相對(duì)位移則為 150 - 100 = 50,這么做的好處是使用 4 字節(jié)保存位移即可,可以節(jié)省非常多的磁盤空間。(ps:kafka真的是極致的壓縮了數(shù)據(jù)存儲(chǔ)的空間)
2.優(yōu)化的二分查找算法
kafka沒有使用我們熟知的跳表或者B+Tree結(jié)構(gòu)來設(shè)計(jì)索引,而是使用了一種更為簡(jiǎn)單且高效的查找算法:二分查找。但是相對(duì)于傳統(tǒng)的二分查找,kafka將其進(jìn)行了部分優(yōu)化,個(gè)人覺得設(shè)計(jì)的非常巧妙,在這里我會(huì)進(jìn)行詳述。
在這之前,我先補(bǔ)充一下kafka索引文件的構(gòu)成:每個(gè)索引文件包含若干條索引項(xiàng)。不同索引文件的索引項(xiàng)的大小不同,比如offsetIndex索引項(xiàng)大小是8B,timeIndex索引項(xiàng)的大小是12B。

這里以offsetIndex為例子來詳述kafka的二分查找算法:
1)普通二分查找
offsetIndex每個(gè)索引項(xiàng)大小是8B,但操作系統(tǒng)訪問內(nèi)存時(shí)的最小單元是頁,一般是4KB,即4096B,會(huì)包含了512個(gè)索引項(xiàng)。而找出在索引中的指定偏移量,對(duì)于操作系統(tǒng)訪問內(nèi)存時(shí)則變成了找出指定偏移量所在的頁。假設(shè)索引的大小有13個(gè)頁,如下圖所示:

由于Kafka讀取消息,一般都是讀取最新的偏移量,所以要查詢的頁就集中在尾部,即第12號(hào)頁上。根據(jù)二分查找,將依次訪問6、9、11、12號(hào)頁。

當(dāng)隨著Kafka接收消息的增加,索引文件也會(huì)增加至第13號(hào)頁,這時(shí)根據(jù)二分查找,將依次訪問7、10、12、13號(hào)頁。

可以看出訪問的頁和上一次的頁完全不同。之前在只有12號(hào)頁的時(shí)候,Kafak讀取索引時(shí)會(huì)頻繁訪問6、9、11、12號(hào)頁,而由于Kafka使用了mmap來提高速度,即讀寫操作都將通過操作系統(tǒng)的page cache,所以6、9、11、12號(hào)頁會(huì)被緩存到page cache中,避免磁盤加載。但是當(dāng)增至13號(hào)頁時(shí),則需要訪問7、10、12、13號(hào)頁,而由于7、10號(hào)頁長(zhǎng)時(shí)間沒有被訪問(現(xiàn)代操作系統(tǒng)都是使用LRU或其變體來管理page cache),很可能已經(jīng)不在page cache中了,那么就會(huì)造成缺頁中斷(線程被阻塞等待從磁盤加載沒有被緩存到page cache的數(shù)據(jù))。在Kafka的官方測(cè)試中,這種情況會(huì)造成幾毫秒至1秒的延遲。
2)kafka優(yōu)化的二分查找
Kafka對(duì)二分查找進(jìn)行了改進(jìn)。既然一般讀取數(shù)據(jù)集中在索引的尾部。那么將索引中最后的8192B(8KB)劃分為“熱區(qū)”(剛好緩存兩頁數(shù)據(jù)),其余部分劃分為“冷區(qū)”,分別進(jìn)行二分查找。這樣做的好處是,在頻繁查詢尾部的情況下,尾部的頁基本都能在page cahce中,從而避免缺頁中斷。
下面我們還是用之前的例子來看下。由于每個(gè)頁最多包含512個(gè)索引項(xiàng),而最后的1024個(gè)索引項(xiàng)所在頁會(huì)被認(rèn)為是熱區(qū)。那么當(dāng)12號(hào)頁未滿時(shí),則10、11、12會(huì)被判定是熱區(qū);而當(dāng)12號(hào)頁剛好滿了的時(shí)候,則11、12被判定為熱區(qū);當(dāng)增至13號(hào)頁且未滿時(shí),11、12、13被判定為熱區(qū)。假設(shè)我們讀取的是最新的消息,則在熱區(qū)中進(jìn)行二分查找的情況如下:

當(dāng)12號(hào)頁未滿時(shí),依次訪問11、12號(hào)頁,當(dāng)12號(hào)頁滿時(shí),訪問頁的情況相同。當(dāng)13號(hào)頁出現(xiàn)的時(shí)候,依次訪問12、13號(hào)頁,不會(huì)出現(xiàn)訪問長(zhǎng)時(shí)間未訪問的頁,則能有效避免缺頁中斷。
3.mmap的使用
利用稀疏索引,已經(jīng)基本解決了高效查詢的問題,但是這個(gè)過程中仍然有進(jìn)一步的優(yōu)化空間,那便是通過 mmap(memory mapped files) 讀寫上面提到的稀疏索引文件,進(jìn)一步提高查詢消息的速度。
究竟如何理解 mmap?前面提到,常規(guī)的文件操作為了提高讀寫性能,使用了 Page Cache 機(jī)制,但是由于頁緩存處在內(nèi)核空間中,不能被用戶進(jìn)程直接尋址,所以讀文件時(shí)還需要通過系統(tǒng)調(diào)用,將頁緩存中的數(shù)據(jù)再次拷貝到用戶空間中。
1)常規(guī)文件讀寫

app拿著inode查找讀取文件
address_space中存儲(chǔ)了inode和該文件對(duì)應(yīng)頁面緩存的映射關(guān)系
頁面緩存缺失,引發(fā)缺頁異常
通過inode找到磁盤地址,將文件信息讀取并填充到頁面緩存
頁面緩存處于內(nèi)核態(tài),無法直接被app讀取到,因此要先拷貝到用戶空間緩沖區(qū),此處發(fā)生內(nèi)核態(tài)和用戶態(tài)的切換
tips:這一過程實(shí)際上發(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ù)時(shí)將用戶態(tài)Buffer數(shù)據(jù)拷貝到內(nèi)核態(tài)Buffer(CPU拷貝),最后通過DMA拷貝將數(shù)據(jù)拷貝到NIC Buffer。同時(shí),還伴隨著四次上下文切換。
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)程虛擬地址做了映射,并不會(huì)招致系統(tǒng)調(diào)用,以及額外的內(nèi)存 copy 開銷,從而提高了文件讀取效率。具體到 Kafka 的源碼層面,就是基于 JDK nio 包下的 MappedByteBuffer 的 map 函數(shù),將磁盤文件映射到內(nèi)存中。只有索引文件的讀寫才用到了 mmap。
3.2.4 消息存儲(chǔ)-磁盤順序?qū)?/h1>
對(duì)于我們常用的機(jī)械硬盤,其讀取數(shù)據(jù)分3步:
尋道;
尋找扇區(qū);
讀取數(shù)據(jù);
前兩個(gè),即尋找數(shù)據(jù)位置的過程為機(jī)械運(yùn)動(dòng)。我們常說硬盤比內(nèi)存慢,主要原因是這兩個(gè)過程在拖后腿。不過,硬盤比內(nèi)存慢是絕對(duì)的嗎?其實(shí)不然,如果我們能通過順序讀寫減少尋找數(shù)據(jù)位置時(shí)讀寫磁頭的移動(dòng)距離,硬盤的速度還是相當(dāng)可觀的。一般來講,IO速度層面,內(nèi)存順序IO > 磁盤順序IO > 內(nèi)存隨機(jī)IO > 磁盤隨機(jī)IO。這里用一張網(wǎng)上的圖來對(duì)比一下相關(guān)IO性能:

Kafka在順序IO上的設(shè)計(jì)分兩方面看:
LogSegment創(chuàng)建時(shí),一口氣申請(qǐng)LogSegment最大size的磁盤空間,這樣一個(gè)文件內(nèi)部盡可能分布在一個(gè)連續(xù)的磁盤空間內(nèi);
.log文件也好,.index和.timeindex也罷,在設(shè)計(jì)上都是只追加寫入,不做更新操作,這樣避免了隨機(jī)IO的場(chǎng)景;
3.2.5 Page Cache的使用

為了優(yōu)化讀寫性能,Kafka利用了操作系統(tǒng)本身的Page Cache,就是利用操作系統(tǒng)自身的內(nèi)存而不是JVM空間內(nèi)存。這樣做的好處有:
避免Object消耗:如果是使用 Java 堆,Java對(duì)象的內(nèi)存消耗比較大,通常是所存儲(chǔ)數(shù)據(jù)的兩倍甚至更多。
避免GC問題:隨著JVM中數(shù)據(jù)不斷增多,垃圾回收將會(huì)變得復(fù)雜與緩慢,使用系統(tǒng)緩存就不會(huì)存在GC問題
相比于使用JVM或in-memory cache等數(shù)據(jù)結(jié)構(gòu),利用操作系統(tǒng)的Page Cache更加簡(jiǎn)單可靠。
首先,操作系統(tǒng)層面的緩存利用率會(huì)更高,因?yàn)榇鎯?chǔ)的都是緊湊的字節(jié)結(jié)構(gòu)而不是獨(dú)立的對(duì)象。
其次,操作系統(tǒng)本身也對(duì)于Page Cache做了大量?jī)?yōu)化,提供了 write-behind、read-ahead以及flush等多種機(jī)制。
再者,即使服務(wù)進(jìn)程重啟,JVM內(nèi)的Cache會(huì)失效,Page Cache依然可用,避免了in-process cache重建緩存的過程。
通過操作系統(tǒng)的Page Cache,Kafka的讀寫操作基本上是基于內(nèi)存的,讀寫速度得到了極大的提升。
3.3 消費(fèi)端的高性能設(shè)計(jì)
3.3.1 批量消費(fèi)
生產(chǎn)者是批量發(fā)送消息,消息者也是批量拉取消息的,每次拉取一個(gè)消息batch,從而大大減少了網(wǎng)絡(luò)傳輸?shù)?overhead。在這里kafka是通過fetch.min.bytes參數(shù)來控制每次拉取的數(shù)據(jù)大小。默認(rèn)是 1 字節(jié),表示只要 Kafka Broker 端積攢了 1 字節(jié)的數(shù)據(jù),就可以返回給 Consumer 端,這實(shí)在是太小了。我們還是讓 Broker 端一次性多返回點(diǎn)數(shù)據(jù)吧。
并且,在生產(chǎn)者高性能設(shè)計(jì)目錄里面也說過,生產(chǎn)者其實(shí)在 Client 端對(duì)批量消息進(jìn)行了壓縮,這批消息持久化到 Broker 時(shí),仍然保持的是壓縮狀態(tài),最終在 Consumer 端再做解壓縮操作。
3.3.2 零拷貝-磁盤消息文件的讀取
1.zero-copy定義
零拷貝并不是不需要拷貝,而是減少不必要的拷貝次數(shù)。通常是說在IO讀寫過程中。
零拷貝字面上的意思包括兩個(gè),“零”和“拷貝”:
“拷貝”:就是指數(shù)據(jù)從一個(gè)存儲(chǔ)區(qū)域轉(zhuǎn)移到另一個(gè)存儲(chǔ)區(qū)域。
“零” :表示次數(shù)為0,它表示拷貝數(shù)據(jù)的次數(shù)為0。
實(shí)際上,零拷貝是有廣義和狹義之分,目前我們通常聽到的零拷貝,包括上面這個(gè)定義減少不必要的拷貝次數(shù)都是廣義上的零拷貝。其實(shí)了解到這點(diǎn)就足夠了。
我們知道,減少不必要的拷貝次數(shù),就是為了提高效率。那零拷貝之前,是怎樣的呢?
2.傳統(tǒng)IO的流程
做服務(wù)端開發(fā)的小伙伴,文件下載功能應(yīng)該實(shí)現(xiàn)過不少了吧。如果你實(shí)現(xiàn)的是一個(gè)web程序 ,前端請(qǐng)求過來,服務(wù)端的任務(wù)就是:將服務(wù)端主機(jī)磁盤中的文件從已連接的socket發(fā)出去。關(guān)鍵實(shí)現(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)知識(shí)點(diǎn)。
3.零拷貝相關(guān)知識(shí)點(diǎn)
1)內(nèi)核空間和用戶空間
操作系統(tǒng)為每個(gè)進(jìn)程都分配了內(nèi)存空間,一部分是用戶空間,一部分是內(nèi)核空間。內(nèi)核空間是操作系統(tǒng)內(nèi)核訪問的區(qū)域,是受保護(hù)的內(nèi)存空間,而用戶空間是用戶應(yīng)用程序訪問的內(nèi)存區(qū)域。 以32位操作系統(tǒng)為例,它會(huì)為每一個(gè)進(jìn)程都分配了4G (2的32次方)的內(nèi)存空間。
內(nèi)核空間:主要提供進(jìn)程調(diào)度、內(nèi)存分配、連接硬件資源等功能
用戶空間:提供給各個(gè)程序進(jìn)程的空間,它不具有訪問內(nèi)核空間資源的權(quán)限,如果應(yīng)用程序需要使用到內(nèi)核空間的資源,則需要通過系統(tǒng)調(diào)用來完成。進(jìn)程從用戶空間切換到內(nèi)核空間,完成相關(guān)操作后,再?gòu)膬?nèi)核空間切換回用戶空間。
2)用戶態(tài)&內(nèi)核態(tài)
如果進(jìn)程運(yùn)行于內(nèi)核空間,被稱為進(jìn)程的內(nèi)核態(tài)
如果進(jìn)程運(yùn)行于用戶空間,被稱為進(jìn)程的用戶態(tài)。
3)上下文切換
cpu上下文
CPU 寄存器,是CPU內(nèi)置的容量小、但速度極快的內(nèi)存。而程序計(jì)數(shù)器,則是用來存儲(chǔ) CPU 正在執(zhí)行的指令位置、或者即將執(zhí)行的下一條指令位置。它們都是 CPU 在運(yùn)行任何任務(wù)前,必須的依賴環(huán)境,因此叫做CPU上下文。
cpu上下文切換
它是指,先把前一個(gè)任務(wù)的CPU上下文(也就是CPU寄存器和程序計(jì)數(shù)器)保存起來,然后加載新任務(wù)的上下文到這些寄存器和程序計(jì)數(shù)器,最后再跳轉(zhuǎn)到程序計(jì)數(shù)器所指的新位置,運(yùn)行新任務(wù)。
一般我們說的上下文切換 ,就是指內(nèi)核(操作系統(tǒng)的核心)在CPU上對(duì)進(jìn)程或者線程進(jìn)行切換。進(jìn)程從用戶態(tài)到內(nèi)核態(tài)的轉(zhuǎn)變,需要通過系統(tǒng)調(diào)用 來完成。系統(tǒng)調(diào)用的過程,會(huì)發(fā)生CPU上下文的切換 。
4)DMA技術(shù)
DMA,英文全稱是Direct Memory Access ,即直接內(nèi)存訪問。DMA 本質(zhì)上是一塊主板上獨(dú)立的芯片,允許外設(shè)設(shè)備和內(nèi)存存儲(chǔ)器之間直接進(jìn)行IO數(shù)據(jù)傳輸,其過程不需要CPU的參與 。
我們一起來看下IO流程,DMA幫忙做了什么事情。

可以發(fā)現(xiàn),DMA做的事情很清晰啦,它主要就是幫忙CPU轉(zhuǎn)發(fā)一下IO請(qǐng)求,以及拷貝數(shù)據(jù) 。
之所以需要DMA,主要就是效率,它幫忙CPU做事情,這時(shí)候,CPU就可以閑下來去做別的事情,提高了CPU的利用效率。
4.kafka消費(fèi)的zero-copy
1)實(shí)現(xiàn)原理
零拷貝并不是沒有拷貝數(shù)據(jù),而是減少用戶態(tài)/內(nèi)核態(tài)的切換次數(shù)以及CPU拷貝的次數(shù)。零拷貝實(shí)現(xiàn)有多種方式,分別是
mmap+write
sendfile
在服務(wù)端那里,我們已經(jīng)知道了kafka索引文件使用的mmap來進(jìn)行零拷貝優(yōu)化的,現(xiàn)在告訴你kafka消費(fèi)者在讀取消息的時(shí)候使用的是sendfile來進(jìn)行零拷貝優(yōu)化。
linux 2.4版本之后,對(duì)sendfile做了優(yōu)化升級(jí),引入SG-DMA技術(shù),其實(shí)就是對(duì)DMA拷貝加入了scatter/gather操作,它可以直接從內(nèi)核空間緩沖區(qū)中將數(shù)據(jù)讀取到網(wǎng)卡。使用這個(gè)特點(diǎn)搞零拷貝,即還可以多省去一次CPU拷貝 。
sendfile+DMA scatter/gather實(shí)現(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實(shí)現(xiàn)的零拷貝,I/O發(fā)生了2 次用戶空間與內(nèi)核空間的上下文切換,以及2次數(shù)據(jù)拷貝。其中2次數(shù)據(jù)拷貝都是包DMA拷貝 。這就是真正的 零拷貝(Zero-copy) 技術(shù),全程都沒有通過CPU來搬運(yùn)數(shù)據(jù),所有的數(shù)據(jù)都是通過DMA來進(jìn)行傳輸?shù)摹?/p>
2)底層實(shí)現(xiàn)
Kafka數(shù)據(jù)傳輸通過 TransportLayer 來完成,其子類 PlaintextTransportLayer 通過Java NIO 的 FileChannel 的 transferTo 和 transferFrom 方法實(shí)現(xiàn)零拷貝。底層就是sendfile。消費(fèi)者從broker讀取數(shù)據(jù),就是由此實(shí)現(xiàn)。
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException { ? return fileChannel.transferTo(position, count, socketChannel);
}
tips: transferTo 和 transferFrom 并不保證一定能使用零拷貝。實(shí)際上是否能使用零拷貝與操作系統(tǒng)相關(guān),如果操作系統(tǒng)提供 sendfile 這樣的零拷貝系統(tǒng)調(diào)用,則這兩個(gè)方法會(huì)通過這樣的系統(tǒng)調(diào)用充分利用零拷貝的優(yōu)勢(shì),否則并不能通過這兩個(gè)方法本身實(shí)現(xiàn)零拷貝。
4 總結(jié)
文章第一部分為大家講解了高性能常見的優(yōu)化手段,從”秘籍”和”道法”兩個(gè)方面來詮釋高性能設(shè)計(jì)之路該如何走,并引申出計(jì)算和IO兩個(gè)優(yōu)化方向。