RequestBatcher 0.3.x 版本,优雅升级

更新概览

  1. [委托 Handler](#委托 Handler)
  2. [Named RequestBatcher](#Named RequestBatcher)
  3. 可观测性

项目地址:

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 类的稳定调用性能几乎没有差别;这组结果不包含首次绑定的编译开销,测试条件和原始数据见:

https://github.com/eventhorizon-cli/RequestBatcher/blob/core-v0.3.4/docs/evaluations/delegate-handlers/README.md

委托 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,返回可空价格:

https://github.com/eventhorizon-cli/RequestBatcher/tree/core-v0.3.4/samples/RequestBatcher.Deduplication

此外,可观测性示例通过 Docker Compose 启动 Cart、Pricing、PostgreSQL 和 Grafana OTel LGTM,并持续生成请求,展示合批后的跨服务链路及指标:

https://github.com/eventhorizon-cli/RequestBatcher/tree/core-v0.3.4/samples/RequestBatcher.Observability

完整的参数约定与使用说明见中文 README:

https://github.com/eventhorizon-cli/RequestBatcher/blob/core-v0.3.4/README.zh-CN.md