1. kafka消費者概念
1.1 消費者和消費者群組
假設我們有一個應用程序需要從一個 Kafka 主題讀取消息并驗證這些消息,然后再把它們保存起來。應用程序需要創建一個消費者對象,訂閱主題并開始接收消息,然后驗證消息并保存結果。過了一陣子,生產者往主題寫入消息的速度超過了應用程序驗證數據的速度,這個時候該怎么辦?如果只使用單個消費者處理消息,應用程序會遠跟不上消息生成的速度。顯然,此時很有必要對消費者進行橫向伸縮。就像多個生產者可以向相同的主題寫入消息一樣,我們也可以使用多個消費者從同一個主題讀取消息,對消息進行分流。
Kafka 消費者從屬于消費者群組。一個群組里的消費者訂閱的是同一個主題,每個消費者接收主題一部分分區的消息。
假設主題 T1 有 4 個分區,我們創建了消費者 C1,它是群組 G1 里唯一的消費者,我們用它訂閱主題 T1。消費者 C1 將收到主題 T1 全部 4 個分區的消息,如圖 1-1 所示。
如果在群組 G1 里新增一個消費者 C2,那么每個消費者將分別從兩個分區接收消息。我們假設消費者 C1 接收分區 0 和分區 2 的消息,消費者 C2 接收分區 1 和分區 3 的消息,如圖1-2 所示。
如果群組 G1 有 4 個消費者,那么每個消費者可以分配到一個分區,如圖 1-3 所示。
如果我們往群組里添加更多的消費者,超過主題的分區數量,那么有一部分消費者就會被閑置,不會接收到任何消息,如圖 1-4 所示。
往群組里增加消費者是橫向伸縮消費能力的主要方式。Kafka 消費者經常會做一些高延遲的操作,比如把數據寫到數據庫或 HDFS,或者使用數據進行比較耗時的計算。在這些情況下,單個消費者無法跟上數據生成的速度,所以可以增加更多的消費者,讓它們分擔負載,每個消費者只處理部分分區的消息,這就是橫向伸縮的主要手段。我們有必要為主題創建大量的分區,在負載增長時可以加入更多的消費者。不過要注意,不要讓消費者的數
量超過主題分區的數量,多余的消費者只會被閑置。
除了通過增加消費者來橫向伸縮單個應用程序外,還經常出現多個應用程序從同一個主題讀取數據的情況。實際上,Kafka 設計的主要目標之一,就是要讓 Kafka 主題里的數據能夠滿足企業各種應用場景的需求。在這些場景里,每個應用程序可以獲取到所有的消息,而不只是其中的一部分。只要保證每個應用程序有自己的消費者群組,就可以讓它們獲取到主題所有的消息。不同于傳統的消息系統,橫向伸縮 Kafka 消費者和消費者群組并不會
對性能造成負面影響。
在上面的例子里,如果新增一個只包含一個消費者的群組 G2,那么這個消費者將從主題T1 上接收所有的消息,與群組 G1 之間互不影響。群組 G2 可以增加更多的消費者,每個消費者可以消費若干個分區,就像群組 G1 那樣,如圖 1-5 所示。總的來說,群組 G2 還是會接收到所有消息,不管有沒有其他群組存在。
簡而言之,為每一個需要獲取一個或多個主題全部消息的應用程序創建一個消費者群組,然后往群組里添加消費者來伸縮讀取能力和處理能力,群組里的每個消費者只處理一部分消息。
1.2 消費者群組和分區再均衡
我們已經從上一個小節了解到,群組里的消費者共同讀取主題的分區。一個新的消費者加入群組時,它讀取的是原本由其他消費者讀取的消息。當一個消費者被關閉或發生崩潰時,它就離開群組,原本由它讀取的分區將由群組里的其他消費者來讀取。在主題發生變化時,比如管理員添加了新的分區,會發生分區重分配。
分區的所有權從一個消費者轉移到另一個消費者,這樣的行為被稱為再均衡。再均衡非常重要,它為消費者群組帶來了高可用性和伸縮性(我們可以放心地添加或移除消費者),不過在正常情況下,我們并不希望發生這樣的行為。在再均衡期間,消費者無法讀取消息,造成整個群組一小段時間的不可用。另外,當分區被重新分配給另一個消費者時,消費者當前的讀取狀態會丟失,它有可能還需要去刷新緩存,在它重新恢復狀態之前會拖慢應用程序。我們將在本章討論如何進行安全的再均衡,以及如何避免不必要的再均衡。
消費者通過向被指派為群組協調器的 broker(不同的群組可以有不同的協調器)發送心跳來維持它們和群組的從屬關系以及它們對分區的所有權關系。只要消費者以正常的時間間隔發送心跳,就被認為是活躍的,說明它還在讀取分區里的消息。消費者會在輪詢消息(為了獲取消息)或提交偏移量時發送心跳。如果消費者停止發送心跳的時間足夠長,會話就會過期,群組協調器認為它已經死亡,就會觸發一次再均衡。如果一個消費者發生崩潰,并停止讀取消息,群組協調器會等待幾秒鐘,確認它死亡了才會觸發再均衡。在這幾秒鐘時間里,死掉的消費者不會讀取分區里的消息。在清理消費者時,消費者會通知協調器它將要離開群組,協調器會立即觸發一次再均衡,盡量降低處理停頓。在本章的后續部分,我們將討論一些用于控制發送心跳頻率和會話過期時間的配置參數,以及如何根據實際需要來配置這些參數。
分配分區是怎樣的一個過程
當消費者要加入群組時,它會向群組協調器發送一個 JoinGroup 請求。第一
個加入群組的消費者將成為“群主”。群主從協調器那里獲得群組的成員列
表(列表中包含了所有最近發送過心跳的消費者,它們被認為是活躍的),
并負責給每一個消費者分配分區。它使用一個實現了 PartitionAssignor 接
口的類來決定哪些分區應該被分配給哪個消費者。
Kafka 內置了兩種分配策略,在后面的配置參數小節我們將深入討論。分配
完畢之后,群主把分配情況列表發送給群組協調器,協調器再把這些信息發
送給所有消費者。每個消費者只能看到自己的分配信息,只有群主知道群組
里所有消費者的分配信息。這個過程會在每次再均衡時重復發生。
2.創建kafka消費者
在讀取消息之前,需要先創建一個 KafkaConsumer 對象。創建KafkaConsumer 對象與創建KafkaProducer 對象非常相似——把想要傳給消費者的屬性放在 Properties 對象里。本章后續部分會深入討論所有的屬性。在這里,我們只需要使用 3 個必要的屬性: bootstrap.servers 、 key.deserializer 和 value.deserializer 。
第 1 個 屬 性 bootstrap.servers 指 定 了 Kafka 集 群 的 連 接 字 符 串。 它 的 用 途 與 在KafkaProducer 中的用途是一樣的,另外兩個屬性 key.deserializer 和 value.deserializer 與生產者的 serializer 定義也很類似,不過它們不是使用指定的類把 Java 對象轉成字節數組,而是使用指定的類把字節數組轉成 Java 對象。第 4 個屬性 group.id 不是必需的,不過我們現在姑且認為它是必需的。它指定了KafkaConsumer 屬于哪一個消費者群組。創建不屬于任何一個群組的消費者也是可以的,只是這樣做不太常見,在本書的大部分章節,我們都假設消費者是屬于某個群組的。
下面的代碼片段演示了如何創建一個 KafkaConsumer 對象:
Properties props = new Properties();
props.put("bootstrap.servers", "broker1:9092,broker2:9092");
props.put("group.id", "CountryCounter");
props.put("key.deserializer",
"org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer",
"org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer = new KafkaConsumer<String,
String>(props);
我們假設消費的鍵和值都是字符串類型,所以使用的是內置的 StringDeserializer ,并且使用字符串類型創建了 KafkaConsumer 對象。唯一不同的是新增了 group.id 屬性,它指定了消費者所屬群組的名字。
3. 訂閱主題
創建好消費者之后,下一步可以開始訂閱主題了。 subscribe() 方法接受一個主題列表作為參數,使用起來很簡單:
consumer.subscribe(Collections.singletonList("customerCountries")); ?
? 為了簡單起見,我們創建了一個只包含單個元素的列表,主題的名字叫作“customerCountries”。我們也可以在調用 subscribe() 方法時傳入一個正則表達式。正則表達式可以匹配多個主題,如果有人創建了新的主題,并且主題的名字與正則表達式匹配,那么會立即觸發一次再均衡,消費者就可以讀取新添加的主題。如果應用程序需要讀取多個主題,并且可以處理不同類型的數據,那么這種訂閱方式就很管用。在 Kafka 和其他系統之間復制數據時,使用正則表達式的方式訂閱多個主題是很常見的做法。
要訂閱所有與 test 相關的主題,可以這樣做:
consumer.subscribe("test.*");
4. 輪循
消息輪詢是消費者 API 的核心,通過一個簡單的輪詢向服務器請求數據。一旦消費者訂閱了主題,輪詢就會處理所有的細節,包括群組協調、分區再均衡、發送心跳和獲取數據,開發者只需要使用一組簡單的 API 來處理從分區返回的數據。消費者代碼的主要部分如下所示:
try {
while (true) { ?
ConsumerRecords<String, String> records = consumer.poll(100); ?
for (ConsumerRecord<String, String> record : records) ?
{
log.debug("topic = %s, partition = %s, offset = %d, customer = %s,
country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());
int updatedCount = 1;
if (custCountryMap.countainsValue(record.value())) {
updatedCount = custCountryMap.get(record.value()) + 1;
}
custCountryMap.put(record.value(), updatedCount)
JSONObject json = new JSONObject(custCountryMap);
System.out.println(json.toString(4)) ?
}
}
} finally {
consumer.close(); ?
}
? 這是一個無限循環。消費者實際上是一個長期運行的應用程序,它通過持續輪詢向Kafka 請求數據。稍后我們會介紹如何退出循環,并關閉消費者。
? 這一行代碼非常重要。就像鯊魚停止移動就會死掉一樣,消費者必須持續對 Kafka 進行輪詢,否則會被認為已經死亡,它的分區會被移交給群組里的其他消費者。傳給poll() 方法的參數是一個超時時間,用于控制 poll() 方法的阻塞時間(在消費者的緩沖區里沒有可用數據時會發生阻塞)。如果該參數被設為 0, poll() 會立即返回,否則它會在指定的毫秒數內一直等待 broker 返回數據。
? poll() 方法返回一個記錄列表。每條記錄都包含了記錄所屬主題的信息、記錄所在分區的信息、記錄在分區里的偏移量,以及記錄的鍵值對。我們一般會遍歷這個列表,逐條處理這些記錄。 poll() 方法有一個超時參數,它指定了方法在多久之后可以返回,不管有沒有可用的數據都要返回。超時時間的設置取決于應用程序對響應速度的要求,比如要在多長時間內把控制權歸還給執行輪詢的線程。
? 把結果保存起來或者對已有的記錄進行更新,處理過程也隨之結束。在這里,我們的目的是統計來自各個地方的客戶數量,所以使用了一個散列表來保存結果,并以 JSON 的格式打印結果。在真實場景里,結果一般會被保存到數據存儲系統里。? 在退出應用程序之前使用 close() 方法關閉消費者。網絡連接和 socket 也會隨之關閉,并立即觸發一次再均衡,而不是等待群組協調器發現它不再發送心跳并認定它已死亡,因為那樣需要更長的時間,導致整個群組在一段時間內無法讀取消息。輪詢不只是獲取數據那么簡單。在第一次調用新消費者的 poll() 方法時,它會負責查找GroupCoordinator,然后加入群組,接受分配的分區。如果發生了再均衡,整個過程也是在輪詢期間進行的。當然,心跳也是從輪詢里發送出去的。所以,我們要確保在輪詢期間所做的任何處理工作都應該盡快完成。
4. 消費者的配置
到目前為止,我們學習了如何使用消費者 API,不過只介紹了幾個配置屬性—— bootstrap.servers 、 group.id 、 key.deserializer 和 value.deserializer 。Kafka的文檔列出了所有與消費者相關的配置說明。大部分參數都有合理的默認值,一般不需要修改它們,不過有一些參數與消費者的性能和可用性有很大關系。接下來介紹這些重要的屬性。
- fetch.min.bytes
該屬性指定了消費者從服務器獲取記錄的最小字節數。broker 在收到消費者的數據請求時,如果可用的數據量小于 fetch.min.bytes 指定的大小,那么它會等到有足夠的可用數據時才把它返回給消費者。這樣可以降低消費者和 broker 的工作負載,因為它們在主題不是很活躍的時候(或者一天里的低谷時段)就不需要來來回回地處理消息。如果沒有很多可用數據,但消費者的 CPU 使用率卻很高,那么就需要把該屬性的值設得比默認值大。如果消費者的數量比較多,把該屬性的值設置得大一點可以降低 broker 的工作負載。 - fetch.max.wait.ms
我們通過 fetch.min.bytes 告訴 Kafka,等到有足夠的數據時才把它返回給消費者。而 feth.max.wait.ms 則用于指定 broker 的等待時間,默認是 500ms。如果沒有足夠的數據流入Kafka,消費者獲取最小數據量的要求就得不到滿足,最終導致 500ms 的延遲。如果要降低潛在的延遲(為了滿足 SLA),可以把該參數值設置得小一些。如果 fetch.max.wait.ms 被設為 100ms,并且 fetch.min.bytes 被設為 1MB,那么 Kafka 在收到消費者的請求后,要么返回 1MB 數據,要么在 100ms 后返回所有可用的數據,就看哪個條件先得到滿足。 - max.partition.fetch.bytes
該屬性指定了服務器從每個分區里返回給消費者的最大字節數。它的默認值是 1MB,也就是說, KafkaConsumer.poll() 方法從每個分區里返回的記錄最多不超過 max.partition.fetch.bytes 指定的字節。如果一個主題有 20 個分區和 5 個消費者,那么每個消費者需要至少 4MB 的可用內存來接收記錄。在為消費者分配內存時,可以給它們多分配一些,因為如果群組里有消費者發生崩潰,剩下的消費者需要處理更多的分區。 max.partition.fetch.bytes 的值必須比 broker 能夠接收的最大消息的字節數(通過 max.message.size 屬性配置)大,否則消費者可能無法讀取這些消息,導致消費者一直掛起重試。在設置該屬性時,另一個需要考慮的因素是消費者處理數據的時間。消費者需要頻繁調用 poll() 方法來避免會話過期和發生分區再均衡,如果單次調用 poll() 返回的數據太多,消費者需要更多的時間來處理,可能無法及時進行下一個輪詢來避免會話過期。如果出現這種情況,可以把 max.partition.fetch.bytes 值改小,或者延長會話過期時間。 - session.timeout.ms
該屬性指定了消費者在被認為死亡之前可以與服務器斷開連接的時間,默認是 3s。如果消費者沒有在 session.timeout.ms 指定的時間內發送心跳給群組協調器,就被認為已經死亡,協調器就會觸發再均衡,把它的分區分配給群組里的其他消費者。該屬性與heartbeat.interval.ms 緊密相關。heartbeat.interval.ms 指定了 poll() 方法向協調器發送心跳的頻率,session.timeout.ms 則指定了消費者可以多久不發送心跳。所以,一般需要同時修改這兩個屬性, heartbeat.interval.ms 必須比 session.timeout.ms小,一般是 session.timeout.ms 的三分之一。如果 session.timeout.ms 是 3s,那么 heartbeat.interval.ms 應該是 1s。把 session.timeout.ms 值設得比默認值小,可以更快地檢測和恢復崩潰的節點,不過長時間的輪詢或垃圾收集可能導致非預期的再均衡。把該屬性的值設置得大一些,可以減少意外的再均衡,不過檢測節點崩潰需要更長的時間。 - auto.offset.reset
該屬性指定了消費者在讀取一個沒有偏移量的分區或者偏移量無效的情況下(因消費者長時間失效,包含偏移量的記錄已經過時并被刪除)該作何處理。它的默認值是 latest ,意思是說,在偏移量無效的情況下,消費者將從最新的記錄開始讀取數據(在消費者啟動之后生成的記錄)。另一個值是 earliest ,意思是說,在偏移量無效的情況下,消費者將從起始位置讀取分區的記錄。 - enable.auto.commit
我們稍后將介紹幾種不同的提交偏移量的方式。該屬性指定了消費者是否自動提交偏移量,默認值是 true 。為了盡量避免出現重復數據和數據丟失,可以把它設為 false ,由自己控制何時提交偏移量。如果把它設為 true ,還可以通過配置 auto.commit.interval.ms屬性來控制提交的頻率。 - partition.assignment.strategy
我們知道,分區會被分配給群組里的消費者。 PartitionAssignor 根據給定的消費者和主題,決定哪些分區應該被分配給哪個消費者。Kafka 有兩個默認的分配策略。
Range
該策略會把主題的若干個連續的分區分配給消費者。假設消費者 C1 和消費者 C2 同時訂閱了主題 T1 和主題 T2,并且每個主題有 3 個分區。那么消費者 C1 有可能分配到這兩個主題的分區 0 和分區 1,而消費者 C2 分配到這兩個主題的分區 2。因為每個主題擁有奇數個分區,而分配是在主題內獨立完成的,第一個消費者最后分配到比第二個消費者更多的分區。只要使用了 Range 策略,而且分區數量無法被消費者數量整除,就會出現這種情況。
RoundRobin
該策略把主題的所有分區逐個分配給消費者。如果使用 RoundRobin 策略來給消費者 C1和消費者 C2 分配分區,那么消費者 C1 將分到主題 T1 的分區 0 和分區 2 以及主題 T2的分區 1,消費者 C2 將分配到主題 T1 的分區 1 以及主題 T2 的分區 0 和分區 2。一般來說,如果所有消費者都訂閱相同的主題(這種情況很常見),RoundRobin 策略會給所有消費者分配相同數量的分區(或最多就差一個分區)。可以通過設置 partition.assignment.strategy 來選擇分區策略。默認使用的是 org.apache.kafka.clients.consumer.RangeAssignor ,這個類實現了 Range 策略,不過也可以把它改成 org.apache.kafka.clients.consumer.RoundRobinAssignor 。我們還可以使用自定義策略,在這種情況下, partition.assignment.strategy 屬性的值就是自定義類的名字。 - client.id
該屬性可以是任意字符串,broker 用它來標識從客戶端發送過來的消息,通常被用在日志、度量指標和配額里。 - max.poll.records
該屬性用于控制單次調用 call() 方法能夠返回的記錄數量,可以幫你控制在輪詢里需要處理的數據量。 - receive.buffer.bytes 和 send.buffer.bytes
socket 在讀寫數據時用到的 TCP 緩沖區也可以設置大小。如果它們被設為 -1,就使用操作系統的默認值。如果生產者或消費者與 broker 處于不同的數據中心內,可以適當增大這些值,因為跨數據中心的網絡一般都有比較高的延遲和比較低的帶寬。
6 提交和偏移量
每次調用 poll() 方法,它總是返回由生產者寫入 Kafka 但還沒有被消費者讀取過的記錄,我們因此可以追蹤到哪些記錄是被群組里的哪個消費者讀取的。之前已經討論過,Kafka不會像其他 JMS 隊列那樣需要得到消費者的確認,這是 Kafka 的一個獨特之處。相反,消費者可以使用 Kafka 來追蹤消息在分區里的位置(偏移量)。我們把更新分區當前位置的操作叫作提交。
那么消費者是如何提交偏移量的呢?消費者往一個叫作 _consumer_offset 的特殊主題發送消息,消息里包含每個分區的偏移量。如果消費者一直處于運行狀態,那么偏移量就沒有什么用處。不過,如果消費者發生崩潰或者有新的消費者加入群組,就會觸發再均衡,完成再均衡之后,每個消費者可能分配到新的分區,而不是之前處理的那個。為了能夠繼續之前的工作,消費者需要讀取每個分區最后一次提交的偏移量,然后從偏移量指定的地方繼續處理。如果提交的偏移量小于客戶端處理的最后一個消息的偏移量,那么處于兩個偏移量之間的消息就會被重復處理,如圖 6-1 所示。
如果提交的偏移量大于客戶端處理的最后一個消息的偏移量,那么處于兩個偏移量之間的消息將會丟失,如圖 6-2 所示。
所以,處理偏移量的方式對客戶端會有很大的影響。
KafkaConsumer API 提供了很多種方式來提交偏移量。
6.1 自動提交
最簡單的提交方式是讓消費者自動提交偏移量。如果 enable.auto.commit 被設為 true ,那么每過 5s,消費者會自動把從 poll() 方法接收到的最大偏移量提交上去。提交時間間隔由 auto.commit.interval.ms 控制,默認值是 5s。與消費者里的其他東西一樣,自動提交也是在輪詢里進行的。消費者每次在進行輪詢時會檢查是否該提交偏移量了,如果是,那么就會提交從上一次輪詢返回的偏移量。不過,在使用這種簡便的方式之前,需要知道它將會帶來怎樣的結果。
假設我們仍然使用默認的 5s 提交時間間隔,在最近一次提交之后的 3s 發生了再均衡,再均衡之后,消費者從最后一次提交的偏移量位置開始讀取消息。這個時候偏移量已經落后了 3s,所以在這 3s 內到達的消息會被重復處理。可以通過修改提交時間間隔來更頻繁地提交偏移量,減小可能出現重復消息的時間窗,不過這種情況是無法完全避免的。在使用自動提交時,每次調用輪詢方法都會把上一次調用返回的偏移量提交上去,它并不知道具體哪些消息已經被處理了,所以在再次調用之前最好確保所有當前調用返回的消息都已經處理完畢(在調用 close() 方法之前也會進行自動提交)。一般情況下不會有什么問題,不過在處理異常或提前退出輪詢時要格外小心。自動提交雖然方便,不過并沒有為開發者留有余地來避免重復處理消息。
6.2 提交當前偏移量
大部分開發者通過控制偏移量提交時間來消除丟失消息的可能性,并在發生再均衡時減少重復消息的數量。消費者 API 提供了另一種提交偏移量的方式,開發者可以在必要的時候提交當前偏移量,而不是基于時間間隔。
把 auto.commit.offset 設為 false ,讓應用程序決定何時提交偏移量。使用 commitSync()提交偏移量最簡單也最可靠。這個 API 會提交由 poll() 方法返回的最新偏移量,提交成功后馬上返回,如果提交失敗就拋出異常。要記住, commitSync() 將會提交由 poll() 返回的最新偏移量,所以在處理完所有記錄后要確保調用了 commitSync() ,否則還是會有丟失消息的風險。如果發生了再均衡,從最近一批消息到發生再均衡之間的所有消息都將被重復處理。
下面是我們在處理完最近一批消息后使用 commitSync() 方法提交偏移量的例子。
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records)
{
System.out.printf("topic = %s, partition = %s, offset =
%d, customer = %s, country = %s\n",
record.topic(), record.partition(),
record.offset(), record.key(), record.value()); ?
}
try {
consumer.commitSync(); ?
} catch (CommitFailedException e) {
log.error("commit failed", e) ?
}
}
? 我們假設把記錄內容打印出來就算處理完畢,這個是由應用程序根據具體的使用場景來決定的。
? 處理完當前批次的消息,在輪詢更多的消息之前,調用 commitSync() 方法提交當前批次最新的偏移量。
? 只要沒有發生不可恢復的錯誤, commitSync() 方法會一直嘗試直至提交成功。如果提交失敗,我們也只能把異常記錄到錯誤日志里。
6.3 異步提交
手動提交有一個不足之處,在 broker 對提交請求作出回應之前,應用程序會一直阻塞,這樣會限制應用程序的吞吐量。我們可以通過降低提交頻率來提升吞吐量,但如果發生了再均衡,會增加重復消息的數量。這個時候可以使用異步提交 API。我們只管發送提交請求,無需等待 broker 的響應。
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records)
{
System.out.printf("topic = %s, partition = %s,
offset = %d, customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());
}
consumer.commitAsync(); ?
}
? 提交最后一個偏移量,然后繼續做其他事情。
在成功提交或碰到無法恢復的錯誤之前, commitSync() 會一直重試,但是 commitAsync()不會,這也是 commitAsync() 不好的一個地方。它之所以不進行重試,是因為在它收到服務器響應的時候,可能有一個更大的偏移量已經提交成功。假設我們發出一個請求用于提交偏移量 2000,這個時候發生了短暫的通信問題,服務器收不到請求,自然也不會作出任何響應。與此同時,我們處理了另外一批消息,并成功提交了偏移量 3000。如果commitAsync() 重新嘗試提交偏移量 2000,它有可能在偏移量 3000 之后提交成功。這個時候如果發生再均衡,就會出現重復消息。我們之所以提到這個問題的復雜性和提交順序的重要性,是因為 commitAsync() 也支持回調,在 broker 作出響應時會執行回調。回調經常被用于記錄提交錯誤或生成度量指標,不過如果你要用它來進行重試,一定要注意提交的順序。
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("topic = %s, partition = %s,
offset = %d, customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());
}
consumer.commitAsync(new OffsetCommitCallback() {
public void onComplete(Map<TopicPartition,
OffsetAndMetadata> offsets, Exception e) {
if (e != null)
log.error("Commit failed for offsets {}", offsets, e);
}
}); ?
}
? 發送提交請求然后繼續做其他事情,如果提交失敗,錯誤信息和偏移量會被記錄下來。重試異步提交我們可以使用一個單調遞增的序列號來維護異步提交的順序。在每次提交偏移量之后或在回調里提交偏移量時遞增序列號。在進行重試前,先檢查回調的序列號和即將提交的偏移量是否相等,如果相等,說明沒有新的提交,那么可以安全地進行重試。如果序列號比較大,說明有一個新的提交已經發送出去了,應該停止重試。
6.4 同步和異步組合提交
一般情況下,針對偶爾出現的提交失敗,不進行重試不會有太大問題,因為如果提交失敗是因為臨時問題導致的,那么后續的提交總會有成功的。但如果這是發生在關閉消費者或再均衡前的最后一次提交,就要確保能夠提交成功。因此,在消費者關閉前一般會組合使用 commitAsync() 和 commitSync() 。它們的工作原理如下(后面講到再均衡監聽器時,我們會討論如何在發生再均衡前提交偏移量):
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
System.out.println("topic = %s, partition = %s, offset = %d,
customer = %s, country = %s\n",
record.topic(), record.partition(),
record.offset(), record.key(), record.value());
}
consumer.commitAsync(); ?
}
} catch (Exception e) {
log.error("Unexpected error", e);
} finally {
try {
consumer.commitSync(); ?
} finally {
consumer.close();
}
}
? 如果一切正常,我們使用 commitAsync() 方法來提交。這樣速度更快,而且即使這次提交失敗,下一次提交很可能會成功。
? 如果直接關閉消費者,就沒有所謂的“下一次提交”了。使用 commitSync() 方法會一直重試,直到提交成功或發生無法恢復的錯誤。
6.5 提交特定的偏移量
提交偏移量的頻率與處理消息批次的頻率是一樣的。但如果想要更頻繁地提交該怎么辦?如果 poll() 方法返回一大批數據,為了避免因再均衡引起的重復處理整批消息,想要在批次中間提交偏移量該怎么辦?這種情況無法通過調用 commitSync() 或 commitAsync() 來實現,因為它們只會提交最后一個偏移量,而此時該批次里的消息還沒有處理完。幸運的是,消費者 API 允許在調用 commitSync() 和 commitAsync() 方法時傳進去希望提交的分區和偏移量的 map。假設你處理了半個批次的消息,最后一個來自主題“customers”分區 3 的消息的偏移量是 5000,你可以調用 commitSync() 方法來提交它。不過,因為消費者可能不只讀取一個分區,你需要跟蹤所有分區的偏移量,所以在這個層面上控制偏移量的提交會讓代碼變復雜。
下面是提交特定偏移量的例子:
private Map<TopicPartition, OffsetAndMetadata> currentOffsets =
new HashMap<>(); ?
int count = 0;
...
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records)
{
System.out.printf("topic = %s, partition = %s, offset = %d,
customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value()); ?
currentOffsets.put(new TopicPartition(record.topic(),
record.partition()), new
OffsetAndMetadata(record.offset()+1, "no metadata")); ?
if (count % 1000 == 0) ?
consumer.commitAsync(currentOffsets,null); ?
count++;
}
}
? 用于跟蹤偏移量的 map。
? 記住, printf 只是處理消息的臨時方案。
? 在讀取每條記錄之后,使用期望處理的下一個消息的偏移量更新 map 里的偏移量。下一次就從這里開始讀取消息。
? 我們決定每處理 1000 條記錄就提交一次偏移量。在實際應用中,你可以根據時間或記錄的內容進行提交。
5這里調用的是 commitAsync() ,不過調用 commitSync() 也是完全可以的。當然,在提交特定偏移量時,仍然要處理可能發生的錯誤。
7 再均衡監聽器
在提交偏移量一節中提到過,消費者在退出和進行分區再均衡之前,會做一些清理工作。你會在消費者失去對一個分區的所有權之前提交最后一個已處理記錄的偏移量。如果消費者準備了一個緩沖區用于處理偶發的事件,那么在失去分區所有權之前,需要處理在緩沖區累積下來的記錄。你可能還需要關閉文件句柄、數據庫連接等。
在為消費者分配新分區或移除舊分區時,可以通過消費者 API 執行一些應用程序代碼,在調用 subscribe() 方法時傳進去一個ConsumerRebalanceListener 實例就可以了。ConsumerRebalanceListener 有兩個需要實現的方法。
(1) public void onPartitionsRevoked(Collection<TopicPartition> partitions) 方法會在
再均衡開始之前和消費者停止讀取消息之后被調用。如果在這里提交偏移量,下一個接管分區的消費者就知道該從哪里開始讀取了。
(2) public void onPartitionsAssigned(Collection<TopicPartition> partitions) 方法會在重新分配分區之后和消費者開始讀取消息之前被調用。下面的例子將演示如何在失去分區所有權之前通過 onPartitionsRevoked() 方法來提交偏移量。在下一節,我們會演示另一個同時使用了 onPartitionsAssigned() 方法的例子。
private Map<TopicPartition, OffsetAndMetadata> currentOffsets=
new HashMap<>();
private class HandleRebalance implements ConsumerRebalanceListener { ?
public void onPartitionsAssigned(Collection<TopicPartition>
partitions) { ?
}
public void onPartitionsRevoked(Collection<TopicPartition>
partitions) {
System.out.println("Lost partitions in rebalance.
Committing current
offsets:" + currentOffsets);
consumer.commitSync(currentOffsets); ?
}
}
try {
consumer.subscribe(topics, new HandleRebalance()); ?
while (true) {
ConsumerRecords<String, String> records =
consumer.poll(100);
for (ConsumerRecord<String, String> record : records)
{
System.out.println("topic = %s, partition = %s, offset = %d,
customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());
currentOffsets.put(new TopicPartition(record.topic(),
record.partition()), new
OffsetAndMetadata(record.offset()+1, "no metadata"));
}
consumer.commitAsync(currentOffsets, null);
}
} catch (WakeupException e) {
// 忽略異常,正在關閉消費者
} catch (Exception e) {
log.error("Unexpected error", e);
} finally {
try {
consumer.commitSync(currentOffsets);
} finally {
consumer.close();
System.out.println("Closed consumer and we are done");
}
}
? 首先實現 ConsumerRebalanceListener 接口。
? 在獲得新分區后開始讀取消息,不需要做其他事情。
? 如果發生再均衡,我們要在即將失去分區所有權時提交偏移量。要注意,提交的是最近處理過的偏移量,而不是批次中還在處理的最后一個偏移量。因為分區有可能在我們還在處理消息的時候被撤回。我們要提交所有分區的偏移量,而不只是那些即將失去所有權的分區的偏移量——因為提交的偏移量是已經處理過的,所以不會有什么問題。調用commitSync() 方法,確保在再均衡發生之前提交偏移量。
? 把 ConsumerRebalanceListener 對象傳給subscribe() 方法,這是最重要的一步。
8 從特定偏移量處開始處理記錄
到目前為止,我們知道了如何使用 poll() 方法從各個分區的最新偏移量處開始處理消息。不過,有時候我們也需要從特定的偏移量處開始讀取消息。
如果你想從分區的起始位置開始讀取消息,或者直接跳到分區的末尾開始讀取消息,可以使用 seekToBeginning(Collection<TopicPartition> tp) 和 seekToEnd(Collection<TopicPartition> tp) 這兩個方法。
不過,Kafka 也為我們提供了用于查找特定偏移量的 API。它有很多用途,比如向后回退幾個消息或者向前跳過幾個消息(對時間比較敏感的應用程序在處理滯后的情況下希望能夠向前跳過若干個消息)。在使用 Kafka 以外的系統來存儲偏移量時,它將給我們帶來更大的驚喜。試想一下這樣的場景:應用程序從 Kafka 讀取事件(可能是網站的用戶點擊事件流),對它們進行處理(可能是使用自動程序清理點擊操作并添加會話信息),然后把結果保存到數據庫、NoSQL 存儲引擎或 Hadoop。假設我們真的不想丟失任何數據,也不想在數據庫里多次保存相同的結果。
這種情況下,消費者的代碼可能是這樣的:
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records)
{
currentOffsets.put(new TopicPartition(record.topic(),
record.partition()),
new OffsetAndMetadata(record.offset()+1);
processRecord(record);
storeRecordInDB(record);
consumer.commitAsync(currentOffsets);
}
}
在這個例子里,每處理一條記錄就提交一次偏移量。盡管如此,在記錄被保存到數據庫之后以及偏移量被提交之前,應用程序仍然有可能發生崩潰,導致重復處理數據,數據庫里就會出現重復記錄。
如果保存記錄和偏移量可以在一個原子操作里完成,就可以避免出現上述情況。記錄和偏移量要么都被成功提交,要么都不提交。如果記錄是保存在數據庫里而偏移量是提交到Kafka 上,那么就無法實現原子操作。
不過,如果在同一個事務里把記錄和偏移量都寫到數據庫里會怎樣呢?那么我們就會知道記錄和偏移量要么都成功提交,要么都沒有,然后重新處理記錄。
現在的問題是:如果偏移量是保存在數據庫里而不是 Kafka 里,那么消費者在得到新分區時怎么知道該從哪里開始讀取?這個時候可以使用 seek() 方法。在消費者啟動或分配到新分區時,可以使用 seek() 方法查找保存在數據庫里的偏移量。
下面的例子大致說明了如何使用這個 API。使用ConsumerRebalanceListener 和 seek() 方法確保我們是從數據庫里保存的偏移量所指定的位置開始處理消息的。
public class SaveOffsetsOnRebalance implements
ConsumerRebalanceListener {
public void onPartitionsRevoked(Collection<TopicPartition>
partitions) {
commitDBTransaction(); ?
}
public void onPartitionsAssigned(Collection<TopicPartition>
partitions) {
for(TopicPartition partition: partitions)
consumer.seek(partition, getOffsetFromDB(partition)); ?
}
}
}
consumer.subscribe(topics, new SaveOffsetOnRebalance(consumer));
consumer.poll(0);
for (TopicPartition partition: consumer.assignment())
consumer.seek(partition, getOffsetFromDB(partition)); ?
while (true) {
ConsumerRecords<String, String> records =
consumer.poll(100);
for (ConsumerRecord<String, String> record : records)
{
processRecord(record);
storeRecordInDB(record);
storeOffsetInDB(record.topic(), record.partition(),
record.offset()); ?
}
commitDBTransaction();
}
? 使用一個虛構的方法來提交數據庫事務。大致想法是這樣的:在處理完記錄之后,將記錄和偏移量插入數據庫,然后在即將失去分區所有權之前提交事務,確保成功保存了這些信息。
? 使用另一個虛構的方法來從數據庫獲取偏移量,在分配到新分區的時候,使用 seek()方法定位到那些記錄。
? 訂閱主題之后,開始啟動消費者,我們調用一次 poll() 方法,讓消費者加入到消費者群組里,并獲取分配到的分區,然后馬上調用 seek() 方法定位分區的偏移量。要記住,seek() 方法只更新我們正在使用的位置,在下一次調用 poll() 時就可以獲得正確的消息。如果 seek() 發生錯誤(比如偏移量不存在), poll() 就會拋出異常。
? 另一個虛構的方法,這次要更新的是數據庫里用于保存偏移量的表。假設更新記錄的速度非常快,所以每條記錄都需要更新一次數據庫,但提交的速度比較慢,所以只在每個批次末尾提交一次。這里可以通過很多種方式進行優化。通過把偏移量和記錄保存到同一個外部系統來實現單次語義可以有很多種方式,不過它們都需要結合使用 ConsumerRebalanceListener 和 seek() 方法來確保能夠及時保存偏移量,并保證消費者總是能夠從正確的位置開始讀取消息。
9 如何退出
在之前討論輪詢時就說過,不需要擔心消費者會在一個無限循環里輪詢消息,我們會告訴消費者如何優雅地退出循環。如果確定要退出循環,需要通過另一個線程調用 consumer.wakeup() 方法。如果循環運行在主線程里,可以在 ShutdownHook 里調用該方法。要記住, consumer.wakeup() 是消費者唯一一個可以從其他線程里安全調用的方法。調用 consumer.wakeup() 可以退出 poll() ,并拋出 WakeupException 異常,或者如果調用 consumer.wakeup() 時線程沒有等待輪詢,那么異常將在下一輪調用 poll() 時拋出。我們不需要處理 WakeupException ,因為它只是用于跳出循環的一種方式。不過,在退出線程之前調用 consumer.close() 是很有必要的,它
會提交任何還沒有提交的東西,并向群組協調器發送消息,告知自己要離開群組,接下來就會觸發再均衡,而不需要等待會話超時。下面是運行在主線程上的消費者退出線程的代碼。這些代碼經過了簡化,你可以在這里查
看完整的代碼:http://bit.ly/2u47e9A。
Runtime.getRuntime().addShutdownHook(new Thread() {
public void run() {
System.out.println("Starting exit...");
consumer.wakeup(); ?
try {
mainThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
...
try {
// 循環,直到按下Ctrl+C鍵,關閉的鉤子會在退出時進行清理
while (true) {
ConsumerRecords<String, String> records =
movingAvg.consumer.poll(1000);
System.out.println(System.currentTimeMillis() + "
-- waiting for data...");
for (ConsumerRecord<String, String> record :
records) {
System.out.printf("offset = %d, key = %s,
value = %s\n",
record.offset(), record.key(),
record.value());
}
for (TopicPartition tp: consumer.assignment())
System.out.println("Committing offset at
position:" +
consumer.position(tp));
movingAvg.consumer.commitSync();
}
} catch (WakeupException e) {
// 忽略關閉異常 ?
} finally {
consumer.close(); ?
System.out.println("Closed consumer and we are done");
}
}
? ShutdownHook 運行在單獨的線程里,所以退出循環最安全的方式只能是調用 wakeup()方法。
? 在另一個線程里調用 wakeup() 方法,導致 poll() 拋出 WakeupException 。你可能想捕獲異常以確保應用不會意外終止,但實際上這不是必需的。
? 在退出之前,確保徹底關閉了消費者。