ABP vNext 速率限制在多租户场景落地
ABP vNext 速率限制在多租户场景落地
📚 目录
- ABP vNext 速率限制在多租户场景落地
- 1. 背景与目标 🎯
- 架构鸟瞰 🗺️
- 2. 架构与边界 🧱
- 3. 策略设计 🧩
- 请求通过链式限流的判定流程 🔗
- 4. 可复现代码 💻
- 5. 端点粒度与白名单 🧰
- 中间件/策略位置小抄 🧭
- 6. 可观测 📊
- 指标采集链路(OTel → Prom/Grafana)🛰️
- 7. 分布式配额与“自然日对齐” 🗓️
- Redis 日配额(自然日)示意 🧮
- 8. 压测与调参 🧪
- 9. 与 ABP 特性/计费联动 & 回退 🧷
- 10. 安全与稳健性补充 🛡️
1. 背景与目标 🎯
- 痛点:秒级突发⚡️、恶意脚本🤖、重接口堆积⏳与“大租户”挤占公共资源。
- 目标:User → Tenant 两层在全局链路顺序链式校验(任一拒绝即 429);Client 并发用命名策略 只套在重接口,避免误伤轻量端点。
- ASP.NET Core 内置 Fixed / Sliding / TokenBucket / Concurrency 四种 limiter 与 分区(Partitioned) 模型,并支持
PartitionedRateLimiter.CreateChained
顺序组合多个分区限流器(官方链式方式)。
架构鸟瞰 🗺️
2. 架构与边界 🧱
-
入口层限流:限流在 API 入口(中间件/端点策略)执行;服务间流量另用网关/服务侧限流。
-
多租户上下文(ABP):Tenant 解析支持 Header/Query/Route/Cookie/Claims/Domain,默认键名
__tenant
。- 生产若经 Nginx,默认会丢弃带下划线的请求头,需
underscores_in_headers on;
或改用X-Abp-Tenant
(本文代码已兼容)。
- 生产若经 Nginx,默认会丢弃带下划线的请求头,需
-
中间件顺序(要点):如果使用端点级策略(
RequireRateLimiting
/[EnableRateLimiting]
),必须在UseRouting()
之后启用UseRateLimiter()
;若按“用户”限流,建议在UseAuthentication()
之后。上图与代码均按“认证→限流→授权”示范,可按业务权衡调整。
3. 策略设计 🧩
- User 级(TokenBucket):每秒平滑补给(如 1 token/sec,桶 60),吸收个人秒级抖动。
- Tenant 级(Fixed/Sliding):日/月配额;按租户档位差异化。
- Client 级(Concurrency):端点命名策略加在重接口,避免把并发限制施加到所有端点。
- 组合与拒绝:全局链条用
CreateChained(User→Tenant)
;重接口再叠加heavy
并发策略;任一失败即 429,同时写Retry-After
(若 limiter 提供该元数据)。
请求通过链式限流的判定流程 🔗
4. 可复现代码 💻
要点:
- 链式:
CreateChained(userLimiter, tenantLimiter)
;- OnRejected:从
Lease
元数据读Retry-After
,返回 RFC 7807 Problem Details(含traceId
与retryAfter
);- 分区键清洗/限长,防 DoS;
- 并发限流为命名策略,只套在重接口;
- 租户头:优先
__tenant
,兼容X-Abp-Tenant
;- FixedWindow(日) 仅用于单机演示,生产换 Redis/日期键或滑动窗(第 7 节)。
dotnet add package Microsoft.AspNetCore.RateLimiting
dotnet add package System.Threading.RateLimiting
// Program.cs
using Microsoft.AspNetCore.RateLimiting;
using System.Net.Mime;
using System.Text.Json;
using System.Threading.RateLimiting;
using Volo.Abp.MultiTenancy;
using System.Linq;var builder = WebApplication.CreateBuilder(args);// 控制器(避免 MapControllers() 报空)
builder.Services.AddControllers();builder.Services.Configure<RatePlanOptions>(builder.Configuration.GetSection("RateLimit"));
builder.Services.AddSingleton<ITenantPlanProvider, InMemoryTenantPlanProvider>();builder.Services.AddRateLimiter(options =>
{options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;options.OnRejected = async (context, ct) =>{double? retryAfterSec = null;if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var ra)){retryAfterSec = ra.TotalSeconds;context.HttpContext.Response.Headers.RetryAfter = ((int)ra.TotalSeconds).ToString();}context.HttpContext.Response.ContentType = MediaTypeNames.Application.ProblemJson;var body = new{type = "about:blank",title = "Too Many Requests",status = 429,detail = "Rate limit exceeded",instance = context.HttpContext.Request.Path.ToString(),traceId = context.HttpContext.TraceIdentifier,retryAfter = retryAfterSec};await context.HttpContext.Response.WriteAsync(JsonSerializer.Serialize(body), ct);};// --- 全局链条 1) User(TokenBucket:每秒补给) ---var userLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>{var userId = SanitizeKey(ctx.Request.Headers["X-User-Id"].ToString(), "anon");var tenantId = GetTenantId(ctx);var plan = ctx.RequestServices.GetRequiredService<ITenantPlanProvider>().Get(tenantId);return RateLimitPartition.GetTokenBucketLimiter($"U:{userId}", _ => new TokenBucketRateLimiterOptions{TokenLimit = Math.Max(1, plan.UserBurstPerMin), // 例如 60TokensPerPeriod = 1, // 每秒补给 1ReplenishmentPeriod = TimeSpan.FromSeconds(1),AutoReplenishment = true,QueueLimit = 0});});// --- 全局链条 2) Tenant(日配额:FixedWindow,演示用;生产换 Redis/日期键或滑动窗) ---var tenantLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>{var tenantId = GetTenantId(ctx);var plan = ctx.RequestServices.GetRequiredService<ITenantPlanProvider>().Get(tenantId);return RateLimitPartition.GetFixedWindowLimiter($"T:{tenantId}:D", _ => new FixedWindowRateLimiterOptions{PermitLimit = plan.DailyQuota,Window = TimeSpan.FromDays(1), // 注意:窗口锚点为创建时刻,非自然日AutoReplenishment = true,QueueLimit = 0});});// 使用官方链式把 User→Tenant 串起来options.GlobalLimiter = PartitionedRateLimiter.CreateChained(userLimiter, tenantLimiter);// --- 端点命名策略:Client 并发,仅给重接口使用 ---options.AddPolicy("heavy", httpContext =>{var client = SanitizeKey(httpContext.Request.Headers["X-Client-Id"].ToString(), "default");var tenant = GetTenantId(httpContext);var plan = httpContext.RequestServices.GetRequiredService<ITenantPlanProvider>().Get(tenant);return RateLimitPartition.GetConcurrencyLimiter($"C:{client}", _ => new ConcurrencyLimiterOptions{PermitLimit = plan.ClientConcurrency,QueueLimit = 200,QueueProcessingOrder = QueueProcessingOrder.OldestFirst});});
});var app = builder.Build();// ✅ 建议顺序:认证→限流→授权(端点级策略需在 Routing 之后)
app.UseRouting();
app.UseAuthentication();
app.UseMultiTenancy(); // ABP,一般放在认证之后
app.UseRateLimiter(); // 尽早拦截超量请求
app.UseAuthorization();// 重接口套“heavy”并发策略
var heavy = app.MapGroup("/api/orders").RequireRateLimiting("heavy");
heavy.MapGet("/", () => Results.Ok(new { ok = true }));app.MapControllers();
app.Run();// —— 辅助:多租户与分区键清洗 —— //static string GetTenantId(HttpContext ctx)
{// 兼容:优先 __tenant(ABP 默认),次选 X-Abp-Tenant(Nginx 下划线问题)var h = ctx.Request.Headers;var candidate = h["__tenant"].ToString();if (string.IsNullOrWhiteSpace(candidate))candidate = h["X-Abp-Tenant"].ToString();if (!string.IsNullOrWhiteSpace(candidate))return SanitizeKey(candidate, "anon-tenant");var current = ctx.RequestServices.GetService<ICurrentTenant>();return current?.Id?.ToString() ?? "anon-tenant";
}static string SanitizeKey(string? raw, string fallback, int maxLen = 64)
{if (string.IsNullOrWhiteSpace(raw)) return fallback;var cleaned = new string(raw.Where(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.').ToArray());if (string.IsNullOrEmpty(cleaned)) return fallback;return cleaned.Length <= maxLen ? cleaned : cleaned[..maxLen];
}// 档位配置(IOptionsMonitor 支持热更新)
public record RatePlanOptions(int DefaultDailyQuota, int DefaultUserBurstPerMin, int DefaultClientConcurrency);
public interface ITenantPlanProvider { TenantPlan Get(string tenantId); }
public record TenantPlan(int DailyQuota, int UserBurstPerMin, int ClientConcurrency);public sealed class InMemoryTenantPlanProvider : ITenantPlanProvider
{private readonly IOptionsMonitor<RatePlanOptions> _opt;public InMemoryTenantPlanProvider(IOptionsMonitor<RatePlanOptions> opt) => _opt = opt;public TenantPlan Get(string tenantId){var o = _opt.CurrentValue;// 生产可改为 DB/缓存按租户档位返回return new(o.DefaultDailyQuota, o.DefaultUserBurstPerMin, o.DefaultClientConcurrency);}
}
5. 端点粒度与白名单 🧰
- 重接口“更严”:将耗时/热点端点编组到
heavy
,叠加并发限制;静态资源、健康检查、Swagger 可[DisableRateLimiting]
放行。 - 应用方式:
MapGroup(...).RequireRateLimiting("heavy")
或控制器/Action 用[EnableRateLimiting("heavy")]
/[DisableRateLimiting]
。
中间件/策略位置小抄 🧭
6. 可观测 📊
aspnetcore.rate_limiting.requests
(请求尝试获取租约次数)aspnetcore.rate_limiting.queued_requests
(排队中的请求数)aspnetcore.rate_limiting.request.time_in_queue
(排队等待时长)aspnetcore.rate_limiting.request_lease.duration
(租约持有时长)aspnetcore.rate_limiting.active_request_leases
(活跃租约数)
看板建议
- 维度:policy / endpoint / tenant;
- 关注:允许/排队/拒绝率、
time_in_queue
p95/p99、活跃租约与并发占用; - 告警:稳定期 429≈0;突发期 3–5%;重接口队列不“锯齿”。
指标采集链路(OTel → Prom/Grafana)🛰️
7. 分布式配额与“自然日对齐” 🗓️
重点:内置 limiter 计数默认进程内。多实例下 Tenant 日/月配额必须外部化(Redis/DB)。并且 FixedWindow(TimeSpan.FromDays(1)) 的“1 天窗口锚点=创建时刻”,并非自然日 00:00。
建议实现:
- 日配额:Redis
INCR
+EXPIRE
,key 形如quota:{tenant}:{yyyyMMdd}
(自然日对齐),或使用滑动窗口; - 秒级突发是否外部化:视一致性要求决定(允许轻微误差可用本地桶,严格一致可用 Redis/Lua 令牌桶)。
Redis 日配额(自然日)示意 🧮
Redis Lua 令牌桶(示例,节选)
-- KEYS[1] = bucket key
-- ARGV = max_tokens, refill_tokens, refill_period_ms, cost
local key = KEYS[1]
local max = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local period = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or max
local ts = tonumber(data[2]) or redis.call('PTIME')
local now = redis.call('PTIME')
local elapsed = now - ts
local add = math.floor(elapsed / period) * refill
tokens = math.min(max, tokens + add)if tokens >= cost thentokens = tokens - costredis.call('HMSET', key, 'tokens', tokens, 'ts', now)redis.call('PEXPIRE', key, period * 2)return {1, tokens}
elseredis.call('HMSET', key, 'tokens', tokens, 'ts', now)redis.call('PEXPIRE', key, period * 2)return {0, tokens}
end
8. 压测与调参 🧪
与服务端一致:用 Header 传
__tenant
(或X-Abp-Tenant
)、X-User-Id
、X-Client-Id
;未启用认证时也能分桶。
// rate-limit-multi-tenant.js
import http from 'k6/http';
import { check, sleep } from 'k6';export let options = {stages: [{ duration: '10s', target: 200 }, // 突发{ duration: '3m', target: 200 }, // 稳定{ duration: '30s', target: 0 } // 退潮]
};const TENANT = __ENV.TENANT_ID || 't-acme';export default function () {const u = __ITER % 50 === 0 ? `hot-${__VU}` : `u-${__VU}`; // 制造热点const headers = {'__tenant': TENANT, // 或 'X-Abp-Tenant': TENANT'X-Client-Id': 'web','X-User-Id': u};const res = http.get('https://localhost:5001/api/orders', { headers });check(res, { 'not 429': r => r.status !== 429 });sleep(0.2);
}
调参范式
- User/TokenBucket:设定“每用户可接受行为”(如 60/min),观察
request.time_in_queue
p95; - Tenant/配额:按套餐与历史 DAU/QPS,优先滑动窗或 Redis/日期键;
- Client/Concurrency(heavy):盯
queued_requests
与端点 p95,避免长队列; - 告警阈值:稳定期 429≈0;突发期 3–5%;生产变更走灰度 + 看板盯数。
9. 与 ABP 特性/计费联动 & 回退 🧷
- Feature/Setting:为租户下发不同档位(
Tenant:DailyQuota
、User:BurstPerMin
、Client:Concurrency
),支持灰度与一键回退。 - 计费/风控:超额触发加价或限流加强;异常拒绝率触发降级/告警。
- 故障回退:Redis 不可用或策略热更新失败时,回落到安全默认策略。
10. 安全与稳健性补充 🛡️
- 分区键基数与输入来源:分区键应来自可信身份(认证用户 ID、注册的 ClientId);对外部键做清洗/限长/白名单,匿名流量可“合桶”,以防用户输入分区键导致的 DoS。
- DDoS 与限流的边界:限流不是 DDoS 全解;需叠加 WAF/CDN/云防护。
- 并发限流与 Retry-After:并发 limiter 通常不提供可预测的
Retry-After
,响应体应兼容无该字段(上面的 ProblemDetails 已兼容)。