根本原因是租户识别、连接切换、查询过滤任一环节断裂;须最早期用$_SERVER['HTTP_X_FORWARDED_HOST']或['HTTP_HOST']正则提取子域名并绑定App::instance()->tenant,Db::setConnectConfig后立即Db::clearConnection,模型全局作用域需覆盖count/JOIN/软删除且从可信上下文取tenant_id。

ThinkPHP多租户数据隔离失败,根本原因往往不是“没加tenant_id”,而是租户识别、连接切换、查询过滤三个环节中任一环断裂。常见现象如User::find(1)查出其他租户数据、count()返回0、CLI任务跨库写入,都不是偶然Bug,而是隔离链路断点的必然结果。
租户识别必须在请求最早期完成
不能依赖 session、cookie、URL 参数或 request()->domain() —— 这些在队列、定时器、CLI 命令中全部失效,且易被伪造。必须从可信服务端变量提取:
- 优先读
$_SERVER['HTTP_X_FORWARDED_HOST'](反向代理场景) - 降级读
$_SERVER['HTTP_HOST'](直连 Nginx 场景) - 用正则严格提取子域名,例如:
/^([a-z0-9\-]+)\.example\.com$/i,捕获组$matches[1]为原始标识 - 清洗后绑定到全局可信上下文:
think\facade\App::instance()->tenant = $safeId,禁止写全局变量或 session - 该中间件(如
app/middleware/CheckTenant.php)必须排在app/middleware.php的第一位
数据库连接必须强制重建并清空缓存
ThinkPHP 默认复用连接池,只改配置不清理,旧连接仍连着默认库,导致写错库、查不到表、跨租户串查。
- 根据租户标识生成完整库名(如
db_tenant1),不能只传前缀 - 调用
Db::setConnectConfig('tenant', $config)注册专用连接配置 - 立即执行
Db::clearConnection('tenant'),确保后续首次Db::connect('tenant')重建连接 - 所有模型统一声明
protected $connection = 'tenant',禁用Db::table()等绕过模型连接的方式 - PDO 的
charset和表前缀(prefix)也需按租户动态设置,否则报Table not found
模型全局作用域必须覆盖所有SQL类型
默认全局作用域对 count()、join()、软删除等场景无效,需专项处理:
立即学习“PHP免费学习笔记(深入)”;
- 在基类模型
boot()中显式注册:static::addGlobalScope('tenant', new TenantScope()) -
TenantScope必须实现ScopeInterface,且从App::instance()->tenant取值,不可读 session 或 input - 聚合查询(
count、sum)和关联查询(with、join)需在作用域中显式补全where条件 - 启用软删除时,租户条件与
delete_time IS NULL必须共存但不能独立注册 —— 合并进同一作用域,避免 SQL 括号嵌套错乱 - 需要跨租户操作(如后台管理)时,用
withoutGlobalScope('tenant'),而非useGlobalScope(false)
缓存、日志、Session、静态资源必须按租户隔离
漏掉任一环节都可能造成信息泄露或状态污染:
- 缓存键名加租户前缀,如
cache_tenant1:user_123;Redis 实例建议按租户分库或加命名空间 - Session 配置中设置
session.name或session.path包含租户标识,避免不同租户共享会话 - 日志文件路径或通道名称加入
tenantId,防止日志混杂难以排查 - 静态资源(如上传文件)存储路径按租户隔离,例如
public/uploads/tenant1/avatar.jpg - SDK 实例(如短信、支付客户端)也应按租户动态初始化,避免密钥复用或签名错误



















