目录
- 前言
- 简介
- 请求如何合并:机会式合并
- [安装与 DI 注册](#安装与 DI 注册)
- 适用场景
- 不适用场景
- [API 概览](#API 概览)
- [无返回值 API](#无返回值 API)
- [带返回值 API](#带返回值 API)
- 无返回值批处理
- 带返回值批处理
- 显式批量提交
- 单次提交与显式批量提交
- 请求处理流程
- 配置
- [Partition 数量与并发数](#Partition 数量与并发数)
- [用利特尔法则(Little's Law)估算
MaxConcurrency](#用利特尔法则(Little’s Law)估算 MaxConcurrency) - [联合调整
BatchSize、Partition 和MaxConcurrency](#联合调整 BatchSize、Partition 和 MaxConcurrency) - 拆分调用时,分别估算两层并发
- 用压测验证合并与去重收益
- [Partition Key 路由](#Partition Key 路由)
- 路由规则与调度
- 处理语义
- 依赖注入与日志
- 示例应用
前言
写完上一篇《在 .NET 中优雅地合并并发请求》之后,我又对 RequestBatcher 做了几轮重构和迭代,围绕批处理 API、返回值、内部调度、分区路由和内存分配优化了不少内容。因此重新写一篇博客,介绍当前版本的使用方式和处理语义。
简介
RequestBatcher 是一个运行在进程内的 .NET 批处理库,原生支持单请求和批量请求 。调用方通过
ExecuteAsync(request) 提交单个请求,或通过 ExecuteAsync(requests) 提交一批请求,并等待对应结果。
RequestBatcher 会合并已经排队的请求,限制 Handler 批次并发数和队列容量,再将批次交给应用的 Handler 处理。
它适用于支持一次处理多个数据项的数据库、缓存和下游 API。

多个独立的并发请求可以先合并为一个批次,再执行一次下游批量操作。图示仅用于说明:只有已经在同一 Partition 中排队的请求
才可能合并为同一批。
一次批量 API 调用,不等于一次 Handler 调用。 下图中,A、B 各调用一次批量 API,分别提交商品
[101, 102, 103] 和 [102, 103, 104] 的查询请求。

请求按 ProductId 逐项路由。图中,101、103 进入一个 Partition,102、104 进入另一个;来自 A、B 的请求
在各 Partition 中合并成 Handler 批次。
去重由 Handler 在当前批次内完成。 两个 Handler 批次分别查询 [101, 103] 和 [102, 104],再按
ProductId 为各自批次中的请求回填结果:本例共查询 4 个商品,6 个请求各自得到结果。
这只是可能的分批结果。实际批次受入队时机、BatchSize 和调度影响,相同 key 不保证一次收齐。
请求如何合并:机会式合并
上面的示意图展示了请求可能如何合并。本节说明这种批次是如何形成的。
RequestBatcher 基于 BufferQueue,将 .NET 进程内已经排队的并发请求
合并为批次,交给应用的 Handler 处理。当前依赖 BufferQueue 2.0.3,
应用无需单独注册或配置它。
这种批次处理可以理解为机会式攒批(opportunistic batching) :请求写入 BufferQueue 后会发出 push 通知,唤醒空闲的消费流程。
消费流程取得空闲执行名额后,通过 pull API 拉取当时已经排队的请求;它不会为了凑满 BatchSize 而等待固定时间窗口。
调用方通过 DI 获取 IRequestBatcher<TRequest> 或 IRequestBatcher<TRequest, TResponse>,提交请求并等待各自的结果。
调度循环分发一个批次后会继续运行。只要还有空闲名额,就可以继续拉取和分发下一批,无需等待刚分发的批次完成。
只有所有分配的 Partition 都没有数据时,才等待下一次 push 通知。
这个过程不依赖定时轮询,也没有固定的收集窗口。有空闲名额时,等待主要来自唤醒和调度;名额占满时,请求仍需排队。
BatchSize 只限制每批的最大数量:低流量时一批可能只有一个请求,突发流量时则可能合并同一 Partition 中的多个请求。

图中展示正常消费流程,省略了取消与停止分支。BufferQueue 的消费循环
会在进入等待前检查通知版本;如果这期间有新数据通知到达,就重新尝试拉取,避免漏掉唤醒。
请求仅保存在进程内,进程故障后无法恢复尚未完成的请求。
调用方无需先收集请求,也能获得批量处理。 多个调用方可以并发地各自提交一个
TRequest;RequestBatcher 会将同一 Partition 中已排队的零散请求合并为最多BatchSize项的处理批次。
ExecuteAsync(IEnumerable<TRequest>)只是额外的提交方式,并不是产生批量处理的前提。
安装与 DI 注册
bash
dotnet add package RequestBatcher
支持 .NET 8 和 .NET 10。
启动时通过 AddRequestBatcher 注册带返回值的 Handler 和批处理服务。Handler 依赖应用自己的 IPriceStore 实现;调用时从
同一个 DI 容器获取 IRequestBatcher<PriceQuery, PriceQuote?>,或在应用服务的构造函数中注入它。
csharp
using Microsoft.Extensions.DependencyInjection;
using RequestBatcher;
// 应用启动时,注册存储服务和带返回值的批处理服务。
services.AddScoped<IPriceStore, PriceStore>();
services.AddRequestBatcher<PriceQuery, PriceQuote?, PriceQueryHandler>(
ServiceLifetime.Scoped,
options =>
{
// 4 个 Handler 批次并发执行名额,由 8 个队列 Partition 共享。
options.MaxConcurrency = 4;
options.BatchSize = 100;
options.MaxPendingRequests = 5_000;
// 相同的商品 ID 路由到同一 Partition;不同批次仍可能并发执行。
options.UsePartitionKey(query => query.ProductId, partitionCount: 8);
});
// 调用处从应用自己的 Service Provider 获取带返回值的泛型接口。
var batcher = serviceProvider.GetRequiredService<IRequestBatcher<PriceQuery, PriceQuote?>>();
var quote = await batcher.ExecuteAsync(new PriceQuery(ProductId: 101));
代码示例需要引入 Microsoft.Extensions.DependencyInjection 和 RequestBatcher 命名空间。下方带返回值批处理定义了
PriceQuery、PriceQuote、PriceQueryHandler 以及 IPriceStore 依赖。应用服务中推荐通过构造函数注入
IRequestBatcher<PriceQuery, PriceQuote?>。
适用场景
当请求彼此独立、可以接受在内存中短暂排队(不会等待凑满 BatchSize,只需等待全局执行名额可用),且下游一次
处理多项更合适时,可以使用 RequestBatcher:
- 能够使用批量
INSERT、UPDATE或UPSERT的数据库写入; - 原生支持多项输入的缓存读写或下游 API;
- 需要限制内部队列容量和下游并发量的短时流量突发;
- 调用方取消时,只需要撤销尚未分发的请求;已分发的操作应继续执行并返回实际处理结果;
- 可以在同一处理批次内合并相关请求,且正确性不依赖 Handler 执行顺序。
数据库更新还需要注意事务边界:如果一次 Handler 调用只成功一部分会造成数据不一致,应由 Handler 在同一个事务中
完成整批更新。RequestBatcher 只负责传递 Handler 的处理结果,不会回滚已经提交的数据。
不适用场景
RequestBatcher 既不是持久化后台队列,也不是事务协调器:
- 已接收的请求必须在进程故障后仍可恢复。此时应使用持久化存储或可靠消息队列。
- 操作必须随调用方事务一起提交或回滚。应将它留在原事务中。
- 下游操作必须依赖自动重试,或副作用必须严格恰好一次(exactly-once)。RequestBatcher 不提供这些保证。
- 下游操作必须等待最小批量凑齐,或者依赖固定的收集窗口。RequestBatcher 会直接处理当前已排队的请求。
- 单个调用方断开、超时或取消后,正在执行的下游操作也必须立即停止。一个处理批次可能包含来自多个调用方的
请求,因此单个调用方的CancellationToken不会传给 Handler,也不能取消这个共享的 Handler 调用。
API 概览
无返回值 API
当调用方只需要知道 Handler 是否成功完成时,使用 IRequestBatcher<TRequest>:
csharp
public interface IRequestBatcher<in TRequest>
{
// 提交一个请求,等待它实际的 Handler 处理结果。
Task ExecuteAsync(TRequest request, CancellationToken cancellationToken = default);
// 提交调用方已有的一组请求;返回的 Task 等待整次提交完成。
Task ExecuteAsync(
IEnumerable<TRequest> requests,
CancellationToken cancellationToken = default);
}
public interface IRequestBatchHandler<TRequest>
{
// 处理一个批次;成功返回后,该批次中的所有请求都会完成。
ValueTask HandleAsync(
IReadOnlyList<TRequest> requests,
CancellationToken cancellationToken = default);
}
带返回值 API
当每个请求都需要一个强类型返回值时,使用 IRequestBatcher<TRequest, TResponse>:
csharp
public interface IRequestBatcher<in TRequest, TResponse>
{
// 提交一个请求,返回它的强类型结果。
Task<TResponse> ExecuteAsync(
TRequest request,
CancellationToken cancellationToken = default);
// 提交一组请求,返回结果的顺序与输入序列保持一致。
Task<IReadOnlyList<TResponse>> ExecuteAsync(
IEnumerable<TRequest> requests,
CancellationToken cancellationToken = default);
}
public interface IRequestBatchHandler<TRequest, TResponse>
{
// 处理一个批次,并为每个条目设置且仅设置一个结果。
ValueTask HandleAsync(
IReadOnlyList<RequestBatchItem<TRequest, TResponse>> entries,
CancellationToken cancellationToken = default);
}
RequestBatcher 只会在内部等待每次 Handler 调用返回的 ValueTask 一次,不会将它返回给调用方。同步完成的 Handler
可以因此避免分配 Task;普通异步 I/O 仍可直接使用 async ValueTask 实现。
无返回值批处理
先定义请求类型和批量 Handler:
csharp
public sealed record OrderWriteRequest(long OrderId, decimal Amount);
public interface IOrderStore
{
Task WriteBatchAsync(
IReadOnlyList<OrderWriteRequest> requests,
CancellationToken cancellationToken);
}
public sealed class OrderWriteBatchHandler(IOrderStore store)
: IRequestBatchHandler<OrderWriteRequest>
{
public async ValueTask HandleAsync(
IReadOnlyList<OrderWriteRequest> requests,
CancellationToken cancellationToken = default)
{
await store.WriteBatchAsync(requests, cancellationToken);
}
}
注册时同时指定 Handler 和生命周期;最小配置只需设置批次上限:
csharp
services.AddRequestBatcher<OrderWriteRequest, OrderWriteBatchHandler>(
ServiceLifetime.Scoped,
options =>
{
options.MaxConcurrency = 4;
options.BatchSize = 256;
options.MaxPendingRequests = 5_000;
options.UsePartitionKey(request => request.OrderId, partitionCount: 8);
});
在常规应用服务中注入 IRequestBatcher<TRequest>:
csharp
public sealed class OrderService(IRequestBatcher<OrderWriteRequest> batcher)
{
public Task SaveAsync(
OrderWriteRequest request,
CancellationToken cancellationToken = default) =>
batcher.ExecuteAsync(request, cancellationToken);
}
SaveAsync 直接返回 RequestBatcher 创建的 Task。只有 Handler 完成包含该请求的批次后,这个 Task 才会
完成;调用方无需知道请求进入了哪个批次或 Partition。
带返回值批处理
如果每个请求都需要返回一个值,请使用带返回值的 API。Handler 接收由
RequestBatchItem<TRequest, TResponse> 包装的条目列表;每个条目都包含原始请求和返回值槽位,其中 entry.Request
是原始的 TRequest。下面的示例使用标准 LINQ
Distinct,先在当前处理批次内合并重复的商品 ID,再查询存储层:
csharp
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using RequestBatcher;
public sealed record PriceQuery(long ProductId);
public sealed record PriceQuote(long ProductId, decimal Price);
public interface IPriceStore
{
// 存储层对每个请求的 ID 最多返回一条结果,返回顺序不固定。
Task<IReadOnlyList<PriceQuote>> FindAsync(
IEnumerable<long> productIds,
CancellationToken cancellationToken);
}
public sealed class PriceQueryHandler(IPriceStore store)
: IRequestBatchHandler<PriceQuery, PriceQuote?>
{
public async ValueTask HandleAsync(
IReadOnlyList<RequestBatchItem<PriceQuery, PriceQuote?>> entries,
CancellationToken cancellationToken = default)
{
var keys = entries
.Select(entry => entry.Request.ProductId)
.Distinct()
.ToArray();
var prices = await store.FindAsync(
keys,
cancellationToken);
var pricesByProductId = prices.ToDictionary(price => price.ProductId);
entries.SetResponses(request => pricesByProductId.GetValueOrDefault(request.ProductId));
// 如果不需要查询去重,可以删除 Distinct(),保留同一个响应工厂。
// 也可以将上面的查询和赋值替换为下面两种方式之一:
// 1. 查询所有请求 ID,再逐个为条目设置结果:
// var prices = await store.FindAsync(entries.Select(entry => entry.Request.ProductId), cancellationToken);
// var pricesByProductId = prices.ToDictionary(price => price.ProductId);
// foreach (var entry in entries)
// {
// entry.SetResponse(pricesByProductId.GetValueOrDefault(entry.Request.ProductId));
// }
// 2. 已经得到与输入顺序对齐的结果序列时,使用按输入顺序赋值的重载:
// var prices = await store.FindAsync(entries.Select(entry => entry.Request.ProductId), cancellationToken);
// var pricesByProductId = prices.ToDictionary(price => price.ProductId);
// var orderedResponses = entries
// .Select(entry => pricesByProductId.GetValueOrDefault(entry.Request.ProductId))
// .ToArray();
// entries.SetResponses(orderedResponses);
// 不要将原始后端结果直接传给按顺序赋值的重载:结果可能无序,也可能缺少某些请求的 ID。
}
}
public sealed class PriceService(IRequestBatcher<PriceQuery, PriceQuote?> priceBatcher)
{
public Task<PriceQuote?> FindAsync(
long productId,
CancellationToken cancellationToken = default) =>
priceBatcher.ExecuteAsync(new PriceQuery(productId), cancellationToken);
public Task<IReadOnlyList<PriceQuote?>> FindManyAsync(
IEnumerable<PriceQuery> requests,
CancellationToken cancellationToken = default) =>
priceBatcher.ExecuteAsync(requests, cancellationToken);
}
先在应用现有的服务集合中注册应用自己的 IPriceStore 实现(示例中写作 PriceStore)和 Batcher,再在应用服务中注入
IRequestBatcher<PriceQuery, PriceQuote?>。可运行的 PostgreSQL Web API 示例
直接用 Dapper 和 PostgreSQL 实现同样的查询流程:
csharp
using Microsoft.Extensions.DependencyInjection;
using RequestBatcher;
services.AddScoped<IPriceStore, PriceStore>();
services.AddRequestBatcher<PriceQuery, PriceQuote?, PriceQueryHandler>(
ServiceLifetime.Scoped,
options =>
{
options.MaxConcurrency = 4;
options.BatchSize = 100;
options.MaxPendingRequests = 5_000;
options.UsePartitionKey(query => query.ProductId, partitionCount: 8);
});
Handler 返回前,必须为每个请求设置一次返回值,不能遗漏或重复设置。示例使用标准 LINQ Distinct,只为当前
Handler 调用中的每个不同商品 ID 查询一次存储层。Distinct 也支持匿名组合键、值元组和自定义相等比较器。存储层未
返回的商品 ID 会映射为 null。
去重只发生在当前 Handler 批次内: 它不会合并不同处理批次或不同队列 Partition 中的条目。selector 重载会先按 key
为后端无序结果建立字典,再为每个条目的原始 request 计算一次结果,因此重复条目会得到相同返回值。
所有返回值计算成功后才会统一赋值;factory 抛出异常不会留下部分赋值。引用类型结果会被共享,不会复制。
如果不需要去重,移除 Distinct() 并保留相同的 response factory 即可。还可以逐个调用
entry.SetResponse(response),或先生成严格按原始输入顺序排列、包含重复和缺失请求位置的结果,再调用
entries.SetResponses(responses)。
面向 key 的 selector 重载适合后端无序结果;有序重载不能直接接收后端原始行。显式批量重载即使将请求拆到多个
处理批次或队列 Partition,仍会按输入顺序返回结果。
只有 Handler 成功完成且对应请求的返回值已设置,调用方的 Task<TResponse> 才会成功完成。
显式批量提交
调用方已经持有多个请求时,可以一次提交:
csharp
await batcher.ExecuteAsync(orderWriteRequests, cancellationToken);
RequestBatcher 会先为请求序列创建快照,再统一提交给内部队列。Wait 模式下,请求数量不超过
MaxPendingRequests 时,会等到队列有足够空间后将整组请求一起入队;超过该上限时,会按输入顺序分段,每段最多
MaxPendingRequests 个请求,等待队列腾出足够空间后依次入队。Fail 模式下,如果队列剩余空间不足以容纳整组
请求,就立即拒绝整组请求。
返回的 Task 会等待本次提交中的所有请求。一次提交不等于一次 Handler 调用: 请求仍可能按 BatchSize 或
Partition 路由拆成多个处理批次。
显式批量提交不是 Partition 边界。 配置多个 Partition 后,RequestBatcher 会逐项路由,并不会将整次提交
强制放入同一 Partition。未配置 Partition Key,或每项请求计算出相同的 Partition Key 时,整组请求才会落在同一个
Partition。
MaxConcurrency = 1只限制 Handler 并发数,不会合并带有不同 Key 的 Partition。
单次提交与显式批量提交
本文中的提交 是指调用方执行一次 ExecuteAsync;处理批次 是指一次 HandleAsync 收到的
IReadOnlyList<TRequest> 或 IReadOnlyList<RequestBatchItem<TRequest, TResponse>>。
提交和处理批次不是同一个边界。
| 行为 | 单次提交 | 显式批量提交 |
|---|---|---|
| 输入 | 直接提交传入的 TRequest。 |
枚举一次序列并创建快照;空序列立即完成。 |
| 容量 | 占用一个请求的排队空间。 | Wait 等待队列有足够空间后入队,超过容量上限的请求组会按输入顺序分段入队;Fail 在无法立即容纳整组请求时拒绝整组。 |
| 路由 | 按当前路由模式处理这个请求。 | 每个请求独立路由,因此一次提交可以跨多个 Partition。 |
| Handler 调用 | 可能与同一 Partition 中已排队的其他请求一起交给 Handler。 | 不形成处理批次边界;请求可按 Partition 和 BatchSize 拆分,并可能并行执行。 |
| 完成 | 返回的 Task 表示这个请求的实际处理结果。 |
返回的 Task 等待本次提交中的每个请求。 |
| 失败 | 这次 Handler 调用失败时,该处理批次中的所有请求都会失败。 | 部分请求可能已经成功,其他处理批次仍可能失败;整组 Task 会失败,但不会回滚已经成功的操作。 |
| 取消 | 只能取消尚未交给 Handler 的请求。 | 同一个 CancellationToken 用于整组中的每个请求;尚未分发的请求可以取消,已分发的请求继续返回实际结果。 |
请求处理流程
内部队列的 Partition 数量不代表 Handler 并发数。 Partition 只用于组织请求的排队和路由;同时执行的 Handler
批次数由全局 MaxConcurrency 限制。
同一 Partition 中的多个批次可以并发执行,所有 Partition 共享同一组执行名额。
未配置 UsePartitionKey 时,RequestBatcher 固定使用一个内部 Partition,避免将彼此无关的请求分散到多个队列,
也让它们自然地保留在一起进行批量处理。
配置有限整数数值键或非 null 字符串键以及显式 Partition 数量后,队列使用指定数量的 Partition,使相同键保持
局部性,同时让不同键可以独立推进。P 只是路由细节;H = MaxConcurrency 始终是全局 Handler 批次并发上限。
单个 Partition 可以连续产生多个异步 Handler 批次,因此 Partition 数量不会限制为一次只能执行一个批次。
- 调用方通过
ExecuteAsync提交单个请求。 - RequestBatcher 根据容量配置接收请求,并将它路由到一个内存 Partition。
- 内部
BatchDispatchLoop先获取一个空闲执行名额,再拉取并自动提交最多BatchSize个已排队请求。 - 处理结果会完成该批次中每个调用方持有的
Task。
已接收请求的排队时长主要取决于全局执行名额何时可用,而不是等待凑满 BatchSize。RequestBatcher 不会为凑满
单个批次设置额外的收集窗口:只要有执行名额,它就会立即用当前已排队的请求发起 Handler 调用,即使只有一个请求。
BatchSize 只限制单次调用的最大请求数。

架构图展示了协调器、内存队列中的各个 Partition,以及每个已接收请求独立完成 Task 的处理路径。
调度流程
H = MaxConcurrency 是全局同时执行的 Handler 批次数上限。未配置 Partition Key 时,内部只有一个 Partition;配置
数值或字符串 Partition Key 时,P 是传给 UsePartitionKey 的显式 Partition 数量。

一个 BatchDispatchLoop 负责所有 Partition,并共享同一组全局执行名额。它只有在取得执行名额后才会从队列拉取
批次,因此请求只在 BufferQueue 中等待,不会进入应用自行维护的转交队列。名额按一次 Handler 批次调用完成释放,
而不是批次内某个请求单独完成时释放。任一正在执行的 Handler 批次完成后,其名额会立即归还,调度循环随即可以
拉取并启动下一批;无需等待其他正在执行的批次全部完成。
配置
| 选项 | 默认值 | 行为 |
|---|---|---|
BatchSize |
128 |
单次 Handler 调用包含的请求数量上限。 |
MaxConcurrency |
1 |
Handler 批次最大并发数;它始终是 Handler 并发上限,与按键路由的 Partition 数量相互独立。 |
MaxPendingRequests |
8192 |
内部 BufferQueue 中尚未被拉取执行的请求容量上限。 |
FullMode |
Wait |
队列空间不足时,Wait 等待空位,Fail 立即拒绝本次提交。 |
UsePartitionKey(selector, partitionCount) |
未配置(单 Partition) | 配置按键路由和显式 Partition 数量;partitionCount 必须大于 1,相同键值的请求进入同一 Partition。 |
Handler 执行不保证顺序,包括 MaxConcurrency = 1 的情况。
Partition 数量与并发数
不调用 UsePartitionKey 时,所有请求只使用一个 Partition ,不随 CPU 数量或 MaxConcurrency 增加。
单 Partition 仍能连续提供多个并发执行的 Handler 批次。需要提高下游并发时,应调整 MaxConcurrency。
没有按 key 路由的需求时,单 Partition 是更合适的默认选择。 现有 Partition benchmark
在 BatchSize = 64、MaxConcurrency = 8 下对比了 1 个和 8 个 Partition:无 key 场景中,多 Partition 的
吞吐量为单 Partition 的 0.97--1.01 倍,没有显示出稳定优势。
在预先入队的 64 个无 key 请求中,单 Partition 形成 1 个批次,8 个 Partition 则形成 8 个批次。保持单
Partition 可以避免这种批次拆散,减少不满批次的 Handler 调用,同时保留由 MaxConcurrency 控制的异步并发能力。
需要按 key 路由时,再通过 UsePartitionKey 显式设置 Partition 数量。下面配置使用 8 个队列 Partition,共享
4 个 Handler 执行名额:
csharp
builder.Services.AddRequestBatcher<ProductQueryItem, ProductBatchHandler>(
ServiceLifetime.Scoped,
options =>
{
// 单 Pod 的 Handler 批次并发上限。6 个 Pod x 4 个名额 = 集群最多 24 个 Handler 批次并发。
options.MaxConcurrency = 4;
options.BatchSize = 100;
options.MaxPendingRequests = 5_000;
// 只负责路由和局部性。8 个队列 Partition 与 4 个执行名额相互独立。
options.UsePartitionKey(item => item.ProductId, partitionCount: 8);
});
partitionCount 决定请求分布在哪些队列中,相同键值始终路由到同一 Partition。MaxConcurrency 限制同一个
批处理服务中同时执行的 Handler 批次数,所有 Partition 共享这些名额。同一 Partition 也可以同时执行多个批次。
用利特尔法则(Little's Law)估算 MaxConcurrency
以查询为例,假设一个 Handler 批次对应一次下游批量查询,且 Handler 的其他耗时可忽略。
此时可使用下游批量调用 QPS 和平均响应时间(RT,单位为秒)估算:
text
平均并发调用数 = 批量调用 QPS × RT
单 Pod MaxConcurrency ≈ ceil(平均并发调用数 / Pod 数量 × 安全系数)
已有功能可以取监控数据,新功能可以先用预期值或压测结果;QPS 和 RT 必须来自同一种操作、同一组配置。
上线后再用实际指标校准。
例如,6 个活跃 Pod 的下游批量查询合计为 1,920 QPS,平均 RT 为 50 ms,
对应的平均并发调用数约为 1,920 × 0.050 = 96。流量均匀分配时,每 Pod 平均约 96 / 6 = 16 个并发调用。
16 是平均负载的估算值,不是直接推荐的并发上限。 设置 MaxConcurrency 时,应结合流量波动、下游限流和连接池容量留出余量,
并通过压测确认延迟和吞吐是否满足要求。
联合调整 BatchSize、Partition 和 MaxConcurrency
沿用上例,原来每次批量查询 20 个不同商品,合计 1,920 × 20 = 38,400 个请求项/秒,跨调用可能重复。
可以试验 BatchSize = 80:假设一次拉到 80 项,Handler 按商品去重后剩 60 个,再按每次 30 个商品并行查询,
等待两次查询完成后,为全部 80 项回填结果。

合并让不同提交共享一次去重机会;拆分让下游每次仍只处理较小的批量。 两者的大小可以分别选择:
BatchSize 限制去重前的请求项数,下游每次查询多少个商品由 Handler 控制。
按上述假设,Handler 每秒处理 38,400 / 80 = 480 批,下游执行 480 × 2 = 960 次查询,共查询
960 × 30 = 28,800 个商品项。与原来相比,调用次数减少 50%,查询商品项数减少 25%,所有请求仍得到结果。

图中假设下游平均 RT 从 50 ms 增至 55 ms,这是估算示例,不是压测结果 。更少的调用和重复查询是潜在收益,
更长的 RT 是代价;是否值得,还要比较端到端延迟和实际资源消耗。
BatchSize = 80 不保证每批有 80 项,也不保证去重后只剩 60 个商品。Handler 应按去重后的实际数量拆分,
例如完全没有重复时,需要查询 30 + 30 + 20 三批。请求只在同一 Pod、同一 Partition 的当前队列中合并,不会额外等待凑满 。
Partition 数量和消费速度都会影响合并效果,应从较少的 Partition 开始压测。
拆分调用时,分别估算两层并发
MaxConcurrency 限制 Handler 批次并发,不直接限制 Handler 内部的下游调用并发。 上述示例中的 Handler 批次 QPS
为 480;估算其并发时,应使用整个 Handler 的平均耗时,包括等待所有子查询、结果回填和作用域释放,而不是单次下游 RT:
text
单 Pod MaxConcurrency ≈ ceil(Handler 批次 QPS × Handler 平均耗时 / Pod 数量 × 安全系数)
还要按最坏拆分数量检查下游并发。以 MaxConcurrency = 4 为例,80 项最多拆成三次并行查询,
同一个 Batcher 就可能同时执行 4 × 3 = 12 次下游调用;多个 Pod 还需合计。

这个 4 只用于说明并发关系,不是上例的推荐配置。若需要严格限制下游调用数,应在 Handler 中为子查询设置共享的并发限制。
用压测验证合并与去重收益
以原来每次批量查询 20 个商品为基线,保持请求数据、到达速率、重复比例、热点分布及下游资源限制一致,
覆盖低流量、正常流量和突发流量,重点比较:
- 工作量:实际每批项数、去重后商品数、下游调用次数和查询总项数(跨批重复查询仍计数)。
- 延迟与吞吐:下游及 Handler 平均 RT、调用方端到端 P95/P99、排队时间、吞吐量及错误和限流情况。
- 资源开销:应用和下游的 CPU、内存分配、连接池占用。

每轮调整后,用实测的 Handler 批次 QPS 和耗时重新估算并发,再复测合并效果。
目标是减少下游工作量,同时满足延迟和资源要求,不以批次越大或下游 QPS 越低为唯一标准。
MaxPendingRequests 控制队列容量,可按 单个批处理服务的请求项数/秒 × 可接受排队时间 估算,
再用突发流量验证接收延迟和内存占用。
Partition Key 路由
Partition Key 是一项可选的路由规则,用于将相关请求路由到同一 Partition:
csharp
options.MaxConcurrency = 4;
options.UsePartitionKey(query => query.ProductId, partitionCount: 8);
值相同的有限整数数值键,或值相同的非 null 字符串键,会进入指定数量中的同一 Partition。不同键值仍可能落入
同一 Partition,因此键值与 Partition 不是一一对应关系。partitionCount 必须大于 1。
Partition 路由只决定请求进入哪个 Partition:它不会强制同一键值的全部请求进入同一个处理批次,也不会自动去重。
它提供了一个路由边界,不提供执行顺序或互斥保证。
示例:合并重复查询请求
同一批次中的多个 PriceQuery 可能查询相同的 ProductId。Handler 先对商品 ID 去重,再执行一次批量查询,
最后按 ProductId 将结果回填给每个原始请求,包括重复请求。回填不依赖数据库返回顺序。
按 ProductId 路由会让相同商品的请求进入同一 Partition,但不保证进入同一批次。
去重只作用于当前 Handler 批次;不同批次仍可能查询同一个商品,这不是跨批次缓存。
可运行的 PostgreSQL Web API 示例展示了完整流程:
PriceQueryHandler 使用 Distinct() 去重商品 ID,查询后建立商品 ID 到结果的字典,再通过
entries.SetResponses(request => pricesByProductId.GetValueOrDefault(request.ProductId)) 回填结果;商品不存在时返回 null。
路由规则与调度
MaxConcurrency 是全局并发 Handler 批次数上限。未配置 Partition Key 时,内部队列固定使用一个 Partition;配置
数值或字符串 Partition Key 时,Partition 数量由 UsePartitionKey 的 partitionCount 指定,由一个
BatchDispatchLoop 从所有 Partition 拉取。Partition 数量在注册时确定,且不受 CPU 数量限制。
| 配置 | 请求如何路由 | 调度行为 |
|---|---|---|
未配置 UsePartitionKey |
所有请求进入同一个 Partition,避免将无关请求分散到多个队列。 | 批次共享全局执行名额;单个 Partition 可以连续提供多个异步批次。 |
配置 UsePartitionKey(selector, partitionCount) |
有限整数数值键或非 null 字符串键将相同值路由到指定 Partition;不同键仍可能共享 Partition。 |
相同键值的不同 Handler 批次仍可同时分发执行。 |
RequestBatcher 不提供全局、Partition 内或 Partition Key 级别的 Handler 执行顺序保证。Partition Key 只决定队列
路由,不能用于串行化处理。
启用按键路由时,队列会在有数据的 Partition 之间轮转拉取机会,但不保证 Handler 执行时间相等,也不保证不同
键值之间公平。字符串路由使用完整键值,共同前缀本身不会强制不同键值进入同一 Partition。具体分区归属属于
内部实现,可能随版本变化;同一个 Batcher 实例中的相同键值仍会路由到同一 Partition。这些变化可能影响处理批次
的组成,以及 Handler 在单个批次内能够合并的重复工作量。
Handler 会在分发调用链上执行,直到完成或遇到尚未完成的异步等待。在此之前的 CPU 密集计算或同步阻塞会
推迟其他批次的分发,即使仍有空闲执行名额。MaxConcurrency 是并发上限,不会自动将同步 Handler 代码并行化。
处理语义
完成与失败
- Handler 正常完成时,该处理批次中所有调用方的
Task都会成功完成。 - Handler 抛出异常时,这些调用方会收到原始异常。
- RequestBatcher 不会自动重试失败的 Handler。
- 显式批量重载会等待本次提交中的所有请求;只要涉及的某次 Handler 调用失败,最终
Task就会失败。 - 一次显式批量提交可能跨越多个 Handler 调用。如果其中多次调用失败,
await会按普通Task语义抛出其中一个
原始异常;所有不同的异常实例都可以从Task.Exception.InnerExceptions读取。同一次 Handler 异常即使影响
多个请求,也只记录一次。
调用方取消
调用方取消只能移除尚未交给 Handler 的请求。一旦 Handler 开始执行,RequestBatcher 会返回实际处理结果,而不会
将调用方的 Task 改成已取消。这样可以避免取消状态掩盖已经发生的副作用。
传给 Handler 的 CancellationToken 属于 RequestBatcher 自身的处理生命周期,不属于任何一个调用方。因此,
一个调用方取消不会中止同一批中的其他请求。
显式批量提交会将调用方传入的同一个 CancellationToken 应用于组内每个请求。因此取消后可能同时存在"尚未
分发而取消"的请求和"已经分发并继续执行"的请求。返回的 Task 仍会等待全部请求;其中存在失败时最终状态为
失败,否则只要有请求被取消,最终状态就是取消。已经完成的副作用不会回滚。
容量与背压
MaxPendingRequests 是内部 BufferQueue 中仍处于排队状态的请求容量上限。分发循环拉取批次时会自动提交消费进度,
随后才启动 Handler。因此,除了排队容量外,最多还有 MaxConcurrency * BatchSize 个已拉取请求正在执行。它不限制
一次显式提交的请求数量,也不限制正在等待容量的调用方数量:
FullMode = Wait在队列空间不足时异步等待,并支持调用方在等待期间取消。Wait模式下,一组请求的数量不超过MaxPendingRequests时,会等到队列能容纳整组请求后一起入队;超过时,
按输入顺序分段,每段最多MaxPendingRequests个请求,等待队列腾出足够空间后依次入队。
中途取消时,已分发的请求仍会继续处理,尚未分发的请求会被取消。FullMode = Fail在队列剩余空间不足以容纳本次提交的全部请求时,立即拒绝整次提交,包括请求总数超过
MaxPendingRequests的情况。此时返回的Task会失败,异常为RequestBatchQueueFullException,本次提交的请求
均不会入队。
停止与释放
DisposeAsync 会停止接收新请求,处理完已经接收的所有提交(包括仍在等待容量的提交),然后释放协调器持有的资源。
并发或重复调用共享同一次释放操作。通过 AddRequestBatcher 注册时,由应用容器负责异步释放。
依赖注入与日志
AddRequestBatcher 会将 Handler、协调器和内部 BufferQueue topic 注册到现有的 IServiceCollection 中。应用无需
直接配置 BufferQueue,RequestBatcher 也不会创建嵌套的 Service Provider。
Scoped 和 Transient Handler 会在每个处理批次专属的异步作用域中解析一次。Singleton Handler 会跨批次
复用;当 MaxConcurrency > 1 时,它必须保证线程安全。
日志会通过应用现有的 Microsoft.Extensions.Logging 管道写入,类别为
RequestBatchCoordinator<TRequest>。Handler 异常会连同原始 Exception 一起记录,日志不会包含请求内容。
示例应用
完整的批量查询、重复更新合并和版本保护实现,可以参考 PostgreSQL Web API 示例。