KafkaConsumer(消費者)每次呼叫 poll()方法,它總是返回由生產者寫入 Kafka但還沒有被消費者讀取過的記錄, 我們因 此可以追蹤到哪些記錄是被群組裡的哪個消費者讀取的。之前已經討論過, Kafka 不會像其他 JMS 佇列那樣需要得到消費者的確認,這是 Kafka 的一個獨特之處。相反,消 費者可以使用 Kafka來追蹤訊息在分割槽裡的位置(偏移量)。
我們把更新分割槽當前位置的操作叫作提交。
那麼消費者是如何提交偏移量的呢?消費者往一個 叫作 _consumer_offset 的特殊主題傳送 訊息,訊息裡包含每個分割槽的偏移量。 如果消費者一直處於執行狀態,那麼偏移量就沒有 什麼用處。不過,如果悄費者發生崩潰或者有新 的消費者加入群組,就會觸發再均衡,完 成再均衡之後,每個消費者可能分配到新 的分割槽,而不是之前處理的那個。為了能夠繼續 之前的工作,消費者需要讀取每個分割槽最後一次提交 的偏移量,然後從偏移量指定的地方 繼續處理。
如果提交的偏移量小於客戶端處理 的最後一個訊息的偏移量 ,那麼處於兩個偏移量之間的 訊息就會被重複處理,如圖 4-6所示。
如果提交的偏移量大於客戶端處理的最後一個訊息的偏移量,那麼處於兩個偏移量之間的 訊息將會丟失,如圖 4-7 所示。
所以,處理偏移量的方式對客戶端會有很大的影響。 KafkaConsumer API提供了很多種方式來提交偏移量。
自動提交
最簡單的提交方式是讓悄費者自動提交偏移量。如果enable.auto.commit被設為 true,那麼每過5s,消費者會自動把從 poll() 方法接收到的最大偏移量提交上去。提交時間間隔由 auto.commit.interval.ms 控制,預設值是 5s。與消費者裡的其他東西 一樣,自動提交也是在輪詢(poll() )裡進行的。消費者每次在進行輪詢時會檢查是否該提交偏移量了,如果是,那 麼就會提交從上一次輪詢返回的偏移量。
不過,在使用這種簡便的方式之前,需要知道它將會帶來怎樣的結果。
假設我們仍然使用預設的 5s提交時間間隔,在最近一次提交之後的 3s發生了再均衡,再 均衡之後,消費者從最後一次提交的偏移量位置開始讀取訊息。這個時候偏移量已經落後 了 3s,所以在這 3s 內到達的訊息會被重複處理。可以通過修改提交時間間隔來更頻繁地提交偏移量,減小可能出現重複訊息的時間窗,不過這種情況是無也完全避免的 。
在使用自動提交時 ,每次呼叫輪詢方怯都會把上一次呼叫返 回的偏移量提交上去,它並不 知道具體哪些訊息已經被處理了,所以在再次呼叫之前最好確保所有當前呼叫返回 的訊息 都已經處理完畢(在呼叫 close() 方法之前也會進行自動提交)。 一般情況下不會有什麼問 題,不過在處理異常或提前退出輪詢時要格外小心 。
自動提交雖然方便 , 不過並沒有為開發者留有餘地來避免重複處理訊息。
提交當前偏移量
大部分開發者通過控制偏移量提交時間來消除丟失訊息的可能性,井在發生再均衡時減少 重複訊息的數量。消費者 API提供了另一種提交偏移量的方式 , 開發者可以在必要的時候 提交當前偏移盤,而不是基於時間間隔。
取消自動提交,把 auto.commit.offset 設為 false,讓應用程式決定何時提交 偏 移量。使用 commitSync() 提交偏移量最簡單也最可靠。這個 API會提交由 poll() 方法返回 的最新偏移量,提交成 功後馬上返回,如果提交失敗就丟擲異常。
要記住, commitSync() 將會提交由 poll() 返回的最新偏移量 , 所以在處理完所有記錄後要 確保呼叫了 commitSync(),否則還是會有丟失訊息的風險。如果發生了再均衡,從最近一 批訊息到發生再均衡之間的所有訊息都將被重複處理。
下面是我們在處理完最近一批訊息後使用 commitSync() 方法提交偏移量的例子。
非同步提交
同步提交有一個不足之處,在 broker對提交請求作出迴應之前,應用程式會一直阻塞,這樣會限制應用程式的吞吐量。我們可以通過降低提交頻率來提升吞吐量,但如果發生了再均衡, 會增加重複訊息的數量。
這個時候可以使用非同步提交 API。我們只管傳送提交請求,無需等待 broker的響應。
在成功提交或碰到無怯恢復的錯誤之前, commitSync() 會一直重試(應用程式也一直阻塞),但是 commitAsync() 不會,這也是 commitAsync() 不好的 一個地方。它之所以不進行重試,是因為在它收到 伺服器響應的時候,可能有一個更大的偏移量已經提交成功。假設我們發出一個請求用於提交偏移量 2000,這個時候發生了短暫的通訊問題 ,伺服器收不到請求,自然也不會 作出任何響應。與此同時,我們處理了另外一批訊息,併成功提交了偏移量 3000。如果 commitAsync() 重新嘗試提交偏移量 2000,它有可能在偏移量 3000之後提交成功。這個時 候如果發生再均衡,就會出現重複訊息。
我們之所以提到這個問題的複雜性和提交順序的重要性,是因為 commitAsync()也支援回 調,在 broker 作出響應時會執行回撥。回撥經常被用於記錄提交錯誤或生成度量指標, 不 過如果你要用它來進行重試, 一定要注意提交的順序。
重試非同步提交
我們可以使用一個單調遞增的序列號來維護非同步提交的順序。在每次提交偏 移量之後或在回撥裡提交偏移量時遞增序列號。在進行重試前,先檢查回撥 的序列號和即將提交的偏移量是否相等,如果相等,說明沒有新的提交,那麼可以安全地進行重試。如果序列號比較大,說明有一個新的提交已經傳送出去了,應該停止重試。
同步和非同步組合提交
一般情況下,針對偶爾出現的提交失敗,不進行重試不會有太大問題,因為如果提交失敗 是 因為臨時問題導致的,那麼後續的提交總會有成功的。但如果這是發生在關閉消費者或 再均衡前的最後一次提交,就要確保能夠提交成功。
因此,在消費者關閉前一般會組合使用 commitAsync()和 commitSync()。它們的工作原理如下(後面講到再均衡監聽器時,我們會討論如何在發生再均衡前提交偏移量):
提交特定的偏移量
提交偏移量的頻率與處理訊息批次的頻率是一樣的。但如果想要更頻繁地提交出怎麼辦?如果 poll() 方法返回一大批資料,為了避免因再均衡引起的重複處理整批訊息,想要在批次中間提交偏移量該怎麼辦?這種情況無法通過呼叫 commitSync()或 commitAsync() 來實現,因為它們只會提交最後一個偏移量,而此時該批次裡的訊息還沒有處理完。
幸運的是,消費者 API 允許在呼叫 commitSync()和 commitAsync()方法時傳進去希望提交 的分割槽和偏移量的 map。假設你處理了半個批次的訊息, 最後一個來自主題“customers” 分割槽 3 的訊息的偏移量是 5000, 你可以呼叫 commitSync() 方法來提交它。不過,因為消費者可能不只讀取一個分割槽, 你需要跟蹤所有分割槽的偏移量,所以在這個層面上控制偏移 量 的提交會讓程式碼變複雜。
下面是提交特定偏移量的例子 :
再均衡監聽器
在提交偏移量一節中提到過,消費者在退出和進行分割槽再均衡之前,會做一些清理工作。
你會在消費者失去對一個分割槽的所有權之前提交最後一個已處理記錄的偏移量。如果消費 者準備了 一 個緩衝區用於處理偶發的事件,那麼在失去分割槽所有權之前, 需要處理在緩衝 區累積下來的記錄。你可能還需要關閉檔案控制程式碼、資料庫連線等。
在為消費者分配新分割槽或移除舊分割槽時,可以通過消費者 API執行 一 些應用程式程式碼,在呼叫 subscribe()方法時傳進去一個ConsumerRebalancelistener例項就可以了。 ConsumerRebalancelistener有兩個需要實現的方法。
(1) public void onPartitionsRevoked(Collection<TopicPartition> partitions)方法會在 再均衡開始之前和消費者停止讀取訊息之後被呼叫。如果在這裡提交偏移量,下一個接 管分割槽 的消費者就知道該從哪裡開始讀取了。
(2) public void onPartitionsAssigned(Collection<TopicPartition> partitions)方法會在 重新分配分割槽之後和消費者開始讀取訊息之前被呼叫。
下面的例子將演示如何在失去分割槽所有權之前通過 onPartitionsRevoked()方法來提交偏移量。在下一節,我們會演示另一個同時使用了 onPartitionsAssigned()方法的例子。
從特定偏移量處開始處理記錄
到目前為止,我們知道了如何使用 poll() 方法從各個分割槽的最新偏移量處開始處理訊息。 不過,有時候我們也需要從特定的偏移量處開始讀取訊息。
如果你想從分割槽的起始位置開始讀取訊息,或者直接跳到分割槽的末尾開始讀取訊息, 可以使 用 seekToBeginning(Collection<TopicPartition> tp) 和 seekToEnd(Collection<TopicPartition> tp) 這兩個方法。
不過, Kafka也為我們提供了用 於查詢特定偏移量的 API。 它有很多用途,比如向 後回退 幾個訊息或者向前跳過幾個訊息(對時間比較敏感的應用程式在處理滯後的情況下希望能 夠向前跳過若干個訊息)。在使用 Kafka 以外的系統來儲存偏移量時,它將給我們 帶來更 大的驚喜。
試想一下這樣的場景:應用程式從 Kafka讀取事件(可能是網站的使用者點選事件流 ),對 它們進行處理(可能是使用自動程式清理點選操作井新增會話資訊),然後把結果保 存到 資料庫、 NoSQL 儲存引擎或 Hadoop。假設我們真的不想丟失任何資料,也不想在資料庫 裡多次儲存相同的結果。
這種情況下,消費者的程式碼可能是這樣的 :
在這個例子裡,每處理一條記錄就提交一次偏移量。儘管如此, 在記錄被儲存到資料庫之後以及偏移量被提交之前 ,應用程式仍然有可能發生崩潰,導致重複處理資料,資料庫裡就會出現重複記錄。
如果儲存記錄和偏移量可以在一個原子操作裡完成,就可以避免出現上述情況。記錄和偏 移量要麼 都被成功提交,要麼都不提交。如果記錄是儲存在資料庫裡而偏移量是提交到 Kafka 上,那麼就無法實現原子操作。
不過 ,如果在同一個事務裡把記錄和偏移量都寫到資料庫裡會怎樣呢?那麼我們就會知道 記錄和偏移量要麼都成功提交,要麼都沒有,然後重新處理記錄。
現在的問題是:如果偏移量是儲存在資料庫裡而不是 Kafka裡,那麼消費者在得到新分割槽 時怎麼知道該從哪裡開始讀取?這個時候可以使用 seek() 方法。在消費者啟動或分配到新 分割槽時 ,可以使用 seek()方法查詢儲存在資料庫裡的偏移量。
下面的例子大致說明了如何使用這個 API。 使用 ConsumerRebalancelistener和 seek() 方 戰確保我們是從資料庫裡儲存的偏移量所指定的位置開始處理訊息的。
通過把偏移量和記錄儲存到同 一個外部系統來實現單次語義可以有很多種方式,不過它們 都需要結合使用 ConsumerRebalancelistener和 seek() 方法來確保能夠及時儲存偏移量, 井保證消費者總是能夠從正確的位置開始讀取訊息。
如何退出
在之前討論輪詢時就說過,不需要擔心消費者會在一個無限迴圈裡輪詢訊息,我們會告訴消費者如何優雅地退出迴圈。
如果確定要退出迴圈,需要通過另一個執行緒呼叫 consumer.wakeup()方法。如果迴圈執行 在主執行緒裡,可以在 ShutdownHook裡呼叫該方法。要記住, consumer.wakeup() 是消費者 唯一一個可以從其他執行緒裡安全呼叫的方法。呼叫 consumer.wakeup()可以退出 poll(), 並丟擲 WakeupException異常,或者如果呼叫 cconsumer.wakeup() 時執行緒沒有等待輪詢, 那 麼異常將在下一輪呼叫 poll()時丟擲。我們不需要處理 WakeupException,因為它只是用於跳出迴圈的一種方式。不過, 在退出執行緒之前呼叫 consumer.close()是很有必要的, 它 會提交任何還沒有提交的東西 , 並向群組協調器(broker)傳送訊息,告知自己要離開群組,接下來 就會觸發再均衡 ,而不需要等待會話超時。
下面是執行在主執行緒上的消費者退出執行緒的程式碼 。