Webman本身不内置多租户能力,但通过域名或请求头识别租户、在最早期中间件中调用TenantContext::setContext()注入上下文,并在末尾调用destroy()防止协程复用污染,结合动态数据库连接切换与分层RBAC权限控制(平台层校验租户状态、租户层校验接口权限、数据层强制tenant_id过滤),可构建安全可控的SaaS多租户系统。

Webman 本身不内置多租户能力,但它的中间件机制、容器扩展性和数据库连接动态切换能力,让构建多租户 SaaS 系统变得可控。关键不在“能不能”,而在“如何把 tenant_id 或 domain 绑定到请求生命周期,并确保所有数据操作自动带上隔离上下文”。
如何识别租户并注入上下文
租户识别必须在请求最早期完成,否则后续 ORM 查询、缓存、日志都可能错乱。madong-Admin 和多数生产实践都采用域名绑定(tenant1.example.com)或请求头(X-Tenant-ID),而非 URL 参数——后者易被篡改且无法用于静态资源或 WebSocket 场景。
- 域名识别需配合
MultiSiteMiddleware中间件:从$request->header()['X-Origin-Domain']或$_SERVER['HTTP_HOST']提取主域名,查库匹配租户记录 - 识别后必须调用
TenantContext::setContext(),把tenant_id、db_name、isolation_mode写入当前协程上下文(Webman 基于 Workerman,使用Co\Channel或AsyncLocalStorage实现) - 务必在中间件末尾调用
TenantContext::destroy()—— 否则协程复用时残留上一个租户的上下文,这是线上最隐蔽的数据越界来源
如何动态切换数据库连接(schema 或 database 模式)
Webman 默认使用 PDO 连接池,不支持运行时切换 host/port/dbname。要实现多租户,必须绕过默认连接池,按租户构造独立连接实例,或重写连接管理逻辑。
Webman 2.2.0版本强化了 TCP/UDP 服务支持,优化路由组管理,并增强异步任务处理能力。结合协程与连接池技术,Webman 能轻松应对高并发场景,适用于网站、接口服务、即时通讯、物联网及游戏开发,兼具高性能、灵活扩展与稳定可靠,是多场景 PHP 服务开发的理想选择。
- 共享库 + 独立 Schema(如
tenant_123.users):修改database.php中的dbname为占位符,再在模型基类的getTableName()方法中拼接前缀:return $this->prefix . TenantContext::id() . '.' . $this->table; - 独立数据库(如
tenant_db_123):不能复用全局连接配置。应在TenantContext::setContext()后,用PDO手动 new 一个连接,并绑定到 DI 容器的特定 key(如db.tenant),所有租户内模型强制使用该连接 - 切记禁用 Laravel/ThinkPHP 风格的 “全局 scope 自动加 tenant_id” —— 在 schema/database 隔离模式下,加字段毫无意义,反而增加查询负担和误判风险
为什么 RBAC 权限控制必须分层设计
权限系统不是“给用户分配菜单”,而是三段式校验:平台层(是否允许访问该租户)、租户层(角色是否有接口权限)、数据层(当前用户能否查看/修改这条记录)。漏掉任一层,就等于开门揖盗。
- 平台层拦截靠中间件:检查
TenantService::get()返回的租户状态(status=active、expired_at > now),失败直接返回403 - 租户层用
Casbin或自定义策略引擎:规则模型应包含tenant_id字段,例如p, tenant_123, /api/v1/orders, POST, allow,避免跨租户策略污染 - 数据层过滤必须在 DAO 层统一处理:查询订单时,自动追加
WHERE tenant_id = ?;更新时校验WHERE id = ? AND tenant_id = ?—— 这步不能依赖前端传参,也不能只靠中间件,必须下沉到 Repository 或 QueryBuilder
哪些地方最容易忽略 tenant_id 泄露
多租户系统的崩溃点往往不在主流程,而在旁路通道:缓存键、消息队列、定时任务、日志聚合、错误追踪。这些地方一旦没带 tenant_id,轻则数据混杂,重则触发合规事故。
- Redis 缓存键必须含租户标识:
md:tenant_123:user:profile:1001,绝不能是user:profile:1001 - 异步任务投递时显式传入
tenant_id:Client::send('sync_user_data', ['tenant_id' => 123, 'user_id' => 1001]),并在消费者中再次调用TenantContext::setContext() - Cron 定时任务不能“全局扫描”,必须按租户分片执行:先查出所有有效租户列表,再 foreach 调度子任务,每个子任务独占
tenant_id上下文 - 所有异常日志必须打标:
tenant_id=123, user_id=1001, path=/api/v1/invoices,否则排查时根本分不清是哪个租户报的错

















