当前位置: 首页 > news >正文

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 顺序组合多个分区限流器(官方链式方式)。

架构鸟瞰 🗺️

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), // 例如 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]

中间件/策略位置小抄 🧭

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

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 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-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 已兼容)。

http://www.dtcms.com/a/343516.html

相关文章:

  • Leetcode 深度优先搜索 (13)
  • Leetcode 深度优先搜索 (12)
  • 20250821 圆方树总结
  • 通信基础理论
  • C语言基础习题——01
  • plantsimulation小知识25.08.21 对话框的使用方法
  • 深圳大学-计算机信息管理课程实验 C++ 自考模拟题
  • 【LeetCode】18. 四数之和
  • C语言:字符函数与字符串函数(2)
  • ORA-16331: container is not open ORA-06512: at “SYS.DBMS_LOGMNR“
  • Hexo 博客图片托管:告别本地存储,用 PicGo + GitHub 打造高速稳定图床
  • ArcMap 数据框裁剪(Data Frame Clip)操作教程
  • Service方法事务失效的原因是什么?
  • 2025-08-21 Python进阶8——命名空间作用域
  • PiscCode实现MediaPipe 的人体姿态识别:三屏可视化对比实现
  • 算法题Day4
  • WaitForSingleObject函数详解
  • JavaScript 性能优化实战技术文章大纲
  • C++手撕LRU
  • 中国之路 向善而行 第三届全国自驾露营旅游发展大会在阿拉善启幕
  • Webpack的使用
  • 5.Shell脚本修炼手册---Linux正则表达式(Shell三剑客准备启动阶段)
  • AI 时代的 “人机协作”:人类与 AI 如何共塑新生产力
  • 7.Shell脚本修炼手册---awk基础入门版
  • camel中支持的模型与工具
  • 爬虫基础学习-POST方式、自定义User-Agent
  • FCN网络结构讲解与Pytorch逐行讲解实现
  • 小程序个人信息安全检测技术:从监管视角看加密与传输合规
  • 限流技术:从四大限流算法到Redisson令牌桶实践
  • SpringBoot整合HikariCP数据库连接池