文章目錄
Kafka生產者
kafka可以用做消息隊列,消息總線,數據存儲平臺
ProducerRecord={Topic,partition,key,value}
kafka生產消息到broker步驟
- 生產者封裝爲ProducerRecord消息對象
- 將消息通過序列化器序列化爲字節數組
- 將消息通過分區器進行分區:根據鍵選擇一個分區,然後放到緩衝區
- 啓用一個線程將這些記錄按批發送到相應的broker上
- 服務器收到消息返回一個響應,若消息寫入成功,則返回記錄元數據對象={主題+分區+offset}
否則返回錯誤,生產者進行重試,若還是返回錯誤,則生產者返回錯誤信息
生產者的配置屬性
- 必要配置
bootstrap.servers:無需包含所有broker地址,最好提供兩個
key.serializer:通過序列化器將鍵對象序列化爲字節數組-StringSerializer IntegerSerializer
value.serializer:通過序列化器將值對象序列化 - 非必要配置
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發送數據包緩衝區大小 - 怎麼保證消息的有序性
當發送兩個消息,第一個批次失敗重試,第二個 批次成功,然後第一個批次成功>>導致倒序
a)不建議retires設爲0,因爲寫入成功也是很重要的。
b)建議設置max.in.flight.requests.per.connection=1
嚴重影響生產者吞吐率,但對消息順序有要求的情況下才 這麼幹
生產者發送消息的方式
爲了提高吞吐量,可以增加生產者數量,增加線程數量
- 發送並忘記:將消息發送broker,但不關心是否正常送達,生產者還是會自動嘗試重試
- 同步發送:調用send()返回future對象,調用get方法可以知道消息是否發送成功
KafkaProducer一般會發生兩種錯誤1. 可重試錯誤-連接錯誤,無leader錯誤-重試錯誤
2. 非重試錯誤-消息太大異常 - 異步發送:調用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的註冊和拉取
- 使用生成的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);
}
- 使用一般Avro對象發送到kafka
待寫
分區器
ProducerRecord={topic+key+value},鍵可以設置爲null
- 鍵的作用:
a) 作爲消息的附加信息
b) 決定消息寫到主題的哪個分區,擁有相同鍵的消息被寫到同一個分區
如果鍵爲空,則使用默認分區器:消息將隨機發送到主題的分區中
如果鍵不爲空,並且使用默認分區器:消息將進行散列,實現在不改變分區數的情況下相同的鍵映射到同一個分區 - 自定義分區
目的:
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,或者做比較耗時的計算,導致數據消費不出去,從而橫向伸縮單個應用程序,但是不要讓消費者的數量超過分區的數量,多餘的消費者只會被閒置 算不算併發呢???
- 兩個消費者——四個分區
- 四個分區,四個消費者
- 六個消費者——四個分區。一個消費者可以接收多個分區,但一個分區只能發送到羣組的一個消費者
- 一個主題,兩個消費者羣組
kafka除了通過增加消費者 橫向伸縮單個應用程序外,
還可以通過增加消費者組,保證多個應用程序獲取同一個主題的所有數據。
總結:爲每一個需要獲取主題所有分區的應用程序創建一個消費者羣組,通過添加消費者橫向伸縮單應用讀取,處理能力
分區再均衡
羣組的消費者共同讀取主題的分區,若新增新得消費者,則它讀取的是原本由其他消費者讀取的消息。
-
什麼是分區再均衡
再均衡:分區的所有權從一個消費者轉移到另一個消費者,它爲消費者羣組帶來了高可用和伸縮性,但是也會造成整個羣組一小段時間的不可用。 -
怎麼觸發分區再均衡
羣組的消費者共同讀取主題的分區
a)羣組協調器檢查到消費者崩潰or有新得消費者加入羣組
b)分配新的分區 -
分配分區的過程
a) 消費者加入羣組後,向羣組協調器發一個joinGroup請求,第一個加入的成爲羣主
b) 羣主從協調器中獲得所有消費者列表
c) 羣主通過PartitionAssignor接口的邏輯,給每一個消費者分配分區 -
心跳檢測的作用
消費者通過向羣組協調器的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()方法關閉消費者
}
}
- 消費者第一次調用poll方法,會查找羣組協調器,然後加入羣組,接受分配的分區
- 在輪詢中發送心跳,所以我們確保在輪詢期間任何工作都應該儘快完成
消費者配置
必要配置
- bootstrap.servers:指定kafka集羣
- key.dserializer:指定鍵的反序列化器
- 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) 自定義類的名稱
偏移量的作用
- Kafka無需得到消費者的確認,消費者通過追蹤消息在分區裏的偏移量而保證消費成功
- 怎麼從斷點處繼續消費消息
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做出響應,這樣會限制了應用程序的吞吐量。
- commitAsync()在成功提交或碰到可恢復錯誤之前不進行重試,因爲他可能將一個更大的偏移量提交成功。
- 重試異步提交,使用單調遞增的序列號維護異步提交的順序,在重試前先檢查回調的序列號和即將提交的序列號是否相等
若相等,說明沒有新得提交
若不相等,說明有一個新得提交已經發送,應該停止重試
例如:某程序提交偏移量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
}
})