- 面向 ASP-NET Core 8/9/10(
System.Threading.RateLimiting与内建中间件) - 聚焦服务端优雅限流与并发控制:可维护、可观测、可测试、可扩展
- 兼顾单机与分布式、网关与应用内、策略与参数选择
一、核心概念与适用场景
- 速率限制(Rate Limiting)
- 约束单位时间内的请求频次或吞吐(如每秒 N 次、每分钟 M 次)。
- 目的:抑制恶意/突发流量、保护下游系统、控制成本。
- 常见策略:固定窗口、滑动窗口、令牌桶。
- 并发限制(Concurrency Limiting)
- 约束同时进行中的请求数量(例如最多并发 200 个)。
- 目的:避免资源争用、线程池/数据库连接耗尽、提升稳定性。
- 应用层 vs 网关层
- 网关层(Nginx/Envoy/API Gateway/Cloudflare)适合粗粒度与分布式限流。
- 应用层(ASP-NET 中间件)适合细粒度、按用户/端点/身份的策略与自定义响应。
选择建议:
- 外部防护与全局流控:优先网关层;内部细粒度与业务化响应:应用层配合。
- CPU/内存敏感的重请求:并发限制优先;突发/恶意流量:速率限制优先。
- 需要突发缓冲但平滑速率:令牌桶;强调时间边界:固定窗口;追求公平与精度:滑动窗口。
二、ASP-NET Core 内建限流中间件概览
ASP-NET Core 7+ 引入 Rate Limiting 中间件,基于 System.Threading.RateLimiting:
- 策略类型
- FixedWindowLimiterOptions(固定窗口)
- SlidingWindowLimiterOptions(滑动窗口)
- TokenBucketRateLimiterOptions(令牌桶)
- ConcurrencyLimiterOptions(并发限制)
- 分区(PartitionedRateLimiter)
- 按用户、IP、租户、端点等维度为不同分区生成独立限流器,互不干扰。
- 配置入口
AddRateLimiter / UseRateLimiter- 命名策略(
AddPolicy + [EnableRateLimiting]/[DisableRateLimiting])
- 自定义拒绝响应
- OnRejected 事件,编写 Retry-After、返回 ProblemDetails 等。
注意:内建中间件是单机进程内限流,分布式需自行实现(如 Redis)或依赖网关。
三、最小可行配置示例(全局速率限制 + 并发限制)
Program.cs(.NET 7/8+):
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
// 全局限流:IP 分区的令牌桶 + 并发限制叠加
builder.Services.AddRateLimiter(options =>
{
options.OnRejected = async (context, token) =>
{
// 构造标准化拒绝响应
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
// 可选:告知下次可重试时间
var retryAfter = TimeSpan.FromSeconds(2);
context.HttpContext.Response.Headers["Retry-After"] = ((int)retryAfter.TotalSeconds).ToString();
await context.HttpContext.Response.WriteAsync("Too many requests", token);
};
// 全局策略:按 IP 分区的令牌桶
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
{
var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetTokenBucketLimiter(
partitionKey: ip,
factory: key => new TokenBucketRateLimiterOptions
{
TokenLimit = 100, // 桶容量(突发)
TokensPerPeriod = 50, // 每周期填充数量(持续速率)
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
AutoReplenishment = true,
QueueLimit = 0, // 不排队,超限直接拒绝
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});
});
// 并发限制(可叠加)
options.AddPolicy("concurrency", httpContext =>
RateLimitPartition.GetConcurrencyLimiter(
partitionKey: "global",
factory: key => new ConcurrencyLimiterOptions
{
PermitLimit = 200, // 最大并发
QueueLimit = 100, // 等待队列长度
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));
});
var app = builder.Build();
app.UseRateLimiter();
// 针对特定端点启用并发策略
app.MapGet("/heavy", async () =>
{
await Task.Delay(500); // 模拟重处理
return Results.Ok("heavy done");
}).RequireRateLimiting("concurrency");
// 测试端点(受全局令牌桶约束)
app.MapGet("/ping", () => "pong");
app.Run();
要点:
- 全局令牌桶防止突发请求冲击。
- 对重型端点叠加并发限制(即使速率不高也可避免同时过多占用资源)。
- OnRejected 保证统一可诊断的 429/503 响应。
四、命名策略与端点级应用
命名策略便于针对不同路由、方法、租户应用不同限流参数。
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("read-sliding", httpContext =>
RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: GetUserOrIp(httpContext),
factory: key => new SlidingWindowLimiterOptions
{
PermitLimit = 300, // 窗口总许可
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6, // 10s 段
QueueLimit = 50,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));
options.AddPolicy("write-fixed", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: GetUserOrIp(httpContext),
factory: key => new FixedWindowLimiterOptions
{
PermitLimit = 60, // 每分钟最多 60 次写
Window = TimeSpan.FromMinutes(1),
QueueLimit = 20,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));
});
app.MapGroup("/api")
.MapGet("/items", GetItems)
.RequireRateLimiting("read-sliding");
app.MapGroup("/api")
.MapPost("/items", CreateItem)
.RequireRateLimiting("write-fixed");
也可使用特性:
using Microsoft.AspNetCore.RateLimiting;
[EnableRateLimiting("write-fixed")]
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase { /* ... */ }
五、分区(Partitioning)设计
分区键建议稳定且可识别,典型维度:
- 用户标识(UserId/Subject)
- API Key / ClientId
- 租户(TenantId)
- 客户端 IP(注意代理场景 X-Forwarded-For)
- 端点维度(RouteId + Method)
示例:
static string GetUserOrIp(HttpContext ctx)
{
var userId = ctx.User?.FindFirst("sub")?.Value;
if (!string.IsNullOrEmpty(userId)) return $"user:{userId}";
var apiKey = ctx.Request.Headers["X-API-Key"].FirstOrDefault();
if (!string.IsNullOrEmpty(apiKey)) return $"key:{apiKey}";
var ip = ctx.Request.Headers["X-Forwarded-For"].FirstOrDefault()
?? ctx.Connection.RemoteIpAddress?.ToString()
?? "unknown";
return $"ip:{ip}";
}
实践要点:
- 统一源识别:在反向代理后要正确处理 X-Forwarded-For。
- 防御伪造:API Key/授权体系的分区优于仅用 IP。
- 分区数量管理:分区过多可能增大内存占用,需配合缓存过期策略(见后述)。
六、策略类型与参数选择
- FixedWindow(固定窗口)
- 优点:实现简单、常用于“每分钟最多 N 次”。
- 缺点:窗口边界附近不公平(窗口重置时可能突发)。
- 参数:PermitLimit、Window、QueueLimit、QueueProcessingOrder。
- SlidingWindow(滑动窗口)
- 优点:更平滑、公平;突发更可控。
- 参数:SegmentsPerWindow(越多越平滑但开销略增)。
- TokenBucket(令牌桶)
- 优点:支持突发(桶容量)、可配置补充速率(每周期填充)。
- 适用于需短时突发但长期速率受控的场景。
- 参数:TokenLimit、TokensPerPeriod、ReplenishmentPeriod、AutoReplenishment、QueueLimit。
- ConcurrencyLimiter(并发限制)
- 限制同时处理的请求数;用于保护 CPU、数据库连接池、线程池。
- 参数:PermitLimit、QueueLimit、QueueProcessingOrder。
队列与拒绝:
QueueLimit=0:超限直接拒绝(更可预测,避免排队导致尾部延迟)。QueueLimit>0:可在短时拥塞时缓冲,但需权衡延迟与公平。- 建议重型端点并发限制 QueueLimit 适度(如 50-100),轻型端点令牌桶
QueueLimit=0。
七、拒绝响应设计与客户端提示
统一拒绝语义:
- 429 Too Many Requests(速率超限)
- 503 Service Unavailable(并发/资源不足)
HTTP 头:
- Retry-After(秒或日期):告知客户端重试时间。
- RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset(IETF 草案):便于客户端自适应。
- 自定义 X-RateLimit-*(兼容历史客户端)。
示例(OnRejected):
options.OnRejected = async (context, token) =>
{
var response = context.HttpContext.Response;
response.StatusCode = StatusCodes.Status429TooManyRequests;
response.Headers["Retry-After"] = "2"; // 简单示意
var problem = new
{
type = "https://httpstatuses.com/429",
title = "Rate limit exceeded",
status = 429,
detail = "Please retry later",
traceId = context.HttpContext.TraceIdentifier
};
response.ContentType = "application/json";
await response.WriteAsJsonAsync(problem, cancellationToken: token);
};
八、观测性与指标埋点
建议以 OpenTelemetry/Meter 记录核心指标:
- 允许数(acquire_success)、拒绝数(acquire_rejected)
- 当前并发(concurrency_in_use)
- 排队长度(queue_length)
- 每分区统计(标签:policy、partitionKey)
示例(简单 Meter):
using System.Diagnostics.Metrics;
var meter = new Meter("MyApp.RateLimiting", "1.0");
var rejectedCounter = meter.CreateCounter<long>("ratelimit_rejected");
var permitCounter = meter.CreateCounter<long>("ratelimit_permits");
builder.Services.AddRateLimiter(options =>
{
options.OnRejected = async (context, token) =>
{
var policy = context.Lease?.ToString() ?? "unknown";
rejectedCounter.Add(1, KeyValuePair.Create<string, object?>("policy", policy));
context.HttpContext.Response.StatusCode = 429;
await context.HttpContext.Response.WriteAsync("Too many requests", token);
};
// 注意:成功获取许可的事件需在业务层记录,因为中间件内部不直接暴露
});
同时启用 ASP-NET Core 日志:
- 分类:
Microsoft.AspNetCore.RateLimiting.RateLimitingMiddleware - 提取 traceId 与策略名,便于关联分析。
九、内存与分区缓存管理
PartitionedRateLimiter 会为不同分区创建限流器。分区数很大时,需控制生命周期。
策略:
- 使用 MemoryCache 为每个分区创建一次限流器,设置滑动过期(无请求一段时间后回收)。
- 将 PartitionedRateLimiter.Create 的 factory 包裹在缓存逻辑中。
示例:
using Microsoft.Extensions.Caching.Memory;
var cache = new MemoryCache(new MemoryCacheOptions
{
SizeLimit = 100_000 // 视规模而定
});
builder.Services.AddRateLimiter(options =>
{
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
{
var key = GetUserOrIp(ctx);
var limiter = cache.GetOrCreate(key, entry =>
{
entry.SlidingExpiration = TimeSpan.FromMinutes(10);
entry.Size = 1;
return new TokenBucketRateLimiter(
new TokenBucketRateLimiterOptions
{
TokenLimit = 200,
TokensPerPeriod = 100,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
AutoReplenishment = true,
QueueLimit = 0,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});
});
return RateLimitPartition.Create(key, _ => limiter);
});
});
十、分布式限流(Redis)示例
内建中间件不跨进程。分布式场景(多实例/容器化)可使用 Redis 实现 Lua 原子脚本。
令牌桶(Redis)示例思路:
- Key:
rl:{partition} - 字段:
- tokens(当前令牌)
- timestamp(上次补充时间)
- 每次请求:
- 依据当前时间补充令牌:
tokens = min(capacity, tokens + elapsed * rate) - 若
tokens >= cost(通常为 1),扣减并允许;否则拒绝。
- 依据当前时间补充令牌:
Lua 伪代码:
local key = KEYS[1]
local capacity = tonumber(ARGV[1]) -- 桶容量
local rate = tonumber(ARGV[2]) -- 每秒令牌
local now = tonumber(ARGV[3]) -- 当前时间戳秒
local cost = tonumber(ARGV[4]) -- 请求消耗令牌数(默认 1)
local data = redis.call("HMGET", key, "tokens", "ts")
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
local elapsed = math.max(0, now - ts)
tokens = math.min(capacity, tokens + elapsed * rate)
if tokens >= cost then
tokens = tokens - cost
redis.call("HMSET", key, "tokens", tokens, "ts", now)
redis.call("EXPIRE", key, 3600)
return {1, tokens} -- 允许
else
redis.call("HMSET", key, "tokens", tokens, "ts", now)
redis.call("EXPIRE", key, 3600)
return {0, tokens} -- 拒绝
end
ASP-NET 中间件接入(自定义 Middleware):
app.Use(async (ctx, next) =>
{
var key = $"rl:{GetUserOrIp(ctx)}";
var allowed = await RedisAllowAsync(key, capacity:200, ratePerSec:100, cost:1);
if (!allowed)
{
ctx.Response.StatusCode = 429;
await ctx.Response.WriteAsync("Too many requests");
return;
}
await next();
});
并发限制(分布式 Semaphore):
- 采用 Redis 有界信号量(SetNX + TTL 或基于 Redlock 的资源锁集合)。
- 进入时申请一个令牌 key,如 sem:{resource}:{id},数量不超过 PermitLimit。
- 退出时删除令牌。崩溃由 TTL 自动回收。
伪实现要点:
- 进入:INCR 计数,如果 > PermitLimit 则 DECR 并拒绝;成功则设置 TTL。
- 退出:DECR;确保幂等(使用请求/连接 id 作为成员,集合去重)。
十一、成本加权限流(Cost-based)
某些请求“机重”不同(轻查询 vs 重写),希望动态消耗不同令牌。内建 RateLimitingMiddleware Acquire 的默认行为是每请求消耗 1 单位。要实现成本加权需自行中间件或使用 Redis 令牌桶的 cost 参数。
示例(自定义中间件 + TokenBucketRateLimiter):
var limiter = new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions
{
TokenLimit = 1000,
TokensPerPeriod = 500,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
AutoReplenishment = true,
QueueLimit = 0
});
app.Use(async (ctx, next) =>
{
var cost = EstimateCost(ctx); // 根据路由/方法/查询参数估算代价
using var lease = await limiter.AcquireAsync(cost, ctx.RequestAborted);
if (!lease.IsAcquired)
{
ctx.Response.StatusCode = 429;
await ctx.Response.WriteAsync("Rate limit exceeded");
return;
}
await next();
});
注意:ASP-NET Core 内建 RateLimitingMiddleware 的命名策略接口不会传入动态成本,需要自建中间件。
十二、与 Polly、YARP、网关的协同
- Polly v8 提供 RateLimit 策略,可在 HttpClient 或委托管道层对下游调用施加限流,与应用入口限流互补。
- YARP(反向代理)可在集群入口实现路由级限流,结合 ASP-NET 应用内策略实现双层防护。
- Nginx/Envoy/API Gateway/Cloudflare:
- 统一接入层限流(基于 IP/路径),抵御恶意流量。
- 应用层细粒度(基于用户/租户)保证业务公平与透明响应。
实践:
- 外层粗粒度 + 内层细粒度,避免双重排队(队列长度总体可控)。
- 共享限流上下文信息(X-RateLimit-* 头、TraceId)便于端到端调试。
十三、测试与基准
- 压测工具:k6、wrk、bombardier
- 验证并发限制效果:同时起多个连接,观察 503/排队。
- 验证速率限制:持续高压,统计 429 比例与延迟分布。
- 集成测试(TestServer)
- 通过 Task.Delay 模拟重型端点,断言拒绝比例。
- 时序行为:控制请求节律,测试滑动窗口精度。
- 观测验证:
- 指标:拒绝数、排队长度、平均延迟、P95/P99。
- 日志:策略名、分区键、traceId。
十四、常见陷阱与规避
- 仅用 IP 作为分区在代理场景不可靠:需处理 X-Forwarded-For,或优先用户/API Key。
- 全局固定窗口在边界突发不公平:对精度敏感用滑动窗口或令牌桶。
- 过度排队导致长尾延迟:QueueLimit 保守;重型端点优先拒绝而非排队。
- 并发限制过小引发吞吐崩溃;过大引发资源耗尽:通过基准测算 PermitLimit。
- 测试环境时钟与补充周期不稳定:令牌桶 AutoReplenishment 依赖定时器,需留出缓冲。
- 大量分区内存膨胀:采用缓存滑动过期;对匿名流量合并分区或启用网关限流。
- 429 与重试风暴:客户端需指数退避;服务端提供清晰 Retry-After。
- 流式/长连接端点(SSE、gRPC streaming):
- 令牌桶只在连接建立时限流,连接存活应采用并发限制或连接数限制。
- 多层限流叠加导致难以调参:统一文档化策略、参数、优先级与覆盖范围。
十五、性能与架构考量
- 中间件开销低,限流器操作为 O(1);但分区查找与缓存需注意热路径优化。
- 在 ThreadPool 压力下,并发限制有助于避免上下文切换与过度排队。
- 与数据库/下游调用结合:并发限制的上限不应超过连接池/资源最大可承载值。
- 配置与代码分离:策略参数放入配置文件/FeatureFlag,支持动态调整(重启或热更新)。
十六、按端点成本模型与权重路由
在复杂系统中,不同端点的单位成本差异较大:
- 建议建立端点权重表(成本估算),结合成本加权令牌桶。
- 重写/导出类端点设置更严格的并发限制与队列上限。
- 对只读高频查询采用滑动窗口或令牌桶,允许短时突发。
示例(端点权重表):
static int EstimateCost(HttpContext ctx)
{
var route = ctx.GetRouteData()?.Values["action"]?.ToString() ?? "default";
return route switch
{
"Export" => 10,
"Report" => 5,
_ => 1
};
}
十七、配额(Quota)与限流结合
限流是瞬时控制,配额是周期总量控制(每日/每月总调用数)。两者需结合:
- 配额检查:在授权后读取 Redis/数据库计数,超过配额直接拒绝。
- 限流:在配额内仍需平滑频率。
简单配额计数:
// 伪代码:每日配额
var key = $"quota:{DateTime.UtcNow:yyyyMMdd}:{GetUserOrIp(ctx)}";
var count = await redis.StringIncrementAsync(key);
await redis.KeyExpireAsync(key, TimeSpan.FromDays(1));
if (count > dailyLimit)
{
ctx.Response.StatusCode = 429;
await ctx.Response.WriteAsync("Daily quota exceeded");
return;
}
十八、代码组织与可维护性
- 中间件层:公共限流与并发控制、统一拒绝响应。
- 端点层:命名策略绑定、特殊端点的额外约束。
- 分区服务:统一获取分区键,内置防御与缓存。
- 配置中心:策略参数与分区规则可配置。
- 观测层:统一指标、日志上下文、TraceId 贯通。
- 测试层:压测脚本与集成测试用例与阈值基线。
十九、参考实现:完整 Program.cs(复合策略)
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
// 观测与配置(省略),假设从配置读取下列值
var globalBurst = 200;
var globalRate = 100;
var heavyConcurrency = 100;
var heavyQueue = 50;
builder.Services.AddRateLimiter(options =>
{
options.OnRejected = async (context, token) =>
{
var res = context.HttpContext.Response;
res.StatusCode = StatusCodes.Status429TooManyRequests;
res.Headers["Retry-After"] = "1";
await res.WriteAsync("Rate limit exceeded", token);
};
// 全局令牌桶(按用户/API-Key/IP)
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
{
var key = GetUserOrIp(ctx);
return RateLimitPartition.GetTokenBucketLimiter(key, _ => new TokenBucketRateLimiterOptions
{
TokenLimit = globalBurst,
TokensPerPeriod = globalRate,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
AutoReplenishment = true,
QueueLimit = 0
});
});
// 读多写少:读滑动窗口,写固定窗口
options.AddPolicy("read", ctx =>
RateLimitPartition.GetSlidingWindowLimiter(GetUserOrIp(ctx), _ => new SlidingWindowLimiterOptions
{
PermitLimit = 300,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6,
QueueLimit = 20
}));
options.AddPolicy("write", ctx =>
RateLimitPartition.GetFixedWindowLimiter(GetUserOrIp(ctx), _ => new FixedWindowLimiterOptions
{
PermitLimit = 60,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 10
}));
// 重型端点并发限制
options.AddPolicy("heavy", ctx =>
RateLimitPartition.GetConcurrencyLimiter("heavy", _ => new ConcurrencyLimiterOptions
{
PermitLimit = heavyConcurrency,
QueueLimit = heavyQueue,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));
});
var app = builder.Build();
app.UseRateLimiter();
app.MapGroup("/api")
.MapGet("/items", GetItems)
.RequireRateLimiting("read");
app.MapGroup("/api")
.MapPost("/items", CreateItem)
.RequireRateLimiting("write");
app.MapGet("/export", async () =>
{
await Task.Delay(1000); // 模拟重型导出
return Results.Ok();
}).RequireRateLimiting("heavy");
app.Run();
static string GetUserOrIp(HttpContext ctx)
{
var userId = ctx.User?.FindFirst("sub")?.Value;
if (!string.IsNullOrEmpty(userId)) return $"user:{userId}";
var apiKey = ctx.Request.Headers["X-API-Key"].FirstOrDefault();
if (!string.IsNullOrEmpty(apiKey)) return $"key:{apiKey}";
var ip = ctx.Request.Headers["X-Forwarded-For"].FirstOrDefault()
?? ctx.Connection.RemoteIpAddress?.ToString()
?? "unknown";
return $"ip:{ip}";
}
static IResult GetItems() => Results.Ok(new[] { "a", "b" });
static IResult CreateItem() => Results.Ok();
二十、生产实践建议清单
- 网关层启用基础限流与 DDoS 防护;应用层实施细粒度策略。
- 明确分区规则与优先顺序(用户/Key > 租户 > IP)。
- 对重型端点设置并发限制与较小队列;轻型端点令牌桶 QueueLimit=0。
- 启用统一拒绝响应与 Retry-After;客户端遵循退避策略。
- 指标与日志必须可视化:拒绝率、排队时长、当前并发、分区分布。
- 限流参数基于压测校准;高峰期支持动态调整(FeatureFlag/配置刷新)。
- 分布式场景采用 Redis/Lua 或网关方案;避免在应用层做复杂分布式锁,保持简单可控。
- 对流式/长连接单独评估并发与连接数限制。
- 文档化策略与端点映射,纳入变更流程与回归测试。
- 针对突发与资源紧张场景进行混沌演练(注入高并发/高流量),验证拒绝与恢复行为。