ABP vNext 速率限制在多租户场景落地
本文介绍了如何在 ABP vNext中落地 多租户速率限制:结合 用户 TokenBucket 突发控制、租户日配额(Redis/滑动窗)、重接口并发限制,并通过 CreateChained 链式策略实现多层防护。同时涵盖 中间件顺序、分区键安全、自然日对齐、可观测指标与 k6 压测,帮助 SaaS 场景实现高性能、高可用、可复现的限流体系。
·
ABP vNext 速率限制在多租户场景落地
📚 目录
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), // 例如 60
TokensPerPeriod = 1, // 每秒补给 1
ReplenishmentPeriod = 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 then
tokens = tokens - cost
redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, period * 2)
return {1, tokens}
else
redis.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 已兼容)。
更多推荐
所有评论(0)