.Net(c#)使用 Kafka 小結

麥比烏斯皇發表於2020-05-17

.Net(c#)使用 Kafka 小結

1.開篇

由於專案中必須使用 kafka 來作為訊息元件,所以使用 kafka 有一段時間了。不得不感嘆 kafka 是一個相當優秀的訊息系統。下面直接對使用過程做一總結,希望對大家有用。

1.1.kafka 部署

kafka 的簡單搭建我們使用 docker 進行,方便快捷單節點。生產環境不推薦這樣的單節點 kafka 部署。

1.1.1.確保安裝了 docker 和 docker-compose

網上很多教程,安裝也簡單,不作為重點贅述。

1.1.2.編寫 docker-compose.yml

將以下內容直接複製到新建空檔案docker-compose.yml中。

version: "3"
services:
  zookeeper:
    image: wurstmeister/zookeeper
    ports:
      - "2181:2181"
  kafka:
    image: wurstmeister/kafka
    depends_on: [zookeeper]
    ports:
      - "9092:9092"
    environment:
      KAFKA_ADVERTISED_HOST_NAME: localhost
      KAFKA_CREATE_TOPICS: "test"
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

1.1.3.容器構建提交

docker-compose.yml檔案的目錄下執行以下命令:

docker-compose build # 打包
docker-compose up # 啟動, 新增 -d 可以後臺啟動。

看到日誌輸出:

Creating network "desktop_default" with the default driver
Creating desktop_zookeeper_1 ... done
Creating desktop_kafka_1     ... done
Attaching to desktop_zookeeper_1, desktop_kafka_1
zookeeper_1  | ZooKeeper JMX enabled by default
zookeeper_1  | Using config: /opt/zookeeper-3.4.13/bin/../conf/zoo.cfg
zookeeper_1  | 2020-05-17 03:34:31,794 [myid:] - INFO  [main:QuorumPeerConfig@136] - Reading configuration from: /opt/zookeeper-3.4.13/bin/../conf/zoo.cfg
...
zookeeper_1  | 2020-05-17 03:34:31,872 [myid:] - INFO  [main:ZooKeeperServer@836] - tickTime set to 2000
...
kafka_1      | Excluding KAFKA_VERSION from broker config

沒有錯誤輸出說明部署成功。

2.kafka 客戶端選擇

在 github 上能夠找到好幾個 c#可以使用的 kafka 客戶端。大家可以去搜一下,本文就只說明rdkafka-dotnetconfluent-kafka-dotnet

2.1.rdkafka-dotnet

我們生產環境中就使用的該客戶端。在該專案 github 首頁中可以看到:

var config = new Config() { GroupId = "example-csharp-consumer" };
using (var consumer = new EventConsumer(config, "127.0.0.1:9092"))
{
    consumer.OnMessage += (obj, msg) =>
    {
        //...
    };
}

沒錯,使用它的原因就是它提供了EventConsumer,可以直接非同步訂閱訊息。整體上來說該客戶端非常的穩定,效能優良。使用過程中比較難纏的就是它的配置,比較不直觀。它基於librdkafka(C/C++)實現,配置 Config 類中顯式配置比較少,大多數是通過字典配置的,比如:

var config = new Config();
config["auto.offset.reset"] = "earliest";//配置首次訊息偏移位置為最早

這對於新手來說並不是很友好,很難想到去這樣配置。當然如果有 librdkafka 的使用經驗會好很多。大多數配置在 librdkafka 專案的CONFIGURATION

還有一個需要注意的是 Broker 的版本支援Broker version support: >=0.8,也在 librdkafka 專案中可以找到。

2.2 confluent-kafka-dotnet

confluent-kafka-dotnet 是 rdkafka-dotnet(好幾年沒有維護了)的官方後續版本。推薦使用 confluent-kafka-dotnet,因為配置相對友好,更加全面。比如:

var conf = new ConsumerConfig
{
    AutoOffsetReset = AutoOffsetReset.Earliest//顯式強型別賦值配置
};

對於 EventConsumer 怎麼辦呢?在專案變更記錄中已經明確提出移除了 OnMessage 多播委託,而 EventConsumer,也就不存在了。但這不難,我們可以參照基專案寫一個:

public class EventConsumer<TKey, TValue> : IDisposable
{
    private Task _consumerTask;
    private CancellationTokenSource _consumerCts;
    public IConsumer<TKey, TValue> Consumer { get; }
    public ConsumerBuilder<TKey, TValue> Builder { get; set; }
    public EventConsumer(IEnumerable<KeyValuePair<string, string>> config)
    {
        Builder = new ConsumerBuilder<TKey, TValue>(config);
        Consumer = Builder.Build();
    }
    public event EventHandler<ConsumeResult<TKey, TValue>> OnConsumeResult;
    public event EventHandler<ConsumeException> OnConsumeException;
    public void Start()
    {
        if (Consumer.Subscription?.Any() != true)
        {
            throw new InvalidOperationException("Subscribe first using the Consumer.Subscribe() function");
        }
        if (_consumerTask != null)
        {
            return;
        }
        _consumerCts = new CancellationTokenSource();
        var ct = _consumerCts.Token;
        _consumerTask = Task.Factory.StartNew(() =>
        {
            while (!ct.IsCancellationRequested)
            {
                try
                {
                    var cr = Consumer.Consume(TimeSpan.FromSeconds(1));
                    if (cr == null) continue;
                    OnConsumeResult?.Invoke(this, cr);
                }
                catch (ConsumeException e)
                {
                    OnConsumeException?.Invoke(this, e);
                }
            }
        }, ct, TaskCreationOptions.LongRunning, TaskScheduler.Default);
    }
    public async Task Stop()
    {
        if (_consumerCts == null || _consumerTask == null) return;
        _consumerCts.Cancel();
        try
        {
            await _consumerTask;
        }
        finally
        {
            _consumerTask = null;
            _consumerCts = null;
        }
    }
    public void Dispose()
    {
        if (_consumerTask != null)
        {
            Stop().Wait();
        }
        Consumer?.Dispose();
    }
}

使用測試:

static async Task Main(string[] args)
{
    Console.WriteLine("Hello World!");
    var conf = new ConsumerConfig
    {
        GroupId = "test-consumer-group",
        BootstrapServers = "localhost:9092",
        AutoOffsetReset = AutoOffsetReset.Earliest,
    };
    var eventConsumer = new EventConsumer<Ignore, string>(conf);
    eventConsumer.Consumer.Subscribe(new[] {"test"});
    eventConsumer.OnConsumeResult += (sen, cr) =>
    {
        Console.WriteLine($"Receive '{cr.Message.Value}' from '{cr.TopicPartitionOffset}'");
    };
    do
    {
        var line = Console.ReadLine();
        switch (line)
        {
            case "stop":
                eventConsumer.Stop();
                break;
            case "start":
                eventConsumer.Start();
                break;
        }
    } while (true);
}

3.功能擴充套件

!!!以下討論都是對confluent-kafka-dotnet。

由於使用者終端也使用了 kafka 客戶端訂閱訊息。如果終端長時間沒有上線,並且訊息過期時間也較長,服務端會存有大量訊息。終端一上線就會讀取到大量的堆積訊息,很容易就把記憶體耗盡了。考慮到客戶端不是長期線上的場景,無需不間斷的處理所有訊息,服務端才適合這個角色(:。所以客戶端只需每次從登入時的最新點開始讀取就可以了,歷史性統計就交給伺服器去做。

最便捷的方法是每次客戶端連線都使用新的groupid,用時間或者guid撒鹽。但這樣會使服務端記錄大量的group資訊(如果終端很多m個,並且終端斷開連線重連的次數也會很多隨機n次,那麼也是m*n個group資訊),勢必對服務端效能造成影響。

另一種方法是在保持groupid不變的情況下,修改消費偏移。那如何去設定位置偏移為最新點呢?

3.1 錯誤思路 AutoOffsetReset

在配置中存在一個讓新手容易產生誤解的配置項AutoOffsetReset.Latest自動偏移到最新位置。當你興沖沖的準備大幹一番時發現只有首次建立GroupId時會起作用,當 groupid 已經存在 kafka 記錄中時它就不管用了。

3.2 提交偏移 Commit

我們能夠在IConsumer<TKey, TValue>中找到該 commit 方法,它有三個過載:

1. 無參函式。就是提交當前客戶端`IConsumer<TKey, TValue>.Assignment`記錄的偏移。
2. 引數ConsumeResult<TKey, TValue>。一次僅提交一個偏移。當然配置中預設設定為自動提交(`conf.EnableAutoCommit = true;`),無需手動提交。
3. 引數IEnumerable<TopicPartitionOffset> offsets。直接提交到某一個位置。TopicPartitionOffset有三個決定性屬性:話題topic、分割槽:partition、偏移offset。

第三個函式就是我們想要的,我們只需得到對應引數TopicPartitionOffset的值就可以。

3.2.1.TopicPartition的獲取

topic 是我們唯一可以確定的。在IConsumer<TKey, TValue>.Assignment中可以得到 topic 和 partition。但遺憾的是它只有不會立即有值。我們只能主動去服務端獲取,在IAdminClient中找到了可獲取該資訊的方法,所以我們做一擴充套件:

public static IEnumerable<TopicPartition> GetTopicPartitions(ConsumerConfig config, string topic, TimeSpan timeout)
{
    using var adv = new AdminClientBuilder(config).Build();
    var topPns = adv.GetTopicPartition(topic, timeout);
    return topPns;
}

public static IEnumerable<TopicPartition> GetTopicPartition(this AdminClient client, string topic, TimeSpan timeout)
{
    var mta = client.GetMetadata(timeout);
    var topicPartitions = mta.Topics
        .Where(t => topic == t.Topic)
        .SelectMany(t => t.Partitions.Select(tt => new TopicPartition(t.Topic, tt.PartitionId)))
        .ToList();
    return topicPartitions;
}

3.2.2. TopicPartitionOffset獲取

我們還差 offset 的值,通過IConsumer<TKey, TValue>.QueryWatermarkOffsets方法可以查到當前水位,而其中 High 水位就是最新偏移。

現在我們可以完成我們的任務了嗎?問題再次出現,雖然客戶端表現得從最新點消費了,但是在此之前的卡頓和類似與記憶體溢位讓人不得心安。Commit 還是消費了所有訊息:(,只不過暗搓搓的進行。在所有訊息消費期間讀取所有未消費,然後拼命提交。客戶端哪有這麼大的記憶體和效能呢。最終,找到一個和第三個 commit 方法一樣接受引數的方法Assign,一試果然靈驗。

public static void AssignOffsetToHighWatermark<TKey, TValue>(this IConsumer<TKey, TValue> consumer, TopicPartition partition, TimeSpan timeout)
{
    var water = consumer.QueryWatermarkOffsets(partition, timeout);
    if (water == null || water.High == 0) return;
    var offset = new TopicPartitionOffset(partition.Topic, partition.Partition, water.High);
    consumer.Assign(offset);
}

3.2.3.實際使用

最終的使用示例:

//...
var topicPartitions = ConsumerEx.GetTopicPartitions(conf, "test", TimeSpan.FromSeconds(5));
topicPartitions?.ToList().ForEach(t =>
{
    eventConsumer.Consumer.AssignOffsetToHighWatermark(t, TimeSpan.FromSeconds(5));
});
eventConsumer.Start();//在消費事件開始之前就可以進行偏移設定
//...

請注意,如果您關閉了自動提交功能,並且不主動提交任何偏移資訊,那麼服務端對該 group 的偏移記錄將一直不變,Assign 函式並不會改變任何服務的偏移記錄。

4.總結

這一圈下來整個 kafka 的基本消費流程也就搞清楚了。kafka 消費者需要對消費的訊息進行提交。事實上,每個訊息體裡都有偏移資訊。不提交對於服務端來說就是客戶端沒有處理過該訊息,將不會更改已消費偏移。以此來保證訊息消費的可靠性。這和 tcp 中三次握手有異曲同工之妙。

服務端儲存著每一個 groupid 對應的已經提交偏移Committed Offset。當然客戶端不提交它是不會變更的(不考慮直接操作服務端的形式)。

客戶端儲存自己的當前偏移Current Offset,可以通過AssignCommit進行更改,二者區別是Commit將連同提交到服務端對應的偏移中進行更改,而Assign僅改變客戶端偏移,這一更改記錄在IConsumer<TKey, TValue>.Assignment中,首次啟動時客戶端非同步向服務端請求Committed Offset來對其賦值。這就是在 3.2 節中我們沒有立即得到該值的的原因,該值將在可能在幾秒中後被賦值,所以寫了一個主動獲取的方法GetTopicPartition。客戶端下一次消費將根據IConsumer<TKey, TValue>.Assignment進行。

使用AdminClientBuilder.GetMetadata函式可以得到對應話題的後設資料,包括:topic、partition、Brokers 等。

使用IConsumer<TKey, TValue>.QueryWatermarkOffsets函式可以得到當前服務端的水位,low 為最早的偏移(可能不是 0,考慮訊息過期被刪除的情況),high 為最新的偏移。

相關文章