多租户平台要同时回答两个问题:“这个请求会不会压垮系统”和”这个客户有没有为这次调用付费”。它们由两套独立机制回答,混为一谈是常见的架构错误:
- 嘈杂邻居靠 Host 层限流兜底:Redis 令牌桶在网关侧短路洪峰;
- 超出套餐靠 PlatformBilling 配额检查计量:按套餐余量决定放行还是拒绝。
BitzOrcas 没有一个横切所有消息的”配额管道”。Mediator 管线里不存在配额拦截行为;[QuotaConsumer] 之类的声明特性也不存在。拦截点只有两处:Host 中间件层与显式的配额服务调用。
两套机制的分工
| 关注点 | 归属 | 典型信号 |
|---|---|---|
| 防刷、防雪崩 | Host RateLimiting | HTTP 429 |
| 套餐内调用次数 | PlatformBilling 配额 | 业务结果里的配额错误 |
| 用量对账 | UsageMeter.Record | /api/platform-billing/usage |
第一步:Host 限流
Api 组合根默认启用限流(RateLimiting:Enabled=true)。RedisTokenBucketRateLimiter 以 Redis 令牌桶实现跨实例一致的全局速率约束,分区策略还包含滑动窗口与固定窗口变体。令牌不足时直接返回 429,请求根本不会进入 Mediator 管线。
第二步:计费配额检查
套餐配额不挂在管道上,而是作为业务语义在应用层显式检查。QuotaService.Check 对照当前套餐与 UsageMeter 余量给出结论:
// Plan 为当前租户生效套餐;UsageMeter 是权威用量事实聚合。var check = quotaService.Check(plan, usageMeter, meterCode: "reporting.advanced_export", quantity: 1);
// 配额不足不是异常:它是可预期的业务拒绝,走稳定错误码而不是抛异常。if (check.IsFailure) return Result.Failure<string>(check.Error);// usageId 由调用方分配,重复提交同一 usageId 不产生第二条事实。var record = RecordUsageCommand.Create( tenantId: currentTenant.Tenant.EffectiveTenantId, usageId: usageId, meterCode: "reporting.advanced_export", quantity: 1, occurredAt: clock.UtcNow);RecordUsageCommand 经 [GenerateEndpoint(HttpRoute.Post, "/api/platform-billing/usage")] 暴露,要求 platform-billing.usage.create 权限;最终由 UsageMeter.Record(usageId, quantity, occurredAt) 把次数写入聚合。
总结
- 429 永远来自 Host 限流层;套餐超额表现为业务层的配额错误结果。前端据此分流重试策略,不要把两者都当服务器故障;
- 配额治理的原子性在数据库(用量聚合的条件更新),不在管道;这保证了用量事实与业务事务各自独立可审计;
- 如果需要按 Feature 维度做运行时开关,那是 License Feature 或 Tenant Feature Entitlement 的职责域,不属于本页机制。