租户标识必须在中间件统一提取并绑定,配额检查应通过AOP式拦截实现,响应需含标准限流头与重试信息,存储采用Redis主计数+MySQL对账双写策略。

租户标识从哪来:必须统一提取逻辑,别在每个接口里手写
ThinkPHP 接口的租户上下文不能靠 $request->param('tenant_id') 随便取——实际场景中,租户标识可能来自请求头(X-Tenant-ID)、JWT payload、子域名(tenant1.example.com),甚至数据库反查(通过 token 关联用户再查所属租户)。如果每个控制器都自己解析一次,极易漏判、错判或绕过。
- 必须在中间件(如
TenantContextMiddleware)中完成统一提取和绑定 - 提取失败时直接返回
400 Bad Request或401 Unauthorized,不往下传空/默认租户 - 提取后应存入
think\Container或think\facade\App::instance()的 request scope,避免全局变量污染 - 切忌在模型或 service 层重复解析——那会导致配额校验点分散、难以审计
配额检查放哪:别塞进业务逻辑,用 AOP 式拦截更可靠
把配额判断写在 UserController::create() 里?不行。一旦新增接口或重构方法,很容易漏加 checkQuota($tenantId, 'user_create'),而且配额策略(比如“每小时最多创建 100 用户”)和业务逻辑耦合后,后续改限流规则就得翻所有控制器。
- 推荐用行为(
behavior)或自定义中间件,在路由调度前完成配额校验 - 校验逻辑应基于抽象资源类型(如
'api_call'、'storage_mb'、'sms_count'),而非具体接口名 - 使用 Redis 原子操作(
INCR+EXPIRE)实现滑动窗口计数,避免 MySQL 行锁拖慢高并发请求 - 注意:Redis key 必须包含租户 ID 和时间窗口,例如
quota:tenant_abc:api_call:2024052014
配额超限怎么响应:别只 throw Exception,要带明确重试线索
常见错误是捕获配额不足后直接抛 HttpException(429),但前端不知道还能不能重试、什么时候能重试、当前用了多少。
- 必须在响应头中返回标准限流字段:
X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset(Unix timestamp) - 响应体 JSON 中需含
code(如"QUOTA_EXCEEDED")、message(如"API call quota exceeded. Reset at 2024-05-20T14:32:18Z")和reset_at字段 - 不要用
sleep()或延迟重试逻辑代替 HTTP 状态码——客户端该由自己决定是否重试 - 特别注意:ThinkPHP 的
Response对象设置 header 要在send()前,否则被忽略
租户配额数据怎么存:MySQL + Redis 双写不是必须,但得有兜底
有人图省事全放 Redis,结果 Redis 故障或重启,配额清零 → 租户白嫖;也有人全走 MySQL,每次请求都 UPDATE quota_log SET used = used + 1,扛不住峰值。
立即学习“PHP免费学习笔记(深入)”;
- 推荐「Redis 主计数 + MySQL 定期对账」:日常计数走 Redis,每小时用定时任务(
php think quota:sync)把 Redis 数据批量落库并校验偏差 - MySQL 表至少含:
tenant_id、resource_type、used、limit、updated_at、last_sync_at - Redis 失效时,自动降级为「只读 MySQL 配额 + 拒绝写操作」,而不是跳过校验
- 别用
tenant_id当主键直接建表——要支持多资源类型,联合索引(tenant_id, resource_type)才合理
配额控制真正的难点不在代码怎么写,而在「租户边界是否真正隔离」。比如一个 SQL 查询没加 WHERE tenant_id = ?,或者缓存 key 漏了租户前缀,配额再严也没用。上线前必须人工核对所有涉及租户数据的操作点,不能只信中间件里那一行 setTenantId()。



















