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

发布于:2025-09-01 ⋅ 阅读:(18) ⋅ 点赞:(0)

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



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_PLAN uuid plan_id PK text name bigint base_quota bigint refill_rate bigint burst_capacity text period QUOTA_ASSIGNMENT uuid tenant_id uuid plan_id FK timestamptz start_at timestamptz end_at jsonb features USAGE_LEDGER USAGE_DAILY SETTLEMENT 被分配 产生明细 汇总 结算入账

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

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

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


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

Client API (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] [不足] Client API (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 then
  redis.call('SET', KEYS[3], now)
  if redis.call('EXISTS', KEYS[2]) == 0 then
    redis.call('SET', KEYS[2], cap)
  end
else
  local dt = now - last
  if dt < 0 then dt = 0 end                 -- ✅ 防时钟回拨
  if dt > 0 then
    local 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 end
    redis.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 then
  redis.call('DECRBY', KEYS[1], cost)
  return {1, cost, 0, 0}
end

-- 5) 余额 + 突发
local need = cost - bal
if need <= burst then
  if bal > 0 then redis.call('SET', KEYS[1], 0) end
  redis.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; // 预加载脚本 SHA
    private 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/s
        long cap  = 2_000_000L;  // 2,000 -> 2,000,000 milli

        var 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); // ✅ 全链路 long
            return 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-recording
  rules:
  - record: tenant_feature:quota_used_rate_5m
    expr: sum by (tenant, feature) (rate(quota_used_total[5m]))
  - record: tenant_feature:http_429_ratio_5m
    expr: 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:7
    command: ["redis-server", "--appendonly", "yes"]
    ports: ["6379:6379"]
  pg:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: dev
      POSTGRES_USER: dev
      POSTGRES_DB: quota
    ports: ["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)”用法可直接映射到你的后台。