更新概览
- [委托 Handler](#委托 Handler)
- [Named RequestBatcher](#Named RequestBatcher)
- 可观测性
项目地址:
https://github.com/eventhorizon-cli/RequestBatcher
NuGet:
https://www.nuget.org/packages/RequestBatcher/
本文基于正式发布的 RequestBatcher v0.3.4 源码和中文 README 编写:
https://github.com/eventhorizon-cli/RequestBatcher/tree/core-v0.3.4
前言
RequestBatcher 是一个进程内请求批处理库。调用方可以独立提交请求,RequestBatcher 将已经排队的请求合并成批次,交给应用的 Handler 处理,再将结果分别返回给对应的调用方。
基本用法和处理语义见前一篇文章,本文介绍委托 Handler、Named RequestBatcher 和可观测性。
现有内存 Benchmark 中,委托 Handler 与 Handler 类的稳定调用性能几乎没有差别;这组结果不包含首次绑定的编译开销,测试条件和原始数据见:
委托 Handler:在调用处组织处理逻辑
以批量查询商品价格为例,原来的写法需要实现并注册 IRequestBatchHandler。示例中的 IPriceStore.FindAsync 接收商品 ID 和取消令牌,返回价格字典,缺失商品不出现在字典中:
csharp
public sealed class PriceByIdHandler(
[FromKeyedServices("catalog")] IPriceStore store)
: IRequestBatchHandler<int, decimal?>
{
public async ValueTask HandleAsync(
IReadOnlyList<RequestBatchEntry<int, decimal?>> entries,
CancellationToken cancellationToken = default)
{
var ids = entries.Select(entry => entry.Request).Distinct().ToArray();
var pricesById = await store.FindAsync(ids, cancellationToken).ConfigureAwait(false);
entries.SetResponses(id =>
pricesById.TryGetValue(id, out var price) ? price : (decimal?)null);
}
}
注册时指定 Handler 类型,并通过 handlerLifetime 设置 Handler 的生命周期:
csharp
services.AddRequestBatcher<int, decimal?, PriceByIdHandler>(
batcherName: "price",
handlerLifetime: ServiceLifetime.Scoped);
使用委托 Handler 后,可以省去 PriceByIdHandler 类,把相同的查询与回填逻辑直接写在 ExecuteAsync 调用处:
csharp
using Microsoft.Extensions.DependencyInjection;
using RequestBatcher;
public sealed class PriceLookup(
[FromKeyedServices("price")] IRequestBatcher<int, decimal?> batcher)
{
public Task<IReadOnlyList<decimal?>> FindAsync(
IEnumerable<int> productIds,
CancellationToken cancellationToken = default) =>
batcher.ExecuteAsync(
productIds,
static async ValueTask (
IReadOnlyList<RequestBatchEntry<int, decimal?>> entries,
[FromKeyedServices("catalog")] IPriceStore store,
CancellationToken batchCancellation) =>
{
var ids = entries.Select(entry => entry.Request).Distinct().ToArray();
var pricesById = await store.FindAsync(ids, batchCancellation)
.ConfigureAwait(false);
entries.SetResponses(id =>
pricesById.TryGetValue(id, out var price) ? price : (decimal?)null);
},
cancellationToken);
}
委托方式改用 AddDelegateRequestBatcher 注册,与上面的 Handler 类注册二选一:
csharp
services.AddDelegateRequestBatcher<int, decimal?>(batcherName: "price");
services.AddScoped<PriceLookup>();
委托方式无需指定 handlerLifetime,处理委托由 RequestBatcher 实例绑定并跨批次复用。
委托所需的服务在每个批次执行时解析,仍遵循各自注册的 DI 生命周期。
提交请求时,通过 ExecuteAsync 的 handler 参数传入处理委托。它支持单个请求和显式列表提交,也支持有返回值、无返回值两种形式。
参数注入
Handler 类通过构造函数注入服务,委托 Handler 则通过方法参数注入服务。
RequestBatcher 执行批次时,从 DI 容器解析参数所需的服务。
上面的委托参数分别是:
entries:由 RequestBatcher 提供本次实际处理的请求批次,必须放在第一个参数。store:通过[FromKeyedServices("catalog")]注入指定 Key 的IPriceStore;普通服务参数无需该属性,直接按类型解析。batchCancellation:由 RequestBatcher 提供批次处理的取消令牌,可以省略这个参数。
上面的 Keyed 服务需注册到 DI 容器:
csharp
services.AddKeyedScoped<IPriceStore, PriceStore>("catalog");
这里的 "catalog" 选择存储服务,PriceLookup 构造函数中的 "price" 选择 RequestBatcher 实例。
使用 Lambda 时,需要显式加上 static,不能捕获外部局部变量或实例状态;所需服务通过委托参数注入。
调用方传入请求、处理委托和自己的取消令牌;委托参数由 RequestBatcher 填充。
有服务参数时,每个实际执行批次都会创建独立的异步 DI Scope。
Scoped 服务在批次内共享,Singleton 服务在容器内共享,Transient 服务按每个参数分别解析。服务来自批次自己的 Scope,即使一个批次合并了多个 HTTP 请求,也不会使用其中某个请求的 Scope。
处理委托和调用器会被缓存,服务实例按批次解析。调用方收到完成结果前,批次 Scope 的异步释放已经完成;没有服务参数时,无需创建 Scope。
batchCancellation 属于 RequestBatcher 自身的处理生命周期。正常调用 DisposeAsync 时,会先停止接收新请求、等待已接收的请求和执行中的批次完成,最后取消这个令牌以停止消费循环。
ExecuteAsync 最后的 cancellationToken 属于调用方,只能在分发前取消自己的请求。
一旦请求已经交给 Handler,就继续返回实际处理结果,单个调用方取消不会中止共享批次。
静态实现与委托绑定
传入的处理委托必须使用静态实现。除了上面的静态 Lambda,还支持静态匿名方法、显式声明为 static 的局部函数,以及指向普通或泛型静态方法的方法组。
也可以在首次提交前选用下面的静态方法写法,替代前面的 Lambda 实现:
csharp
public sealed class PriceLookup(
[FromKeyedServices("price")] IRequestBatcher<int, decimal?> batcher)
{
public Task<IReadOnlyList<decimal?>> FindAsync(
IEnumerable<int> productIds,
CancellationToken cancellationToken = default) =>
batcher.ExecuteAsync(productIds, HandlePricesAsync, cancellationToken);
private static async ValueTask HandlePricesAsync(
IReadOnlyList<RequestBatchEntry<int, decimal?>> entries,
[FromKeyedServices("catalog")] IPriceStore store,
CancellationToken batchCancellation)
{
var ids = entries.Select(entry => entry.Request).Distinct().ToArray();
var pricesById = await store.FindAsync(ids, batchCancellation).ConfigureAwait(false);
entries.SetResponses(id =>
pricesById.TryGetValue(id, out var price) ? price : (decimal?)null);
}
}
同一个 RequestBatcher 实例只绑定一次处理委托,后续提交需要传入相等的委托。
相等性按委托对应的方法和目标对象判断,无需始终传入同一个委托对象。
重复调用 PriceLookup 实例的 FindAsync,使用的是同一处 Lambda 或同一个静态方法。已经绑定 Lambda 的实例改传其他方法组,会在入队前报错。
不同位置的 Lambda 即使代码相同,也可能对应不同的 Handler 目标。多处业务共用一个 RequestBatcher 实例时,可以统一通过 PriceLookup 提交,或统一使用同一个静态方法;不同处理逻辑则使用不同的 batcherName。
委托可以通过变量、字段、参数、属性或工厂传递;每次 ExecuteAsync 都需要传入 Handler。
签名与编译期检查
无返回值模式使用 AddDelegateRequestBatcher<TRequest>() 注册,委托首参数为 IReadOnlyList<TRequest>。
有返回值模式使用 IReadOnlyList<RequestBatchEntry<TRequest, TResponse>>,每个条目都需要设置响应,包括表示未找到的 null。
委托支持同步 void、Task 和 ValueTask 返回类型,不支持 async void、Task<T> 或 ValueTask<T>。响应通过条目回填。
参数必须是必需参数,不支持 ref、out 等按引用参数。Handler 可以省略整个 CancellationToken 参数;声明时最多一个,而且不能写 = default。
前例中 PriceLookup.FindAsync 的调用方令牌可以有默认值,委托 Handler 的注入参数则不能有默认值。
NuGet 包内置两个默认按编译错误报告的 Analyzer 诊断:
RB0001:对能够确定来源的 Handler,检查其是否使用静态实现。RB0002:检查能够确定的委托签名,包括首参数类型、返回类型、参数修饰符、默认值及注入属性等。
当委托经过属性、工厂或参数传递时,Analyzer 可能无法确定最终目标;这类传递仍然允许,调用方需要保证实际使用静态实现。运行时继续检查实际签名、服务绑定和委托一致性,无法可靠区分源码中的静态 Lambda 与不捕获变量的普通 Lambda。
委托自动绑定依赖动态代码和反射元数据,当前不支持 NativeAOT 或裁剪。
Named RequestBatcher
价格查询和库存查询都接收商品 ID,可以分别注册为 "price" 和 "stock"。以下代码替换前面的单独注册:
csharp
services.AddDelegateRequestBatcher<int, decimal?>(
batcherName: "price",
configure: options =>
{
options.BatchSize = 100;
options.MaxConcurrency = 4;
});
services.AddDelegateRequestBatcher<int, int?>(
batcherName: "stock",
configure: options =>
{
options.BatchSize = 200;
options.MaxConcurrency = 2;
});
"price" 返回价格,"stock" 返回库存数量。
两个实例分别持有自己的 Handler、队列、容量和并发限制。
解析与注入
可以直接从应用的 Service Provider 解析指定名称的 RequestBatcher 实例:
csharp
var priceBatcher = serviceProvider
.GetRequiredKeyedService<IRequestBatcher<int, decimal?>>("price");
var stockBatcher = serviceProvider
.GetRequiredKeyedService<IRequestBatcher<int, int?>>("stock");
在应用服务构造函数中,可以通过 [FromKeyedServices("price")] 注入对应的 RequestBatcher 实例,前面的 PriceLookup 就采用这种方式。
ASP.NET Core Controller Action 和 Minimal API Endpoint 参数也支持使用同一属性注入对应实例。
无名称的注册继续提供默认服务,通过普通的 GetRequiredService 或构造函数注入解析。默认实例和命名实例可以共存。
名称、配置与路由
名称区分大小写,不能为 null、空字符串或空白。
同一请求类型与名称只能注册一个 RequestBatcher 实例,即使返回类型不同,也不能复用同一个名称再次注册。
例如,价格查询的 MaxConcurrency 为 4、库存查询为 2,两者访问同一下游时,应考虑合计的并发量。
各个 RequestBatcher 的参数通过注册回调配置,注册后固定,当前不支持运行时调整。
batcherName 用于选择实例,UsePartitionKey 配置实例内部的请求路由。以下代码替换 "price" 的注册,按商品 ID 路由:
csharp
services.AddDelegateRequestBatcher<int, decimal?>(
batcherName: "price",
configure: options =>
{
options.BatchSize = 100;
options.MaxConcurrency = 4;
options.UsePartitionKey(id => id, partitionCount: 8);
});
相同商品 ID 进入同一个 Partition,8 个 Partition 共享实例的 4 个 Handler 执行名额。
相同 ID 的请求仍可能分到多个并发执行的批次。批次内去重由 Handler 完成,执行顺序和跨批次缓存由业务控制。
请求合并仍采用机会式攒批:有执行名额时处理当前已经排队的请求,不等待固定的收集窗口。队列容量、异常回传、分发前取消和容器释放时排空请求等规则同样适用。RequestBatcher 在进程内工作,进程故障后无法恢复尚未完成的请求。
可观测性
OpenTelemetry 集成
核心 RequestBatcher 包通过 .NET 内置 API 提供名为
RequestBatcher 的 ActivitySource 和 Meter,不依赖 OpenTelemetry。可选的
RequestBatcher.OpenTelemetry 包提供注册扩展,
并根据请求提交的采样结果决定 Handler 批次是否采样。两个包独立维护版本号。
安装集成包,以及应用需要的 Hosting、Instrument 和 Exporter 包:
bash
dotnet add package RequestBatcher
dotnet add package RequestBatcher.OpenTelemetry
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
在应用现有的 OpenTelemetry Provider 中注册 Tracing 和 Metrics:
csharp
using Microsoft.Extensions.DependencyInjection;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddRequestBatcherInstrumentation()
.AddOtlpExporter())
.WithMetrics(metrics => metrics
.AddRequestBatcherInstrumentation()
.AddOtlpExporter());
仅安装包不会自动开始采集;需要在使用的 Provider 中注册对应扩展。
链路追踪
| Span | 关注内容 |
|---|---|
RequestBatcher.Execute |
一次 ExecuteAsync 提交,有调用方当前的 Span 时作为它的子 Span,否则自身为根 Span。耗时包含容量等待、排队、Handler 处理和结果完成。 |
RequestBatcher.Handle |
一次 Handler 批次,是独立链路的根 Span;Handler 内通过 Instrument 采集的数据库和 HTTP 调用显示为它的子 Span。 |
查看链路时,从 Execute 沿 Link 跳转到实际处理其请求的 Handle。一次提交可以跨多个 Handler 批次,
一个 Handler 批次也可以合并多次提交的请求。Link 用于在这些链路之间跳转,不会改变 Span 的父子关系,也不保证执行顺序。
Named RequestBatcher 会在操作名后追加注册名称,例如 RequestBatcher.Execute pricing-queries 和
RequestBatcher.Handle pricing-queries。过滤和查看 Span 时,可以关注这些属性:
requestbatcher.name:配置的 Batcher 名称,未配置时省略。requestbatcher.request.type:公开请求类型的短名,例如Int32,不含命名空间。requestbatcher.batch.size:一个 Handler 批次中的请求数。requestbatcher.batch.submission.count:该 Handler 批次合并了多少次不同的ExecuteAsync提交。
同一次提交的多项请求,在每个批次中只计一次。
Handler 不会继承调用方的 HTTP Span、日志 Scope、Baggage 或其他 AsyncLocal 值。
租户标识等业务数据应通过请求显式传递;Handler 内创建的 Activity 会正常关联到当前批次 Span。

采样
注册 Tracing 扩展后,应用的 Sampler 决定每个 Execute Span 是否采样。
集成包根据进入当前 Handler 批次的请求所对应的 Execute Span,决定 Handle 是否采样:
| 当前批次对应的 Execute Span | Handle 采样行为 |
|---|---|
| 至少一个已采样 | 采样该 Handler 批次。 |
| 全部未采样 | 不采样该 Handler 批次。 |

Handler 内数据库和 HTTP 子 Span 仍使用应用的 Sampler。ParentBasedSampler 表示子 Span 跟随父 Span 的采样决定,
没有父 Span 时使用指定的根采样器。例如 ParentBasedSampler(new AlwaysOnSampler()) 会采样根 Span,
子 Span 则跟随父级。集成包对独立的 Handle 根 Span 使用上表中的规则。
也可以选择不安装集成包,直接注册 AddSource("RequestBatcher")。此时应用的 Sampler 会独立决定 Handle
是否采样,因此即使 Execute 已采样,对应的 Handler 处理链路仍可能缺失。采样、导出及 Link 数量限制也可能导致
关联不完整;大批次需要保留更多关联时,应调整 SDK 的 Span Link 上限。0% 采样不等于完全关闭 Instrument。
上下文与 Link 的实现,以及性能评估,见可观测性设计。
Metrics
如果只需要 Metrics,可以选择不安装集成包,直接监听 Meter:
csharp
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics.AddMeter("RequestBatcher"));
也可以安装 RequestBatcher.OpenTelemetry,
使用前面示例中的 Metrics AddRequestBatcherInstrumentation() 扩展。
| 指标名称 | 类型 | 单位 | 含义 |
|---|---|---|---|
requestbatcher.requests.submitted |
Counter<long> |
{request}(请求) |
已接收的请求数。 |
requestbatcher.requests.processed |
Counter<long> |
{request}(请求) |
Handler 已处理的请求数,包含失败的处理。 |
requestbatcher.batches.processed |
Counter<long> |
{batch}(批次) |
已处理的 Handler 批次数。 |
requestbatcher.batch.size |
Histogram<long> |
{request}(请求) |
每个 Handler 批次的请求数。 |
requestbatcher.batch.duration |
Histogram<double> |
ms(毫秒) |
Handler 批次耗时,包含 Handler 作用域释放。 |
维度只包含请求类型短名(requestbatcher.request.type)和可选的 Batcher 名称(requestbatcher.name)。
同名类型会共享请求类型维度,需要区分时可以配置不同的 Batcher 名称。不记录请求内容、租户值或 Partition Key。
指标名称已包含 requestbatcher.;示例的 Prometheus Exporter 会生成
requestbatcher_batches_processed_total。导出端不要重复添加 requestbatcher 命名空间/前缀,
以免出现 requestbatcher_requestbatcher_*。
自行使用 System.Diagnostics.Metrics.MeterListener 采集时,需要显式订阅想采集的指标:
csharp
listener.InstrumentPublished = (instrument, meterListener) =>
{
if (instrument.Meter.Name == "RequestBatcher")
{
meterListener.EnableMeasurementEvents(instrument);
}
};
- 发现指标或注册测量值回调都不等于订阅。每个监听者对想采集的每个指标调用一次
EnableMeasurementEvents;
如需只采集部分指标,可以按instrument.Name筛选。 - 为计数和批次大小注册
long回调,为耗时注册double回调。在提交请求前调用Start(),
并让监听者存活到 Batcher 关闭完成后。订阅前的测量值不会补发。 - 回调同步执行,也可能被多个线程并发调用,应保持轻量、线程安全,避免阻塞 I/O 和异常向外传播。
回调返回后仍要使用的标签需要复制。聚合和导出由自定义 Listener 自行实现。
Logs
内置日志通过应用现有的 Microsoft.Extensions.Logging 管道输出,无需安装 OpenTelemetry 集成包。
日志类别前缀为 RequestBatcher.RequestBatchCoordinator;可以在 appsettings.json 中用 RequestBatcher 前缀统一调整级别:
json
{
"Logging": {
"LogLevel": {
"RequestBatcher": "Warning"
}
}
}
该配置保留 Warning 及以上级别;Debug 可查看启动和停止日志,Trace 还会记录每批处理的开始与完成。
Handler 失败日志包含原始异常,库本身不会记录请求内容。
可运行示例
委托 Handler 和 Handler 类各有一套独立的 PostgreSQL 示例。委托版在提交处使用静态 Lambda,注入批次 Scope 中的 Keyed PriceStore,包括价格写入和 POST /prices/query 批量查询:
https://github.com/eventhorizon-cli/RequestBatcher/tree/core-v0.3.4/samples/RequestBatcher.Delegates
Handler 类版本使用名为 "price" 的 RequestBatcher 实例,直接接收商品 ID,返回可空价格:
此外,可观测性示例通过 Docker Compose 启动 Cart、Pricing、PostgreSQL 和 Grafana OTel LGTM,并持续生成请求,展示合批后的跨服务链路及指标:
完整的参数约定与使用说明见中文 README:
https://github.com/eventhorizon-cli/RequestBatcher/blob/core-v0.3.4/README.zh-CN.md