《kafka權威指南》之生產者和消費者

Kafka生產者

kafka可以用做消息隊列,消息總線,數據存儲平臺
ProducerRecord={Topic,partition,key,value}

kafka生產消息到broker步驟

  1. 生產者封裝爲ProducerRecord消息對象
  2. 將消息通過序列化器序列化爲字節數組
  3. 將消息通過分區器進行分區:根據鍵選擇一個分區,然後放到緩衝區
  4. 啓用一個線程將這些記錄按批發送到相應的broker上
  5. 服務器收到消息返回一個響應,若消息寫入成功,則返回記錄元數據對象={主題+分區+offset}
    否則返回錯誤,生產者進行重試,若還是返回錯誤,則生產者返回錯誤信息

生產者的配置屬性

  1. 必要配置
    bootstrap.servers:無需包含所有broker地址,最好提供兩個
    key.serializer:通過序列化器將鍵對象序列化爲字節數組-StringSerializer IntegerSerializer
    value.serializer:通過序列化器將值對象序列化
  2. 非必要配置
    acks參數:指定有多少個分區接收到消息才判定寫入成功
    a) acks=0:生產者無需等待服務器的響應,支持最大發送速度
    b) acks=1:只要集羣leader接收到消息,生產者就會接收到成功響應
    c) acks=all:當所有參與複製的節點全部接收到消息,生產者纔會接收到成功響應,最安全
    buffer.memory:設置生產者內存緩衝區,當生產數據速度>發送到服務器的速度
    compression.type:默認不會壓縮消息,可設置snappy(佔用最少cpu),gzip(最大壓縮比)。
    retires:指定生產者重發消息的次數,kafka會自動重試,通常處理那些不可重試的錯誤or重試次數超上限的情況
    retry.backoff.ms:指定重發時間間隔>>>>總的重試時間大於kafka集羣奔潰恢復的時間
    batch.size:指定一個批次使用的內存空間。 解決多個消息發送到同一分區的需求
    linger.ms:分批發送的等待時間,可以增加吞吐量
    max.in.flight.requests.per.connection:指定生產者在接收到到服務器響應之前可以發送的消息個數
    max.block.ms:指定生產者的阻塞時間。當生產者緩衝區已滿則會阻塞生產者
    max.request.size:生產者發送的請求大小,單個請求多個消息的大小
    receive.buffer.bytes:指定TCP socket接收數據包緩衝區大小
    send.buffer.bytes:指定TCP socket發送數據包緩衝區大小
  3. 怎麼保證消息的有序性
    當發送兩個消息,第一個批次失敗重試,第二個 批次成功,然後第一個批次成功>>導致倒序
    a)不建議retires設爲0,因爲寫入成功也是很重要的。
    b)建議設置max.in.flight.requests.per.connection=1
    嚴重影響生產者吞吐率,但對消息順序有要求的情況下才 這麼幹

生產者發送消息的方式

爲了提高吞吐量,可以增加生產者數量,增加線程數量

  1. 發送並忘記:將消息發送broker,但不關心是否正常送達,生產者還是會自動嘗試重試
  2. 同步發送:調用send()返回future對象,調用get方法可以知道消息是否發送成功
    KafkaProducer一般會發生兩種錯誤1. 可重試錯誤-連接錯誤,無leader錯誤-重試錯誤
    2. 非重試錯誤-消息太大異常
  3. 異步發送:調用send()並傳入回調函數,服務器在返回響應時異步回調該函數
    實現CallBack接口

序列化器

JSON,Avro,Thrift,Protobuf

自定義序列化

消息對象:ProducerRecord<String,Student>

public class Student{
    private int id;
    private String name;
    public Student(int id,String name){
          this.id=id;
          this.name=name;
    } 
}

public class StudentSerializer implements Serializer<Student>{
     public void configure(Map configs,boolean isKey){
          //無需配置
     }
     public byte[] serialize(String topic,Student data){
          byte[] name;
          int size;
          if(data==null){
               return null;
          }else{
               if(data.getName() !=null){
                    name=data.getName().getBytes("UTF-8");
                    size=name.length;
               }else{
                    name=new byte[0];
                    size=0;
               }
          }
          ByteBuffer buffer=ByteBuffer.allocate(4+4+size);
          buffer.putInt(data.getID());
          buffer.putInt(size);
          buffer.put(name);
     }
}

使用Avro序列化—共享數據文件的方式

Avro使用schema來定義,schema一般會內嵌在數據文件裏。

{
     ”namespace“:"customerManagment.avro",
     "type":"record",
     "name":"Customer",
     "fields":[
              {"name":"id","type":"int"},
              {”name“:"name",type:"string"}{"name":"email",type:["null","string"],"default":"null"}
       ]
}

在kafka裏使用Avro

我們將寫入的數據保存在schema保存在註冊表,然後在記錄中引用schema的標識符。
讀取數據的應用程序通過標識符從註冊表里拉取schema來反序列化。
序列化器和反序列化器分別負責處理schema的註冊和拉取
在這裏插入圖片描述

  1. 使用生成的Avro對象發送到Kafka
Properties props=new properties();
props.put("bootstrap.servers","ip:port");
props.put("key.serializer","...kafkaArvroSerializer");
props.put("value.serializer","...kafkaArvroSerializer");
props.put("schema.registry.url","schemaUrl");  //指向schema得存儲位置
String topic="topicname";
Producer<String,Student> producer=new KafkaProducer<String,Student>(props);
while(true){ 
      Student stu=StudentGenerator.getNext(); //使用生成的Avro對象發送消息
      ProducerRecord<String,Student> record=new ProducerRecord<>(topic,stu.getId(),stu);
      producer.send(record);
}
  1. 使用一般Avro對象發送到kafka
    待寫

分區器

ProducerRecord={topic+key+value},鍵可以設置爲null

  1. 鍵的作用:
    a) 作爲消息的附加信息
    b) 決定消息寫到主題的哪個分區,擁有相同鍵的消息被寫到同一個分區
    如果鍵爲空,則使用默認分區器:消息將隨機發送到主題的分區中
    如果鍵不爲空,並且使用默認分區器:消息將進行散列,實現在不改變分區數的情況下相同的鍵映射到同一個分區
  2. 自定義分區
    目的:
    a) 防止增加分區無法保證鍵與分區的映射不變
    b) 防止鍵總是映射到同一分區中
## key=banana映射到最後分區,其他鍵散列到其他分區
public class OrderPartition implements Partitioner{
    public void configure(Map<String,?>){}
    public void close(){};
    public int partition(String topic,Object key,byte[] keyBytes,
                                      Object value,byte[] valueBytes,Cluster cluster){
        List<PartitionInfo> partitions=cluster.partitionsForTopic(topic);
        int numPartitions=partitions.size();
        if((keyBytes == null) || (!(key instanceOf String))){
               throw new InvalidRecordException();
        }
        if("banana".equals((String)key)){
            return numPartitions; //控制器總是分配到最後一個分區
        }
        //其它鍵散列到其他分區
        return Match.abs(Utils.murmur2(keyBytes)) % (numPartitions-1);
    }
}

Kafka消費者

消費者和消費者組

消費者消費消息流程
創建消費者,訂閱主題,確認主題分區的偏移量,接收消息,驗證並處理消息

過了一會,生產者寫入消息的速度>>>應用程序驗證處理消息的速度,只有單個消費者的情況,會遠遠跟不上消息生成的速度
這是就有必要對消費者進行橫向擴容(類似多個生產者同時向主題寫入消息)

一個消費者組只能訂閱一個主題,一個消費者可以接收多個分區的消息
kafka的消費者經常做一些高延遲的工作,比如把數據寫到數據庫或HDFS,或者做比較耗時的計算,導致數據消費不出去,從而橫向伸縮單個應用程序,但是不要讓消費者的數量超過分區的數量,多餘的消費者只會被閒置 算不算併發呢???

  1. 兩個消費者——四個分區
    在這裏插入圖片描述
  2. 四個分區,四個消費者
    在這裏插入圖片描述
  3. 六個消費者——四個分區。一個消費者可以接收多個分區,但一個分區只能發送到羣組的一個消費者
    在這裏插入圖片描述
  4. 一個主題,兩個消費者羣組
    kafka除了通過增加消費者 橫向伸縮單個應用程序外,
    還可以通過增加消費者組,保證多個應用程序獲取同一個主題的所有數據。
    總結:爲每一個需要獲取主題所有分區的應用程序創建一個消費者羣組,通過添加消費者橫向伸縮單應用讀取,處理能力
    在這裏插入圖片描述

分區再均衡

羣組的消費者共同讀取主題的分區,若新增新得消費者,則它讀取的是原本由其他消費者讀取的消息。

  1. 什麼是分區再均衡
    再均衡分區的所有權從一個消費者轉移到另一個消費者,它爲消費者羣組帶來了高可用和伸縮性,但是也會造成整個羣組一小段時間的不可用。

  2. 怎麼觸發分區再均衡
    羣組的消費者共同讀取主題的分區
    a)羣組協調器檢查到消費者崩潰or有新得消費者加入羣組
    b)分配新的分區

  3. 分配分區的過程
    a) 消費者加入羣組後,向羣組協調器發一個joinGroup請求,第一個加入的成爲羣主
    b) 羣主從協調器中獲得所有消費者列表
    c) 羣主通過PartitionAssignor接口的邏輯,給每一個消費者分配分區

  4. 心跳檢測的作用
    消費者通過向羣組協調器的broker發送心跳檢測,來維持它們和羣組的從屬關係 和分區的所有權關係,被用來檢查消費者是否奔潰

創建kafka消費者

規則:一個消費者使用一個線程,最好是將消費者邏輯封裝在自己對象中,然後使用ExecutorService啓用多個線程,使得每個消費者運行在自己線程中。

Properties props=new Properties();
props.put("bootstrap.servers","ip:port");
props.put("key.dserializer","StringDeserializer");
props.put("value.dserializer","StringDeserializer");
##  1. 創建消費者
KafkaConsumer<String,String> consumer=new KafkaConsumer<String,String>(props);
##  2. 訂閱主題,傳入主題列表or正則表達式
consumer.subscribe(Collection.singletonList("student"));
##  3. 輪詢消息——若消費者訂閱了主題,輪詢進行羣組協調,分區再均衡,發送心跳,獲取數據
while(true){ //需要不斷進行輪詢,否則會被認爲已經死亡
    try(){
	     ConsumerRecords<String,String> records=consumer.poll(100); //不斷地消費消息,緩衝區沒有數據時產生阻塞的時間
	     fore(ConsumerRecord<String,String> record:records){
	         syso(record.topic(),record.partition(),record.offset(),record.key(),record.value());
	         
         }
    }finally{
        consumer.close(); //退出應用程序之前使用close()方法關閉消費者
    }
}
  1. 消費者第一次調用poll方法,會查找羣組協調器,然後加入羣組,接受分配的分區
  2. 在輪詢中發送心跳,所以我們確保在輪詢期間任何工作都應該儘快完成

消費者配置

必要配置

  1. bootstrap.servers:指定kafka集羣
  2. key.dserializer:指定鍵的反序列化器
  3. value.dserializer:指定值的反序列化器
    非必要配置
    fetch.min.bytes:指定消費者獲取的最小字節數。如果沒有很多可用數據,但消費者的CPU使用率卻很高,那麼就需要將該屬性調大
    fetch.max.wait.ms:指定消費者等待達到最小字節的時間,默認500ms
    max.partition.fetch.bytes:消費者從分區中獲取的最大字節數,默認1MB
    a)若最大消息字節數太大,導致消費者可能讀取不到消息 [消息100MB 50MB]
    b)若消費者處理的時間過長,可能導致消費者會話過期和發生分區再均衡
    session.timeout.ms:消費者被認定超時的時間,若超時就會進行再均衡
    heartbeat.interval.ms:消費者發送心跳的時間,不超過session.timout.out,一般爲其1/3
    auto.offset.reset:指定消費者在偏移量無效的情況下讀取數據起始位置:latest,earliest
    enable.auto.commit:指定消費者是否自動提交,默認true。爲了避免出現重複數據或數據丟失,可設置爲fasle
    partition.assignment.strategy:指定kafka的分區分配策略
    a)Range:把主題的若干連續分區分配給消費者,默認策略
    b)RoundRobin:把主題的分區逐個分配給消費者
    c) 自定義類的名稱

偏移量的作用

  1. Kafka無需得到消費者的確認,消費者通過追蹤消息在分區裏的偏移量而保證消費成功
  2. 怎麼從斷點處繼續消費消息
    a)提交偏移量:消費者向_consumer_offset的特殊主題提交所有分區偏移量的消息
    b)斷點續傳:分區再均衡發生後,消費者讀取到每個分區最後一次提交的偏移量,然後從偏移量指定的地方繼續消費

數據重複消費,丟失原理

poll()方法會返回本輪數據和最大偏移量
提交方式:自動提交,手動同步提交,手動異步提交
若提交的偏移量<客戶端處理的最後一個消息的偏移量,那麼消息就會被重複處理
在這裏插入圖片描述

若提交的偏移量>客戶端處理的最後一個消息的偏移量,那麼消息就會丟失
在這裏插入圖片描述

自動提交

enable.auto.commit=true,那麼每隔5s,消費者就會自動將從poll方法接收到的最大偏移量提交上去,提交時間間隔由auto.commit.interval.ms控制。
自動提交是在輪詢中進行的,消費者每次輪詢時都會將上一輪返回得偏移量提交上去。

缺點:若5s自動提交,在最近一次提交之後得3s發生再均衡,再均衡之後,消費者從最後一次提交的偏移量位置開始讀取數據,最終會導致重複消費這3秒得數據。可通過縮短提交時間間隔來減小重複消費得時間窗口

自動提交雖然簡單,但沒有給開發者留有餘地來解決重複消費問題。

手動同步提交

auto.commit.offset=false,然後通過commitSync方法手動提價當前偏移量,而不是基於時間間隔。
commitSync():每當處理完當前批次得消息,調用commitSync()提交當前批次最新得偏移量,然後進行下一次論消費
在成功提交或碰到可恢復錯誤之前,commitSyn()會一直嘗試重試

while(true){
   ConsumerRecords<String,Stirng> recods=consumer.poll(100);
   for(ConsumerRecord<Stirng,String> record:records){
        //處理消費得消息
        //record.offset()期望處理的下一個偏移量
        syso(record.offset(),record.key(),record.value());
   }
   //手動提交當前批次最新偏移量
   consumer.commitSync();
}

手動異步提交

同步提交,程序會一直阻塞直到broker做出響應,這樣會限制了應用程序的吞吐量。

  1. commitAsync()在成功提交或碰到可恢復錯誤之前不進行重試,因爲他可能將一個更大的偏移量提交成功。
  2. 重試異步提交,使用單調遞增的序列號維護異步提交的順序,在重試前先檢查回調的序列號和即將提交的序列號是否相等
    若相等,說明沒有新得提交
    若不相等,說明有一個新得提交已經發送,應該停止重試

例如:某程序提交偏移量2000,發生短暫錯誤服務器收不到,但接收到了一個5000的偏移量。如果再重新提交2000的話又會發生重複消費問題。

while(true){
   ConsumerRecords<String,Stirng> recods=consumer.poll(100);
   for(ConsumerRecord<Stirng,String> record:records){
        //處理消費得消息
        syso(record.offset(),record.key(),record.value());
   }
   //手動提交當前批次最新偏移量
   consumer.commitAsyn();
}

異步提交爲了防止重複消費,通過回調來記錄提交錯誤or生成度量指標作用,也可以用它進行重試

while(true){
   ConsumerRecords<String,Stirng> recods=consumer.poll(100);
   for(ConsumerRecord<Stirng,String> record:records){
        //處理消費得消息
        syso(record.offset(),record.key(),record.value());
   }
   //手動提交當前批次最新偏移量
   consumer.commitAsyn(new offsetCommitCallBack(){
         public void onComplete(Map(TopicPartition,offsetAndMetadata) offsets,Exception e){
               if(e !=null){
                   log..error("error");
               }
         }
   });
}

同步和異步組合提交

問題:
一般情況下,不進行重試不會出現問題,但是如果發生在關閉消費者或再均衡前的最後一次提交,就要確保能提交成功
在消費者關閉前一般會組合同步和異步提交

while(true){
   try{
	   ConsumerRecords<String,Stirng> recods=consumer.poll(100);
	   for(ConsumerRecord<Stirng,String> record:records){
	        //處理消費得消息
	        syso(record.offset(),record.key(),record.value());
	   }
	   consumer.commitAsync(); //一般不需要進行重試,即使提交失敗,下一次提交很快能成功
   }catch(Exception e){
       log.error("error");
   }finally{ 
        try{
            consumer.commitSync(); //關閉瀏覽器就不能等下一次提交了,做同步提交,直到提交成功或發生無法恢復的錯誤
        }finally{
            consumer.close();
        }
   }
   //手動提交當前批次最新偏移量
   consumer.commitAsyn(new offsetCommitCallBack(){
         public void onComplete(Map(TopicPartition,offsetAndMetadata) offsets,Exception e){
               if(e !=null){
                   log..error("error");
               }
         }
   });
}

提交特定的偏移量

提交偏移量的頻率==處理消息批次的頻率
問題:
若poll()返回一大批數據,爲了避免再均衡引起重複處理整批消息的問題,需要更加頻繁的提交偏移量
然而commitSync()和commitAsync()只會提交最後一個偏移量

## 跟蹤各分區的offset
private  Map<TopPartition,OffsetAndMetadata> currentOffsets=new HashMap<>();
int count;
while(true){
    ConsumerRecords<String,String> records=consumer.poll(100);
    for(ConsumerRecord record:records{ //沒輪都提交偏移量
        syso(record.offset()); //處理這個消息
        //期望處理的下一個消息的偏移量更新map,下一次就從這裏開始讀取數據
        currentOffsets.put(new TopicPartition(record.topic(),record.partition()),
                           new OffsetAndMetadata(record.offset()+1,"no metadata"));
        if(count % 1000 ==0){ //每1000條記錄就提交一次偏移量
            consumer.commitAsync(currentOffsets,null);
            count++;
        }  
    }
}

再均衡監聽器

問題:退出or再均衡後,消費者失去對一個分區的所有權
退出解決:退出之前提交偏移量,等到再均衡之後,消費者從偏移量+1的位置開始消費
再均衡解決:再均衡開始前提交偏移量,等到再均衡之後,消費者從偏移量+1的位置開始消費

//定義再均衡器
private class HandlerReblance implements CosumerRebalanceListener{
      //消費者停止讀取消息之後和再均衡開始前被調用,若在此提交偏移量,下一個接管分區的消費者就知道從哪裏讀取了
      public void onPartitionsRevoked(Collection<TopicPartition> partitions){
            consumer.commitSync();
      }
      //分配分區之後和消費者開始讀取消息之前被調用    
      public void onPartitionAssigned(Collection<TopicPartition> partitions){
             
      }
}
//提交各分區偏移量
private  Map<TopPartition,OffsetAndMetadata> currentOffsets=new HashMap<>();
int count;
try{
  //訂閱主題,並指定再均衡器
  consumer.subscribe(topic,new HandleRebalance()); 
  //循環消費消息
  while(true){
    ConsumerRecords<String,String> records=consumer.poll(100);
    for(ConsumerRecord record:records{ //沒輪都提交偏移量
        syso(record.offset()); //處理這個消息
        //期望處理的下一個消息的偏移量更新map,下一次就從這裏開始讀取數據
        currentOffsets.put(new TopicPartition(record.topic(),record.partition()),
                           new OffsetAndMetadata(record.offset()+1,"no metadata"));
        if(count % 1000 ==0){ //每1000條記錄就提交一次偏移量
            consumer.commitAsync(currentOffsets,null);
            count++;
        }  
    }
}
}catch(){
    log.error("error")
}finally{
    consumer.commitSync(curentOffsets); //在
    consumer.close();
}

從特定偏移量處開始處理記錄

可以向前跳過幾個消息or向後退幾個消息
例子:應用程序從kafka讀取用戶點擊事件,然後程序進行處理(添加會話信息),最後把結果保存到數據庫

while(true){
   ConsumerRecords<String,Stirng> recods=consumer.poll(100);
   for(ConsumerRecord<Stirng,String> record:records){
        //處理消費得消息
        syso(record.offset(),record.key(),record.value());
        //沒處理一條記錄,提交一次偏移量
        consumer.commitAsyn();
   }
}

實現保存消息的單意語義

問題:當記錄保存到數據庫之後,偏移量被提交到kafka之前。應用程序任然可能崩潰,導致了重複處理數據
解決:將保存記錄和提交偏移量放在一個原子操作裏完成,但是偏移量是提交到kafka,記錄是提交到數據庫,無法保證原子性
將消息和偏移量在一個事務中保存到數據庫中(外部系統)中,消費者新增分區時,讀取數據庫中每個分區中的偏移量來決定從哪裏開始讀取消息

private class SaveOffsetOnRebalance implements CosumerRebalanceListener{
      //重分配分區之後被調用
      public void onPartitionAssigned(Collection<TopicPartition> partitions){
             
      }
      //再均衡開始前被調用
      public void onPartitionsRevoked(Collection<TopicPartition> partitions){
            //consumer.commitSync(); 
            for(TopicPartition partition:partitions){
                consumer.seek(partition,getOffsetFromDB(partition)); //在分配到新分區之後,使用seek定位到那些記錄
            }
      }
}
try{
  //訂閱主題,並指定再均衡器
  consumer.subscribe(topic,new HandleRebalance(consumer)); 
  //訂閱主題之後,啓動消費者
  for(TopicPartition partition:partitions){
       consumer.seek(partition,getOffsetFromDB(partition)); //在分配到新分區之後,使用seek定位到那些記錄,等待消費者消費
  }
  while(true){
    ConsumerRecords<String,String> records=consumer.poll(100);
    for(ConsumerRecord record:records{ //沒輪都提交偏移量
        syso(record.offset()); //處理這個消息
        storeRecordInDb(record);
        storeOffsetInDb(record.topic(),record.partition(),record.offset())
    }
    commitDBTransaction();
}

如何退出

如何退出,我們需要另一個線程調用consumer.wakeup()方法
調用consumer.wakeup()方法可以退出poll()方法,並拋出WakeupException異常
退出線程前調用consumer.close()很有必要,因爲它可以提交任何沒有提交的東西,並告訴羣組協調器自己離開羣組,觸發再均衡

##  簡化代碼
Runtime.getRuntime().addShutdownHook(new Thread(){
       pubic void run(){
            consumer.wakeup();
            mainThread.join
       }
})
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章