與生產者對應的是消費者,應用程序通過KafkaConsumer來訂閱主題,并從訂閱的主題中拉取消息。不過我們需要先了解消費者和消費組的概念,否則無法理解如何使用KafkaConsumer。
消費者與消費組
每個消費者對應一個消費組,當消息發布到主題后,只會被投遞給訂閱它的每個消費組中的一個消費者。如下圖所示:
主題中,共有四個分區,P0、P1、P2、P3,有兩個消費組A和B都訂閱了這個主題,消費組A中有4個消費者(C0、C1、C2、C3),消費者B有兩個消費者(C4、C5)。按照Kafka默認的規則,消費組A中的每一個消費者分配到一個分區,消費組B中每一個消費者分配到兩個分區,兩個消費組之間互不影響。
每個消費者只能消費被分配到的分區中的消息。換言之,每個分區只能被一個消費組中的一個消費者所消費。
再來看一下消費組內消費者的個數變化時所對應分區分配的演變。假設目前某消費組內只有一個消費者C0,訂閱了一個主題,這個主題包含7個分區,P0/P1/P2/P3/P4/P5/P6。也就是說,這個消費者訂閱了7個分區:
此時消費組內又增加了新的消費者C1,按照既定的邏輯,需要將原來消費者C0的部分分區分配給消費者C1消費:
C0和C1各自消費所分配的分區,彼此間并無邏輯上的干擾。緊接著又增加消費者C2:
消費者與消費組這種模型可以讓整體的消費能力具備橫向伸縮,我們可以增加(減少)消費者的個數來提高(或降低)整體的消費能力。對于分區數固定的情況,一味地增加消費者并不能讓消費能力一直得到增強,如果消費者過多,出現了消費者個數大于分區個數的情況,就會有消費者分配不到任何分區。如下圖所示:
以上分配策略都是基于默認的分區分配策略進行分析的,可以通過消費者客戶端參數partition.assignment.strategy
來設置消費者與訂閱主題之間的分區分配策略。
對于消息中間件而言,一般有兩種消息投遞模式:點對點模式和發布/訂閱模式。點對點模式是基于隊列的,消息生產者發送消息到隊列,消息消費者從隊列中接收消息。發布訂閱模式以主題為內容節點,主題可以認為是消息傳遞的中介,使得消息訂閱者和發布者保持獨立,不需要進行接觸即可保持消息的傳遞,在消息的一對多廣播時采用。
- 如果消費者都屬于同一消費組,那么所有的消息都會被均衡的投遞給每一個消費者,即每條消息都只會被一個消費者處理,這就相當于點對點模式的應用。
- 如果所有消費者都隸屬于不同的消費組,那么所有的消息都會被廣播給所有的消費者,即每條消息都會被所有的消費者處理,這就相當于訂閱/發布應用。
可以通過消費者客戶端參數group.id來配置,默認值為空字符串。消費組是邏輯上的概念,它將消費者進行歸類,消費者并非邏輯上的概念,它是實際上的應用實例,它可以是一個線程,也可以是一個進程,同一個消費組內的消費者可以部署在同一臺機器上,也可以部署在不同的機器上。
客戶端開發
采用目前流行的新消費者(java語言編寫)客戶端。
一個正產的消費邏輯需要以下幾個步驟
- 配置消費者客戶端參數及創建響應的客戶端實例。
- 訂閱主題。
- 拉取消息并消費。
- 提交消費位移。
- 關閉消費者實例。
一個基本的消費者案例如下:
public class Consumer {
public static final String brokerList = "192.168.0.138:9092";
public static final String topic = "topic-demo";
public static final String group = "group-id";
public static final String client = "client-id";
public static final AtomicBoolean isRunning = new AtomicBoolean(true);
public static Properties initConfig(){
Properties properties = new Properties();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,brokerList);
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
properties.put(ConsumerConfig.GROUP_ID_CONFIG,group);
properties.put(ConsumerConfig.CLIENT_ID_CONFIG,client);
return properties;
}
public static void main(String[] args) {
KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(initConfig());
consumer.subscribe(Collections.singletonList(topic));
try {
while (isRunning.get()) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
records.forEach(record->{
System.out.println("topic="+record.topic()+", partition="+record.partition()+", offset="+record.offset());
System.out.println("key="+record.key()+", value="+record.value());
});
}
} catch (Exception e) {
e.printStackTrace();
} finally {
consumer.close();
}
}
}
bootstrap.servers:指定kafka集群地址,可以設置一個或多個,用逗號隔開。注意這里不需要設置集群中全部的broker地址,消費者會從現有的配置中查找全部的集群成員。如果只設置一個地址,啟動時為避免該地址機器宕機連接不到集群,最好設置兩個或兩個以上地址。
key.deserializer和value.deserializer:與生產者客戶端參數相對應。消費者從kafka獲取到的消息格式都是字節數組(byte[]),所以需要執行相應的反序列化操作才能還原成原有的對象格式。
client.id:客戶端id,如果不設置,會自動生成一個非空字符串,內容形式為consumer-1,consumer-2這種格式。
消費者客戶端參數眾多,在這里羅列講解沒有意義,之后會一一詳解。
訂閱主題與分區
一個消費者可以訂閱一個或多個主題。如上代碼示例,通過consumer.subscribe方式訂閱主題,對于這個方法而言,既可以以集合的方式訂閱多個主題,也可以以正則表達式的形式訂閱特定模式的主題。subscribe的幾個重載的方法如下:
void subscribe(Collection<String> var1);
void subscribe(Collection<String> var1, ConsumerRebalanceListener var2);
void assign(Collection<TopicPartition> var1);
void subscribe(Pattern var1, ConsumerRebalanceListener var2);
void subscribe(Pattern var1);
如果消費者采用的是正則表達式的方式訂閱,在之后的創建過程中,如果有人又創建了新的主題,并且主題的名字與正則表達式相匹配,那么這個消費者就可以消費到新添加的主題中的消息。如果應用程序需要消費多個主題,并且可以處理不同的類型,那么這種訂閱方式就很有效。在kafka和其他系統之間進行數據賦值時,這種正則表達式的方式顯得很常見。
consumer.subscribe(Pattern.compile("topic-.*"));
重載方法中有一個ConsumerRebalanceListener ,這個是用來設置相應的再均衡監聽器的,之后會講。
消費者不但可以訂閱主題,還可以直接訂閱主題的特定分區,通過assign方法來實現這一功能。
void assign(Collection<TopicPartition> partitions);
這個方法只接受一個參數partitions,用來指定分區集合。關于TopicPartition類,用來表示分區,這個類的內部結果如下所示:
public final class TopicPartition implements Serializable {
private final int partition;
private final String topic;
其他省略
有兩個屬性,partition和topic,分別代表自身的分區編號和主題名稱,這個類和我們所說的主題-分區概念對應起來。在案例代碼清單中,我們使用assign方法替代subscribe方法,訂閱主題topic-demo的分區0。
consumer.assign(Arrays.asList(new TopicPartition("topic-demo",0)));//訂閱主題topic-demo的分區0
如果,我們事先不知道主題中有多少個分區怎么辦?partitionsFor方法可以用來查詢指定主題的元數據信息,定義如下:
List<PartitionInfo> partitionsFor(String topic);
其中,PartitionInfo類型即為主題的分區元數據信息:
public class PartitionInfo {
private final String topic;//主題名稱
private final int partition;//分區編號
private final Node leader;//leader副本所在的位置
private final Node[] replicas;//分區的AR集合
private final Node[] inSyncReplicas;//分區的ISR集合
private final Node[] offlineReplicas;//分區的OSR集合
通過partitionsFor方法的協助,我們可以通過assign方法來實現訂閱主題全部分區的功能:
List<TopicPartition> partitions = new ArrayList<>();
//獲取主題的全部分區
consumer.partitionsFor("topic-demo").forEach(partitionInfo -> {
System.out.println("分區:"+partitionInfo.partition());
partitions.add(new TopicPartition(partitionInfo.topic(),partitionInfo.partition()));
});
//通過assign方法來實現訂閱主題全部分區
consumer.assign(partitions);
既然有訂閱,那就有取消訂閱,可以使用unsubscribe方法取消訂閱。
//取消訂閱
consumer.unsubscribe();
集合訂閱的方式、正則表達式的訂閱方式和指定分區的訂閱方式,分別代表了3種不同的訂閱狀態:AUTO_TOPICS、AUTO_PATTERN、USER_ASSIGNED,如果沒有訂閱那么狀態為NONE。這三種狀態是互斥的,在一個消費者中,只能使用其中的一種。通過sbscribe方法訂閱的主題具有消費者自動再均衡的功能,在多個消費者的情況下根據分區策略來自動分配各個消費者與分區的關系。當消費組內的消費者增加或減少時,分區分配關系會自動調整,以實現消費負載均衡及故障自動轉移。而通過assign方法訂閱分區時,是不具備消費者自動均衡的功能。
反序列化
在「kafka」kafka-clients,java編寫生產者客戶端及原理剖析我們講過了生產者的序列化與消費者的反序列化程序demo。Kafka提供的反序列器有ByteBufferDeserializer、ByteArrayDeserializer、BytesDeserializer、DoubleDeserializer、FloatDeserializer、IntegerDeserializer、LongDeserializer、ShortDeserializer、StringDeserializer,這些反序列化器都實現了Deserializer接口,該接口有三個方法:
void configure(Map<String, ?> var1, boolean var2);//用來配置當前類
T deserialize(String var1, byte[] var2);//用來執行反序列化
void close();//關閉當前序列化器
我們來看一下StringDeserilizer的源碼:
public class StringDeserializer implements Deserializer<String> {
private String encoding = "UTF8";
public StringDeserializer() {
}
public void configure(Map<String, ?> configs, boolean isKey) {
String propertyName = isKey ? "key.deserializer.encoding" : "value.deserializer.encoding";
Object encodingValue = configs.get(propertyName);
if (encodingValue == null) {
encodingValue = configs.get("deserializer.encoding");
}
if (encodingValue instanceof String) {
this.encoding = (String)encodingValue;
}
}
public String deserialize(String topic, byte[] data) {
try {
return data == null ? null : new String(data, this.encoding);
} catch (UnsupportedEncodingException var4) {
throw new SerializationException("Error when deserializing byte[] to string due to unsupported encoding " + this.encoding);
}
}
public void close() {
}
}
configure方法用來定義編碼格式,默認就UTF-8就好了,不用管這個。我們看一下自定義反序列化器,只要實現了Deserializer接口即可:
public class UserDeserializer implements Deserializer<User> {
@Override
public void configure(Map<String, ?> map, boolean b) {
}
@Override
public User deserialize(String s, byte[] bytes) {
ByteBuffer byteBuffer = ByteBuffer.wrap(bytes);
int nameLength = byteBuffer.getInt();
byte name[] = new byte[nameLength];
byteBuffer.get(name,0,nameLength);
int age = byteBuffer.getInt();
return new User().setAge(age).setName(new String(name));
}
@Override
public void close() {
}
}
public class User {
private String name;
private int age = -1;
public String getName() {
return name;
}
public User setName(String name) {
this.name = name;
return this;
}
public int getAge() {
return age;
}
public User setAge(int age) {
this.age = age;return this;
}
}
總之,就是將kafka返回的字節序列轉化成你的業務對象。關于序列化,我會在之后寫一篇當下流行的序列化方法匯總的博文,比如Avro、JSON、Thrif、ProtoBuf或Protostuff等,歡迎關注。
這里簡單舉一例,用Protostuff來實現序列化與反序列化:
//依賴
<dependency>
<groupId>io.protostuff</groupId>
<artifactId>protostuff-core</artifactId>
<version>1.7.2</version>
</dependency>
<dependency>
<groupId>io.protostuff</groupId>
<artifactId>protostuff-runtime</artifactId>
<version>1.7.2</version>
</dependency>
序列化:
public class ProtostuffUserSerializer implements Serializer<User> {
@Override
public void configure(Map<String, ?> map, boolean b) {
}
@Override
public byte[] serialize(String s, User user) {
if (user == null){
return null;
}
Schema schema = RuntimeSchema.getSchema(user.getClass());
LinkedBuffer buffer = LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE);
byte[] protostuff = null;
protostuff = ProtostuffIOUtil.toByteArray(user,schema,buffer);
buffer.clear();
return protostuff;
}
@Override
public void close() {
}
}
反序列化
public class ProtostuffUserDesirializer implements Deserializer<User> {
@Override
public void configure(Map<String, ?> map, boolean b) {
}
@Override
public User deserialize(String s, byte[] bytes) {
if (bytes == null) {
return null;
}
Schema schema = RuntimeSchema.getSchema(User.getClass());
User user = new User();
ProtostuffIOUtil.mergeFrom(bytes, user, schema);
return user;
}
@Override
public void close() {
}
}
消息消費
Kafka的消費是基于拉模式的。消息的消費一般有兩種模式:推模式和拉模式。推模式是服務器主動將消息推送給消費者,拉模式是消費者向服務端發送請求拉取消息。
從代碼示例中可以看出,消費是一個不斷輪詢的過程,消費者重復調用poll方法,返回的是所訂閱主題(分區)上的一組消息。
返回的消息類型為ConsumerRecord,源碼如下所示:
public class ConsumerRecord<K, V> {
public static final long NO_TIMESTAMP = -1L;
public static final int NULL_SIZE = -1;
public static final int NULL_CHECKSUM = -1;
private final String topic;//主題
private final int partition;//分區
private final long offset;//所屬分區偏移量
private final long timestamp;//時間戳
//兩種類型,CreateTime 和 LogAppendTime
//分別代表消息創建的時間,追加到日志的時間
private final TimestampType timestampType;
private final int serializedKeySize;//key經過序列化后的大小,如果key為空,該值為-1
private final int serializedValueSize;//value經過序列化后的大小,如果value為空,該值為-1
private final Headers headers;//消息的頭部內容
private final K key;//消息的鍵
private final V value;//消息的值
private final Optional<Integer> leaderEpoch;
private volatile Long checksum;//CRC32的校驗值
部分省略
實例代碼中,我們通過遍歷消息集合處理每一條消息,除此之外,我們還可以按照分區維度來進行消費,這一點很有用,在手動提交位移時尤為明顯,ConsumerRecords提供了一個records(TopicPartition)方法來獲取消息中指定分區的消息,此方法的定義如下:
public List<ConsumerRecord<K, V>> records(TopicPartition partition) {
List<ConsumerRecord<K, V>> recs = (List)this.records.get(partition);
return recs == null ? Collections.emptyList() : Collections.unmodifiableList(recs);
}
修改實例代碼,將所有消息按分區處理:
//按分區處理消息
for (TopicPartition tp : records.partitions()){//獲取所有分區
for (ConsumerRecord<String, String> record : records.records(tp)){//獲取指定分區的消息
System.out.println("partition:"+record.partition()+"----value:"+record.value());
}
}
此外,ConsumerRecords類中還提供了幾個方法來方便開發人員對消息集進行處理:
- count方法,獲取消息個數
- isEmpty方法,判斷返回的消息是否為空
- empty方法,獲取一個空的消息集
到目前為止,可以建單人位,poll方法只是拉取一下消息而已,但就其內部邏輯而言并不簡單,它涉及消費位移、消費者協調器、組協調器、消費者選舉、分區分配的分發、再均衡的邏輯、心跳等內容。后續會詳細介紹。
位移提交
對于Kafka的分區而言,它的每條消息都有唯一的offset,用來表示消息在分區中對應的位置。消費者使用offset來表示消費到分區中某個消息所在的位置。offset,顧名思義,偏移量,也可翻譯為位移。在每次調用poll()方法時,它返回的是還沒有消費過的消息集,要做到這一點,就需要記錄上一次消費過的位移。并且這個位移必須做持久化保存,而不是單單保存在內存中,否則消費者重啟之后就無法知道之前的消費位移了。
當加入新的消費者的時,必然會有再均衡的動作,對于同一分區而言,它可能在再均衡動作之后分配給新的消費者,如果不持久化保存消費位移,那么這個新的消費者也無法知道之前的消費位移。消費者位移存儲在Kafka內部的主題_consumer_offsets中。
這種把消費位移存儲起來(持久化)的動作稱為“提交”,消費者再消費完消息之后需要執行消費位移的提交。
如下圖,假設當前消費者已經消費了x位置的消息,那么我們就可以說消費者的消費位移為x。
不過,需要明確的是,當前消費者需要提交的消費位移并不是x,而是x+1,對應上圖的position,他表示下一條需要拉取的消息的位置。在消費者中還有一個commited offset的概念,它表示已經提交過的消費位移。
KafkaConsumer類提供了position(TopicPartition)和commited(TopicPartition)兩個方法來分別獲取上面所說的position和commiited offset的值。
為了論證lastConsumedOffset、commited offset 和position之間的關系,我們使用上面兩個方法來做相關演示。我們向主題中分區編號為0的分區發送若干消息,之后再創建一個消費者去消費其中的消息,等待消費完這些消息之后,同步提交消費位移。最后觀察上面三者的值。
//定義主題topic-demo,分區編號為0
TopicPartition topicPartition = new TopicPartition("topic-demo",0);
consumer.assign(Arrays.asList(topicPartition));//訂閱主題topic-demo的分區0
long lastConsumedOffset = -1;//當前消費到的位移
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(Long.MAX_VALUE));
//獲取主題topic-demo的分區0的消息
List<ConsumerRecord<String, String>> partitionRecords = records.records(topicPartition);
//獲取最后一條消息的偏移量,該消息是已經消費的最后一條消息
lastConsumedOffset = partitionRecords.get(partitionRecords.size()-1).offset();
consumer.commitSync();//同步提交消費位移
System.out.println("consumed Offset is "+lastConsumedOffset);
OffsetAndMetadata offsetAndMetadata = consumer.committed(topicPartition);
System.out.println("commited Offset is "+offsetAndMetadata.offset());
long position = consumer.position(topicPartition);
System.out.println("the offset of the netxt record is "+position);
打印結果
consumed Offset is 182
commited Offset is 183
the offset of the netxt record is 183
可以看出,消費者消費到此分區的最大偏移量為182,對應的消費位移lastConsumedOffset 也就是182。在消費完之后執行同步提交,但是最終結果顯示所提交的位移commited offset 為183,并且下一次所要拉取的消息的起始偏移量position為183,結論
position = commited offset = lastConsumedOffset + 1
當然,position和commited offset的值不會一直相同,這一點會在下面的示例中有所體現。
對于位移提交具體時機的把握也很有講究,有可能造成重復消費和消息丟失的現象。參考下圖所示,x代表上一次提交的消費位移,說明已經完成了x-1之前的所有消息的消費。x+3表示當前正在處理的位置。如果poll拉取到消息之后就進行了位移提交,即提交了x+7,那么當前消費x+3的時候遇到了異常,在故障恢復之后, 我們重新拉取到的消息是從x+7開始的。也就是說,x+3到x+6之間的消息并未消費,如此便發生了消息丟失的現象。
再考慮另一種情形,位移提交的動作是在消費完所有拉取到的消息之后才執行的,那么當消費x+3的時候遇到了異常,在故障恢復之后,我們重新拉取的消息是從x開始的。也就是說 x到x+2之間的消息又重新消費了一遍,故而發生了重復消費的現象。
而實際情況可能更加復雜。在kafka中默認的消費位移的提交方式是自動提交,這個由消費客戶端參數enable.auto.commit配置,默認為true。當然這個默認的自動提交不是每消費一條消息就提交一次,而是定期提交,這個定期的周期時間由客戶端參數auto.commit.interval.ms配置,默認值為5秒,此參數生效的前提是enable.auto.commit為true。
在默認情況下,消費者客戶端每隔5秒會將拉取到的每個分區中的最大的消息位移進行提交。自動位移提交的動作實在poll方法的邏輯里完成的,在每次真正向服務器發起拉取請求之前會檢查是否可以進行位移提交,如果可以,那么就會提交上一次輪詢的位移。
在kafka消費的編程邏輯中位移是一大難點,自動提交消費位移的方式非常簡便,它免去了復雜的位移提交邏輯,讓代碼更簡潔。但隨之而來的是重復消費和消費丟失的問題。假設剛提交完一次消費位移,然后拉取一批消息進行消費,在下一次自動提交消費位移之前,消費者崩潰了,那么又得從上一次位移提交的地方重新開始消費。我們可以通過減少位移提交的時間間隔來減少重復消息的窗口大小,但這樣并不能避免重復消費的發送,而且也會使位移提交更加頻繁。
自動位移提交的方式在正常情況下不會發生消息丟失和重復消費的現象,但是在編程的世界里異常不可避免。自動提交無法做到精確的位移管理。Kafka提供了手動管理位移提交的操作,這樣可以使開發人員對消費位移的管理控制更加靈活。很多時候并不是說poll拉取到消息就算消費完成,而是需要將消息寫入到數據庫、寫入本地緩存,或者是更加復雜的業務處理。在這些場景下,所有的業務處理完成才能認為消息被成功消費,手動的提交方式讓開發人員根據程序的邏輯在合適的地方進行位移提交。手動提交功能的前提是enable.auto.commit配置為false,手動提交分為同步提交和異步提交,對應于KafkaConsumer中的commitSync和commitAsync兩個方法。
- commitSync
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(Long.MAX_VALUE));
// 按分區處理消息
for (TopicPartition tp : records.partitions()){
for (ConsumerRecord<String, String> record : records.records(tp)){
System.out.println("partition:"+record.partition()+"----value:"+record.value());
}
}
consumer.commitSync();
先將拉取到的每一條消息進行處理,然后對整個消息集做同步提交。針對上面的示例還可以修改為批量處理+批量提交的方式。
final int minBatchSize = 200;
List<ConsumerRecord> buffer = new Arraylist<>();
whie(true){
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(Long.MAX_VALUE));
for (TopicPartition tp : records.partitions()){
for (ConsumerRecord<String, String> record : records.records(tp)){
buffer.add(record);
}
}
if(buffer.size() >= minBatchSize){
//do local processing with buffer
consumer.commitSync();
buffer.clear;
}
}
上面的示例中,將拉取到的消息存入緩存buffer,等到累積到足夠多的時候,再做相應的批量處理,之后再做批量提交。
這兩個示例都有重復消費的問題,如果在業務邏輯處理完之后,并且在同步位移提交之前,程序出現了崩潰。那么恢復之后,只能從上一次位移提交的地方拉取消息。
commitSync方法會根據poll拉取到的最新位移來進行提交,即position的位置,只要沒有發生不可恢復的錯誤,它就會阻塞消費者線程直至位移提交完成。對于不可恢復的錯誤,如CommitFailedException/WakeupException/InterruptException/AuthenticationException/AuthorizationException等,我們可以將其捕獲并做針對性的處理。
commitSync提交位移的頻率和拉取批次消息、處理批次消息的頻率是一致的,如果想尋求更細粒度、更準確的提交,那么就需要commitSync另一個含參的方法,
public void commitSync(Map<TopicPartition, OffsetAndMetadata> offsets)
參數offsets用來提交指定分區的位移。無參的commitSync方法只能提交當前批次對應的position值。如果需要提交一個中間值,比如業務每消費一條消息就提交一次位移,那么就可以使用這種方式
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(Long.MAX_VALUE));
records.forEach(record->{
System.out.println("topic="+record.topic()+", partition="+record.partition()+", offset="+record.offset());
long offset = record.offset();
TopicPartition partition = new TopicPartition(record.topic(), record.partition());
//每消費一條消息提交一次位移
consumer.commitSync(Collections.singletonMap(partition,new OffsetAndMetadata(offset+1)));
System.out.println("_______________________________");
});
在實際應用中,很少有這種每消費一條消息,就提交一次消費位移的場景。commitSync方法本身是同步進行的,會消耗一定的性能。更多的時候,是按照分區的粒度劃分提交位移的界限,
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(Long.MAX_VALUE));
// 按分區處理消息
for (TopicPartition tp : records.partitions()){
//獲取當前分區的所有消息
List<ConsumerRecord<String,String>> partitionRecords = records.records(tp);
for (ConsumerRecord<String, String> record : partitionRecords){
System.out.println("partition:"+record.partition()+"----value:"+record.value());
}
//當前分區最后一條消息的位移
long lastConsumedOffset = partitionRecords.get(partitionRecords.size() -1).offset();
//按分區的粒度,進行位移提交
consumer.commitSync(Collections.singletonMap(tp,new OffsetAndMetadata(lastConsumedOffset+1)));
}
與commitSync相反,異步提交的方式commitAsync在執行的時候,消費者線程不會阻塞,可能在提交消費位移的結果返回之前就開始了新一輪的拉取操作。可以是消費者的性能增強。
void commitAsync();
void commitAsync(OffsetCommitCallback var1);
void commitAsync(Map<TopicPartition, OffsetAndMetadata> var1, OffsetCommitCallback var2);
第一個無參的方法和第三個方法中的offsets都很好理解,對照commitSync方法即可。關鍵是這里第二個方法和第三個方法中的OffsetCommitCallback參數,它提供了一個異步提交的回調方法,當位移提交完成后回調OffsetCommitCallback里的onComplete方法:
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(Long.MAX_VALUE));
// 按分區處理消息
for (TopicPartition tp : records.partitions()){
//獲取當前分區的所有消息
List<ConsumerRecord<String,String>> partitionRecords = records.records(tp);
for (ConsumerRecord<String, String> record : partitionRecords){
System.out.println("partition:"+record.partition()+"----value:"+record.value());
}
//當前分區最后一條消息的位移
long lastConsumedOffset = partitionRecords.get(partitionRecords.size() -1).offset();
//按分區的粒度,進行位移提交
consumer.commitAsync(Collections.singletonMap(tp, new OffsetAndMetadata(lastConsumedOffset + 1)), new OffsetCommitCallback() {
@Override
public void onComplete(Map<TopicPartition, OffsetAndMetadata> map, Exception e) {
if(e == null){
System.out.println(map);
}else{
System.out.println("提交失敗");
}
}
});
}
控制或關閉消費
KafkaConsumer提供了對消費速度進行控制的方法,有些場景,需要我們暫停某些分區的消費而先消費其他分區,當達到一定條件時再恢復這些分區的消費。pause()和resume()方法來分別實現暫停某些分區在拉取操作時返回數據給客戶端和恢復某些分區向客戶端返回數據的操作。
void pause(Collection<TopicPartition> var1);
void resume(Collection<TopicPartition> var1);
還有一個無參的paused方法返回被暫停的分區集合
Set<TopicPartition> paused();
之前的示例展示的都是使用一個while循環來包裹住poll方法及相應的消費邏輯,如何優雅的退出這個循環也很有考究。還有一種方式是調用KafkaConsumer的wakeup方法,調用該方法可以退出poll的邏輯,并拋出WakeupException異常,我們不需要處理這個異常,它只是跳出循環的方式。
跳出循環以后一定要顯示執行關閉動作以釋放運行過程中占用的各種系統資源,包括內存資源,socket連接等等。KafkaConsumer提供了close方法實現關閉
指定位移消費
正是有了消費位移的持久化,才使消費者在關閉、崩潰或者遇到再均衡的時候,可以讓接替的消費者能夠根據存儲的消費位移繼續進行消費。
當一個新的消費組建立的時候,它根本沒有可以查找的消費位移。或者消費組內的一個新消費者訂閱了一個新的主題,它也沒有可以查找的消費位移。
當消費者查找不到所記錄的消費位移的時候,就會根據消費者客戶端參數auto.offset.reset的配置來決定從何處開始進行消費,這個參數的默認值為latest,表示從分區末尾開始消費。
如圖,按照默認的配置,消費者會從8開始消費,更加確切的說是從8開始拉取消息。如果將auto.offset.reset設置成earliest,那么消費者會從起始處,也就是0開始消費。
properties.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG,"earliest");
到目前為止,我們知道消息的拉取是根據poll方法中的邏輯來處理的,這個邏輯對于普通開發人員來說是個黑盒子,無法精確的掌控其消費的起始位置。有些場景我們需要更細粒度的掌控,可以讓我們從特定的位移處開始拉取消息,seek方法正好提供了這個功能,讓我們得以追前消費或回溯消費。
void seek(TopicPartition var1, long offset);
seek方法中的參數partition表示分區,而offset參數用來指定從分區的哪個位置開始消費。seek方法只能重置消費者分配到的分區的消費位置,而分區的分配是在poll方法的調用過程中實現的。也就是說在執行seek方法之前需要先執行一次poll方法,等到分配到分區之后才可以重置消費位置。
consumer.poll(Duration.ofMillis(10000));
Set<TopicPartition> assignment = consumer.assignment();
for(TopicPartition tp : assignment){
consumer.seek(tp,2);
}
while (true){
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(Long.MAX_VALUE));
.....
}
consumer.seek(tp,2)設置每個分區消費的位置是2。
如果消費組內的消費者在啟動的時候能夠找到消費位移,除非發生越界,否則auto.offset.reset參數并不會奏效,此時如果想指定從開頭或末尾開始消費,就需要seek方法的幫助了,如下代碼所示:
Set<TopicPartition> set = new HashSet<>();
while (set.size() == 0){
consumer.poll(Duration.ofMillis(10000));
set = consumer.assignment();
}
Map<TopicPartition, Long> topicPartitionLongMap = consumer.endOffsets(set);
for (TopicPartition tp: set){
consumer.seek(tp,topicPartitionLongMap.get(tp));
}
endOffsets方法用來獲取指定分區的末尾的消息位置,與endOffsets對應的是beginningOffsets,一個分區的起始為止起初是0,但并不代表每時每刻都是0,因為日志清理的動作會清理舊的數據,所以分區的位置會自然而然的增加。
有時候我們并不知道特定的消費位置,卻知道一個相關的時間點,比如我們想要消費昨天8點之后的消息,KafkaConsumer提供了一個offsetForTimes方法:
Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch);
Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> var1, Duration timestampsToSearch);
timestampsToSearch是一個Map類型,key為待查詢的分區,而value為待查詢的時間戳,該方法會返回時間戳大于等于待查詢時間的第一條消息對應的位置和時間戳,對應于OffsetAndTimestamp中的offset和timestamp字段。
Set<TopicPartition> set = new HashSet<>();
Map<TopicPartition,Long> map = new HashMap<>();
while (set.size() == 0){
consumer.poll(Duration.ofMillis(10000));
set = consumer.assignment();
}
for (TopicPartition tp: set){
map.put(tp,System.currentTimeMillis()-1*24*3600*1000);
}
Map<TopicPartition, OffsetAndTimestamp> offsets = consumer.offsetsForTimes(map);
for (TopicPartition tp: set){
OffsetAndTimestamp offsetAndTimestamp = offsets.get(tp);
if (offsetAndTimestamp!=null)
consumer.seek(tp,offsetAndTimestamp.offset());
}
消費者攔截器
消費者攔截器主要在消費到消息或在提交消費位移的時候進行一些定制化的工作。
消費者攔截器需要實現ConsumerInterceptor接口,該接口有三個方法:
public interface ConsumerInterceptor<K, V> extends Configurable, AutoCloseable {
ConsumerRecords<K, V> onConsume(ConsumerRecords<K, V> var1);
void onCommit(Map<TopicPartition, OffsetAndMetadata> var1);
void close();
}
KafkaConsumer會在poll方法返回之前調用攔截器的onConsume方法來對消息進行相應的定制化操作,比如修改返回的內容、按照某種規則過濾消息。如果onConsume方法拋出異常,那么會被捕獲并記錄到日志,但是異常不會在向上傳遞。
KafkaConsumer會在提交完消費位移之后調用調用攔截器的onCommit方法,可以使用這個方法來記錄跟蹤所提交的位移信息,比如當消費者調用commitSync的無參方法時,我們不知道提交的具體細節,可以使用攔截器onCommit方法做到這一點。
在某些場景中,會對消息設置一個有效期的屬性,如果某條消息在既定的時間窗口內無法到達,那么就被視為無效,它也不需要再被繼續處理了。下面使用消費者攔截器實現一個簡單的TTL功能
public class ConsumerInterceptorTTL implements ConsumerInterceptor<String,String> {
private static final long EXPIRE_INTERVAL = 10 * 1000;
@Override
public ConsumerRecords<String, String> onConsume(ConsumerRecords<String, String> consumerRecords) {
long now = System.currentTimeMillis();
//構建新的分區消息映射表
Map<TopicPartition, List<ConsumerRecord<String,String>>> newRecords = new HashMap<>();
//遍歷分區
for (TopicPartition tp : consumerRecords.partitions()){
//獲取分區內的消息
List<ConsumerRecord<String,String>> tpRecords = consumerRecords.records(tp);
List<ConsumerRecord<String,String>> newTpRecords = new ArrayList<>();
//遍歷消息,做判斷
for (ConsumerRecord<String, String> tpRecord : tpRecords) {
//拿到10秒以內的消息
if (now - tpRecord.timestamp() < EXPIRE_INTERVAL){
newTpRecords.add(tpRecord);
}
}
if (!newTpRecords.isEmpty()){
newRecords.put(tp, newTpRecords);
}
}
return new ConsumerRecords<>(newRecords);
}
@Override
public void onCommit(Map<TopicPartition, OffsetAndMetadata> map) {
map.forEach((tp,offset)->{
System.out.println(tp+":"+offset.offset());
});
}
@Override
public void close() {
}
@Override
public void configure(Map<String, ?> map) {
}
}
我們使用消息的timestamp字段來判定是否過期,如果消息的時間戳與當前的時間戳相差超過10秒則判定為過期,那么這條消息也就被過濾掉而不返回給消費者客戶端。
自定義攔截器實現后,需要在KafkaConsumer中配置該攔截器,通過參數interceptor.classes參數實現:
properties.put(ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG, ConsumerInterceptorTTL.class);
關于KafkaConsumer的多線程實現
KafkaProducer是線程安全的,然而KafkaConsumer是非線程安全的。KafkaConsumer當中定義了一個acquire方法,用來檢測當前是否只有一個線程在操作,若有其他線程正在操作則會拋出異常。KafkaConsumer中的每個公用方法在執行所要執行的動作之前都會調用這個方法,只有wakeup方法是個例外。
private void acquire() {
long threadId = Thread.currentThread().getId();
if (threadId != this.currentThread.get() && !this.currentThread.compareAndSet(-1L, threadId)) {
throw new ConcurrentModificationException("KafkaConsumer is not safe for multi-threaded access");
} else {
this.refcount.incrementAndGet();
}
}
KafkaConsumer非線程安全并不意味著我們在消費消息的時候只能以單線程的方式運行。如果生產者發送消息的速度大于消費者處理消息的速度,那么就會有越來越多的消息得不到及時的處理,造成一定的時延。除此之外,kafka中存在消息保留機制,有些消息有可能在被消費之前就被清理了,從而造成消息的丟失。我們可以通過多線程的方式實現消息消費,多線程的目的就是提高整體的消費能力。多線程的實現方式有多種,第一種也是最常見的方式:線程封閉,即為每個線程實例化一個KafkaConsumer對象。
一個線程對一個KafkaConsumer實例,我們可以稱為消費線程。一個消費線程可以消費一個或多個分區中的消息,所有的消費線程都隸屬于同一個消費組。這種方式實現的并發度受限于分區的實際個數,文章開頭講過,當消費者個數大于分區個數時,就會有部分消費線程一直處于空閑的狀態。
第二種方式是,多個消費線程同時消費同一個分區,這個通過assign、seek等方法實現,這樣可以打破原有的消費線程的個數不能超過分區數的限制,不過這種方式對于位移提交和順序控制的處理就會變得非常復雜,實際應用的很少。一般而言,分區時消費線程的最小劃分單位。我們通過實際編碼實現第一種:
public class MultiConsumerThreadDemo {
public static final String brokerList = "192.168.3.8:9092";
public static final String topic = "topic";
public static final String group = "group-id42";
public static final String client = "client-id2";
public static Properties initConfig(){
Properties properties = new Properties();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,brokerList);
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
//消費位移自動提交
properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,true);
properties.put(ConsumerConfig.GROUP_ID_CONFIG,group);
properties.put(ConsumerConfig.CLIENT_ID_CONFIG,client);
return properties;
}
public static void main(String[] args) {
Properties props = initConfig();
int consumerThreadNum = 4;
for (int i=0;i<consumerThreadNum;i++){
new KafkaConsumerThread(props,topic).start();
}
}
public static class KafkaConsumerThread extends Thread{
private KafkaConsumer<String,String> kafkaConsumer;
public KafkaConsumerThread(Properties props,String topic){
this.kafkaConsumer = new KafkaConsumer<String, String>(props);
this.kafkaConsumer.subscribe(Arrays.asList(topic));
}
@Override
public void run() {
try {
while (true){
ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofMillis(Long.MAX_VALUE));
records.forEach(record->{
System.out.println("topic="+record.topic()+", partition="+record.partition()+", offset="+record.offset());
//消息處理
});
}
} catch (Exception e) {
e.printStackTrace();
} finally {
this.kafkaConsumer.close();
}
}
}
}
內部類KafkaConsumerThread代表消費線程,內部包裹著一 個獨立的KafkaConsumer實例。通過main方法來啟動多個消費線程,一般來講一個主題的分區數在開發時就是確定的,可以將consumerThreadNum設置成不大于分區數的值,如果不知道主題的分區數,也可以通過之前講的partitionsFor方法來動態獲取。
這種方式的優點是每個線程可以按順序消費各個分區中的消息。缺點是,每個消費線程都要維護一個獨立的TCP鏈接,如果分區數和consumerThreadNum都很大,那么會造成不小的系統開銷。
如果消費者對消息處理的速度很快,那么poll拉取的頻次也會更高,進而整體消費性能也會提升。相反,如果客戶端對消息的處理速度很慢,比如進行一個事務性操作,或者等待一個RPC的同步響應,那么poll的拉取頻次也會下降,進而造成整體的性能下降。一般而言,poll拉取的速度是相當快的,而整體消費的瓶頸也正是消息處理這一塊,我們可以將處理消息部分改成多線程的實現方式,如下圖所示
代碼如下:
public class MultiConsumerThreadDemo1 {
public static final String brokerList = "192.168.3.8:9092";
public static final String topic = "topic";
public static final String group = "group-id42";
public static final String client = "client-id2";
public static Properties initConfig(){
Properties properties = new Properties();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,brokerList);
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
//消費位移自動提交
properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,true);
properties.put(ConsumerConfig.GROUP_ID_CONFIG,group);
properties.put(ConsumerConfig.CLIENT_ID_CONFIG,client);
return properties;
}
public static void main(String[] args) {
Properties props = initConfig();
int consumerThreadNum = 4;
for (int i=0;i<consumerThreadNum;i++){
new KafkaConsumerThread(props,topic,Runtime.getRuntime().availableProcessors()).start();
}
}
public static class KafkaConsumerThread extends Thread{
private KafkaConsumer<String,String> kafkaConsumer;
private ExecutorService executorService;
private int threadNum;
public KafkaConsumerThread(Properties props, String topic, int processorNum){
this.kafkaConsumer = new KafkaConsumer<String, String>(props);
this.kafkaConsumer.subscribe(Arrays.asList(topic));
this.threadNum = processorNum;
executorService = new ThreadPoolExecutor(
threadNum,
threadNum,
0L,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy());
}
@Override
public void run() {
try {
while (true){
ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofMillis(Long.MAX_VALUE));
//消息處理
executorService.submit(new RecordsHandler(records));
}
} catch (Exception e) {
e.printStackTrace();
} finally {
this.kafkaConsumer.close();
}
}
}
public static class RecordsHandler implements Runnable {
private final ConsumerRecords<String,String > records;
public RecordsHandler(ConsumerRecords<String, String> records) {
this.records = records;
}
@Override
public void run() {
//真正處理records的地方
}
}
}
RecordHandler類是用來處理消息的,而KafkaConsumerThread類對應的是一個消費線程,里面通過線程池的方式來調用RecordHandler處理一批批消息。注意KafkaConsumerThread類中ThreadPollExecutor里的最后一個參數設置的是CallerRunsPolicy,這樣可以防止線程池的總體消費能力跟不上poll拉取的能力從而導致異常現象的發生。但是這種方式對消息的順序處理能力就比較困難了。注意,代碼中的參數配置properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,true);
,旨在說明在具體實現的時候并沒有考慮位移提交的情況。對于第一種實現方式而言,如果要做具體的位移提交,直接在KafkaConsumerThread中的run方法里實現即可。我們引入一個共享變量offsets來參與提交
每一個處理消息的RecordHandler類在處理完消息之后都將對應的消費位移保存到共享變量offsets中,KafkaConsumerThread在每一次poll方法之后都讀取offsets中的內容并對其進行位移提交。注意在實現過程中需要對其進行加鎖操作,防止出現并發問題。并且在寫入offsets的時候需要注意位移覆蓋的問題
public class MultiConsumerThreadDemo1 {
public static final String brokerList = "192.168.3.8:9092";
public static final String topic = "topic";
public static final String group = "group-id42";
public static final String client = "client-id2";
public static Properties initConfig(){
Properties properties = new Properties();
properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,brokerList);
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
//消費位移自動提交
properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG,true);
properties.put(ConsumerConfig.GROUP_ID_CONFIG,group);
properties.put(ConsumerConfig.CLIENT_ID_CONFIG,client);
return properties;
}
static Map<TopicPartition, OffsetAndMetadata> offsets =new HashMap<>();
public static void main(String[] args) {
Properties props = initConfig();
int consumerThreadNum = 4;
for (int i=0;i<consumerThreadNum;i++){
new KafkaConsumerThread(props,topic,Runtime.getRuntime().availableProcessors()).start();
}
}
public static class KafkaConsumerThread extends Thread{
private KafkaConsumer<String,String> kafkaConsumer;
private ExecutorService executorService;
private int threadNum;
public KafkaConsumerThread(Properties props, String topic, int processorNum){
this.kafkaConsumer = new KafkaConsumer<String, String>(props);
this.kafkaConsumer.subscribe(Arrays.asList(topic));
this.threadNum = processorNum;
executorService = new ThreadPoolExecutor(
threadNum,
threadNum,
0L,
TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy());
}
@Override
public void run() {
try {
while (true){
ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofMillis(Long.MAX_VALUE));
//消息處理
executorService.submit(new RecordsHandler(records));
//位移提交工作
synchronized (offsets){
if(!offsets.isEmpty()){
kafkaConsumer.commitSync(offsets);
offsets.clear();
}
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
this.kafkaConsumer.close();
}
}
}
public static class RecordsHandler implements Runnable {
private final ConsumerRecords<String,String > records;
public RecordsHandler(ConsumerRecords<String, String> records) {
this.records = records;
}
@Override
public void run() {
//真正處理records的地方
//處理完后進行位移操作
for (TopicPartition partition : records.partitions()) {
List<ConsumerRecord<String, String>> tpRecords = this.records.records(partition);
long lastConsumedOffset = tpRecords.get(tpRecords.size()-1).offset();
synchronized (offsets){
if (!offsets.containsKey(partition)){
offsets.put(partition,new OffsetAndMetadata(lastConsumedOffset+1));
}else{
long position = offsets.get(partition).offset();
if (position<lastConsumedOffset+1){
offsets.put(partition,new OffsetAndMetadata(lastConsumedOffset+1));
}
}
}
}
}
}
}