核心在于租户识别前置、数据过滤贯穿全链路、连接复用严格隔离:中间件首位解析Host绑定tenantId,Db::setConnectConfig动态注册租户连接,全局作用域+专项加固覆盖count/JOIN/软删除等所有查询场景。

ThinkPHP 实现多租户系统,核心不在“怎么切库”或“怎么加字段”,而在于租户识别必须前置、数据过滤必须贯穿全链路、连接复用必须严格隔离。漏掉任一环节,都可能造成跨租户数据泄露——哪怕只漏一个 count() 或一次 JOIN。
租户识别必须在中间件首位完成
不能依赖路由、控制器、模型初始化,更不能从 URL 参数或 session 直取。必须在请求刚进入框架时就解析并绑定。
- 读取 $_SERVER['HTTP_HOST'](反向代理下优先看 $_SERVER['HTTP_X_FORWARDED_HOST']),用正则白名单校验子域名,如
/^([a-z0-9\-]+)\.example\.com$/ - 将租户 ID 写入 $request->tenantId,而非 session、cookie 或容器 bind —— 后者在 CLI、队列中不可用
- 该中间件必须排在 app/middleware.php 的第一位,早于路由解析和任何模型加载
数据库连接必须动态注册+显式调用
Db::connect() 不是“临时换连接”,而是从连接池取实例;若配置未变,它会复用旧连接,导致租户串库。
- 使用 Db::setConnectConfig('tenant_' . $tid, $config) 动态注册带租户标识的连接配置
- 模型查询时显式指定:User::connect('tenant_acme')->select(),或在基类 __callStatic 中自动注入
- 禁用硬编码 $connection = 'tenant' 属性——它不响应运行时变化
- 若坚持用 Db::connect(),务必在配置数组中加入唯一字段(如 'tenant_id' => $tid),并重写 getPoolKey() 方法
数据过滤必须覆盖所有查询类型
baseQuery 和全局作用域对 count()、sum()、JOIN、with 关联等场景默认失效,必须专项加固。
立即学习“PHP免费学习笔记(深入)”;
- 全局作用域(TenantScope)需实现 ScopeInterface,并在基模型 boot() 中注册:
static::addGlobalScope('tenant', new TenantScope()) - 聚合查询(count、max 等)必须手动补 tenant_id:
User::where('tenant_id', $tid)->where('status', 1)->count() - JOIN 查询时,每张参与表都需显式加租户条件,不能只加主表;建议封装 joinWithTenant() 方法统一处理
- 软删除启用时,注意 delete_time IS NULL 与 tenant_id 条件的逻辑组合,避免 WHERE 子句被优化绕过
Schema 切换在 MySQL 中不推荐
MySQL 没有 PostgreSQL 那样的 runtime schema 切换能力。USE database 只影响后续语句默认库,无法改变已生成的 SQL 表名引用,且 information_schema 查询不受限。
- 强行模拟 search_path 会导致外键失效、备份混乱、表名冲突
- 单库单表 + tenant_id 字段最易上手,但必须配合索引(KEY idx_tenant_status (tenant_id, status))、代码审计、禁止裸查
- 真正高安全要求的场景,应选 database-per-tenant 方案:每个租户独立数据库,连接池按租户隔离,天然防混查



















