.NET 高效能緩衝佇列實現 BufferQueue

黑洞视界發表於2024-07-29

目錄
  • 前言
  • 適用場景
  • 功能說明
  • 使用示例
  • BufferQueue 內部設計概述
    • Topic 的隔離
    • Partition 的設計
    • 對併發的支援
    • Partition 的動態擴容
    • Segment 的回收機制
  • Benchmark
    • 寫入效能測試
    • 消費效能測試

前言

BufferQueue 是一個用 .NET 編寫的高效能的緩衝佇列實現,支援多執行緒併發操作。

專案地址:https://github.com/eventhorizon-cli/BufferQueue

專案是從 mocha 專案中獨立出來的一個元件,經過修改以提供更通用的緩衝佇列功能。

目前支援的緩衝區型別為記憶體緩衝區,後續會考慮支援更多型別的緩衝區。

適用場景

生產者和消費者之間的速度不一致,需要併發批次處理資料的場景。
因為目前只有記憶體版本,不適用於不允許資料丟失的業務場景。

功能說明

  1. 支援建立多個 Topic,每個 Topic 可以有多種資料型別。每一對 Topic 和資料型別對應一個獨立的緩衝區。

BufferQueue

  1. 支援建立多個 Consumer Group,每個 Consumer Group 的消費進度都是獨立的。支援多個 Consumer Group 併發消費同一個 Topic。

  2. 支援同一個 Consumer Group 建立多個 Consumer,以負載均衡的方式消費資料。

  3. 支援資料的批次消費,可以一次性獲取多條資料。

  4. 支援 pull 模式和 push 模式兩種消費模式。

  5. pull 模式下和 push 模式下都支援 auto commit 和 manual commit 兩種提交方式。auto commit 模式下,消費者在收到資料後自動提交消費進度,如果消費失敗不會重試。manual commit 模式下,消費者需要手動提交消費進度,如果消費失敗只要不提交進度就可以重試。

需要注意的是,當前版本出於簡化實現的考慮,暫不支援消費者的動態擴容和縮容,需要在建立消費者時指定消費者數量。

使用示例

安裝 Nuget 包:

dotnet add package BufferQueue

專案基於 Microsoft.Extensions.DependencyInjection,使用時需要先註冊服務。

BufferQueue 支援兩種消費模式:pull 模式和 push 模式。


builder.Services.AddBufferQueue(options =>
{
    options.UseMemory(bufferOptions =>
        {
            // 每一對 Topic 和資料型別對應一個獨立的緩衝區,可以設定 partitionNumber
            bufferOptions.AddTopic<Foo>("topic-foo1", partitionNumber: 6);
            bufferOptions.AddTopic<Foo>("topic-foo2", partitionNumber: 4);
            bufferOptions.AddTopic<Bar>("topic-bar", partitionNumber: 8);
        })
        // 新增 push 模式的消費者
        // 掃描指定程式集中的標記了 BufferPushCustomerAttribute 的類,
        // 註冊為 push 模式的消費者
        .AddPushCustomers(typeof(Program).Assembly);
});

// 在 HostedService 中使用 pull模式 消費資料
builder.Services.AddHostedService<Foo1PullConsumerHostService>();

pull 模式的消費者示例:

public class Foo1PullConsumerHostService(
    IBufferQueue bufferQueue,
    ILogger<Foo1PullConsumerHostService> logger) : IHostedService
{
    private readonly CancellationTokenSource _cancellationTokenSource = new();

    public Task StartAsync(CancellationToken cancellationToken)
    {
        var token = CancellationTokenSource
            .CreateLinkedTokenSource(cancellationToken, _cancellationTokenSource.Token)
            .Token;

        var consumers = bufferQueue.CreatePullConsumers<Foo>(
            new BufferPullConsumerOptions
            {
                TopicName = "topic-foo1", GroupName = "group-foo1", AutoCommit = true, BatchSize = 100,
            }, consumerNumber: 4);

        foreach (var consumer in consumers)
        {
            _ = ConsumeAsync(consumer, token);
        }

        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _cancellationTokenSource.Cancel();
        return Task.CompletedTask;
    }

    private async Task ConsumeAsync(IBufferPullConsumer<Foo> consumer, CancellationToken cancellationToken)
    {
        await foreach (var buffer in consumer.ConsumeAsync(cancellationToken))
        {
            foreach (var foo in buffer)
            {
                // Process the foo
                logger.LogInformation("Foo1PullConsumerHostService.ConsumeAsync: {Foo}", foo);
            }
        }
    }
}

push 模式的消費者示例:

透過 BufferPushCustomer 特性註冊 push 模式的消費者。

push consumer 會被註冊到 DI 容器中,可以透過建構函式注入其他服務,可以透過設定 ServiceLifetime 來控制 consumer 的生命週期。

BufferPushCustomerAttribute 中的 concurrency 引數用於設定 push consumer 的消費併發數,對應 pull consumer 的 consumerNumber。


[BufferPushCustomer(
    topicName: "topic-foo2",
    groupName: "group-foo2",
    batchSize: 100,
    serviceLifetime: ServiceLifetime.Singleton,
    concurrency: 2)]
public class Foo2PushConsumer(ILogger<Foo2PushConsumer> logger) : IBufferAutoCommitPushConsumer<Foo>
{
    public Task ConsumeAsync(IEnumerable<Foo> buffer, CancellationToken cancellationToken)
    {
        foreach (var foo in buffer)
        {
            logger.LogInformation("Foo2PushConsumer.ConsumeAsync: {Foo}", foo);
        }

        return Task.CompletedTask;
    }
}
[BufferPushCustomer(
    "topic-bar",
    "group-bar",
    100,
    ServiceLifetime.Scoped,
    2)]
public class BarPushConsumer(ILogger<BarPushConsumer> logger) : IBufferManualCommitPushConsumer<Bar>
{
    public async Task ConsumeAsync(IEnumerable<Bar> buffer, IBufferConsumerCommitter committer,
        CancellationToken cancellationToken)
    {
        foreach (var bar in buffer)
        {
            logger.LogInformation("BarPushConsumer.ConsumeAsync: {Bar}", bar);
        }

        var commitTask = committer.CommitAsync();
        if (!commitTask.IsCompletedSuccessfully)
        {
            await commitTask.AsTask();
        }
    }
}

Producer 示例:

透過 IBufferQueue 獲取到指定的 Producer,然後呼叫 ProduceAsync 方法傳送資料。

[ApiController]
[Route("/api/[controller]")]
public class TestController(IBufferQueue bufferQueue) : ControllerBase
{
    [HttpPost("foo1")]
    public async Task<IActionResult> PostFoo1([FromBody] Foo foo)
    {
        var producer = bufferQueue.GetProducer<Foo>("topic-foo1");
        await producer.ProduceAsync(foo);
        return Ok();
    }

    [HttpPost("foo2")]
    public async Task<IActionResult> PostFoo2([FromBody] Foo foo)
    {
        var producer = bufferQueue.GetProducer<Foo>("topic-foo2");
        await producer.ProduceAsync(foo);
        return Ok();
    }

    [HttpPost("bar")]
    public async Task<IActionResult> PostBar([FromBody] Bar bar)
    {
        var producer = bufferQueue.GetProducer<Bar>("topic-bar");
        await producer.ProduceAsync(bar);
        return Ok();
    }
}

BufferQueue 內部設計概述

Topic 的隔離

BufferQueue 有以下的特性:

  • 同一個資料型別 下的 不同 Topic 的 BufferQueue 互不干擾。

  • 同一個 Topic 下的 不同資料型別 的 BufferQueue 互不干擾。

BufferQueue

這個特性是透過以下兩層介面設計實現的:

  • IBufferQueue:根據 TopicName型別引數 T 將請求轉發給具體的 IBufferQueue<T> 實現(藉助 KeyedService 實現),其中引數 T 代表 Buffer 所承載的資料實體的型別。

  • IBufferQueue<T>:具體的 BufferQueue 實現,負責管理 Topic 下的資料。屬於 Buffer 模組的內部實現,不對外暴露。

IBufferQueue

Partition 的設計

為了保證消費速度,BufferQueue 將資料劃分為多個 Partition,每個 Partition 都是一個獨立的佇列,每個 Partition 都有一個對應的消費者執行緒。

Producer 以輪詢的方式往每個 Partition 中寫入資料。
Consumer 最多不允許超過 Partition 的數量,Partition 按平均分配到組內每個 Customer 上。
當一個 Consumer 被分配了多個 Partition 時,以輪訓的方式進行消費。
每個 Partition 上會記錄不同消費組的消費進度,不同組之間的消費進度互不干擾。

Partition

對併發的支援

Producer 支援併發寫入。

Consumer 消費時是繫結 Partition 的,為保證能正確管理 Partition 的消費進度,Consumer 不支援併發消費。

如果要增加消費速度,需建立多個 Consumer。

Partition 的動態擴容

Partition 的基本組成單元是 Segment,Segment 代表儲存資料的陣列,多個 Segment 透過連結串列的形式組合成一個 Partition。

當一個 Segment 寫滿後,透過在其後面追加一個 Segment 實現擴容。

Segment 中用於儲存資料的陣列的每一個元素稱為 Slot,每個 Slot 都有一個Partition 內唯一的自增 Offset。

Segment

Segment 的回收機制

每次在 Partition 中新增 Segment 時,會從頭判斷此前的 Segment 是否已經被所有消費組消費完,回收最後一個消費完的 Segment 作為新的 Segment 追加到 Partition 末尾使用。

SegmentRecycle

Benchmark

測試環境:Apple M2 Max 64GB

寫入效能測試

與 BlockingCollection 對比並發,併發執行緒數為 CPU 邏輯核心數 12, partitionNumber 為 1 和 12。

測試結果
Benchmark

在併發寫入時,BufferQueue 的寫入效能明顯優於 BlockingCollection。

消費效能測試

pull 模式 consumer 與 BlockingCollection 對比並發讀取效能,併發執行緒數為 CPU 邏輯核心數 12,partitionNumber 為 12。

測試結果
Benchmark

在批次消費時,隨著批次大小的增加,BufferQueue 的消費效能優勢更加明顯。

相關文章