插曲:Kafka的生產者案例和消費者原理解析

說出你的願望吧發表於2019-11-26

前言

······

一、Kafka的Producer小案例

假設我們現在有一個電商系統,凡是能登入系統的使用者都是會員,會員的價值體現在,消費了多少錢,就會累計相應的積分。積分可以兌換禮品包,優惠券···等等。

又到了我們的畫圖時間?。首先我們得先來一個訂單系統,那這個訂單系統中肯定就會有資料日誌產生,它現在就是把這些日誌寫到Kafka裡面,日誌我們使用json的方式記錄。圖中的statement表示訂單狀態,此時是已支付。

插曲:Kafka的生產者案例和消費者原理解析

此時擔任我們消費者的肯定就是會員系統了,它要對這個id為1的會員進行積分累計。當然必須要考慮到的情況是,這個會員有可能也會進行退款操作,那相應的積分也會減少。statement此時為cancel取消

插曲:Kafka的生產者案例和消費者原理解析

我們上一講中的設定引數中,提到我們可以給每一個訊息設定一個key,也可以不指定,這個key跟我們要把這個訊息傳送到哪個主題的哪個分割槽是有關係的。比如我們現在有一個主題叫 tellYourDream,主題下面有兩個分割槽,兩個分割槽分別存在兩個副本(此時我們不關注follower,因為它的資料是同步leader的)

Topic:tellYourDream
    p0:leader partition <- follower partition
    p1:leader partition <- follower partition
複製程式碼

如果是不指定key的時候,傳送的一條訊息會以輪詢的方式傳送到分割槽裡面。也就是比如說,我第一條訊息是one,那這個one就傳送到了p0裡面,第二條是two,就傳送到了p1裡面,之後的three就是p0,four就是p1···依次類推。

如果指定key,比如我的key為message1,Kafka就會取得這個key的hash值,取到的數字再對我們的分割槽數取模,然後根據取模的值來決定哪個分割槽(例如我們現在是p0,p1兩個分割槽,取模的值就只會是0,1),取模為0,就傳送到p0,取模為1,就傳送到p1,這樣的做法可以保證key相同的訊息一定會被髮送到同一個分割槽(也可以使用這個特性來規定某些訊息一定會傳送到指定的分割槽)。這個做法和MapReduce的shuffle是不是又類似了,所以這些大資料框架,真的互通點很多。

對於我們剛剛提到的會員系統,如果此時使用者下單時的訊息傳送到了p0,而退款的訊息傳送到了p1,難免有時會發生消費者先消費到p1中的訊息的情況,此時使用者的積分還沒有增加,就已經扣除1000了,顯示就會出現問題。所以為了保證同一個使用者的訊息傳送到同一個分割槽中,我們需要將其指定key。

程式碼部分

因為在 Kafka的生產者原理及重要引數說明 中我們已經把下面的prop.put的所有配置都已經解釋過了,所以這次就直接ctrl+c,ctrl+v上來。其實就是把那時候的建立生產者的程式碼抽取出來成為一個createProducer()方法而已。

public class OrderProducer {
	public static KafkaProducer<String, String> createProducer() {
		Properties props = new Properties();
		props.put("bootstrap.servers", "hadoop1:9092,hadoop2:9092,hadoop3:9092");  
		props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
		props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
		props.put("buffer.memory", 33554432);
		props.put("compression.type", "lz4");
		props.put("batch.size", 32768);
		props.put("linger.ms", 100);
		props.put("retries", 10);//5 10
		props.put("retry.backoff.ms", 300);
		props.put("request.required.acks", "1");
		KafkaProducer<String, String> producer = new KafkaProducer<String, String>(props);
		return producer;
	}
複製程式碼

這裡就是一段生產JSON格式的訊息程式碼而已,也抽取成一個方法。

	public static JSONObject createRecord() {
		JSONObject order=new JSONObject();
		order.put("userId", 12344);
		order.put("amount", 100.0);
		order.put("statement", "pay");
		return order;
	}
複製程式碼

這裡就是直接建立生產者和訊息,此時key使用userId或者訂單id都行,問題不大。

    public static void main(String[] args) throws Exception {
        KafkaProducer<String, String> producer = createProducer();
        JSONObject order=createRecord();
        ProducerRecord<String, String> record = new ProducerRecord<>(
            "tellYourDream",order.getString("userId") ,order.toString());
        producer.send(record, new Callback() {
            @Override
            public void onCompletion(RecordMetadata metadata, Exception exception) {
                if(exception == null) {
                    System.out.println("訊息傳送成功");  
                } else {
                    //進行處理
                }
            }
        });
        Thread.sleep(10000); 
        producer.close();
        }
    }
複製程式碼

此時如果進行過重試機制後,訊息還存在異常的話,公司比較嚴謹的專案都會有備用鏈路,比如把資料存到MySQL,Redis···等來保證訊息不會丟失。

補充:自定義分割槽(可自行了解)

因為其實Kafka自身提供的機制已經基本滿足生產環境中的使用了,所以這塊就不展開詳細的說明了。此外還有自定義序列化,自定義攔截器,這些在工作當中使用得頻率不高,如果用到大概可以進行百度自行學習。

例如,通話記錄中,給客服打電話的記錄要存到一個分割槽中,其餘的記錄均分的分佈到剩餘的分割槽中。我們就這個案例來進行演示,要自定義的情況就要實現Partition介面,然後實現3個方法,說是實現3個,其實主要也就實現partition()這個方法而已。

package com.bonc.rdpe.kafka110.partitioner;
import java.util.List;import java.util.Map;
import org.apache.kafka.clients.producer.Partitioner;
import org.apache.kafka.common.Cluster;
import org.apache.kafka.common.PartitionInfo;

/**
* @Title PhonenumPartitioner.java 
* @Description 自定義分割槽器
* @Date 2018-06-25 14:58:14
*/
public class PhonenumPartitioner implements Partitioner{
    @Override
    public void configure(Map<String, ?> configs) {
        // TODO nothing
    }

    @Override
    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
        // 得到 topic 的 partitions 資訊
        List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
        int numPartitions = partitions.size();
        // 模擬某客服
        if(key.toString().equals("10000") || key.toString().equals("11111")) {
            // 放到最後一個分割槽中
            return numPartitions - 1;
        }
        String phoneNum = key.toString();
        return phoneNum.substring(0, 3).hashCode() % (numPartitions - 1);
    }

    @Override
    public void close() {
        // TODO nothing
    }

}
複製程式碼

使用自定義分割槽器

package com.bonc.rdpe.kafka110.producer;
import java.util.Properties;
import java.util.Random;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;

/**
 * @Title PartitionerProducer.java 
 * @Description 測試自定義分割槽器
 * @Date 2018-06-25 15:10:04
 */public class PartitionerProducer {
        private static final String[] PHONE_NUMS = new String[]{
            "10000", "10000", "11111", "13700000003", "13700000004",
            "10000", "15500000006", "11111", "15500000008", 
            "17600000009", "10000", "17600000011" 
        };

        public static void main(String[] args) throws Exception {
            
            Properties props = new Properties();
            props.put("bootstrap.servers", "192.168.42.89:9092,192.168.42.89:9093,192.168.42.89:9094");
            // 設定分割槽器
            props.put("partitioner.class", "com.bonc.rdpe.kafka110.partitioner.PhonenumPartitioner");
            props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
            props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
    
            Producer<String, String> producer = new KafkaProducer<>(props);
    
            int count = 0;
            int length = PHONE_NUMS.length;
            
            while(count < 10) {
                Random rand = new Random();
                String phoneNum = PHONE_NUMS[rand.nextInt(length)];
                ProducerRecord<String, String> record = new ProducerRecord<>("dev3-yangyunhe-topic001", phoneNum, phoneNum);
                RecordMetadata metadata = producer.send(record).get();
                String result = "phonenum [" + record.value() + "] has been sent to partition " + metadata.partition();
                System.out.println(result);
                Thread.sleep(500);
                count++;
            }
            producer.close();
        }
}
複製程式碼

自定義分割槽結果:

插曲:Kafka的生產者案例和消費者原理解析

二、Kafka消費者原理解析

1.offset 偏移量

此時再次請出我們的kafka叢集,有多個消費者同時去消費叢集中的資訊

插曲:Kafka的生產者案例和消費者原理解析

如果程式一直在穩定執行,那我們的整個流程是不會出現啥問題的,可是現在如果程式停止執行了呢?有可能是程式出現了bug,也有可能是因為我們進行修改手動停止了程式。那下一次恢復的時候,消費者又該從哪個地方開始消費?

Topic:tellYourDream   ConsumerA
    tellYourDream:p0(10000)
    tellYourDream:p1(10001)
複製程式碼

offset就類似於陣列下標的那種理解類似,比如陣列的下標為什麼要從0開始,基於陣列的記憶體模型。就是所處陣列位置離首地址的距離而定。array[0]就是偏移為0的位置,也就是首地址。array[k]也就是偏移為k的位置。kafka中的offset也是同樣的理解,這個偏移量其實就是記錄一個位置而使用的。用來標識消費者這次消費到了這個位置。

在kafka裡面,kafka是不幫忙維護這個offset偏移量的,這個offset需要consumer自行維護。kafka提供了兩個關於offset的引數,一個是enable_auto_commit,當這個引數設定為true的時候,每次重啟kafka都會把所有的資料重新消費一遍。再一個是auto_commit_interval_ms,這個是每次提交offset的一個時間間隔。

這個offset的儲存位置在0.8版本(再次劃重點,0.8之前的kafka儘量不要使用)之前,是存放在zookeeper裡面的。這個設計明顯是存在問題的,整個kafka叢集有眾多的topic,而系統中又有成千上萬的消費者去消費它們,如果offset存放在zookeeper上,消費者每次都要提交給zookeeper這個值,這樣zookeeper能頂得住嗎?如果這時候覺得沒啥問題的同學,那你就是沒認真去讀 插曲:Kafka的叢集部署實踐及運維相關 中的 3.4---消費資訊 啦,趕快去複習一下?。

在0.8版本之後,kafka就把這個offset存在了內部的一個主題裡面,這個主題的名字叫做 consumer_offset。這個內部主題預設有50個分割槽,我們知道,消費者組是有它們的一個group.id的。提交過去的時候,key是group.id+topic+分割槽號(這是為了保證Kakfa叢集中同分割槽的資料偏移量都提交到consumer_offset的同一個分割槽下)。這句話有點繞口,不過請務必讀懂。

value就是當前offset的值,每隔一段時間,kafka內部會對這個topic進行compact。也就是每個group.id+topic+分割槽號就保留最新的那條資料即可。而且因為這個 consumer_offsets可能會接收高併發的請求,所以預設分割槽50個,這樣如果你的kafka部署了一個大的叢集,比如有50臺機器,就可以用50臺機器來抗offset提交的請求壓力,就好很多。

2.Coordinator

每個consumer group都會選擇一個broker作為自己的coordinator,負責監控這個消費組裡的各個消費者的心跳,以及判斷是否當機,然後開啟rebalance, 根據內部的一個選擇機制,會挑選一個對應的Broker,Kafka會把各個消費組均勻分配給各個Broker作為coordinator來進行管理,consumer group中的每個consumer剛剛啟動就會跟選舉出來的這個consumer group對應的coordinator所在的broker進行通訊,然後由coordinator分配分割槽給這個consumer來進行消費。coordinator會盡可能均勻的分配分割槽給各個consumer來消費。

2.1 如何選擇哪臺是coordinator?

首先對消費組的groupId進行hash,接著對consumer_offsets的分割槽數量取模,預設是50,可以通過offsets.topic.num.partitions來設定,找到你的這個consumer group的offset要提交到consumer_offsets的哪個分割槽。比如說:groupId,“membership-consumer-group” -> hash值(數字)-> 對50取模(結果只能是0~49,又是以往的那個套路) -> 就知道這個consumer group下的所有的消費者提交offset的時候是往哪個分割槽去提交offset,找到consumer_offsets的一個分割槽(這裡consumer_offset的分割槽的副本數量預設來說1,只有一個leader),然後對這個分割槽找到對應的leader所在的broker,這個broker就是這個consumer group的coordinator了,consumer接著就會維護一個Socket連線跟這個Broker進行通訊

其實簡單點解釋,就是找到consumer_offsets中編號和它對應的一個分割槽而已。取模後是2,那就找consumer_offsets那50個分割槽中的第二個分割槽,也就是p1。取模後是10,那就找consumer_offsets那50個分割槽中的第十個分割槽,也就是p9.

2.2 coordinator完成了什麼工作

插曲:Kafka的生產者案例和消費者原理解析

然後這個coordinator會選出一個leader consumer(誰先註冊上來,誰就是leader),這時候coordinator也會把整個Topic的情況彙報給leader consumer,,由leader consumer來制定消費方案。之後會傳送一個SyncGroup請求把消費方案返回給coordinator。

用一小段話再總結一遍吧:

首先有一個消費者組,這個消費者組會有一個它們的group.id號,根據這個可以計算出哪一個broker作為它們的coodinator,確定了coordinator之後,所有的consumer都會傳送一個join group請求註冊。之後coordinator就會預設把第一個註冊上來的consumer選擇成為leader consumer,把整個Topic的情況彙報給leader consumer。之後leader consumer就會根據負載均衡的思路制定消費方案,返回給coordinator,coordinator拿到方案之後再下發給所有的consumer,完成流程。

consumer都會向coordinator傳送心跳,可以認為consumer是從節點,coordinator是主節點。當有consumer長時間不再和coordinator保持聯絡,就會重新把分配給這個consumer的任務重新執行一遍。如果斷掉的是leader consumer,就會重新選舉新的leader,再執行剛剛提到的步驟。

2.3 分割槽方案的負載均衡

如果臨時有consumer加入或退出,leader consumer就需要重新制定消費方案。

比如我們消費的一個主題有12個分割槽: p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,p10,p11

假設我們的消費者組裡面有三個消費者

2.3.1 range策略

range策略就是按照partiton的序號範圍

p0~3             consumer1
p4~7             consumer2
p8~11            consumer3
複製程式碼
2.3.2.round-robin策略
consumer1:0,3,6,9
consumer2:1,4,7,10
consumer3:2,5,8,11
複製程式碼

但是前面的這兩個方案有個問題: 假設consuemr1掛了:p0-5分配給consumer2,p6-11分配給consumer3 這樣的話,原本在consumer2上的的p6,p7分割槽就被分配到了 consumer3上。

2.3.3.sticky策略

最新的一個sticky策略,就是說盡可能保證在rebalance的時候,讓原本屬於這個consumer 的分割槽還是屬於他們, 然後把多餘的分割槽再均勻分配過去,這樣儘可能維持原來的分割槽分配的策略

consumer1:0-3
consumer2:  4-7
consumer3:  8-11 
假設consumer3掛了
consumer1:0-3,+8,9
consumer2: 4-7,+10,11
複製程式碼
2.3.4 Rebalance分代機制

在rebalance的時候,可能你本來消費了partition3的資料,結果有些資料消費了還沒提交offset,結果此時rebalance,把partition3分配給了另外一個consumer了,此時你如果提交partition3的資料的offset,能行嗎?必然不行,所以每次rebalance會觸發一次consumer group generation,分代,每次分代會加1,然後你提交上一個分代的offset是不行的,那個partiton可能已經不屬於你了,大家全部按照新的partiton分配方案重新消費資料。

以上就是比較重要的事情了,之後到了輕鬆愉快的程式碼時間。

三、消費者程式碼部分

其實和生產者不能說它們一模一樣可是結構完全就是一樣的,所以會比生產者的時候更加簡短點。因為已經知道有這些東西了,很多東西通過搜尋引擎就不難解決了。

public class ConsumerDemo {
	private static ExecutorService threadPool = Executors.newFixedThreadPool(20);
	
	public static void main(String[] args) throws Exception {
		KafkaConsumer<String, String> consumer = createConsumer();
		
		//指定消費的主題
		consumer.subscribe(Arrays.asList("order-topic"));  
		try {
			while(true) {  
			    
			    //這裡設定的是一個超時時間
				ConsumerRecords<String, String> records = consumer.poll(Integer.MAX_VALUE); 
				
				//對消費到的資料進行業務處理
				for(ConsumerRecord<String, String> record : records) {
					JSONObject order = JSONObject.parseObject(record.value()); 
					threadPool.submit(new CreditManageTask(order));
				}
			}
		} catch(Exception e) {
			e.printStackTrace();
			consumer.close();
		}
	}
	
	private static KafkaConsumer<String, String> createConsumer() {
	
	    //設定引數的環節
		Properties props = new Properties();
		props.put("bootstrap.servers", "hadoop1:9092,hadoop2:9092,hadoop3:9092");
		props.put("group.id", "test-group");
		props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
		props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
		props.put("heartbeat.interval.ms", 1000); // 這個儘量時間可以短一點
		props.put("session.timeout.ms", 10 * 1000); // 如果說kafka broker在10秒內感知不到一個consumer心跳
		props.put("max.poll.interval.ms", 30 * 1000); // 如果30秒才去執行下一次poll
		// 就會認為那個consumer掛了,此時會觸發rebalance
		// 如果說某個consumer掛了,kafka broker感知到了,會觸發一個rebalance的操作,就是分配他的分割槽
		// 給其他的cosumer來消費,其他的consumer如果要感知到rebalance重新分配分割槽,就需要通過心跳來感知
		// 心跳的間隔一般不要太長,1000,500
		props.put("fetch.max.bytes", 10485760);
		props.put("max.poll.records", 500); // 如果說你的消費的吞吐量特別大,此時可以適當提高一些
		props.put("connection.max.idle.ms", -1); // 不要去回收那個socket連線
		// 開啟自動提交,他只會每隔一段時間去提交一次offset
		// 如果你每次要重啟一下consumer的話,他一定會把一些資料重新消費一遍
		props.put("enable.auto.commit", "true");
		// 每次自動提交offset的一個時間間隔
		props.put("auto.commit.ineterval.ms", "1000");
		// 每次重啟都是從最早的offset開始讀取,不是接著上一次
		props.put("auto.offset.reset", "earliest"); 

		KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(props);
		return consumer;
	}
	
	static class CreditManageTask implements Runnable {
		private JSONObject order;
		public CreditManageTask(JSONObject order) {
			this.order = order;
		}
		@Override
		public void run() {
			System.out.println("對訂單進行積分的維護......" + order.toJSONString());    
			// 就可以做一系列的資料庫的增刪改查的事務操作
		}
	}
}
複製程式碼

3.1 消費者的核心引數

3.1.1 【heartbeat.interval.ms】

consumer心跳時間,必須得保持心跳才能知道consumer是否故障了,然後如果故障之後,就會通過心跳下發rebalance的指令給其他的consumer通知他們進行rebalance的操作

3.1.2 【session.timeout.ms】

kafka多長時間感知不到一個consumer就認為他故障了,預設是10秒

3.1.3 【max.poll.interval.ms】

如果在兩次poll操作之間,超過了這個時間,那麼就會認為這個consume處理能力太弱了,會被踢出消費組,分割槽分配給別人去消費,一遍來說結合你自己的業務處理的效能來設定就可以了

3.1.4【fetch.max.bytes】

獲取一條訊息最大的位元組數,一般建議設定大一些

3.1.5 【max.poll.records】

一次poll返回訊息的最大條數,預設是500條

3.1.6 【connection.max.idle.ms】

consumer跟broker的socket連線如果空閒超過了一定的時間,此時就會自動回收連線,但是下次消費就要重新建立socket連線,這個建議設定為-1,不要去回收

3.1.7 【auto.offset.reset】

earliest:
	當各分割槽下有已提交的offset時,從提交的offset開始消費;無提交的offset時,從頭開始消費
	topicA -> partition0:1000   
		  partitino1:2000  			  
latest:
	當各分割槽下有已提交的offset時,從提交的offset開始消費;無提交的offset時,從當前位置開始消費
none:
	topic各分割槽都存在已提交的offset時,從offset後開始消費;只要有一個分割槽不存在已提交的offset,則丟擲異常
複製程式碼

注:我們生產裡面一般設定的是latest

3.1.8 【enable.auto.commit】

這個就是開啟自動提交唯一

3.1.9 【auto.commit.ineterval.ms】

這個指的是多久條件一次偏移量

四、加餐時間:補充第一篇沒提到的內容

日誌二分查詢

其實這也可以被稱作稀鬆索引。也是一個類似跳錶的結構。開啟某主題下的分割槽,我們能看到這樣的一些檔案

00000000000000000000.index(偏移量的索引)
00000000000000000000.log(日誌檔案)
00000000000000000000.timeindex(時間的索引)
複製程式碼

日誌段檔案,.log檔案會對應一個.index和.timeindex兩個索引檔案。kafka在寫入日誌檔案的時候,同時會寫索引檔案,就是.index和.timeindex,一個是位移索引,一個是時間戳索引。

預設情況下,有個引數log.index.interval.bytes限定了在日誌檔案寫入多少資料,就要在索引檔案寫一條索引,預設是4KB,寫4kb的資料然後在索引裡寫一條索引,所以索引本身是稀疏格式的索引,不是每條資料對應一條索引的。而且索引檔案裡的資料是按照位移和時間戳升序排序的,所以kafka在查詢索引的時候,會用二分查詢,時間複雜度是O(logN),找到索引,就可以在.log檔案裡定位到資料了。

插曲:Kafka的生產者案例和消費者原理解析

上面的0,2039···這些代表的是物理位置。為什麼稀鬆索引會比直接一條條讀取速度快,它不是每一條資料都會記錄,是相隔幾條資料的記錄方式,但是就比如現在要消費偏移量為7的資料,就直接先看這個稀鬆索引上的記錄,找到一個6時,7比6大,然後直接看後面的資料,找到8,8比7大,再看回來,確定7就是在6~8之間,而6的物理位置在9807,8的物理位置在12345,直接從它們中間去找。就提升了查詢物理位置的速度。就類似於普通情況下的二分查詢。

ISR機制

光是依靠多副本機制能保證Kafka的高可用性,但是能保證資料不丟失嗎?不行,因為如果leader當機,但是leader的資料還沒同步到follower上去,此時即使選舉了follower作為新的leader,當時剛才的資料已經丟失了。

ISR是:in-sync replica,就是跟leader partition保持同步的follower partition的數量,只有處於ISR列表中的follower才可以在leader當機之後被選舉為新的leader,因為在這個ISR列表裡代表他的資料跟leader是同步的。

如果要保證寫入kafka的資料不丟失,首先需要保證ISR中至少有一個follower,其次就是在一條資料寫入了leader partition之後,要求必須複製給ISR中所有的follower partition,才能說代表這條資料已提交,絕對不會丟失,這是Kafka給出的承諾

那什麼情況下副本會被踢出出ISR呢,如果一個副本超過10s沒有去和leader同步資料的話,那麼它就會被踢出ISR列表。但是這個問題如果解決了(網路抖動或者full gc···等),follower再次和leader同步了,leader會有一個判斷,如果資料差異小就會讓follower重新加入,那麼怎麼才算差異大,怎麼才算小呢,我們們留到原始碼時說明。

finally

這次的篇幅非常非常長,而且需要理解的地方也不少,後面其實本來在kafka的核心裡還有個HW&LEO原理的,可自己都懶得繼續寫了hhh。下次原始碼篇的時候我們們再聊吧。

相關文章