弹性与限流
本章说明 Resilience 阶段的截止时间、熔断、固定窗口限流与并发上限,以及如何按操作配置它们。适用于需要保护下游、限制昂贵操作或控制超时的开发者。
框架不提供网关与流量治理模块:按操作 ID 的限制由内核完成,反向代理与 API 网关交给 YARP、Nginx 等专门的组件。
完整 API 见 Kernel 包。
截止时间
调用上下文带一个绝对截止时间 InvocationContext.Deadline(UTC,基于 TimeProvider)。内核的 DeadlineMiddleware 位于 Resilience 阶段开头,到点即取消下游:
| 结果 | 错误码 | HTTP |
|---|---|---|
| 到达截止时间 | KERNEL_TIMEOUT_001 | 504 |
| 截止时间到达之前调用方取消(连接断开等) | KERNEL_CANCELLED_001 | 408 |
截止时间随调用传播:HTTP 用请求头 xihan-deadline(RFC 3339,UTC),gRPC 用 grpc-timeout,命令行用 --timeout;下游调用的截止时间不晚于上游。操作与仓储只需要把 context.CancellationToken 传下去,不必自己计时。context.RemainingTime(now) 与 context.IsExpired(now) 可读剩余时间。
截止时间到了一律报超时,调用方同时取消也一样:DeadlineMiddleware 只看令牌归因,截止令牌已触发即 KERNEL_TIMEOUT_001,截止令牌没有触发、调用方已取消才是 KERNEL_CANCELLED_001。下游抛出的取消,与下游接住取消、自己写下 KERNEL_CANCELLED_001 结果正常返回(不抛异常)的,按同一规则归因。不要在下游用 context.IsExpired(now) 区分超时与取消——定时器按系统节拍触发,可能早于时钟读数到达截止时间,几毫秒的差就会把超时判成取消,晚于时又会在调用方取消时判成超时;写下取消结果,交给 DeadlineMiddleware 归因。框架自己的插件运行时也这样做:进程外插件运行时把 gRPC 的 Cancelled 原样写成取消,Wasm 运行时原样返回插件写下的取消。
DeadlineMiddleware 自己也不看时钟(已定,ADR-0095):截止令牌还没触发时,即使时钟读数已过截止时间,调用方的取消仍是 KERNEL_CANCELLED_001。定时器晚于时钟读数到达(线程池繁忙、定时器合并)而调用方恰在这段通常不超过一个系统节拍的间隙里取消,这类调用报取消;换来的是归因只有令牌一个来源,在注入的 TimeProvider 下可以确定地复现,抛出与写成结果两条路径也不会分成两种回答。
超时的归因与对外结果(ADR-0085、ADR-0088、ADR-0092)
进程外插件运行时(接住 gRPC 的 Cancelled)、Wasm 宿主与运行时、自己接住取消的操作或 Provider,在截止时间到了的调用上一律是 KERNEL_TIMEOUT_001:
| 看哪里 | 截止时间到了 | 调用方在截止时间到达之前取消 |
|---|---|---|
错误码 / Problem Details type | KERNEL_TIMEOUT_001 | KERNEL_CANCELLED_001 |
| HTTP 状态 | 504 | 408 |
| gRPC 状态 | DeadlineExceeded | Cancelled |
| 命令行退出码 | 7(超时) | 8(取消) |
截止时间与调用方取消同时触发(如客户端在截止时间到点前后断开)时,下游抛出的取消与写成结果的取消都报超时(ADR-0092)。
按状态码或错误码决定重试的调用方要按超时处理:截止时间已经用尽,被调方可能已经完成了副作用,非幂等操作重试前带上幂等键(Baggage xihan.idempotency-key,见幂等键)。调用方在截止时间到达之前自己取消(连接断开、取消令牌)是 KERNEL_CANCELLED_001 / 408。熔断按原结果计数:ResilienceMiddleware 在 DeadlineMiddleware 之内,看到的是下游写下的原结果。审计、追踪与访问日志看到的是改写后的超时。
按操作的限制
内核的 ResilienceMiddleware 紧随 DeadlineMiddleware,只作用于带操作描述的调用;宿主自定义端点、消息派发与作业派发不受它约束。每个操作 ID 各持一份限制实例,判定顺序固定为熔断 → 限流 → 并发:
| 限制 | 语义 | 拒绝码 |
|---|---|---|
| 熔断 | 连续 FailureThreshold 次失败即打开 BreakSeconds 秒;到期半开,只放一次试探,成功关闭、失败重新打开 | KERNEL_UNAVAILABLE_004 |
| 限流 | 固定窗口:每 WindowSeconds 秒最多 PermitLimit 次,超出的最多 QueueLimit 个排队到下一窗口,其余拒绝 | KERNEL_UNAVAILABLE_002 |
| 并发 | 同时执行最多 ConcurrencyLimit 次,超出的最多 ConcurrencyQueueLimit 个排队,其余拒绝 | KERNEL_UNAVAILABLE_003 |
只有 KIND 为 UNAVAILABLE、TIMEOUT、UNEXPECTED 的结果与未处理异常算熔断意义上的失败;校验不通过、找不到、无权限这类调用方错误不算。熔断打开期间仍在执行的调用结束时不改变熔断器状态。
拒绝都以 UNAVAILABLE 种类结束调用(HTTP 503),不进入后续阶段,details 带 id(操作 ID);限流与熔断另带 retryAfter(秒,向上取整,至少 1)。排队等待受调用的取消令牌约束,截止时间到了按 KERNEL_TIMEOUT_001 结束,不会无限等待。限流与并发使用 BCL 的 System.Threading.RateLimiting,熔断是内核自己的三态状态机。
配置
配置节 XiHan:Kernel:Resilience:节本身的键是所有操作的默认策略,Operations:<操作 ID> 下的同名键覆盖该操作,缺省的键沿用默认策略。
| 键 | 类型 | 缺省 | 说明 |
|---|---|---|---|
PermitLimit | 正整数 | 不限流 | 每个窗口允许的调用数 |
WindowSeconds | 正数 | 1 | 限流窗口 |
QueueLimit | 非负整数 | 0 | 窗口用尽时排队等待的调用数 |
ConcurrencyLimit | 正整数 | 不限并发 | 同时执行的调用数上限 |
ConcurrencyQueueLimit | 非负整数 | 0 | 并发用尽时排队等待的调用数 |
FailureThreshold | 正整数 | 不熔断 | 连续失败多少次后打开熔断器 |
BreakSeconds | 正数 | 30 | 熔断打开的时长 |
{
"XiHan": {
"Kernel": {
"Resilience": {
"PermitLimit": 1000,
"WindowSeconds": 1,
"ConcurrencyLimit": 64,
"FailureThreshold": 5,
"BreakSeconds": 30,
"Operations": {
"reports.export": { "ConcurrencyLimit": 2, "PermitLimit": 10, "WindowSeconds": 60 }
}
}
}
}
}一个限制都没配置的操作直接透传。非法值(非正数、Operations 下不合文法的操作 ID)在构建应用时抛出 ArgumentException。XiHan:Kernel:UseBuiltInMiddleware=false 时两个内置中间件都不登记,配置节不再读取。
内核范围之外
按调用方(主体、租户、IP)分桶的限流与自适应并发不在内核范围。需要时在 Resilience 阶段末尾登记应用自己的中间件,用 System.Threading.RateLimiting 的 PartitionedRateLimiter 按 context.GetSecurity() 或调用来源分区:
using System.Threading.RateLimiting;
using XiHan.Framework.Kernel.Pipeline;
using XiHan.Framework.Kernel.Security;
using XiHan.Framework.Primitives;
namespace Shop.Throttling;
public sealed class TenantRateLimitMiddleware : IPipelineMiddleware
{
private readonly PartitionedRateLimiter<string> _limiter = PartitionedRateLimiter.Create<string, string>(
tenant => RateLimitPartition.GetFixedWindowLimiter(tenant, static _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromSeconds(1),
}));
public async ValueTask InvokeAsync(InvocationContext context, PipelineStep nextStep)
{
// 租户在 Tenant 阶段之后才解析,这里按调用方自报的 Baggage 分区,只用于限流、不用于授权
var tenant = context.TryGetBaggage(BaggageKeys.Tenant, out var id) ? id : "anonymous";
using var lease = await _limiter.AcquireAsync(tenant, 1, context.CancellationToken);
if (!lease.IsAcquired)
{
context.SetOutcome(Error.Unavailable("SHOP_UNAVAILABLE_002", "shop.throttling.tenant_limited"));
return;
}
await nextStep(context);
}
}HTTP 层面的连接数、请求体大小与反向代理限流仍用 ASP.NET Core 与网关自身的设置。
