租户识别必须在中间件中完成且置于队列首位,需在请求初期解析Host或HTTP头获取租户ID,绑定到request实例,确保数据库连接、缓存、日志等全部组件基于该ID隔离,杜绝后续环节二次解析。

租户识别必须在中间件里做,且要放在队列最前面
租户识别不是“配个路由”或“写个配置”就能生效的事——它必须在请求刚进来、任何模型、日志、缓存、数据库连接都还没初始化时就完成。常见错误是把识别逻辑塞进控制器或服务类,结果 Db::connect() 已经用默认配置连上库,baseQuery 还没执行,App::instance()->tenant 也还没绑定。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- 在
app/middleware/CheckTenant.php中读取$_SERVER['HTTP_HOST'](反向代理下优先检查$_SERVER['HTTP_X_FORWARDED_HOST']),不依赖request()->domain(),避免 URL 重写干扰 - 该中间件必须置于
app/middleware.php的中间件队列首位,确保早于路由解析和控制器实例化 - 租户 ID 绑定到
think\Request实例更稳妥:$request->tenantId = $tenantId,后续模型、服务都能通过app('request')->tenantId取值 - 子域名需白名单校验,比如只允许
preg_match('/^([a-z0-9\-]+)\.example\.com$/', $host, $matches),防伪造 Host 头
Db::connect() 动态切换连接前必须清池或改键
Db::connect() 内部用静态数组缓存连接实例,键名默认是配置数组的 MD5。多个租户若只改了 database 名而其他字段(host、username、charset)完全一致,MD5 就会撞车,导致复用旧连接——这是最隐蔽的数据串查来源。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- 切换租户前必须调用
Db::clearConnectPool(),但注意这会清空所有连接,影响并发性能 - 更优解是绕过
Db::connect(),直接 newthink\db\Connection,把租户库名作为构造参数透传 - 若坚持用
Db::connect(),请在配置数组中加入唯一字段,例如'tenant_id' => $tenantId,并重写think\db\Connection::getPoolKey()方法,把tenant_id显式纳入键生成逻辑 - 所有模型强制指定
protected $connection = 'tenant';,避免意外走默认连接
模型层 tenant_id 过滤不能只靠 baseQuery
baseQuery() 能拦截 find()、select()、update(),但对 count()、sum()、max() 等聚合查询无效——它们绕过模型生命周期,直通查询构造器。软删除字段叠加后更容易漏条件,典型现象是 User::where('status', 1)->count() 返回 0,实际数据存在。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- 基类模型中封装
tenantCount()方法,内部强制走完整链路:return $this->db()->table($this->getTable())->where('tenant_id', $this->getTenantId())->where(...)->count(); - 全局作用域(
addGlobalScope())比baseQuery()更底层,但count()默认也不触发;必须配合useGlobalScope(true)显式启用 - 关联查询(
with())不继承主模型的租户条件,需在关联定义里手动加->where('tenant_id', $this->tenantId),或改用hasWhere() - 禁止在模型中调用
session()或cookie()获取租户 ID——CLI、队列任务中 session 根本不存在
缓存、Session、日志必须带租户上下文
即使数据库和模型都隔离了,如果缓存键没加租户前缀,cache('user_list') 就可能被不同租户共享;Session 名称若固定为 PHPSESSID,用户在多个租户间切换会互相覆盖;日志里不打租户 ID,排查问题时根本分不清是哪个租户出的问题。
实操建议:
立即学习“PHP免费学习笔记(深入)”;
- 缓存键统一拼接:
cache('tenant_' . app('request')->tenantId . '_user_list'),或重写think\Cache的getCacheKey() - Session 名称动态设置:
config(['session.name' => 'sess_' . app('request')->tenantId]),并在中间件中调用session()->start()前完成 - 日志处理器中注入租户 ID:
Log::channel('daily')->write('info', 'xxx', ['tenant_id' => app('request')->tenantId]) - 所有第三方 SDK(如 Redis 客户端、消息队列 Producer)初始化时,都应将租户 ID 作为上下文透传,避免异步任务丢失隔离边界



















