Kafka(分散式釋出-訂閱訊息系統)工作流程說明

散盡浮華發表於2018-08-08

 

Kafka系統架構
Apache Kafka是分散式釋出-訂閱訊息系統。它最初由LinkedIn公司開發,之後成為Apache專案的一部分。Kafka是一種快速、可擴充套件的、設計內在就是分散式的,分割槽的和可複製的提交日誌服務。

kafka的架構包括以下元件:
話題(Topic):是特定型別的訊息流。訊息是位元組的有效負載(Payload),話題是訊息的分類名或種子(Feed)名。
生產者(Producer):是能夠釋出訊息到話題的任何物件。
服務代理(Broker):已釋出的訊息儲存在一組伺服器中,它們被稱為代理(Broker)或Kafka叢集。
消費者(Consumer):可以訂閱一個或多個話題,並從Broker拉資料,從而消費這些已釋出的訊息。

Kafka儲存策略
1)kafka以topic來進行訊息管理,每個topic包含多個partition,每個partition對應一個邏輯log,有多個segment組成。
2)每個segment中儲存多條訊息(見下圖),訊息id由其邏輯位置決定,即從訊息id可直接定位到訊息的儲存位置,避免id到位置的額外對映。
3)每個part在記憶體中對應一個index,記錄每個segment中的第一條訊息偏移。
4)釋出者發到某個topic的訊息會被均勻的分佈到多個partition上(或根據使用者指定的路由規則進行分佈),broker收到釋出訊息往對應partition的最後一個segment上新增該訊息,當某個segment上的訊息條數達到配置值或訊息釋出時間超過閾值時,segment上的訊息會被flush到磁碟,只有flush到磁碟上的訊息訂閱者才能訂閱到,segment達到一定的大小後將不會再往該segment寫資料,broker會建立新的segment。

Kafka資料保留策略
1)N天前的刪除。
2)保留最近的多少Size資料。

Kafka broker
與其它訊息系統不同,Kafka broker是無狀態的。這意味著消費者必須維護已消費的狀態資訊。這些資訊由消費者自己維護,broker完全不管(有offset managerbroker管理)。
    -  從代理刪除訊息變得很棘手,因為代理並不知道消費者是否已經使用了該訊息。Kafka創新性地解決了這個問題,它將一個簡單的基於時間的SLA應用於保留策略。當訊息在代理中超過一定時間後,將會被自動刪除。
   -  這種創新設計有很大的好處,消費者可以故意倒回到老的偏移量再次消費資料。這違反了佇列的常見約定,但被證明是許多消費者的基本特徵。

Kafka Design
目標
1) 高吞吐量來支援高容量的事件流處理
2) 支援從離線系統載入資料
3) 低延遲的訊息系統

持久化
1) 依賴檔案系統,持久化到本地
2) 資料持久化到log

效率
1) 解決”small IO problem“:
    使用”message set“組合訊息。
    server使用”chunks of messages“寫到log。
    consumer一次獲取大的訊息塊。
2)解決”byte copying“:
    在producer、broker和consumer之間使用統一的binary message format。
    使用系統的page cache。
    使用sendfile傳輸log,避免拷貝。

端到端的批量壓縮(End-to-end Batch Compression)
Kafka支援GZIP和Snappy壓縮協議。

複製(Replication)
1)一個partition的複製個數(replication factor)包括這個partition的leader本身。
2)所有對partition的讀和寫都通過leader。
3)Followers通過pull獲取leader上log(message和offset)
4)如果一個follower掛掉、卡住或者同步太慢,leader會把這個follower從"in sync replicas"(ISR)列表中刪除。
5)當所有的"in sync replicas"的follower把一個訊息寫入到自己的log中時,這個訊息才被認為是"committed"的。
6)如果針對某個partition的所有複製節點都掛了,Kafka預設選擇最先復活的那個節點作為leader(這個節點不一定在ISR裡)。

Leader選舉
Kafka在Zookeeper中為每一個partition動態的維護了一個ISR,這個ISR裡的所有replica都跟上了leader,只有ISR裡的成員才能有被選為leader的可能(unclean.leader.election.enable=false)。
在這種模式下,對於f+1個副本,一個Kafka topic能在保證不丟失已經commit訊息的前提下容忍f個副本的失敗,在大多數使用場景下,這種模式是十分有利的。事實上,為了容忍f個副本的失敗,"少數服從多數"的方式和ISR在commit前需要等待的副本的數量是一樣的,但是ISR需要的總的副本的個數幾乎是"少數服從多數"的方式的一半。

The Producer
傳送確認
通過request.required.acks來設定,選擇是否等待訊息commit(是否等待所有的”in sync replicas“都成功複製了資料)
Producer可以通過acks引數指定最少需要多少個Replica確認收到該訊息才視為該訊息傳送成功。acks的預設值是1,即Leader收到該訊息後立即告訴Producer收到該訊息,此時如果在ISR中的訊息複製完該訊息前Leader當機,那該條訊息會丟失。
推薦的做法是,將acks設定為all或者-1,此時只有ISR中的所有Replica都收到該資料(也即該訊息被Commit),Leader才會告訴Producer該訊息傳送成功,從而保證不會有未知的資料丟失。

負載均衡
1)producer可以自定義傳送到哪個partition的路由規則。預設路由規則:hash(key)%numPartitions,如果key為null則隨機選擇一個partition。
2)自定義路由:如果key是一個user id,可以把同一個user的訊息傳送到同一個partition,這時consumer就可以從同一個partition讀取同一個user的訊息。

非同步批量傳送
批量傳送:配置不多於固定訊息數目一起傳送並且等待時間小於一個固定延遲的資料。

The Consumer
consumer控制訊息的讀取。

Push vs Pull
1) producer push data to broker,consumer pull data from broker
2) consumer pull的優點:consumer自己控制訊息的讀取速度和數量。
3) consumer pull的缺點:如果broker沒有資料,則可能要pull多次忙等待,Kafka可以配置consumer long pull一直等到有資料。

Consumer Position
1) 大部分訊息系統由broker記錄哪些訊息被消費了,但Kafka不是。
2) Kafka由consumer控制訊息的消費,consumer甚至可以回到一個old offset的位置再次消費訊息。

Consumer group
每一個consumer例項都屬於一個consumer group。
每一條訊息只會被同一個consumer group裡的一個consumer例項消費。
不同consumer group可以同時消費同一條訊息。

Consumer Rebalance
Kafka consumer high level API:
如果某consumer group中consumer數量少於partition數量,則至少有一個consumer會消費多個partition的資料。
如果consumer的數量與partition數量相同,則正好一個consumer消費一個partition的資料。
如果consumer的數量多於partition的數量時,會有部分consumer無法消費該topic下任何一條訊息。

Message Delivery Semantics
三種:
At most once—Messages may be lost but are never redelivered.
At least once—Messages are never lost but may be redelivered.
Exactly once—this is what people actually want, each message is delivered once and only once.

Producer:有個”acks“配置可以控制接收的leader的在什麼情況下就回應producer訊息寫入成功。

Consumer:
* 讀取訊息,寫log,處理訊息。如果處理訊息失敗,log已經寫入,則無法再次處理失敗的訊息,對應”At most once“。
* 讀取訊息,處理訊息,寫log。如果訊息處理成功,寫log失敗,則訊息會被處理兩次,對應”At least once“。
* 讀取訊息,同時處理訊息並把result和log同時寫入。這樣保證result和log同時更新或同時失敗,對應”Exactly once“。

Kafka預設保證at-least-once delivery,容許使用者實現at-most-once語義,exactly-once的實現取決於目的儲存系統,kafka提供了讀取offset,實現也沒有問題。

Distribution
Consumer Offset Tracking
1)High-level consumer記錄每個partition所消費的maximum offset,並定期commit到offset manager(broker)。
2)Simple consumer需要手動管理offset。現在的Simple consumer Java API只支援commit offset到zookeeper。

Consumers and Consumer Groups
1)consumer註冊到zookeeper
2)屬於同一個group的consumer(group id一樣)平均分配partition,每個partition只會被一個consumer消費。
3)當broker或同一個group的其他consumer的狀態發生變化的時候,consumer rebalance就會發生。

Zookeeper協調控制
1)管理broker與consumer的動態加入與離開。
2)觸發負載均衡,當broker或consumer加入或離開時會觸發負載均衡演算法,使得一個consumer group內的多個consumer的訂閱負載平衡。
3)維護消費關係及每個partition的消費資訊。

日誌壓縮(Log Compaction)
1)針對一個topic的partition,壓縮使得Kafka至少知道每個key對應的最後一個值。
2)壓縮不會重排序訊息。
3)訊息的offset是不會變的。
4)訊息的offset是順序的。
5)壓縮傳送和接收能降低網路負載。
6)以壓縮後的形式持久化到磁碟。

生產者程式碼示例

import java.util.*;
 
import kafka.javaapi.producer.Producer;
import kafka.producer.KeyedMessage;
import kafka.producer.ProducerConfig;
 
public class TestProducer {
    public static void main(String[] args) {
        long events = Long.parseLong(args[0]);
        Random rnd = new Random();
 
        Properties props = new Properties();
        props.put("metadata.broker.list", "broker1:9092,broker2:9092 ");
        props.put("serializer.class", "kafka.serializer.StringEncoder");
        props.put("partitioner.class", "example.producer.SimplePartitioner");
        props.put("request.required.acks", "1");
 
        ProducerConfig config = new ProducerConfig(props);
 
        Producer<String, String> producer = new Producer<String, String>(config);
 
        for (long nEvents = 0; nEvents < events; nEvents++) { 
               long runtime = new Date().getTime();  
               String ip = “192.168.2.” + rnd.nextInt(255); 
               String msg = runtime + “,www.example.com,” + ip; 
               KeyedMessage<String, String> data = new KeyedMessage<String, String>("page_visits", ip, msg);
               producer.send(data);
        }
        producer.close();
    }
}

Partitioning Code

import kafka.producer.Partitioner;
import kafka.utils.VerifiableProperties;
 
public class SimplePartitioner implements Partitioner {
    public SimplePartitioner (VerifiableProperties props) {
 
    }
 
    public int partition(Object key, int a_numPartitions) {
        int partition = 0;
        String stringKey = (String) key;
        int offset = stringKey.lastIndexOf('.');
        if (offset > 0) {
           partition = Integer.parseInt( stringKey.substring(offset+1)) % a_numPartitions;
        }
       return partition;
  }
 
} 

消費者程式碼示例

import kafka.consumer.ConsumerConfig;
import kafka.consumer.KafkaStream;
import kafka.javaapi.consumer.ConsumerConnector;
 
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
 
public class ConsumerGroupExample {
    private final ConsumerConnector consumer;
    private final String topic;
    private  ExecutorService executor;
 
    public ConsumerGroupExample(String a_zookeeper, String a_groupId, String a_topic) {
        consumer = kafka.consumer.Consumer.createJavaConsumerConnector(
                createConsumerConfig(a_zookeeper, a_groupId));
        this.topic = a_topic;
    }
 
    public void shutdown() {
        if (consumer != null) consumer.shutdown();
        if (executor != null) executor.shutdown();
        try {
            if (!executor.awaitTermination(5000, TimeUnit.MILLISECONDS)) {
                System.out.println("Timed out waiting for consumer threads to shut down, exiting uncleanly");
            }
        } catch (InterruptedException e) {
            System.out.println("Interrupted during shutdown, exiting uncleanly");
        }
   }
 
    public void run(int a_numThreads) {
        Map<String, Integer> topicCountMap = new HashMap<String, Integer>();
        topicCountMap.put(topic, new Integer(a_numThreads));
        Map<String, List<KafkaStream<byte[], byte[]>>> consumerMap = consumer.createMessageStreams(topicCountMap);
        List<KafkaStream<byte[], byte[]>> streams = consumerMap.get(topic);
 
        // now launch all the threads
        //
        executor = Executors.newFixedThreadPool(a_numThreads);
 
        // now create an object to consume the messages
        //
        int threadNumber = 0;
        for (final KafkaStream stream : streams) {
            executor.submit(new ConsumerTest(stream, threadNumber));
            threadNumber++;
        }
    }
 
    private static ConsumerConfig createConsumerConfig(String a_zookeeper, String a_groupId) {
        Properties props = new Properties();
        props.put("zookeeper.connect", a_zookeeper);
        props.put("group.id", a_groupId);
        props.put("zookeeper.session.timeout.ms", "400");
        props.put("zookeeper.sync.time.ms", "200");
        props.put("auto.commit.interval.ms", "1000");
 
        return new ConsumerConfig(props);
    }
 
    public static void main(String[] args) {
        String zooKeeper = args[0];
        String groupId = args[1];
        String topic = args[2];
        int threads = Integer.parseInt(args[3]);
 
        ConsumerGroupExample example = new ConsumerGroupExample(zooKeeper, groupId, topic);
        example.run(threads);
 
        try {
            Thread.sleep(10000);
        } catch (InterruptedException ie) {
 
        }
        example.shutdown();
    }
}

Consumer的一個細節說明

topicCountMap.put(topic, new Integer(a_numThreads));

這裡,如果提供的thread數目(a_numThreads)大於這個topic的partition的數目,有些thread會永遠讀不到訊息。
如果如果提供的thread數目(a_numThreads)小於這個topic的partition的數目,有些thread會從多個partition讀到訊息。
如果一個執行緒從多個partition讀取訊息,無法保證的訊息的順序,只能保證從同一個partition讀取到的訊息是順序的。
增加更多的程式/執行緒消費訊息,會導致Kafka re-balance,可能會改變Partition和消費Thread的對應關係。

相關文章