.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-dotnet和confluent-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
,可以通過Assign
和Commit
進行更改,二者區別是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 為最新的偏移。