ABP vNext 速率限制在多租户场景落地



1. 背景与目标 🎯

  • 痛点:秒级突发⚡️、恶意脚本🤖、重接口堆积⏳与“大租户”挤占公共资源。
  • 目标User → Tenant 两层在全局链路顺序链式校验(任一拒绝即 429);Client 并发命名策略 只套在重接口,避免误伤轻量端点。
  • ASP.NET Core 内置 Fixed / Sliding / TokenBucket / Concurrency 四种 limiter分区(Partitioned) 模型,并支持 PartitionedRateLimiter.CreateChained 顺序组合多个分区限流器(官方链式方式)。

架构鸟瞰 🗺️

ASP.NET Core Pipeline
HTTP
UseAuthentication
UseRouting
UseMultiTenancy(ABP)
UseRateLimiter
UseAuthorization
Endpoints/Controllers
客户端/调用方
API 网关/反向代理
Kestrel/ASP.NET Core
中间件管线
应用服务/领域服务

2. 架构与边界 🧱

  • 入口层限流:限流在 API 入口(中间件/端点策略)执行;服务间流量另用网关/服务侧限流。

  • 多租户上下文(ABP):Tenant 解析支持 Header/Query/Route/Cookie/Claims/Domain默认键名 __tenant

    • 生产若经 Nginx,默认会丢弃带下划线的请求头,需 underscores_in_headers on; 或改用 X-Abp-Tenant(本文代码已兼容)。
  • 中间件顺序(要点):如果使用端点级策略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 提供该元数据)。

请求通过链式限流的判定流程 🔗

收到请求
User TokenBucket
是否可获取?
返回 429
附带 Retry-After(若有)
Tenant 配额
是否可获取?
返回 429
附带 Retry-After(若有)
端点是否标记 heavy?
通过 -> 控制器
Client 并发许可
是否可获取?
返回 429
(并发通常无 Retry-After)
通过 -> 控制器

4. 可复现代码 💻

要点:

  • 链式CreateChained(userLimiter, tenantLimiter)
  • OnRejected:从 Lease 元数据读 Retry-After,返回 RFC 7807 Problem Details(含 traceIdretryAfter);
  • 分区键清洗/限长,防 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]

中间件/策略位置小抄 🧭

UseRouting UseAuthentication UseMultiTenancy UseRateLimiter UseAuthorization MapEndpoints 路由后可解析端点 认证后载入租户上下文 认证 & 多租户之后尽早限流 过限流再授权 端点映射 + 命名策略套用 UseRouting UseAuthentication UseMultiTenancy UseRateLimiter UseAuthorization MapEndpoints

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)🛰️

Rate Limiting Middleware
aspnetcore.rate_limiting.*
OpenTelemetry Exporter
Prometheus
Grafana Dashboard

7. 分布式配额与“自然日对齐” 🗓️

重点:内置 limiter 计数默认进程内。多实例下 Tenant 日/月配额必须外部化(Redis/DB)。并且 FixedWindow(TimeSpan.FromDays(1)) 的“1 天窗口锚点=创建时刻”,并非自然日 00:00

建议实现

  • 日配额:Redis INCR + EXPIRE,key 形如 quota:{tenant}:{yyyyMMdd}(自然日对齐),或使用滑动窗口
  • 秒级突发是否外部化:视一致性要求决定(允许轻微误差可用本地桶,严格一致可用 Redis/Lua 令牌桶)。

Redis 日配额(自然日)示意 🧮

首次
请求到达
生成 key: quota:{tenant}:{yyyyMMdd}
INCR key
计数 > 限额?
返回 429
允许通过
EXPIRE key=当日剩余秒

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-IdX-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:DailyQuotaUser:BurstPerMinClient:Concurrency),支持灰度与一键回退。
  • 计费/风控:超额触发加价或限流加强;异常拒绝率触发降级/告警。
  • 故障回退:Redis 不可用或策略热更新失败时,回落到安全默认策略。

10. 安全与稳健性补充 🛡️

  • 分区键基数与输入来源:分区键应来自可信身份(认证用户 ID、注册的 ClientId);对外部键做清洗/限长/白名单,匿名流量可“合桶”,以防用户输入分区键导致的 DoS
  • DDoS 与限流的边界:限流不是 DDoS 全解;需叠加 WAF/CDN/云防护。
  • 并发限流与 Retry-After:并发 limiter 通常不提供可预测的 Retry-After,响应体应兼容无该字段(上面的 ProblemDetails 已兼容)。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐