KafkaConsumer負責訂閱主題
, 并且從訂閱的主題中拉取消息
。
消費者和消費組
每一個消費者
都有一個對應的消費組
, 當消息發(fā)布到主題之后, 只會被投遞給訂閱它的每個消費組中的一個消費者
。消費組將消費者歸為一類, 每一個消費者只隸屬于一個消費組, 如果所有消費者都隸屬于同一個消費組, 那么就是點對點模式
, 如果所有消費者隸屬于不同消費組就是發(fā)布/訂閱模式
, 可以通過group.id
來配置
如圖所示某主題有4個分區(qū)p0, p1, p2, p3, 有兩個消費組A和B, A中有4個消費者, B中有2個消費者, 按照kafka默認的分配規(guī)則, 分配結果是
消費者組A中每個消費者一個分區(qū)
, 消費者組B中每個消費者分配到2個分區(qū)
, 兩個消費組互不影響。
消費者組的理解
(1) 不使用消費者組的話, 每一條消息都會被
分發(fā)到所有消費者
(相當于每個消費是一個消費者組), 如果消費者組有10個消費者, 不使用消費者組,每一條消息都會被消費10次,消費者組為一個整體來消費主題的所有分區(qū)
。
(2) 使用消費者組的話, 所有消費者組中的消費者是一個整體,每條消息只被消費一次
。
(3) 在消費者組中可以有一個或者多個消費者實例, 這些消費者共享一個公共的groupid
, groupid是一個字符串
,用來唯一標志
一個消費者組,組內的所有消費者協(xié)調在一起來消費訂閱主題的所有分區(qū)。
(4) 同一個topic下的某個分區(qū)只能被消費者組中的一個消費者消費(消費者可以少于主題的分區(qū)
,一個消費者可以消費一個主題的多個分區(qū)
,但是如果主題分區(qū)比消費者組中的消費者少,一個主題也只會發(fā)給一個消費者
不會多發(fā),此時多出來的消費者消費不到任何消息
),不同消費者組中的消費者可以消費相同的分區(qū)
。
(5) 如果消費者組當中消費者的數(shù)量超過了訂閱主題分區(qū)的數(shù)量
,那么多余的消費者就會被閑置
,不會受到任何消息
(6) 一個消費者組的一個消費者,可以消費一個topic下的多個分區(qū)
(消費者比分區(qū)少
)
(7) 同一個topic下的某個分區(qū),可以被多個消費者組
,消費者消息
重平衡:
當新的消費者加入
消費組,它會消費一個或多個分區(qū),而這些分區(qū)之前是由其他消費者負責的;另外,當消費者離開消費組
(比如重啟、宕機等)時,它所消費的分區(qū)會分配給其他分區(qū)。這種現(xiàn)象稱為重平衡(rebalance)。重平衡是Kafka一個很重要的性質
消費者組和Kafka兩種模式的關系
消息中間件有兩種模式電對點(P2P)
,發(fā)布訂閱模式(Pub、Sub)模式
- 如果所有消費者都在同一個消費者組,那么所有消息都會被
均勻
的發(fā)送到每一個消費者,每條消息只會被其中一個消費者消費
,就是點對點模式。 - 如果所有消費者屬于不同的消費者組,那么所有的消息都會被
廣播
到所有消費者,即每條消息都會被所有消費者
處理,就相當于發(fā)布/訂閱模式。
為什么需要消費者組
-
消費效率更高
: 如果沒有消費者組,所有分區(qū)的消息都會被廣播到每一個消費者,壓力肯定大,有了消費者組,組內成員分攤分區(qū)的壓力
,提高消費性能,負載均衡
。 -
消費模式靈活
: 有了消費者組可以方便的設置點對點
模式(所有消費者在一個消費者組)和發(fā)布訂閱
模式(每個消費者在一個不同的消費者組) -
便于故障容災
:消費組會對其成員進行管理,一個消費者宕機后,之前分配給他的分區(qū)會重新分配
給其他的消費者,實現(xiàn)消費者的故障容錯,故障自動轉移
。
客戶端開發(fā)
消費邏輯需要以下幾個步驟
(1) 配置消費者客戶端參數(shù)以及創(chuàng)消費者實例
(2) 訂閱主題
(3) 拉取消費者并且消費
(4) 提交消費者位移
(5) 關閉消費者實例
代碼實現(xiàn)
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicBoolean;
public class KafkaConsumerAnalysis {
public static final String brokerList = "192.168.61.97:9092";
public static final String topic = "test_gp";
public static final String group_id = "group.demo";
public static final AtomicBoolean isRuning = new AtomicBoolean(true);
public static Properties initConfig() {
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
props.put("key.deserializer", StringDeserializer.class.getName());
props.put("value.deserializer", StringDeserializer.class.getName());
props.put("group.id", group_id);
props.put("client.id", "consumer.client.id.demo");
return props;
}
public static void main(String[] args) {
Properties props = initConfig();
KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(props);
consumer.subscribe(Arrays.asList(topic));
try {
while (isRuning.get()) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for (ConsumerRecord<String, String> record: records) {
System.out.println("topic = " + record.topic() + ", partition = " + record.partition() +
", offset = " + record.offset());
System.out.println("key = " + record.key() + ", value = " + record.value());
}
}
} catch (Exception e) {
System.out.println(e);
} finally {
consumer.close();
}
}
}
必要的參數(shù)
消費者客戶端有4個參數(shù)需要設置
(1) bootstrap.servers
: 指定連接的kafka集群所需的broker地址清單, 格式為host1:port1,host2:port2
,可以設置一個或者多個地址, 用逗號隔開, 建議設置2個以上地址
(2) group.id
: 消費者隸屬的消費者組, 不能為空,這個參數(shù)需要設置成具有一定業(yè)務意義
的名稱。
(3) key.deserializer
和 value.deserializer
: 與生產(chǎn)者客戶端的序列化方式一致,與key.serializer
和value.serializer
保持一致。消費者從broker端獲取的消息格式是字節(jié)數(shù)組byte[]
,所以需要響應的反序列化操作才能還原成原有的對象格式。
其他參數(shù)有client.id這個參數(shù)如果不設置KafkaConsumer會自動生成,比如“consumer-1”。
可以使用ConsumerConfig類防止參數(shù)寫錯
public static Properties initConfig() {
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
props.put(ConsumerConfig.GROUP_ID_CONFIG, group_id);
props.put(ConsumerConfig.CLIENT_ID_CONFIG, "consumer.client.id.demo");
return props;
}
消費者相對于生產(chǎn)者,除了必要的序列化參數(shù)之外,多了一個group.id
參數(shù).
訂閱主題和分區(qū)
主要方法有訂閱主題集合
,正則表達式訂閱主題
,定于指定主題的分區(qū)
,這三種互斥
, 只能指定一種。
使用集合和正則表達式訂閱主題
一個消費者可以訂閱一個或多個
主題. 使用subscribe()
方法訂閱主題,可以使用集合
的形式和正則表達式
訂閱多個主題。以下兩種方式都可以訂閱clear_data和clear_data_01兩個主題,正則表達式.*
代表后續(xù)0個或者多個任意字符。
consumer.subscribe(Arrays.asList("clear_data", "clear_data_01"));
正則表達式需要設置再平衡監(jiān)聽器ConsumerRebalanceListener
。
consumer.subscribe(Pattern.compile("clear_data.*"), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection<TopicPartition> collection) {
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> collection) {
}
})
如果主要消費一個主題,可以使用Collections.singletonList()
將一個元素轉化為一個集合,Collections.singletonList()返回的是不可變的集合,這個長度的集合只有1
,可以減少內存空間
。
consumer.subscribe(Collections.singletonList("clear_data"));
如果在腳本中多次調用了了consumer.subscribe方法,最終訂閱的是腳本最下面最新定義的主題。
訂閱指定主題的指定分區(qū)
KafkaConsumer.assign()
方法實現(xiàn)了這一功能, assign接收Collection<TopicPartition>, 其中TopicPartition有2個屬性, topic和partition, 分區(qū)從0開始編號, 可以通過partitionFor()方法獲得主題的分區(qū)信息, partitionFor()接受參數(shù)topic,partitionFor可以查看的參數(shù)包括:
List<PartitionInfo> res = producer.partitionsFor("pira_clear_save_data");
for (PartitionInfo info : res) {
System.out.println("topic:" + info.topic());
System.out.println("partition:" + info.partition());
System.out.println("leader:" + info.leader());
System.out.println("replicas Array:" + Arrays.toString(info.replicas()));
System.out.println("ISR:" + Arrays.toString(info.inSyncReplicas()));;
System.out.println("--------------------------");
}
PartitionInfo跟消息無關,與主題和分區(qū)本身有關,List<PartitionInfo>顯示每個partition
的信息,分別是主題名
,分區(qū)號(0,1,2,...)
,leader副本
所在位置,AR集合
(所有副本集合)的位置,ISR集合
所在位置。
topic:clear_data
partition:2
leader:cloudera02:9092 (id: 77 rack: null)
replicas Array:[cloudera02:9092 (id: 77 rack: null), cloudera01:9092 (id: 78 rack: null), cloudera03:9092 (id: 79 rack: null)]
ISR:[cloudera02:9092 (id: 77 rack: null), cloudera01:9092 (id: 78 rack: null), cloudera03:9092 (id: 79 rack: null)]
--------------------------
topic:clear_data
partition:1
leader:cloudera03:9092 (id: 79 rack: null)
replicas Array:[cloudera03:9092 (id: 79 rack: null), cloudera02:9092 (id: 77 rack: null), cloudera01:9092 (id: 78 rack: null)]
ISR:[cloudera03:9092 (id: 79 rack: null), cloudera02:9092 (id: 77 rack: null), cloudera01:9092 (id: 78 rack: null)]
--------------------------
topic:clear_data
partition:0
leader:cloudera01:9092 (id: 78 rack: null)
replicas Array:[cloudera01:9092 (id: 78 rack: null), cloudera03:9092 (id: 79 rack: null), cloudera02:9092 (id: 77 rack: null)]
ISR:[cloudera01:9092 (id: 78 rack: null), cloudera03:9092 (id: 79 rack: null), cloudera02:9092 (id: 77 rack: null)]
--------------------------
也可以在kafka客戶端使用describe得到分區(qū)信息
Topic:pira_clear_save_data PartitionCount:3 ReplicationFactor:3 Configs:
Topic: pira_clear_save_data Partition: 0 Leader: 78 Replicas: 78,79,77 Isr: 78,79,77
Topic: pira_clear_save_data Partition: 1 Leader: 79 Replicas: 79,77,78 Isr: 79,77,78
Topic: pira_clear_save_data Partition: 2 Leader: 77 Replicas: 77,78,79 Isr: 77,78,79
在知道主題有那些分區(qū)之后可以使用KafkaConsumer.assign()訂閱指定clear_data主題分區(qū)0的消息。
consumer.assign(Arrays.asList(new TopicPartition("clear_data", 0)));
取消訂閱
取消訂閱調用unsubscribe()
方法.
consumer.unsubscribe();
consumer.subscribe(new ArrayList<String>());
consumer.assign(new ArrayList<TopicPartition>());
subscribe()和assign()比較
subscribe具有自動在均衡的功能,來實現(xiàn)消費負載均衡
和故障自動轉移
,而assign不具備這種功能。
消息消費
- kafka中消費是基于
拉模式
的, 消費者主動向服務端發(fā)起請求拉取消息
。kafka中的消息消費是一個不斷輪詢
的過程, 消費者要做的就是重復調用poll()方法
, poll()方法返回的是所訂閱的主題上的一組消息。 - poll方法有一個超時時間參數(shù)
timeout
, 用來控制poll方法的阻塞時間, 在消費者緩沖區(qū)內沒有可用數(shù)據(jù)
時發(fā)生阻塞,是一個Duration
類型, 可以使用ofMillis(), ofSeconds(), ofMinutes(), ofHours()等多種方法指定不同的時間單位,舊版本是一個long
類型。timeout的設置取決于應用程序對響應速度的要求, 比如需要在多長時間內將控制權移交給執(zhí)行輪詢的應用線程
, 可以直接將timeout為0這樣poll()就會立刻返回而不管是否已經(jīng)拉取到消息.如果應用線程唯一的工作就是從Kafka拉取并消費消息, 那么可以設置為最大值Long.MAX_VALUE。 - 消費者消費到的每條消息類型是
ConsumerRecoed
, 和生產(chǎn)者的ProducerRecord對應,poll()方法返回的類型是ConsumerRecords
, 表示一次拉取的所有消息集。
關于poll的理解
(1). poll是拉取到消息立即返回
的,返回自從上次poll到現(xiàn)在的數(shù)據(jù),如果沒有消息可拉,會在poll這個方法中阻塞程序運行
(2). poll的超時時間timeout
是超時時間
,和間隔多長時間
拉取一次數(shù)據(jù)沒有關系
。
(3). 如果kafka中沒有數(shù)據(jù),等到了超時時間
會強制
返回空消息
,相當于如果一直沒有消息,則每隔超時時間
返回空消息。
(4). poll方法內部是一個do while循環(huán),只要有數(shù)據(jù)立即返回退出循環(huán),否則一直消耗超時時間,當剩余時間為0的時候退出do while返回空消息,如果設置超時時間為0,運行一次poll后剩余時間為0立即返回。
(5). 拉取的間隔時間和程序代碼有關,是while true
中兩次調用poll的程序運行間隔時間。
(6). 相同數(shù)據(jù)吞吐量下,while true中消息處理程序
運行越快,兩次調用poll間隔越短,拉取數(shù)據(jù)
的間隔越短,一批次拉取的數(shù)據(jù)越少
;反之,消息處理程序執(zhí)行時間越長,兩次調用poll間隔越長,拉取數(shù)據(jù)間隔越長,一批次拉取數(shù)據(jù)越多。
ConsumerRecords提供了iterator()
方法遍歷消息來消費,也可以根據(jù)分區(qū)
來進行消費和根據(jù)主題
來進行消費。
遍歷消費
consumer.subscribe(Collections.singletonList("clear_data"));
ConsumerRecords<String, String> consumerRecords = consumer.poll(1000L);
for (ConsumerRecord<String, String> record : consumerRecords) {
JSONObject jsonObject = JSON.parseObject(record.value());
// TODO
}
根據(jù)分區(qū)進行消費
for (TopicPartition tp : consumerRecords.partitions()) {
for (ConsumerRecord<String, String> record : consumerRecords.records(tp)) {
JSONObject jsonObject = JSON.parseObject(record.value());
// TODO
}
}
根據(jù)主題進行消費
consumer.subscribe(Arrays.asList("clear_data", "clear_data2"));
ConsumerRecords<String, String> consumerRecords = consumer.poll(1000L);
for (String topic : Arrays.asList("clear_data", "clear_data2")) {
for (ConsumerRecord<String, String> record : consumerRecords.records(topic)) {
JSONObject jsonObject = JSON.parseObject(record.value());
// TODO
}
}
位移提交
對于kafka中的分區(qū)
而言, 他的每條消息都有一個唯一的offset
, 用來表示消息在分區(qū)中對應的位置
.對于消費者而言它也有一個offset概念, 消費者使用offset來表示消費到分區(qū)中某個消息所在的位置.
對于消息在分區(qū)中的位置, offset作為
偏移量
對于消費者消費到的位置, 將offset稱為位移
對于一條消息而言, 它的偏移量和消費者消費他的位移是對等的
- 在每次調用poll()方法時, 它返回的是
沒有被消費過
的消息集, 要做到這一點就必須記錄上一次消費時的位移
, 并且這個位移必須做持久化保存
, 而不是單單保存在內存中, 否則消費者重啟之后就不知道之前消費的位移了.另外如果在消費者組新增了一個消費者, 那么分區(qū)會進行再均衡, 會有分區(qū)從之前的消費者分配到新的消費者, 如果不持久保存消費位移, 那新的消費者就無法知道之前的消費位移。 - 消費位移存儲在kafka日志目錄下的
__consumer_offsets
中, 把消費位移持久化
的動作稱為提交
, 消費者在消費完消息之后需要執(zhí)行消費位移提交。__consumer_offsets可以看做是一個單獨的topic
,分區(qū)數(shù)在kafka.properties的offsets.topic.num.partitions
中設置。
消費位移.png
消費者的消費位移是x, 當消費者消費完, 提交的提交的消費位移是x+1, 表示下一條要拉取的消息位置
kafka中默認消費位移的提交方式是自動提交, 可以在消費者客戶端的參數(shù)中配置
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
這個默認提交的自動提交不是每消費一條就提交一次, 而是定時提交, 默認是每個5秒, 但是自動提交是在poll方法邏輯內完成的, 在每次poll請求之前都會檢查是否可以進行位移提交, 如果可以就會提交上一次輪詢的位移
自動位移提交
可能會導致重復消費
和數(shù)據(jù)丟失
。
(1) 重復消費發(fā)生在消費者崩了, 位移未提交, 下一次重新拉取, 可以通過減小自動提交位移的時間間隔縮短重新拉去的數(shù)據(jù)大小
(2) 數(shù)據(jù)丟失發(fā)生在消費者崩了, 數(shù)據(jù)還沒有處理完, 但是這一批的位移已經(jīng)提交, 重啟消費者從下一批數(shù)據(jù)開始拉取
自動位移提交在正常情況下不會發(fā)生重復消費或者數(shù)據(jù)丟失, 但是異常無法避免, kafka提供了手動位移提交, 很多時候不是消費到信息就算完成, 而是需要將消息寫入數(shù)據(jù)庫
,寫入本地緩存
, 或者更加復雜的業(yè)務處理
才能算消費成功, 此時在進行位移提交, kafka的手動調教方式就是為了給開發(fā)人員根據(jù)邏輯在合適的地方進行位移提交, 開啟手動提交需要修改參數(shù)。
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
手動提交可以細分為同步提交
和異步提交
, 對應KafkaConsumer中的commitSync()
和commitAsync()
。
while (isRuning.get()) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(Long.MAX_VALUE));
for (ConsumerRecord<String, String> record: records) {
System.out.println("topic = " + record.topic() + ", partition = " + record.partition() +
", offset = " + record.offset());
System.out.println("key = " + record.key() + ", value = " + record.value());
}
consumer.commitSync();
}
可以將消息存入內存, 進行批量處理和批量提交
final int minBatchSize = 10;
List<ConsumerRecord> buffer = new ArrayList<>();
while (isRuning.get()) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(Long.MAX_VALUE));
for (ConsumerRecord<String, String> record: records) {
buffer.add(record);
System.out.println(buffer.size());
}
if (buffer.size() >= minBatchSize) {
System.out.println(buffer);
consumer.commitSync();
buffer.clear();
}
}
commitSync()方法會阻塞消費者線程
直至位移提交完成.
控制或關閉消費
KafkaConsumer提供了對消費速度進行控制的方法, 某些情況下我們可能需要暫停謀陷分區(qū)的消費而先消費其他分區(qū), 當達到一定條件是再回復這些分區(qū)的消費. KafkaConsumer中使用pause()和resume()來暫停某些分區(qū)在拉取時返回數(shù)據(jù)給消費者客戶端 和 恢復某些分區(qū)想消費者客戶端返回數(shù)據(jù)
指定位移消費
當一個新的消費組建立的時候, 他根本沒有可以找到的消費位移, 或者新訂閱了一個新的主題, 也沒有可用的位移, 或者當__consumer_offsets主題中有關消費組的位移信息被刪除, 也找不到可用的位移
當kafka中消費者找不到位移的時候, 會根據(jù)消費者客戶端參數(shù) auto.offset.reset 的配置來決定從何處開始消費. 這個參數(shù)的默認值是latest, 表示從分區(qū)末尾開始消費. 如果設置成earliest, 那么消費者會從起始處開始消費. 如果設置成none則找不到位移直接報錯.
kafka中poll無法精確掌控消費的起始位置, auto.offset.reset也只能設置從在開始或者結尾開始消費, 如果要從特定的位移開始消費需要使用KafkaConsumer的seek()方法.
先通過assignment()方法獲取消費者所分配到的分區(qū)信息
consumer.subscribe(Arrays.asList(topic));
consumer.poll(Duration.ofMillis(10000));
Set<TopicPartition> assignment = consumer.assignment();
System.out.println(assignment);
[test_gp-0]
seek方法接受的參數(shù)partition和offset, offset指定從分區(qū)的哪個位置開始消費, seek需要獲取所分配的分區(qū), 要獲取所分配的分區(qū)必須先執(zhí)行一次poll操作, 因為分區(qū)分配實在poll()調用過程中實現(xiàn)的.
consumer.subscribe(Arrays.asList(topic));
consumer.poll(Duration.ofMillis(10000));
Set<TopicPartition> assignment = consumer.assignment();
System.out.println(assignment);
List<TopicPartition> assignment2 = new ArrayList<>(assignment);
consumer.seek(assignment2.get(0), 140);
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for (ConsumerRecord record : records) {
System.out.println(record);
}
}