多租户配额与预算:限额、配额周期与突发桶的结算模型(Final)✨
📚 目录
- 多租户配额与预算:限额、配额周期与突发桶的结算模型(Final)✨
-
- TL;DR 🧭
- 1) 限流 vs. 配额:职责与协同 🧩
- 2) 术语与模型 📘
- 3) 架构与分层(ABP 模块化)🏗️
- 4) 数据与键 🗃️
- 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-After、SLO/录制规则与 k6 压测脚本,开箱即用。
1) 限流 vs. 配额:职责与协同 🧩
- 限流:保护瞬时容量——固定窗、滑动窗、令牌桶、并发限制。
- 配额/预算:管账期总量——结算、滚存、超用处理(软降级/硬封顶)。
- 协同:入口“速率守门”+ 后台“总额记账”;突发桶(burst)≈ 令牌桶的容量 + 补给速率语义。
- 跨时区账期:不少服务“每日配额按 PT 午夜重置”;自研系统需明确账期时区并在 UI 标注。🕰️
2) 术语与模型 📘
- QuotaPlan:
base_quota
、refill_rate
、burst_capacity
、period(day|month|rolling:n)
- Budget:
base + carry_in - used
- BurstBucket:容量
capacity
,按refillRate
(单位/秒)线性补给 - Cost:请求成本(读取=1,导出=5 …),兼容“成本化限流”
3) 架构与分层(ABP 模块化)🏗️
- 实时计量(Redis):余额/突发 原子扣费(Lua,Redis
TIME
统一时钟,毫单位整数);ZSET 滑动窗观测速率。 - 账本与结算(PostgreSQL/SQL Server):
usage_ledger
→usage_daily
→settlement
。 - 入口执行(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 模型 📐
4.3 Redis 键(集群友好,哈希标签同槽)
q:{<tenant>:<feature>}:balance
—— 账期预算余额(毫单位,long)q:{<tenant>:<feature>}:burst
—— 突发桶余额(毫单位,long)q:{<tenant>:<feature>}:burst:last
—— 上次补给秒戳(long)
🧠 Lua 多键操作必须同槽;靠
{…}
标签把相关键固定到同槽。
5) 执行路径(请求→计量→结算)🔁
- 关账:
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"
。 - 避免 402:
Payment Required
为预留/不通行。 - 客户端建议:优先尊重
Retry-After
做固定等待;无该头时采用指数退避(如 1s → 2s → 4s … 上限 60s)。⏳
10) 管理后台与可观测(SLO / 录制规则)📊
指标(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_in
、burst = 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)”用法可直接映射到你的后台。