前言#
在一个典型的后台任务中,生产者和消费者很少能始终保持相同的速度:请求可能在短时间内集中到达,数据库写入适合合并成批次,实时指标和临时统计也往往需要先缓冲、再统一处理。
如果生产和消费发生在同一个 .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,取值从 0 到 8191,共 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。

这里有两个很关键的语义:
- 不同 Consumer Group 会在当前可读范围内独立消费数据,并分别推进自己的进度。
- 同一个 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-update、sales-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.offset。producer.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: long、Timestamp: long、Price: double 和 Quantity: int。MessageSize = 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 对自己的边界保持得比较克制:
- 它不是分布式消息队列,不提供跨机器投递、Broker 集群、副本或服务端确认。
- MemoryMappedFile 是单个 active queue 实例的本地持久化机制,不协调多个进程同时写入同一个 Topic Directory。
- Memory 模式随进程退出丢失数据和 Offset;MemoryMappedFile 只保证恢复已经到达相应 Flush 边界的数据。
- Auto Commit 在业务代码处理批次之前已经推进进度,需要失败重放时应使用 Manual Commit。
- Manual Commit 提供 at-least-once,而不是 exactly-once;业务仍需处理重复。
- Consumer 数量在创建 Group 时确定,不能超过 Partition 数量,当前不支持运行时动态扩缩容。
- MemoryMappedFile 当前没有
BoundedCapacity,磁盘清理由所有已知 Group 的最小已提交进度决定。 - 持久化 Serializer 的格式是数据协议的一部分,修改类型或配置时必须考虑已有记录的兼容性。
- 多个 Partition 可以提高并发度,但只保证 Partition 内顺序,不提供跨 Partition 的全局 FIFO。
- 恢复已有 MemoryMappedFile Topic 时不能调小
PartitionNumber,否则启动会快速失败。
如果需求已经超出这些边界,应选择更完整的消息中间件,而不是在进程内队列外继续叠加分布式协调能力。
总结#
BufferQueue 新版本以强类型 Topic、Consumer Group、Pull/Push 和批量消费为统一模型。Memory 模式面向对少量数据丢失不敏感、数据可以重新生成的低延迟进程内缓冲,并支持可选有界容量;MemoryMappedFile 模式面向需要本地持久化、恢复和 Segment 清理的场景。
选择也很直接:
- 对少量数据丢失不敏感,或者数据可以重新生成,优先使用 Memory;
- 需要跨重启保留数据和消费进度,使用 MemoryMappedFile;
- 需要跨进程协调、跨机器投递和高可用,使用真正的分布式消息队列。