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

多租户配额与预算:限额、配额周期与突发桶的结算模型(Final)

多租户配额与预算:限额、配额周期与突发桶的结算模型(Final)✨


📚 目录

  • 多租户配额与预算:限额、配额周期与突发桶的结算模型(Final)✨
    • TL;DR 🧭
    • 1) 限流 vs. 配额:职责与协同 🧩
    • 2) 术语与模型 📘
    • 3) 架构与分层(ABP 模块化)🏗️
    • 4) 数据与键 🗃️
      • 4.1 PostgreSQL(要点)
      • 4.2 ER 模型 📐
      • 4.3 Redis 键(**集群友好**,哈希标签同槽)
    • 5) 执行路径(请求→计量→结算)🔁
    • 6) ASP.NET Core:成本化限流(**顺序/分区键/动态 Retry-After**)⚙️
    • 7) 原子扣费 Lua(**同槽、统一时钟、毫单位、首调初始化、防负数、防回拨、整数化、返回缺口**)🧮
    • 8) Quota API(**动态 `Retry-After`、长整型安全解析、rate<=0 兜底、结构化日志**)🧰
    • 9) HTTP 语义与客户端退避(**明确规则**)📨
    • 10) 管理后台与可观测(SLO / 录制规则)📊
    • 11) 可复现环境 🐳
    • 12) 压测(k6)🧪
    • 13) 高可用与风控 🛡️


TL;DR 🧭

限流(秒/分级)≠ 配额/预算(日/月/账期)。生产落地通常二者协同:入口用 ASP.NET Core Rate Limiter(固定窗/滑动窗/令牌桶/并发)抑制尖峰;后台以 Redis(原子扣费+滑动窗) + Postgres(账本+结算) 约束账期总量;突发桶用于短时超前消费、按秒线性补给。本文提供 集群友好 Lua(统一 Redis 时钟、哈希标签同槽、毫单位、首调初始化、防负数、防时间回拨、整数化补给)、动态 Retry-AfterSLO/录制规则k6 压测脚本,开箱即用。


1) 限流 vs. 配额:职责与协同 🧩

限流: 秒/分
配额: 日/月/账期
客户端请求
入口守门
Rate Limiter
固定窗 / 滑动窗 / 令牌桶 / 并发
应用逻辑
后台记账
Redis 原子扣费 + 账本
Budget / Burst / Settlement
可观测与告警
突发桶 Legend:
容量=上限, 补给=速率
  • 限流:保护瞬时容量——固定窗、滑动窗、令牌桶、并发限制。
  • 配额/预算:管账期总量——结算、滚存、超用处理(软降级/硬封顶)。
  • 协同:入口“速率守门”+ 后台“总额记账”;突发桶(burst)≈ 令牌桶的容量 + 补给速率语义。
  • 跨时区账期:不少服务“每日配额按 PT 午夜重置”;自研系统需明确账期时区并在 UI 标注。🕰️

2) 术语与模型 📘

  • QuotaPlanbase_quotarefill_rateburst_capacityperiod(day|month|rolling:n)
  • Budgetbase + carry_in - used
  • BurstBucket:容量 capacity,按 refillRate(单位/秒)线性补给
  • Cost:请求成本(读取=1,导出=5 …),兼容“成本化限流”

3) 架构与分层(ABP 模块化)🏗️

Observability
Ledger/Settlement
Realtime
Edge/API
同槽键/统一时钟
滑动窗埋点
幂等明细
AOF/主从/哨兵
备份/WAL/归档
Prometheus Exporter
Grafana/SLO 告警
PostgreSQL
应用服务
结算作业
Lua 原子扣费
Redis
API / ASP.NET Core
Client
Rate Limiter 中间件
  • 实时计量(Redis):余额/突发 原子扣费(Lua,Redis TIME 统一时钟,毫单位整数);ZSET 滑动窗观测速率。
  • 账本与结算(PostgreSQL/SQL Server)usage_ledgerusage_dailysettlement
  • 入口执行(ASP.NET Core Rate Limiter):按端点/分区配置令牌桶/滑动窗/固定窗/并发;“成本化限流”。
  • 多租户穿透(ABP)ICurrentTenant.Id + feature 作为记账/限流主键;无租户直接拒绝计量(防穿透)。

4) 数据与键 🗃️

4.1 PostgreSQL(要点)

-- 推荐 BIGINT 存毫单位
-- usage_ledger:保证 (tenant_id, feature, trace_id) 唯一,重试幂等
-- 其它表:quota_plan / quota_assignment / usage_daily / settlement

4.2 ER 模型 📐

QUOTA_PLANuuidplan_idPKtextnamebigintbase_quotabigintrefill_ratebigintburst_capacitytextperiodQUOTA_ASSIGNMENTuuidtenant_iduuidplan_idFKtimestamptzstart_attimestamptzend_atjsonbfeaturesUSAGE_LEDGERUSAGE_DAILYSETTLEMENT被分配产生明细汇总结算入账

4.3 Redis 键(集群友好,哈希标签同槽)

  • q:{<tenant>:<feature>}:balance —— 账期预算余额(毫单位,long)
  • q:{<tenant>:<feature>}:burst —— 突发桶余额(毫单位,long)
  • q:{<tenant>:<feature>}:burst:last —— 上次补给秒戳(long)

🧠 Lua 多键操作必须同槽;靠 {…} 标签把相关键固定到同槽。


5) 执行路径(请求→计量→结算)🔁

ClientAPI (ASP.NET Core)Redis (Lua)Postgres (Ledger)POST /api/quota/check-and-consume (tenant/feature/cost/traceId)EVALSHA atomic_consume.lua (balance/burst/lastRefill){ok, used, burstUsed, deficit}INSERT usage_ledger (幂等 trace_id)200 { ok:true, used, burstUsed, ... }429 { ok:false, reason:"throttled", retryAfterSec }\n(客户端尊重 Retry-After 或指数退避)alt[ok == 1][不足]ClientAPI (ASP.NET Core)Redis (Lua)Postgres (Ledger)
  • 关账:carry_out = clamp(base + carry_in - used, 0, base * roll_cap_ratio);重置 balance/burst。📅
  • 🔐 TTL 建议:关账/重置时为 balance/burst/lastRefill 设置“账期+缓冲(例如+3天)”的 TTL,避免历史键长期残留。

6) ASP.NET Core:成本化限流(顺序/分区键/动态 Retry-After)⚙️

var builder = WebApplication.CreateBuilder(args);builder.Services.AddRateLimiter(options =>
{options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;options.AddPolicy("cost-export-5", httpContext =>{// 分区键:TenantId + Feature(如需更细,追加 UserId;保持租户级即可)var tenant = httpContext.RequestServices.GetService<ICurrentTenant>();var tenantKey = tenant?.Id?.ToString() ?? "host";var partitionKey = $"{tenantKey}:export";return RateLimitPartition.GetTokenBucketLimiter(partitionKey,_ => new TokenBucketRateLimiterOptions{TokenLimit = 50,                // 突发容量TokensPerPeriod = 10,           // 每秒补给ReplenishmentPeriod = TimeSpan.FromSeconds(1),AutoReplenishment = true,QueueLimit = 0,QueueProcessingOrder = QueueProcessingOrder.OldestFirst});});options.OnRejected = async (ctx, _) =>{if (ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retry))ctx.HttpContext.Response.Headers.RetryAfter = ((int)retry.TotalSeconds).ToString();await Task.CompletedTask;};
});var app = builder.Build();/* ℹ️ 说明:* 最小宿主下并非总要显式 UseRouting,但当你在“端点级”使用* RequireRateLimiting(...) 时,要确保 UseRateLimiter() 在路由之后。*/
app.UseRouting();
app.UseRateLimiter();app.MapPost("/export", () => Results.Ok()).RequireRateLimiting("cost-export-5");app.Run();

7) 原子扣费 Lua(同槽、统一时钟、毫单位、首调初始化、防负数、防回拨、整数化、返回缺口)🧮

-- KEYS[1]=balanceKey  KEYS[2]=burstKey  KEYS[3]=lastRefillKey
-- ARGV[1]=cost_milli  ARGV[2]=refillRate_milli_per_sec  ARGV[3]=burstCapacity_milli
-- 返回:{ok(0/1), used_milli, burst_used_milli, deficit_milli}local cost   = tonumber(ARGV[1])
local rate   = tonumber(ARGV[2])
local cap    = tonumber(ARGV[3])-- 1) 统一使用 Redis 服务器时间
local t = redis.call('TIME')
local now = tonumber(t[1])-- 2) 初始化/补给 lastRefill;首调把 burst 设满,避免 DECRBY 写负数
local lastVal = redis.call('GET', KEYS[3])
local last = tonumber(lastVal or now)
if not lastVal thenredis.call('SET', KEYS[3], now)if redis.call('EXISTS', KEYS[2]) == 0 thenredis.call('SET', KEYS[2], cap)end
elselocal dt = now - lastif dt < 0 then dt = 0 end                 -- ✅ 防时钟回拨if dt > 0 thenlocal cur  = tonumber(redis.call('GET', KEYS[2]) or cap)local add  = math.floor(dt * rate)      -- ✅ 明确整数化补给local next = cur + add; if next > cap then next = cap endredis.call('SET', KEYS[2], next)redis.call('SET', KEYS[3], now)end
end-- 3) 读取余额与突发
local bal   = tonumber(redis.call('GET', KEYS[1]) or 0)
local burst = tonumber(redis.call('GET', KEYS[2]) or cap)-- 4) 优先用余额
if bal >= cost thenredis.call('DECRBY', KEYS[1], cost)return {1, cost, 0, 0}
end-- 5) 余额 + 突发
local need = cost - bal
if need <= burst thenif bal > 0 then redis.call('SET', KEYS[1], 0) endredis.call('DECRBY', KEYS[2], need)return {1, bal, need, 0}
end-- 6) 不足:不扣,返回缺口(用于动态 Retry-After)
local deficit = need - burst
return {0, 0, 0, deficit}

8) Quota API(动态 Retry-After、长整型安全解析、rate<=0 兜底、结构化日志)🧰

[ApiController, Route("api/quota")]
public class QuotaController : ControllerBase
{private readonly IDatabase _redis;private readonly QuotaLedger _ledger;private readonly LoadedLua _lua; // 预加载脚本 SHAprivate readonly ILogger<QuotaController> _logger;[HttpPost("check-and-consume")]public async Task<IActionResult> Consume([FromBody] ConsumeDto dto){var keyBase    = $"q:{{{dto.TenantId}:{dto.Feature}}}";var balance    = (RedisKey)($"{keyBase}:balance");var burst      = (RedisKey)($"{keyBase}:burst");var lastRefill = (RedisKey)($"{keyBase}:burst:last");// 例:20 u/s 与 2000 u 的突发(单位:毫)long rate = 20_000L;     // 20/s  -> 20,000 milli/slong cap  = 2_000_000L;  // 2,000 -> 2,000,000 millivar rr = (RedisResult[])await _redis.ScriptEvaluateAsync(_lua.Sha,new RedisKey[]   { balance, burst, lastRefill },new RedisValue[] { dto.Cost, rate, cap });long ok        = (long)rr[0];long used      = (long)rr[1];long burstUsed = (long)rr[2];long deficit   = (long)rr[3];if (ok == 1){await _ledger.Append(dto, used: used, burstUsed: burstUsed); // ✅ 全链路 longreturn Ok(new { ok = true, used, burstUsed });}// 动态 Retry-After:缺口 / 每秒速率(向上取整);rate 兜底int retrySec = (rate <= 0) ? 60 : Math.Max(1, (int)Math.Ceiling(deficit / (double)rate));Response.Headers.RetryAfter = retrySec.ToString();// 🧾 结构化日志(便于审计/回放)_logger.LogWarning("Quota throttled: tenant={TenantId} feature={Feature} deficit={Deficit} retry={RetrySec}s trace={TraceId}",dto.TenantId, dto.Feature, deficit, retrySec, dto.TraceId);return StatusCode(StatusCodes.Status429TooManyRequests,new { ok = false, reason = "throttled", retryAfterSec = retrySec });}
}

9) HTTP 语义与客户端退避(明确规则)📨

  • 临时超限(速率或余额暂不足):返回 429,务必带 Retry-After(秒或绝对时间)。
  • 本账期硬封顶(直到下期才恢复):返回 403 并在响应体标注 "reason":"quota_exhausted"
  • 避免 402Payment Required 为预留/不通行。
  • 客户端建议:优先尊重 Retry-After固定等待;无该头时采用指数退避(如 1s → 2s → 4s … 上限 60s)。⏳

10) 管理后台与可观测(SLO / 录制规则)📊

指标
quota_balance_milli
burst_balance_milli
quota_used_total
rate_limiter_rejected_total
ledger_lag_seconds
管理后台
配额分配
套餐管理
余额/使用率仪表
突发桶水位
Top Feature 消耗
429/403 命中率

指标(Prometheus)

  • quota_balance_milli{tenant,feature}(Gauge)
  • burst_balance_milli{tenant,feature}(Gauge)
  • quota_used_total{tenant,feature}(Counter)
  • rate_limiter_rejected_total{tenant,feature}(Counter)
  • ledger_lag_seconds(Gauge)

录制规则(Recording Rules,遵循 level:metric:operations 命名)

📝 账期窗口对齐提醒:例如账期为“自然月”,请使用当期 period 视图或标签对齐窗口,避免简单 30d 与账期错位。可在导出指标时附 period_id/period_start 标签;也可按日汇总后由结算任务聚合。

groups:
- name: quota-recordingrules:- record: tenant_feature:quota_used_rate_5mexpr: sum by (tenant, feature) (rate(quota_used_total[5m]))- record: tenant_feature:http_429_ratio_5mexpr: sum by (tenant, feature) (rate(http_requests_total{status="429"}[5m]))/ sum by (tenant, feature) (rate(http_requests_total[5m]))

告警示例

# 剩余额度 < 20%
# 如有 period 标签,可在 Recording 时按 period 过滤/聚合,避免与账期错位
quota_balance_milli/ (quota_balance_milli + increase(quota_used_total[30d])) < 0.2# 5 分钟内 429 比例 > 5%
tenant_feature:http_429_ratio_5m > 0.05

11) 可复现环境 🐳

version: "3.8"
services:redis:image: redis:7command: ["redis-server", "--appendonly", "yes"]ports: ["6379:6379"]pg:image: postgres:16environment:POSTGRES_PASSWORD: devPOSTGRES_USER: devPOSTGRES_DB: quotaports: ["5432:5432"]

初始化提示

  • 关账作业:期初设置 balance = base + carry_inburst = burst_capacity;并为三键设置 TTL = 账期结束时间 + 缓冲
  • usage_ledger(tenant_id, feature, trace_id) 建唯一索引,保障重试幂等;
  • 长整型贯穿全链路(DB→Lua→C#)。
  • (极端数值)若单账户额度理论可超 9e15 毫单位,需评估 Lua 双精度的边界;一般业务不会触达。

12) 压测(k6)🧪

import http from 'k6/http';
import { check, sleep } from 'k6';export const options = {vus: 100, duration: '2m',thresholds: { http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<300'] },
};export default function () {const tenant = __VU % 10;const payload = JSON.stringify({tenantId: `00000000-0000-0000-0000-00000000000${tenant}`,feature: 'export', cost: 5000, traceId: `${__ITER}-${__VU}` // 毫单位});const res = http.post('http://localhost:5000/api/quota/check-and-consume', payload,{ headers: { 'Content-Type': 'application/json' }});check(res, { 'ok or 429/403': r => [200,429,403].includes(r.status) });sleep(0.1);
}

验收清单 📋

  • 自然日/月账期与时区一致;
  • 窗口边界抖动(±1–2s)处理;
  • 重试幂等(trace_id 不重复扣费);
  • 预算曲线、突发水位、429/403 占比与 P95 达标。

13) 高可用与风控 🛡️

  • 热键分摊:按租户×功能切分,必要时旁路估算指标。
  • Redis 可用性:主从/哨兵 + AOF;脚本用服务器时钟降低漂移。
  • 集群键位:所有脚本键使用相同哈希标签 {tenant:feature},避免 CROSSSLOT
  • Shadow Mode:先“只记账不拒绝”,观察一周期;
  • 双写对账:Redis 与 DB 抽样核对,偏差超阈报警;
  • 网关参考:常见“配额 + 限流(burst/rate)”用法可直接映射到你的后台。
http://www.xdnf.cn/news/19324.html

相关文章:

  • 【机械故障】使用扭矩计算物体重量
  • web墨卡托的纬度范围为什么是85°S~85°N?
  • 为何重定义库函数会减少flash体积(从prinf讲解)
  • 为什么计算机使用补码存储整数:补码的本质
  • 【秋招笔试】2025.08.29阿里云秋招笔试题
  • 【Linux】动静态库的制作与原理
  • 第三十二天:数组
  • 刷算法题-数组-02
  • 关于Ctrl+a不能全选的问题
  • Wi-Fi技术——OSI模型
  • VS安装 .NETFramework,Version=v4.6.x
  • React Hooks useMemo
  • [强网杯2019]随便注-----堆叠注入,预编译
  • centos7挂载iscis存储操作记录
  • postman 用于接口测试,举例
  • postman带Token测试接口
  • DAY50打卡
  • Redis 持久化 AOF 与 RDB 的区别
  • Ruoyi-vue-plus-5.x第二篇MyBatis-Plus数据持久层技术:2.1 MyBatis-Plus核心功能
  • audioLDM模型代码阅读(五)—— pipeline
  • Python学习大集合:基础与进阶、项目实践、系统与工具、Web 开发、测试与运维、人工智能(视频教程)
  • 电力电子技术知识学习-----晶闸管
  • VSCode中使用Markdown
  • 从零开始学炒股
  • cordova+umi 创建项目android APP
  • PythonDay42
  • KNN算法常见面试题
  • C数据结构:排序
  • 第25章学习笔记|额外的提示、技巧与技术(PowerShell 实战版)
  • Qt Core 之 QString