高性能进程内队列 BufferQueue 1.0 版本发布

前言#

在一个典型的后台任务中,生产者和消费者很少能始终保持相同的速度:请求可能在短时间内集中到达,数据库写入适合合并成批次,实时指标和临时统计也往往需要先缓冲、再统一处理。

如果生产和消费发生在同一个 .NET 进程里,引入一个独立的消息中间件有时太重;但直接使用普通队列,又很快会遇到批量消费、多消费组、并发分区、消费进度和失败重试等问题。

BufferQueue 就是为这类场景设计的,当前版本支持 .NET 8 及以上。它通过强类型 Topic 组织数据,核心目标是让进程内的数据缓冲与批量处理保持简单,同时提供足够清晰的消费语义。

BufferQueue 新版本提供两种存储实现,它们共享同一套 Producer、Consumer Group、Partition 和批量消费模型:

  • Memory:面向低延迟和高吞吐的进程内分段内存队列;
  • MemoryMappedFile:面向本地持久化和进程重启恢复的内存映射文件队列。

Memory 模式提供优化的并发批量读取和可选有界容量;MemoryMappedFile 模式让数据与已提交进度可以跨重启保留,并提供 Serializer、Flush 策略、Segment 清理策略与严格的恢复校验。强类型 Topic、Consumer Group、Pull/Push 和手动提交模型均可用于两种存储。

它解决的不是"再造一个 Channel"#

ConcurrentQueue<T>BlockingCollection<T>Channel<T> 都是非常实用的基础组件。

特别是 Channel<T>,它已经覆盖了高性能异步生产消费、等待唤醒、背压和有界容量等大量通用场景。对于简单的单队列生产消费模型,它通常已经足够好用。

ConcurrentQueue<T> 更适合直接的线程安全 FIFO 操作,BlockingCollection<T> 则在生产消费模型中提供阻塞等待和有界容量。它们都是可靠的基础组件,只是抽象层次更接近单个队列。

BufferQueue 的关注点不同。它把实际业务中经常需要自己补齐的一组能力放到同一个模型里:

  • 使用 (T, TopicName) 隔离不同类型、不同用途的数据;
  • 一个 Topic 可以划分为多个 Partition;
  • 不同 Consumer Group 各自维护独立的消费进度;
  • 同一个 Group 内的多个 Consumer 分摊 Partition;
  • 原生批量拉取,减少逐条消费带来的调度与同步开销;
  • 同时支持 Pull 和 Push 两种消费方式;
  • 同时支持 Auto Commit 和 Manual Commit;
  • Memory 模式支持有界容量;
  • MemoryMappedFile 模式支持数据、生产进度和消费进度的本地持久化。

它适合"业务和队列在同一个进程内,但消费模型已经比单条 FIFO 更复杂"的场景,而不是用来替代 Kafka、RabbitMQ 或 RocketMQ 这类跨进程、跨机器的消息系统。

Benchmark:优势主要在批量消费#

项目使用 BenchmarkDotNet,对比 BufferQueue 在 Memory 模式下与 Channel<T>BlockingCollection<T> 的并发生产和消费表现。下面重点展示与 Channel<T> 的代表性结果。

测试消息均为 int,取值从 08191,共 8192 条;这里的 MessageSize 表示消息条数,不是单条消息的字节大小。生产测试把这组整数分片给 12 个并发任务,消费测试在计时前把同一组整数预先写入队列。BufferQueue 使用 12 个 Partition,表中数据运行于 Apple M2 Max 和 .NET 10。

生产性能#

模式 MessageSize Producers Channel<T> Mean BufferQueue(Memory)Mean 结果
Unbounded 8192 12 287.0 μs 335.0 μs Channel 约快 1.17x
Bounded 8192 12 300.8 μs 364.1 μs Channel 约快 1.21x

消费性能#

模式 MessageSize BatchSize Consumers Channel<T> Mean BufferQueue(Memory)Mean 结果
Unbounded 8192 1 12 3,146.52 μs 815.80 μs 该组参数下 BufferQueue 约快 3.9x
Bounded 8192 1 12 2,118.13 μs 750.73 μs 该组参数下 BufferQueue 约快 2.8x
Unbounded 8192 100 12 3,384.68 μs 49.25 μs 该组参数下 BufferQueue 约快 69x
Bounded 8192 100 12 2,158.57 μs 53.95 μs 该组参数下 BufferQueue 约快 40x
Unbounded 8192 1000 12 3,485.82 μs 33.97 μs 该组参数下 BufferQueue 约快 103x
Bounded 8192 1000 12 2,115.11 μs 35.68 μs 该组参数下 BufferQueue 约快 59x

这些结果表达了一个很明确的定位:BufferQueue 的生产性能已经接近 Channel<T>,但它真正突出的方向是批量消费吞吐。批次越大,逐条读取、同步和交付成本被摊薄得越明显。

性能数字必须结合工作负载理解。上面的倍数只代表这组消息数量、并发度和 Batch Size,不意味着任意业务都能得到同样结果;当前消费场景中 BufferQueue 的内存分配也高于 Channel。IterationSetup 中的预填充不计入测量,测试衡量的是并发排空队列与批次交付开销,不包含生产和逐条业务处理;Channel 使用 TryRead 逐条读取,BufferQueue 启用 Auto Commit,BatchSize 只影响 BufferQueue。BatchSize 表示每批上限;在 8192 条数据和 12 个 Consumers 下,1000 这一档通常是每个 Consumer 用一批读完分配到的约 682 或 683 条数据。单条处理成本上升后,端到端差距会缩小,因此这些结果更适合用于观察趋势。项目提供了完整的 Benchmark 源码,可以在目标机器上用真实的数据类型和批次大小重新测试:

sql 复制代码
dotnet run -c Release --project tests/BufferQueue.Benchmarks/BufferQueue.Benchmarks.csproj

先看核心模型#

BufferQueue 中最重要的四个概念是 Topic、Partition、Consumer Group 和 Consumer。

同一个类型可以注册多个 Topic,同一个 Topic 名也可以承载不同类型。每一对 (T, TopicName) 都对应一个独立的强类型队列。例如:

arduino 复制代码
(OrderCreated, "order-events")
(OrderCancelled, "order-events")
(OrderCreated, "audit-events")

在公共 API 和 DI 模型中,这三个组合是不同的队列;Memory 模式下它们也完全独立。需要注意的是,MemoryMappedFile 的磁盘路径不包含类型名:同一个 DataDirectory 下,TopicName 必须跨消息类型保持唯一。如果不同类型需要复用同一个名称,应为它们配置不同的 DataDirectory

每个 Topic 内部可以包含多个 Partition。Producer 使用轮询方式把数据分发到各个 Partition;创建一个 Consumer Group 时,这些 Partition 会被均分给组内的 Consumer。

这里有两个很关键的语义:

  1. 不同 Consumer Group 会在当前可读范围内独立消费数据,并分别推进自己的进度。
  2. 同一个 Group 内,一条 Partition 只会分配给一个 Consumer,用分区分配实现负载均衡。

例如,一个订单事件 Topic 可以同时被"更新库存"和"生成报表"两个 Group 消费;两个业务互不抢占数据,也不会互相覆盖进度。

两种存储模式怎么选#

维度 Memory MemoryMappedFile
数据位置 当前进程内存 本地内存映射分段文件
重启后恢复 不支持 支持已到达持久化边界的数据和已提交进度
序列化 不需要 目前内置 System.Text.Json、MessagePack 和 unmanaged struct 序列化器,也支持自定义实现
容量控制 支持 BoundedCapacity 当前不支持有界容量
Segment 清理 所有 Group 越过后复用内存 Segment 可按最慢 Group 的已提交进度删除完整文件 Segment
典型场景 可丢弃的实时采样、临时聚合与批处理 希望应用重启后继续消费的本地缓冲

如果应用重启时丢失少量待处理数据可以接受,或者数据能够重新生成,并且重点是延迟和消费吞吐,选择 Memory。

如果数据和已提交进度需要跨重启保留,同时仍然只运行一个 active queue 实例,选择 MemoryMappedFile。

快速接入#

核心包和可选的 MemoryMappedFile 扩展都已经发布到 NuGet:

csharp 复制代码
dotnet add package BufferQueue
dotnet add package BufferQueue.MemoryMappedFile

其中 BufferQueue 包含公共队列模型、Memory 存储和 Push Consumer;BufferQueue.MemoryMappedFile 是可选的持久化扩展,并依赖核心包。

下面在同一个应用中注册两个 Topic:允许少量丢失、可以重新采集的非关键遥测数据使用 Memory,需要跨重启保留的订单事件使用 MemoryMappedFile。

ini 复制代码
using BufferQueue;
using BufferQueue.Memory;
using BufferQueue.MemoryMappedFile;
using BufferQueue.PushConsumer;

builder.Services.AddBufferQueue(bufferQueue =>
{
    bufferQueue
        .UseMemory(memory =>
        {
            memory.AddTopic<TelemetrySample>(options =>
            {
                options.TopicName = "telemetry-samples";
                options.PartitionNumber = 4;
                options.BoundedCapacity = 100_000;
            });
        })
        .UseMemoryMappedFile(memoryMappedFile =>
        {
            memoryMappedFile.AddTopic<OrderCreated>(options =>
            {
                options.TopicName = "order-events";
                options.PartitionNumber = 4;
                options.DataDirectory = "/var/lib/bufferqueue";
            });
        })
        .AddPushCustomers(typeof(Program).Assembly);
});

每个 (T, TopicName) 只应注册到一种存储模式。Memory 和 MemoryMappedFile 可以共存,但不要把同一个组合重复注册到两种模式;使用 MemoryMappedFile 时,同一 DataDirectory 内还要确保不同类型不会复用相同的 TopicName

生产数据#

Topic 在依赖声明时已经确定,可以直接使用 .NET 的 Keyed Service 注入 Producer:

csharp 复制代码
using Microsoft.Extensions.DependencyInjection;

public sealed class OrderService(
    [FromKeyedServices("order-events")]
    IBufferProducer<OrderCreated> producer)
{
    public ValueTask PublishAsync(OrderCreated order) =>
        producer.ProduceAsync(order);
}

如果 Topic 需要在运行时决定,也可以通过 IBufferQueue 获取:

ini 复制代码
var producer = bufferQueue.GetProducer<OrderCreated>("order-events");
await producer.ProduceAsync(orderCreated);

Memory Topic 设置 BoundedCapacity 后,队列满时 ProduceAsync 会抛出 MemoryBufferQueueFullException。不希望使用异常控制流程时,可以改用 TryProduceAsync

ini 复制代码
var telemetryProducer = bufferQueue.GetProducer<TelemetrySample>("telemetry-samples");
var accepted = await telemetryProducer.TryProduceAsync(sample);
if (!accepted)
{
    // 当前遥测采样可以按业务约定丢弃或降级处理
}

Pull 模式批量消费#

下面创建 4 个 Consumer,并把 order-events 的 4 个 Partition 分配给它们。这里关闭 Auto Commit,业务处理成功后再手动提交:

ini 复制代码
var consumers = bufferQueue.CreatePullConsumers<OrderCreated>(
    new BufferPullConsumerOptions
    {
        TopicName = "order-events",
        GroupName = "inventory-update",
        BatchSize = 200,
        AutoCommit = false
    },
    consumerNumber: 4);

await Task.WhenAll(
    consumers.Select(consumer => ConsumeAsync(consumer, stoppingToken)));

static async Task ConsumeAsync(
    IBufferPullConsumer<OrderCreated> consumer,
    CancellationToken cancellationToken)
{
    await foreach (var batch in consumer.ConsumeAsync(cancellationToken))
    {
        await UpdateInventoryAsync(batch, cancellationToken);
        await consumer.CommitAsync();
    }
}

单个 Consumer 会顺序推进自己负责的 Partition。要提高组内并行度,应在创建 Group 时增加 Consumer 数量;Consumer 数量不能超过 Partition 数量,并且当前不支持在运行时动态增减同一 Group 的 Consumer。

GroupName 建议按消费用途命名,例如 inventory-updatesales-reporting。执行同一用途的多个 Consumer 共享一个 Group,不同业务用途使用不同 Group;不要把机器名、进程号或实例编号作为 Group 名的一部分。

Push 模式消费#

如果不想自己管理消费循环,可以扫描带有 BufferPushCustomerAttribute 的类型,由 Hosted Service 驱动消费:

上面的注册代码已经通过 .AddPushCustomers(typeof(Program).Assembly) 开启扫描。BufferPushCustomerAttribute 是当前 API 的名称,正文仍统一称它创建的消费者为 Push Consumer。

csharp 复制代码
using BufferQueue.PushConsumer;
using Microsoft.Extensions.DependencyInjection;

[BufferPushCustomer(
    topicName: "telemetry-samples",
    groupName: "telemetry-storage",
    batchSize: 500,
    serviceLifetime: ServiceLifetime.Singleton,
    concurrency: 4)]
public sealed class TelemetrySampleConsumer : IBufferAutoCommitPushConsumer<TelemetrySample>
{
    public Task ConsumeAsync(
        IEnumerable<TelemetrySample> batch,
        CancellationToken cancellationToken) =>
        WriteTelemetryAsync(batch, cancellationToken);
}

Push Consumer 仍然建立在同一套 Pull Consumer 模型之上,所以 Partition 分配、批量大小和提交语义保持一致。

Auto Commit 和 Manual Commit 到底有什么区别#

这个选择直接决定失败时的行为。

Auto Commit#

一次 Pull 成功后,BufferQueue 会立即推进消费进度,然后把批次交给业务代码。它使用方便、吞吐路径短,但业务处理失败时,这个批次不会因为"没有提交"而自动重放。

适合允许少量数据遗漏、数据可以重新生成,或者业务自身有独立恢复机制的场景。

Manual Commit#

业务处理成功后显式调用 CommitAsync。如果处理失败且没有提交,同一批数据可以再次被读取,提供 at-least-once 语义。

这也意味着业务处理应尽量做到幂等,因为"至少一次"允许重复,而不承诺"恰好一次"。

未提交只保证消费进度不会推进,BufferQueue 不会主动调度失败重试。应用需要继续或重新启动消费循环,才能再次读取这批数据。

在 MemoryMappedFile 模式下,Commit 还会先强制待提交日志到达 flush 边界,再持久化 Consumer Offset,使已到达持久化边界的记录和已提交进度可以跨进程重启恢复。

Memory 模式为什么适合批量消费#

Memory Partition 不是一个不断扩容的大数组,而是由固定大小的 Segment 组成的链表:

rust 复制代码
head segment -> segment -> ... -> tail segment

Producer 向尾部 Segment 追加数据。写满后,Partition 会创建新 Segment,或者复用已经被所有 Consumer Group 完整消费的旧 Segment。

这里的"所有 Group"很重要。假设报表 Group 比实时计算 Group 慢很多,只要报表 Group 还没有越过某个 Segment,这段内存就不能被复用。这样既能支持多个独立进度,又不会覆盖慢消费者尚未读取的数据。

并发方面,Producer 支持多线程调用。Memory Queue 会在一段很短的临界区内完成 Partition 轮询、容量计数和追加;Consumer 读取已经发布的数据时不获取这个 append lock。多个 Group 可以独立读取和推进各自的进度。

批量消费则把多条数据一次性返回给业务代码,减少逐条读取带来的状态切换、同步和调度成本。这也是 BufferQueue 相比通用 Queue 最明显的性能侧重点。

MemoryMappedFile:把同一套消费模型带到磁盘#

MemoryMappedFile 模式没有在上层重新实现一套队列。两种存储共享 Topic、Producer、Consumer、Consumer Group、Partition 分配、等待唤醒和提交逻辑,差异被隔离在内部 Partition 抽象之后:

r 复制代码
Application
    |
    v
IBufferQueue / IBufferProducer<T>
    |
    v
BufferQueue<T>
    |
    +-- BufferPullConsumer<T>
    +-- IBufferPartition<T>[]
            |
            +-- MemoryBufferPartition<T>
            +-- MemoryMappedFileBufferPartition<T>

配置示例#

MemoryMappedFile 的所有参数都按 Topic 配置。下面两种写法分别展示 MessagePack 和 unmanaged struct 的配置方式;它们是互斥示例,同一个 (T, TopicName) 不要重复注册。

MessagePack、批量 Flush 与 Segment 清理策略#

如果消息量较大,希望减少 Serializer 和显式 Flush 的开销,可以给持久化类型定义稳定的 MessagePack Contract:

csharp 复制代码
dotnet add package MessagePack
csharp 复制代码
using MessagePack;

[MessagePackObject]
public sealed class OrderCreated
{
    [Key(0)]
    public long OrderId { get; set; }

    [Key(1)]
    public long CustomerId { get; set; }

    [Key(2)]
    public decimal Amount { get; set; }

    [Key(3)]
    public long CreatedAtUnixTimeMilliseconds { get; set; }
}

然后配置 MessagePack Serializer、批量 Flush 与 Segment 清理策略:

ini 复制代码
using System.IO;
using BufferQueue;
using BufferQueue.MemoryMappedFile;

builder.Services.AddBufferQueue(bufferQueue =>
{
    bufferQueue.UseMemoryMappedFile(memoryMappedFile =>
    {
        memoryMappedFile.AddTopic<OrderCreated>(options =>
        {
            options.TopicName = "order-events";
            options.PartitionNumber = 4;
            options.DataDirectory = Path.Combine(
                builder.Environment.ContentRootPath,
                "bufferqueue-data");
            options.SegmentSizeInBytes = 64L * 1024 * 1024;
            options.MaxRetainedConsumedSegments = 2;
            options.FlushStrategy = MemoryMappedFileFlushStrategy.Batch;
            options.FlushBatchSize = 100;
            options.Serializer =
                new MessagePackMemoryMappedFileSerializer<OrderCreated>();
        });
    });
});

这表示单个 Partition 累积 100 条记录时显式 Flush,并在所有已知 Consumer Group 都越过旧 Segment 后保留最近 2 个可回收 Segment。发生 Consumer Commit,或者当前 Segment 写满并切换到下一 Segment 时,也会立即 Flush。MaxRetainedConsumedSegments = 2 不是总文件数或磁盘空间上限,慢 Group 仍然会阻止清理。

MessagePack 的数字 Key、Resolver、压缩和其他 Options 都属于持久化格式。应用项目需要直接引用 MessagePack,让 Analyzer 和 Source Generator 在应用侧运行;已有记录仍需读取时,不要复用已经删除字段的 Key,也不要不兼容地修改配置。

固定布局值类型使用 unmanaged Serializer#

对于字段固定、没有任何引用类型的值类型,可以显式固定 Layout 和 Packing:

csharp 复制代码
using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct Quote
{
    public long Sequence;
    public long Timestamp;
    public double Price;
    public int Quantity;
}

注册时指定内置的 unmanaged Serializer:

ini 复制代码
using System.IO;
using BufferQueue;
using BufferQueue.MemoryMappedFile;

builder.Services.AddBufferQueue(bufferQueue =>
{
    bufferQueue.UseMemoryMappedFile(memoryMappedFile =>
    {
        memoryMappedFile.AddTopic<Quote>(options =>
        {
            options.TopicName = "quotes";
            options.PartitionNumber = 4;
            options.DataDirectory = Path.Combine(
                builder.Environment.ContentRootPath,
                "bufferqueue-data");
            options.Serializer =
                UnmanagedMemoryMappedFileSerializer<Quote>.Instance;
        });
    });
});

unmanaged 泛型约束会在编译期排除 string、数组等引用字段。这个格式直接持久化 native 内存表示,所以字段顺序、Packing、Endianness、Runtime 和进程架构都是数据协议的一部分;旧记录仍然存在时不能随意修改 Quote 的布局。

MemoryMappedFile Partition 本质上是一段只追加的本地日志。每个 Partition 独立写入自己的内存映射 Segment 文件,默认 Segment 大小为 256 MiB;一条记录不会跨 Segment,序列化后的单条消息连同记录开销无法放入一个 Segment 时,生产会直接失败。

Segment 中的记录格式保持简单:

arduino 复制代码
4 bytes  payload length,little-endian int32
N bytes  payload
1 byte   record end marker

末尾标记用于在读取和恢复时识别完整记录。如果当前 Segment 放不下下一条记录,Partition 会写入 length = -1 的 Segment End Marker,或者把不足 4 字节的尾部视为 Padding,然后从下一个 Segment 继续追加。

数据和 Offset 按 Topic、Partition 与 Consumer Group 隔离:

bash 复制代码
{DataDirectory}/{TopicName}/partition-00000/
├── 00000000000000000000.log
├── producer.offset
├── earliest.offset
└── offsets/
    └── inventory-update/
        └── consumer.offset
  • producer.offset 记录最近一次成功显式 Flush 对应的安全生产 Checkpoint;
  • consumer.offset 记录某个 Group 已提交的消费位置;
  • earliest.offset 记录保留区间中最早可读的 Segment 边界。

三个 Offset 文件都保存 8 字节 little-endian 整数,并通过临时文件加 Replace/Move 的方式更新,避免进程在写 Checkpoint 中途退出时留下半个有效文件。

更重要的是写入顺序。到达 Flush 边界时,Partition 会先 Flush 日志,再推进 producer.offset。Consumer Commit 则按下面的顺序完成:

rust 复制代码
Flush 待提交日志
    -> 更新 producer.offset
    -> 持久化 consumer.offset
    -> (已配置 Segment 清理策略且存在可回收 Segment 时)
       推进 earliest.offset
       -> 释放映射并删除旧 Segment

这个顺序保证持久化的 Consumer Offset 不会跑到已经安全 Flush 的日志之前;清理时也先缩小逻辑保留范围,再删除物理文件。即使文件删除失败,已经提交的消费进度仍然有效,当前 Commit 会抛出 IOException 明确报告清理失败,后续可以重试。

启动时如何恢复#

启动时,Partition 先读取 earliest.offset,再读取 producer.offsetproducer.offset 是安全 Checkpoint 和扫描提示,但不是唯一事实来源:

  • 如果 producer.offset 不存在,从最早保留位置开始扫描;
  • 如果 Checkpoint 有效但落后于日志,从该位置向后扫描完整记录;
  • 如果尾部只有部分记录,通过 Length、记录末尾标记和 Segment 边界停在最后一个完整位置;
  • 如果 Offset 损坏、越界、没有对齐到记录边界,或者保留区间内缺少 Segment,直接抛出清晰的异常。

因此,恢复可以处理"日志已经 Flush、Checkpoint 还没来得及更新"这样的正常崩溃窗口,但不会静默重置进度、重建缺失文件,或者猜测一个看似可用的起点。

创建新的 Consumer Group 时,BufferQueue 会在它被分配到的每个 Partition 上,从 earliest.offset 写入初始 consumer.offset。这意味着 Group 在第一次 Pull 前就已经参与 Segment 回收水位的计算,也不会读取已经明确回收的历史区间。已有 Group 则从自己最后提交的 Offset 继续读取;未提交的批次仍可能被再次交付。

Segment 如何清理#

MaxRetainedConsumedSegments 用来删除已经完整消费的文件 Segment:

  • null:不自动删除;
  • 0:不保留任何已经可以回收的 Segment;
  • 正数:保留最新的指定数量。

只有当所有已知 Consumer Group 都已提交并越过某个完整 Segment,它才有资格被删除。系统会先推进 earliest.offset,再释放映射并删除文件,保证恢复时不会把已经回收的区间误判为仍然可读。

如果还没有任何已知 Group,BufferQueue 不会自动删除 Segment。因此,这个配置不是磁盘占用的硬上限。慢速、长期不提交、已经离线或废弃的 Group 都会阻止 Segment 回收。要移除废弃 Group,需要先停止 Queue,再到每个 Partition 下删除它完整的 Offset 目录。

序列化器选择#

MemoryMappedFile 提供四种接入方式:

  • 默认的 System.Text.Json;
  • MessagePackMemoryMappedFileSerializer<T>
  • 面向固定布局值类型的 UnmanagedMemoryMappedFileSerializer<T>
  • 自定义 IMemoryMappedFileSerializer<T>

对于普通业务对象,MessagePack 通常能在格式体积和编解码开销之间取得较好平衡。使用默认的 MessagePackSerializerOptions.Standard 时,自定义类型应声明 [MessagePackObject] 和稳定的数字 [Key],应用项目也应直接引用 MessagePack,让 Analyzer 和 Source Generator 在应用侧运行。

对于布局固定的 unmanaged struct,可以使用 UnmanagedMemoryMappedFileSerializer<T>.Instance 直接复制 native 内存表示。字段顺序、packing、endianness、runtime 和进程架构都会成为持久化协议的一部分,已有数据仍需读取时不能随意修改结构布局。它省去了格式编解码,但当前队列契约仍会创建 payload byte[],并不是零拷贝读取。

自定义 Serializer 实例会被同一个 Topic 的多个 Partition 并发调用,因此实现必须线程安全,持久化格式也必须保持前后兼容。

Immediate 还是 Batch Flush#

默认的 Immediate 策略每写入一条记录都会显式 Flush,持久化边界最清晰,但磁盘同步次数也最多。

Batch 策略在单个 Partition 累积到 FlushBatchSize 条记录后再显式 Flush,适合用一定的尾部数据风险换取更少的 Flush 次数。Consumer Commit 与当前 Segment 写满后的切换都是强制 Flush 边界,与 Batch Size 无关。

如果尾部记录尚未达到批量阈值,且之后既没有发生 Consumer Commit,也没有因写满当前 Segment 而切换到下一 Segment,就不能保证这些记录已经显式 Flush;仅 Dispose Queue 或 Service Provider 也不会补建这个边界。要求每次 ProduceAsync 成功都建立显式持久化边界时,应使用默认的 Immediate

MemoryMappedFile Queue 由 DI 容器创建并持有,Service Provider Dispose 会关闭每个 Partition 的 View 和映射文件句柄。这里的 Dispose 负责确定性释放资源,但不等于 Flush Strategy 中的持久化边界。

MemoryMappedFile Benchmark:持久化成本与序列化选择#

下面是重新运行的代表性结果,环境与前面的 Memory Benchmark 相同:Apple M2 Max、12 个逻辑核心、.NET 10、BenchmarkDotNet 0.15.8。测试数据不是 int,而是一个 Pack = 1 的 28 字节值类型,包含 Sequence: longTimestamp: longPrice: doubleQuantity: intMessageSize = 1024 表示消息条数,不是消息字节数。

Benchmark 使用的完整数据结构和生成方式如下:

csharp 复制代码
using System.Runtime.InteropServices;
using MessagePack;

[MessagePackObject]
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct MemoryMappedFileBenchmarkMessage
{
    [Key(0)]
    public long Sequence { get; set; }

    [Key(1)]
    public long Timestamp { get; set; }

    [Key(2)]
    public double Price { get; set; }

    [Key(3)]
    public int Quantity { get; set; }

    public static MemoryMappedFileBenchmarkMessage Create(int sequence) => new()
    {
        Sequence = sequence,
        Timestamp = 638882496000000000 + sequence,
        Price = 1234.56 + (sequence % 100) * 0.01,
        Quantity = sequence % 1000 + 1
    };
}

Queue 生产和消费测试依次调用 Create(0)Create(1023);独立 Serializer 微基准使用 Create(123456789) 创建的单条消息。

生产测试使用 12 个 Partition,12 个并发生产任务共享同一个 Producer,Segment 大小为 64 MiB;表中的 Producers 表示并发生产任务数。MMF 使用默认的 Immediate 策略,因此每条记录都会建立显式 Flush 边界。

存储 Serializer MessageSize Producers Flush Mean ± StdDev
Memory - 1024 12 - 230.8 ± 11.5 μs
MemoryMappedFile System.Text.Json 1024 12 Immediate 220.26 ± 24.34 ms
MemoryMappedFile MessagePack 1024 12 Immediate 132.67 ± 13.70 ms
MemoryMappedFile unmanaged struct 1024 12 Immediate 142.77 ± 30.42 ms

这个生产测试包含各个 MMF Partition 首个 Segment 的创建与映射、序列化、记录写入、每条 Flush 和 producer.offset 更新。Memory 不承担这些持久化语义,所以这张表展示的是"逐条建立持久化边界需要付出多少成本",不是两个等价存储之间的纯队列吞吐排名。文件系统抖动也会盖过很小的序列化差异,不能根据这张表判断 MessagePack 一定比 unmanaged 更快。

消费测试在计时前预填充相同的 1024 条消息,使用 12 个 Consumers、BatchSize = 100 和 Auto Commit:

存储 Serializer MessageSize BatchSize Consumers Mean ± StdDev
Memory - 1024 100 12 30.58 ± 1.46 μs
MemoryMappedFile System.Text.Json 1024 100 12 1.606 ± 0.303 ms
MemoryMappedFile MessagePack 1024 100 12 1.516 ± 0.048 ms
MemoryMappedFile unmanaged struct 1024 100 12 1.803 ± 0.210 ms

预填充不在消费计时内,刚写入的数据通常仍在操作系统页缓存中。测量范围包含并发读取、Payload 创建、反序列化、批次交付和 Auto Commit 的 Consumer Checkpoint;它不代表冷盘读取,也没有测量进程重启后的恢复时间。在这台 12 核机器上,每个 Partition 约有 85 或 86 条消息,因此每个 Consumer 通常用一批完成消费。

为了把 Serializer 自身的差异从文件 Flush 和 Checkpoint IO 中分离出来,项目还对同一个 28 字节值类型单独测量了编解码。下面的微基准不包含 Queue、MMF、记录头或文件 IO:

Serializer Serialize Mean ± StdDev Serialize Allocated Deserialize Mean ± StdDev Deserialize Allocated
System.Text.Json 147.647 ± 2.370 ns 160 B 249.513 ± 1.196 ns 48 B
MessagePack 29.969 ± 0.019 ns 56 B 35.182 ± 0.010 ns 0 B
unmanaged struct 4.338 ± 0.012 ns 56 B 1.780 ± 0.006 ns 0 B

这组结果说明,在当前默认配置下,固定数值结构体使用 MessagePack 和 unmanaged Serializer 可以显著减少纯编解码开销。这里的 MessagePack 使用并验证了 Source Generator 生成的 Formatter,System.Text.Json 则使用 BufferQueue 内置的默认 Serializer,没有配置 Source-generated JsonSerializerContext,因此不能把差异全部归因于格式本身。unmanaged 是最理想的固定布局场景,而且 Serialize 仍会分配 byte[];进入实际 Queue 后,读取记录也会创建 Payload,因此它不能被理解为零拷贝。对于字符串、引用类型或复杂 DTO,应使用真实消息重新测试。

这些都是三次实测迭代的 ShortRun 结果,更适合观察数量级和趋势。尤其对 MMF 来说,磁盘介质、文件系统、Flush Strategy、消息大小和 Commit 频率都会显著改变结果。默认 Immediate 优先提供清晰的逐条持久化边界;选择 Batch 可以减少 Flush 次数,但必须同时接受未到边界的尾部记录风险,性能数字和持久化语义要放在一起看。完整测试代码位于项目的 BufferQueue.Benchmarks,实际使用前建议替换成目标机器、真实消息类型和业务参数重新运行。

使用前需要知道的边界#

BufferQueue 对自己的边界保持得比较克制:

  1. 它不是分布式消息队列,不提供跨机器投递、Broker 集群、副本或服务端确认。
  2. MemoryMappedFile 是单个 active queue 实例的本地持久化机制,不协调多个进程同时写入同一个 Topic Directory。
  3. Memory 模式随进程退出丢失数据和 Offset;MemoryMappedFile 只保证恢复已经到达相应 Flush 边界的数据。
  4. Auto Commit 在业务代码处理批次之前已经推进进度,需要失败重放时应使用 Manual Commit。
  5. Manual Commit 提供 at-least-once,而不是 exactly-once;业务仍需处理重复。
  6. Consumer 数量在创建 Group 时确定,不能超过 Partition 数量,当前不支持运行时动态扩缩容。
  7. MemoryMappedFile 当前没有 BoundedCapacity,磁盘清理由所有已知 Group 的最小已提交进度决定。
  8. 持久化 Serializer 的格式是数据协议的一部分,修改类型或配置时必须考虑已有记录的兼容性。
  9. 多个 Partition 可以提高并发度,但只保证 Partition 内顺序,不提供跨 Partition 的全局 FIFO。
  10. 恢复已有 MemoryMappedFile Topic 时不能调小 PartitionNumber,否则启动会快速失败。

如果需求已经超出这些边界,应选择更完整的消息中间件,而不是在进程内队列外继续叠加分布式协调能力。

总结#

BufferQueue 新版本以强类型 Topic、Consumer Group、Pull/Push 和批量消费为统一模型。Memory 模式面向对少量数据丢失不敏感、数据可以重新生成的低延迟进程内缓冲,并支持可选有界容量;MemoryMappedFile 模式面向需要本地持久化、恢复和 Segment 清理的场景。

选择也很直接:

  • 对少量数据丢失不敏感,或者数据可以重新生成,优先使用 Memory;
  • 需要跨重启保留数据和消费进度,使用 MemoryMappedFile;
  • 需要跨进程协调、跨机器投递和高可用,使用真正的分布式消息队列。
相关推荐
爸爸6199 小时前
空数据状态与引导:多维度 UI 提示设计
后端
Reart10 小时前
Leetcode 674.最长连续递增序列 (719)
后端·算法
芒鸽10 小时前
HarmonyOS ArkUI Search 搜索框:联想词、历史记录与热门标签
后端
Reart11 小时前
Leetcode 714.买卖股票的最佳时机含手续费(719)
后端·算法
芒鸽11 小时前
HarmonyOS ArkUI RichEditor 富文本编辑器:内联样式、Span 管理与格式工具栏
后端
花开彼岸天~11 小时前
Router 路由管理:pushUrl、replaceUrl、back 与参数传递
后端
wear工程师11 小时前
CompletableFuture 的 allOf 到底怎么收结果?面试别只说并行
java·后端
b1305381004912 小时前
DiaryDetail2Page 卡片式详情:琥珀渐变 + 引号装饰
后端
w1395485642212 小时前
AI 周报卡片:智能生成内容的 UI 呈现
后端