架構師-kafka(四)

高級特性

除了正常的消息發送和消費,在使用 Kaflca 的過程中難免會遇到一些其他高級應用類的需求

  • 消費回溯 可以通過原生 Kaflca 提供的KaflcaConsumer.seek()方法來實現
  • 延時隊列
  • 消息軌跡

kafka原生沒有提供上面的高級特性,可以通過一定的手段來擴展 Kaflca,
RabbitMQ, 使用到了延時隊列、消息軌跡

過期時間(TTL)

可以通過消息timestamp字段和ConsumerInterceptor接口的onConsume()方式來實現消息的TTL功能。
過期消息可以 保存在死信隊列中,方便應用通過消費死信隊列中消息來進行診斷系統運行狀況
可以將消息的TTL的設定值以健值對形式保存在消息的headers字段中,消費者消費到這條消息可以在攔截器根據headers字段設定設定的超時來判斷消息是否超時
工具類

public class BytesUtils {
    public static byte[] longToBytes(long res){
        byte[] buffer = new byte[8];
        for (int i = 0; i<8; i++) {
            int offset = 64-(i+1)*8;
            buffer[i]= (byte) ((res>>offset) & 0xff);
        }
        return buffer;
    }
    public static long byteToLong(byte[] b){
        long values = 0;
        for (int i = 0; i < 8; i++) {
            values <<= 8;
            values |= (b[i] & 0xff);
        }
        return values;
    }
}

發送端核心代碼

KafkaProducer<String, String> producer =  new KafkaProducer<>(properties);
String topic = "topic_ttl_test";
ProducerRecord<String,String> record1 = new ProducerRecord<>
			("topic_ttl_test",null,System.currentTimeMillis(),
                 null,"msg_ttl_1",
                 new RecordHeaders().add(
                 	new RecordHeader("ttl",BytesUtils.longToBytes(20))));
 //超時的消息                 	
ProducerRecord<String, String> record2 =
       new ProducerRecord<>(topic, null, System.currentTimeMillis() -5*1000, null,
                "msg_ ttl_ 2", new RecordHeaders ().
                 add (new RecordHeader ("ttl", BytesUtils.longToBytes(5))));
                 
ProducerRecord<String, String> record3 =
       new ProducerRecord<> (topic, null, System. currentTimeMillis (),null, 
       			"msg_tt1_3", new RecordHeaders ()
       			. add (new RecordHeader ("ttl",BytesUtils.longToBytes(30)))) ;
producer.send(record1).get();
producer.send(record2).get();
producer.send(record3).get();

消費端攔截器

public class ConumerInterceptorTTL implements ConsumerInterceptor<String,String> {
    @Override
    public ConsumerRecords<String, String> onConsume(ConsumerRecords<String, String> records) {
        long now = System.currentTimeMillis();
        Map<TopicPartition,List<ConsumerRecord<String,String>>> newRecords = new HashMap<>();
        for (TopicPartition tp:records.partitions()) {
            List<ConsumerRecord<String,String>> tpRecords = records.records(tp);
            List<ConsumerRecord<String,String>> newTpRecords = new ArrayList<>();
            for (ConsumerRecord<String,String> record:tpRecords) {
                Headers headers = record.headers();
                long ttl = -1;
                for (Header header : headers) {
                    if(header.key().equals("ttl")) {
                        ttl = BytesUtils.byteToLong(header.value());
                    }
                }
                // 消息超時判定
                if(ttl>0 && now-record.timestamp() < ttl * 1000){
                    // 可以放在死信隊列中
                }else { // 沒有設置TTL,不需要超時判定
                    newTpRecords.add(record);
                }

            }
            if(!newRecords.isEmpty()){
                newRecords.put(tp,newTpRecords);
            }
        }
        return new ConsumerRecords<>(newRecords);
    }

    @Override
    public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets) {
        offsets.forEach((tp,offset)->
            System.out.println(tp+":"+offset.offset()));
    }

    @Override
    public void close() {
    }

    @Override
    public void configure(Map<String, ?> configs) {

    }
}

經過攔截器處理後,只會收到msg_ttl_和msg_ttl_3這兩條消息

延時隊列

隊列是存儲消息的載體, 延時隊列存儲的對象是延時消息。 所謂的 “延時消息” 是指消息被髮送以後, 並不想讓消費者立刻獲取, 而是等待特定的時間後, 消費者才能獲取這個消息進行消費, 延時隊列一般也被稱爲 “延遲隊列”。 注意延時與TTL的區別, 延時的消息達到目標延時時間後才能被消費, 而TTL的消息達到目標超時時間後會被丟棄

延時隊列的使用場景有很多:

  • 在訂單系統中, 一 個用戶下單之後通常有30分鐘的時間進行支付, 如果30分鐘之內沒有支付成功, 那麼這個訂單將進行異常處理, 這時就可以使用延時隊列來處理這些訂單了。
  • 訂單完成1小時後通知用戶進行評價。
  • 用戶希望通過手機遠程遙控家裏的智能設備在指定時間進行工作。 這時就可以將用戶
    指令發送到延時隊列, 當指令設定的時間到了之後再將它推送到智能設備。

死信隊列和重試隊列

由千某些原因消息無法被正確地投遞, 爲了確保消息不會被無故地丟棄, 一般將其置於一個特殊角色的隊列, 這個隊列一般稱爲死信隊列。 後續分析程序可以通過消費這個死信隊列中的內容來分析當時遇到的異常情況, 進而可以改善和優化系統

與死信隊列對應的還有一個 “ 回退隊列” 的概念, 如果消費者在消費時發生了異常, 那麼就不會對這一次消費進行確認, 進而發生回滾消息的操作之後, 消息始終會放在隊列的頂部,然後不斷被處理和回滾, 導致隊列陷入死循環。 爲了解決這個問題, 可以爲每個隊列設置一個回退隊列, 它和死信隊列都是爲異常處理提供的一種機制保障。 實際情況下, 回退隊列的角色可以由死信隊列和重試隊列來扮演

理解死信隊列, 關鍵是要理解死信。 死信可以看作消費者不能處理收到的消息, 也可以看作消費者不想處理收到的消息, 還可以看作不符合處理要求的消息。 比如消息內包含的消息內容無法被消費者解析, 爲了確保消息的可靠性而不被隨意丟棄, 故將其投遞到死信隊列中, 這裏的死信就可以看作消費者不能處理的消息。 再比如超過既定的重試次數之後將消息投入死信隊列, 這裏就可以將死信看作不符合處理要求的消息

至於死信隊列到底怎麼用, 是從 broker端存入死信隊列,還是從消費端存入死信隊列, 需要先思考兩個問題: 死信有什麼用?爲什麼用?從而引發怎麼用。 在RabbitMQ中, 死信一般通過 broker端存入, 而在Kafka中原本並無死信的概念,所以當需要封裝這一層概念的時候,就可以脫離既定思維的束縛, 根據應用情況選擇合適的實現方式, 理解死信的本質進而懂得如何去實現死信隊列的功能

重試隊列其實可以看作一種回退隊列,具體指消費端消費消息失敗時,爲了防止消息無故丟失而重新將消息回滾到broker中。 與回退隊列不同的是, 重試隊列一般分成多個重試等級,每個重試等級一般也會設置重新投遞延時,重試次數越多投遞延時就越大; 衰減重試
舉個栗子:
消息第一次消費失敗入重試隊列Ql, Ql的重新投遞延時爲5s, 5s過後重新投遞該消息;
如果消息再次消費失敗則入重試隊列Q2,Q2的重新投遞延時爲10s, 10s過後再次投遞該消息
以此類推,重試越多次重新投遞的時間就越久,爲此還需要設置一個上限,超過投遞次數就進入死信隊列。

重試隊列和延時隊列區別
重試隊列與延時隊列有相同的地方, 都需要設置延時級別。
它們的區別是: 延時隊列動作由內部觸發, 重試隊列動作由外部消費端觸發;延時隊列作用一次, 而重試隊列的作用範圍會向後傳遞

kafka命令行工具

時間輪

Kafka中 存在大量的延時操作, 比如延時生產、 延時 拉取和延時 刪除等 。Kafka並沒有使用JDK自帶的Timer 或DelayQueue來實現 延時的功能,而是基於時間輪的概念自定義 實現了一個用千延時功能的定時器(SystemTimer)

JDK中 Timer和DelayQueue的插入和刪除操作的平均時間複雜度爲O(nlogn),而基於時間輪可以將插入和刪除操作的時間複雜度都降爲O(1)

時間輪的應用並非Kafka獨有, 其 應用場景還有很多,在Netty、Akka、 Quartz、 ZooKeeper等組件中都存在時間輪的蹤影。

Kafka中的時間輪(TimingWheel)是一個存儲定時任務的環形隊列, 底層採用數組實現, 數組中的每個元素可以存放一個定時任務列表(TimerTaskList)。TimerTaskList是一個環形的雙向鏈表,鏈表中的每 一項表示的都是定時任務項(TimerTaskEntry), 其中封裝了真正的定時任務(TimerTask)
在這裏插入圖片描述

  • tickMs代表當前時間輪的基本時間跨度
  • wheelSize 時間輪的時間格個數 固定值
  • interval 整個時間輪的總體時間跨度 = tickMsX wheelSize
  • currentTime 時間輪當前所處的時間,currentTime當前指向的時間格也屬千到期部分, 表示剛好到期, 需要 處 理此 時間格 所對 應的TimerTaskList中的所有任務

層級時間輪-多層時間輪
在這裏插入圖片描述
第 一 層的時間輪 tickMs= 1ms、whee!Size=20、interval=20ms。
第二層的時間輪的 tickMs爲第 一 層時間輪的interval, 即20ms。
每一 層時間輪的wheelSize是固定的, 都是 20, 那麼第二層的時間輪的總體時間跨度interval爲400ms。以此類推, 這個 400ms也是第三層的 tickMs的大小, 第三層的時間輪的總體時間跨度爲8000ms
時間輪可以升級,可以可以降級

Kafka中的定時器借了JDK中的DelayQueue來協助推進時間輪。具體做法是對於每個使用到TimerTaskList都加入DelayQueue, "每個用到的TimerTaskList"特指非哨兵節點的定時任務TimerTaskEntry對應的TimerTaskList 。 DelayQueue會根據TimerTaskList對應的超時時間expiration來排序,最短expiration的TimerTaskList會被排在DelayQueue 的隊頭。Kafka中會有 一個線程(ExpiredOperationReaper收割機)來獲取DelayQueue 中到期的任務列表

當 “ 收割機 ” 線程 獲取DelayQueue中超時的任務列表TimerTaskList之後, 既可以根據TimerTaskList 的 expiration來推進時間輪的時間, 也可以就獲取的TimerTaskList 執行相應的操作, 對裏面的TimerTaskEntry該執行過期操作的就 執行過期操作,該降級時間輪的就降級時間輪。

Kafka中的TimingWheel專門用來執行插入和刪除TimerTaskEntry的操作, 而 DelayQueue專門負責時間推進的任務

事務

消息傳輸保障

(1) at most once:至多一次。消息可能會丟失,但絕對不會重複傳輸。
(2) at least once 最少一次。消息絕不會丟失,但可能會重複傳輸。
(3) exactly once:恰好一次。每條消息肯定會被傳輸一次且僅傳輸一次。

Kafka 的消息傳輸保障機制非常直觀 當生產者向 Kafka 發送消息時,一旦消息被成功提交到日誌文件,由於多副本機制的存在,這條消息就不會丟失。如果生產者發送消息到 Kafka之後,遇到了網絡問題而造成通信中斷,那麼生產者就無法判斷該消息是否己經提交。雖然 Kafka無法確定網絡故障期間發生了什麼,但生產者可以進行多次重試來確保消息 已經寫入 Kafka,這個重試的過程中有可能會造成消息的重複寫入,所以這裏 Kafka 提供的消息傳輸保障爲 at least once

對消費者而言,消費者處理消息和提交消費位移的順序在很大程度上決定了消費者提供哪種消息傳輸保障

  • 如果消費者在拉取完消息之後 ,應用邏輯先處理消息後提交消費位移,那麼在消息處理之後且在位移提交之前消費者看機了,待它重新上線之後,會從上一次位移提交的位置拉取,這樣就出現了重複消費,因爲有部分消息已經處理過了只是還沒來得及提交消費位移,此時就對應 at least once 。
  • 如果消費者在拉完消息之後,應用邏輯先提交消費位移後進行消息處理,那麼在位移提交之後且在消息處理完成之前消費者巖機了,待它重新上線之後,會從己 交的位移處開始重新 費,但之前尚有部分消息未進行消 ,如此就會發生消息丟失,此時就對應 most once

Kafka 0.11.0.0 版本引 入幕等和事務這兩個特性,以此來實現 EOS ( exactly once semantics ,精確一次處理語義)

Kafka 中的事務可以使應用程序將消費消息、生產消息提交消費位移當作原子操作來處理,同時成功或失敗,即使該生產或消費會跨多個分區

# 應用程序必須提供唯 transactionalld
properties.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG,”transactionId” ) ; 

在這裏插入圖片描述

// 初始化生產者和消費者
KafkaConsumer<String, String> consumer = 
			new KafkaConsumer<>(getConsumerProperties()); 
consumer.subscribe(Collections.singletonList("topic-source"));
KafkaProducer<String, String> producer = 
		new KafkaProducer<>(getProducerProperties()); 
//初始化事務
producer.initTransac七ions() ; 
while (true) { 
	ConsumerRecords<String, String> records = 
	consumer.poll (Duration. ofMillis (1000)) ; 
	if (!records. isEmpty ()) { 
	Map<TopicParti tion, OffsetAndMetadata> offsets = new HashMap<> (); 
	// 開啓事務
	producer.beginTransaction(); 
	try { 
		for (TopicPartition partition : records.par七itions()) { 
			List<ConsumerRecord<String, String>> partitionRecords
								= records.records(partition);
			for (ConsumerRecord<S七ring, String> record : 
							partitionRecords) { 
					//do some logical processing. 
				ProducerRecord<String, String> producerRecord = 
					new ProducerRecord<> ("topic-sink", record. key (), record.value()); 
				//消費—生產模型
				producer.send(producerRecord);	
			}
			long lastConsumedOffset = partitionRecords.get(partitionRecords.size() - 1) 		
										.offset();
			offsets.pu七(partition,new OffsetAndMetadata(lastConsumedOffset + 1));
		} 
		// 提交消費位移
		producer.sendOffsetsToTransaction(offsets,"groupid"); 
		// 提交事務
		producer.commitTransaction();
	) catch (ProducerFencedException e) { 
		//log the exception 
		//中止事務
		producer.abortTransacion();
	}	

在使用KafkaConsumer的時候要將enable.auto.comm江參數設置爲false, 代碼裏也不能手動提交消費位移。

磁盤存儲

磁盤存儲的性能優化

在這裏插入圖片描述
Kafka依賴於文件系統(更底層地來說就是磁盤)來存儲和緩存消息,如上圖, 層級越高代表速度越快。 很顯然, 磁盤處於一個比較尷尬的位置, 這不禁讓我們懷疑Kafka採用這種待久化形式能否提供有競爭力
的性能。 在傳統的消息中間件 RabbitMQ中, 就使用內存作爲默認的存儲介質, 而磁盤作爲備選介質, 以此實現高吞吐和低延遲的特性。 然而, 事實上磁盤可以比我們預想的要快, 也可能比我們預想的要慢, 這完全取決於我們如何使用它

磁盤、SSD和內存的I/0速度對比
在這裏插入圖片描述
一個由6塊7200r/min的RAID-5陣列組成的磁盤簇的線性(順序)寫入速度可以達到600MB/s, 而隨機寫入速度只有lOOKB/s, 兩者性能相差6000倍。 操作系統可以針對線性讀寫做深層次的優化,比如預讀(read-ahead, 提前將一個比較大的磁盤塊讀入內存)後寫(write-behind, 將很多小的邏輯寫操作合併起來組成一個大的物理寫操作)技術。 順序寫盤的速度不僅比隨機寫盤的速度快, 而且也比隨機寫內存的速度快

Kafka在設計時採用了文件追加的方式來寫入消息, 即只能在日誌文件的尾部追加新的消息, 並且也不允許修改己寫入的消息, 這種方式屬於典型的順序寫盤的操作

我們現在大部分企業仍然用的是機械結構的磁盤,如果把消息以隨機的方式寫入到磁盤,那麼磁盤首先要做的就是尋址,也就是定位到數據所在的物理地址,在磁盤上就要找到對應的柱面、磁頭以及對應的扇區;這個過程相對內存來說會消耗大量時間,爲了規避隨機讀寫帶來的時間消耗,kafka採用順序寫的方式存儲數據。即使是這樣,但是頻繁的I/O操作仍然會造成磁盤的性能瓶頸

頁緩存

頁緩存是操作系統實現的一種主要的磁盤緩存,,但凡設計到緩存的,基本都是爲了提升i/o性能,所以頁緩存是用來減少磁盤I/O操作的。

頁緩存就是把磁盤中的數據緩存到內存中, 把對磁盤的訪間變爲對內存的訪問。 爲了彌補性能上的差異, 現代操作系統越來越 “ 激進地 ” 將內存作爲磁盤緩存, 甚至會非常樂意將所有可用的內存用作磁盤緩存, 這樣當內存回收時也幾乎沒有性能損失, 所有對於磁盤的讀寫也將經由統一的緩存。

磁盤高速緩存有兩個重要因素:
第一,訪問磁盤的速度要遠低於訪問內存的速度,若從處理器L1和L2高速緩存訪問則速度更快。
第二,數據一旦被訪問,就很有可能短時間內再次訪問。正是由於基於訪問內存比磁盤快的多,所以磁盤的內存緩存將給系統存儲性能帶來質的飛越。

當 一 個進程準備讀取磁盤上的文件內容時, 操作系統會先查看待讀取的數據所在的頁(page)是否在頁
緩存(pagecache)中,如果存在(命中)則直接返回數據, 從而避免了對物理磁盤的I/0操作;如果沒有
命中, 則操作系統會向磁盤發起讀取請求並將讀取的數據頁存入頁緩存, 之後再將數據返回給進程。
同樣,如果 一 個進程需要將數據寫入磁盤, 那麼操作系統也會檢測數據對應的頁是否在頁緩存中,如
果不存在, 則會先在頁緩存中添加相應的頁, 最後將數據寫入對應的頁。 被修改過後的頁也就變成了
髒頁, 操作系統會在合適的時間把髒頁中的數據寫入磁盤, 以保持數據的 一 致性

Linux操作系統中的vm.dirty_background_ratio參數用來指定當髒頁數量達到系統內存的百分之多少之後就會觸發pdflush/flush/kdmflush等後臺回寫進程的運行來處理髒頁, 一般設置爲小千10 的值即可,但不建議設置爲0。與這個參數對應的還有一個vm.dirty_ratio參數, 它用來指定當髒頁數量達到系統內存的百分之多少之後就不得不開始對髒頁進行處理,在此過程中, 新的I/O請求會被阻擋直至所有髒頁被沖刷到磁盤中。

Kafka中大量使用了頁緩存, 這是Kafka實現高吞吐的重要因素之 一 。 雖然消息都是先被寫入頁緩存,
然後由操作系統負責具體的刷盤任務的, 但在Kafka中同樣提供了同步刷盤及間斷性強制刷盤(fsync),
可以通過 log.flush.interval.messageslog.flush.interval.ms 參數來控制。

同步刷盤能夠保證消息的可靠性,避免因爲宕機導致頁緩存數據還未完成同步時造成的數據丟失。但是
實際使用上,我們沒必要去考慮這樣的因素以及這種問題帶來的損失,消息可靠性可以由多副本來解
決,同步刷盤會帶來性能的影響。 刷盤的操作由操作系統去完成即可

小知識點:
Linux 系統會使用磁盤的 部分作爲 swap 分區,這樣可以進行進程的調度:把當前非活躍的進程調入 swap 分區,以此把內存空出來讓給活躍的進程。對大量使用系統頁緩存的 Kafka而言,應當儘量避免這種內存的交換,否則會對它各方面的性能產生很大的負面影響 。

磁盤I/O流程

在這裏插入圖片描述
從編程角度而言 般磁盤 I/O 的場景有以下四種

  • (1) 用戶調用標準 庫進行 I/O 操作,數據流爲:應用程序 buffer->C庫標準IObuffer->文件系統頁緩存→通過具體文件系統到磁盤。
  • (2) 用戶調用文件 I/O ,數據流爲 應用程序 buffer 文件系統頁緩存→通過具體文件系
    統到磁盤。
  • (3) 用戶打開文件時使用 DI CT ,繞過頁緩存直接讀寫磁盤
  • (4) 用戶使用類似 dd 工具,並使用 direct 參數,繞過系統 cache 與文件系統直接寫磁盤。

發起 I/O 請求的步驟可以表述爲如下的內容(以最長鏈路爲例)

  • 寫操作: 用戶調用 fwrite 把數據寫入 庫標準 IObuffer 後就返回,即寫操作通常是異步操作;數據寫入 庫標準 IObuffer 後,不會立即刷新到磁盤,會將多次小數據量相鄰寫操作先緩存起來合井,最終調用 write 函數 次性寫入(或者將大塊數據分解多次write 調用)頁緩存;數據到達頁緩存後也不會 即刷新到磁盤,內核有 pdflush 線程在不停地檢測髒頁,判斷是否要寫回到磁盤,如果是則發起磁盤 I/O 請求。

  • 讀操作: 用戶調用 fread 庫標準 buffer 讀取數據,如果成功則返回,否則繼續;到頁緩存中讀取數據,如果成功則返回,否則繼續;發起 請求,讀取數據後緩存 buffer 庫標準 IObuffer 並返回。可以看出,讀操作是同步請求。

  • 1/0 請求處理: 通用塊層根據 1/0 請求構造 個或多個 bio 結構並提交給調度層;調度器將 bio 結構進行排序和合並組織成隊列且確保讀寫操作儘可能理想:將一個或多個進程的讀操作合併到 起讀,將一個或多個進程的寫操作合併到一起寫,儘可能變隨機爲順序 (因爲隨機讀寫比順序讀寫要慢〉,讀必須優先滿足, 而寫也不能等太久

1/0 調度策略

針對不同的應用場 1/0 調度策略也會影響 讀寫性能,目前 Linux 統中的度策略有 種,分別爲NOOPCFQDEADLINEANTICIPATO 默認爲CFQ

NOOP 算法的全寫爲 No Operation 。該算法實現了最簡單的 FIFO 隊列,所有 I/O 請求大致按照先來後到的順序進行操作 。之所以說“大致”,原因是 NOOP FIFO 的基礎上還做了相鄰I/O請求的合併,並不是完全按照先進先出的規則滿足 I/O請求
假設有如下的 1/0 請求序列:
100, 500, 101 , 10, 56 , 1000
NOOP 將會按照如下J滿足 I/O 請求:
100 (101 ), 500,10, 56, 1000
CFQ 算法的全寫爲Completely Fair Queuing ,該算法的特點是按照、 請求的地址進行排序,而不是按照先來後到的順序進行響應
假設有如下的請求序列
100, 500, 101, 10 , 56, 1000
CFQ 會按照如下順序滿足
100, 101, 500, 1000, 10, 56
DEADLINE在CFQ 的基礎上,解決了 1/0 請求“餓死”的極端情況。除了 CFQ 本身具有I/O 排序隊列, DEADLINE 額外分別爲讀 I/O 和寫 1/0 提供了 FIFO 隊列 FIFO 隊列的最大等待時間爲 500ms ,寫 FIFO 隊列的最大等待時間爲 Ss FIFO 隊列內的 請求優先級要比CFQ 隊列中的高,而讀 FIFO 隊列的優先級又比寫 FIFO 隊列的優先級高。優先級可以表示如下
FIFO (Read) > FIFO (Write) > CFQ

ANTICIPATORY在 DEADLINE的基礎上,爲每個讀 I/O 都設置了 6ms 的等待時間窗口 如果在 6ms OS 收到了相鄰位置的讀 I/O 請求,就可以立即滿足。ANTICIPATORY算法通過增加等待時間來獲得更高的性能,假設 個塊設備只有 個物理查找磁頭(例如 個單獨的 SATA 硬盤),將多個隨機的小寫入流合併成 個大寫入流(相當於將隨機讀寫變順序讀寫),通過這個原理來使用讀取/寫入的延時換取最大的讀取/寫入吞 量。適用於大多數環境特別是讀取/寫入較多的環境

零拷貝

消息從發送到落地保存,broker維護的消息日誌本身就是文件目錄,每個文件都是二進制保存,生產者
和消費者使用相同的格式來處理。在消費者獲取消息時,服務器先從硬盤讀取數據到內存,然後把內存
中的數據原封不動的通過socket發送給消費者。雖然這個操作描述起來很簡單,但實際上經歷了很多步
驟。
操作系統將數據從磁盤讀入到內核空間的頁緩存(4次複製的過程)
▪ 應用程序將數據從內核空間讀入到用戶空間緩存中
▪ 應用程序將數據寫回到內核空間到socket緩存中
▪ 操作系統將數據從socket緩衝區複製到網卡緩衝區,以便將數據經網絡發出
在這裏插入圖片描述
通過“零拷貝”技術,可以去掉這些沒必要的數據複製操作,同時也會減少上下文切換次數。現代的unix
操作系統提供一個優化的代碼路徑,用於將數據從頁緩存傳輸到socket;在Linux中,是通過sendfile系
統調用來完成的。Java提供了訪問這個系統調用的方法:FileChannel.transferTo API
使用sendfile,只需要一次拷貝就行,允許操作系統將數據直接從頁緩存發送到網絡上。所以在這個優
化的路徑中,只有最後一步將數據拷貝到網卡緩存中是需要的
在這裏插入圖片描述

零拷貝技術通過DMA (Direct Memory Access) 技術將文件內容複製到內核模式下的 ReadBuffer 中。
DMA 引擎直接將數據從內核模式中傳遞到網卡設備(協議引擎)。

問題

kafka爲什麼不支持讀寫分離

在Kafka中 , 生產者寫入消息、 消費者讀取消息的操作都 是與leader副本進行交互的, 從
而實現的是 一種主寫主讀的生產消費模型

數據庫、Redis等都具備主寫主讀的功能, 與此同時還支持主寫從讀的功能,主寫從讀也就是讀寫分離,爲了與主寫主讀對應,這裏就以主寫從讀來稱呼。

從代碼層面上來說,雖然增加了代碼複雜度,但在 Kafka 中這種功能完全可以支待。對於這個問題,我們可以從 **“ 收益點 ”**這個角度來做具體分析。主寫從讀可以讓從節點去分擔主節點的負載壓力,預防主節點負載過重而從節點卻空閒的情況發生。但是主寫從讀也有2個很明顯的缺點:

(1)數據一致性問題。數據從主節點轉到從節點必然會有一個延時的時間窗口,這個時間窗口會導致主從節點之間的數據不一致 。某一 時刻,在主節點和從節點中A數據的值都爲X,之後將主節點中 A 的值修改爲 Y, 那麼在這個變更通知到從節點之前,應用讀取從節點中的A數據的值並不爲最新的Y, 由此便產生了數據不一致的問題。

(2) 延時問題。類似 Redis 這種組件,數據從寫入主節點到同步至從節點中的過程需要經歷網絡一主節點內存一網絡一從節點內存這幾個階段,整個過程會耗費一定的時間。 而在 Kafka中,主從同步會比 Redis 更加耗時,它需要經歷網絡一主節點內存一主節點磁盤一網絡一從節點內存一從節點磁盤這幾個階段。對延時敏感的應用 而言,主寫從讀的功能並不太適用。

主讀從寫可以均攤一定的負載卻不能做到完全的負載均衡,比如對於數據寫壓力很大 而讀壓力很小的情況,從節點只能分攤很少的負載壓力,而絕大多數壓力還是在主節點上。而在 Kafka中卻可以達到很大程度上的負載均衡, 而且這種均衡是在主寫主讀的架構上實現的,我們來看一下 Kafka的生產消費模型
在這裏插入圖片描述
在 Kafka 集羣中有3個分區,每個分區有3個副本,正好均勻地分佈在3個 broker 上,可以很好的負載,
每個 broker 上的讀寫負載都是一樣的,這就說明 Kafka 可以通過主寫主讀實現主寫從讀實現不了的負載均衡
有以下幾種情況(包含但不僅限於)會造成一定程度上的負載不均衡

  • (1) broker 端的分區分配不均。當創建主題的時候可能會出現某些 broker分配到的分區數多而其他 broker分配到的分區數少,那麼自然而然地分配到的 leader副本也就不均。
  • (2) 生產者寫入消息不均。生產者可能只對某些 broker 中的 leader副本進行大量的寫入操作,而對其他 broker 中的 leader副本不聞不問。
  • (3)消費者消費消息不均。消費者可能只對某些 broker 中的 leader 副本進行大量的拉取操作,而對其他 broker 中的 leader副本不聞不問。
  • (4) leader副本的切換不均。在實際應用中可能會由千 broker 宅機而造成主從副本的切換,或者分區副本的重分配等,這些動作都有可能造成各個 broker 中 leader 副本的分配不均。
    防範措施
    針對第一種情況,在主題創建的時候儘可能使分區分配得均衡,好在 Kafka 中相應的分配算法也是在極力地追求這一 目標,如果是開發入員自定義的分配,則需要注意這方面的內容。
    對於第二和第三種情況,主寫從讀也無法解決。
    對於第四種情況,Kafka 提供了優先副本的選舉來達到 leader 副本的均衡,與此同時,也可以配合相應的監控、告警和運維平臺來實現均衡的優化

在實際應用中,配合監控、告警、運維相結合的生態平臺,在絕大多數情況下 Kafka 都能做到很大程度上的負載均衡

總的來說,Kafka 只支持主寫主讀有幾個優點:可以簡化代碼的實現邏輯,減少出錯的可能;將負載粒度細化均攤,與主寫從讀相比,不僅負載效能更好,而且對用戶可控;沒有延時的影響;在副本穩定的清況下,不會出現數據不一致的情況。爲此,Kafka 又何必再去實現對它而言毫無收益的主寫從讀的功能呢?這一切都得益於 Kafka 優秀的架構設計,從某種意義上來說,主寫從讀是由於設計上的缺陷而形成的權宜之計

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章