本文目录导读:

设计一个健壮的PHP多租户系统,核心挑战在于数据隔离、性能优化和可维护性,根据项目的规模和预算,主要有三种主流的架构模式。
以下是详细的设计方案,从架构选型到具体实现:
三大架构模式对比
| 模式 | 核心策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 数据库分库 | 每个租户1个独立数据库 | 隔离性最强,恢复/备份方便 | 数据库连接数爆炸,维护成本高,跨租户操作困难 | 银行、医疗等强监管行业,大型企业级SaaS |
| 共享数据库,独立Schema | 每个租户1套独立表(表前缀) | 隔离性较好,维护成本适中 | 跨Schema查询复杂,备份粒度粗 | 中型SaaS平台,如CRM、项目管理工具 |
| 共享数据库,共享表 | 所有数据在一套表中,用tenant_id区分 |
运维最简单,硬件成本最低,扩展方便 | 隔离性差,开发易错,需严防SQL泄漏 | 初期创业公司,对隔离要求不高的ToC应用 |
推荐策略: 对于大多数PHP项目(尤其是Laravel/Symfony框架),优先选择“共享数据库,共享表”,因为它的开发效率最高,如果需要更强的隔离,可以采用“数据库分库”,但通常需要引入中间件。
核心实现步骤(以“共享表”方案为例)
数据结构设计
所有与租户相关的业务表都需要包含一个 tenant_id 字段。
-- 租户表(全局唯一)
CREATE TABLE tenants (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
domain VARCHAR(255) UNIQUE NOT NULL, -- 租户域名或子域名
name VARCHAR(255),
status ENUM('active', 'suspended', 'trial') DEFAULT 'active',
config JSON, -- 租户个性化配置,如Logo、时区
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 业务表示例:所有表都必须有 tenant_id
CREATE TABLE orders (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL, -- 关键字段
user_id BIGINT UNSIGNED NOT NULL,
total DECIMAL(10,2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tenant_created (tenant_id, created_at), -- 性能关键:复合索引
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE -- 可选
);
-- 注意:用户表通常也需要 tenant_id,或者租户内部有独立的用户体系
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
email VARCHAR(255),
password VARCHAR(255),
UNIQUE KEY uk_tenant_email (tenant_id, email) -- 确保租户内邮箱唯一
);
中间件实现(Laravel示例)
核心是在每个请求开始时解析出当前租户,并将其信息注入到全局作用域中。
<?php
// app/Http/Middleware/TenantMiddleware.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
class TenantMiddleware
{
public function handle(Request $request, Closure $next)
{
// 1. 解析租户标识(域名、子域名、Header、或URL前缀)
$host = $request->getHost();
// tenant1.example.com
$subdomain = explode('.', $host)[0];
// 2. 根据标识查询租户(强烈推荐使用缓存,如Redis)
$tenant = Tenant::where('domain', $subdomain)->where('status', 'active')->first();
if (!$tenant) {
abort(404, 'Tenant not found or inactive.');
}
// 3. 将当前租户注入到应用的服务容器中
app()->instance('current_tenant', $tenant);
// 或者使用自定义全局变量
config(['app.tenant_id' => $tenant->id]);
// 将租户信息绑定到请求属性
$request->merge(['tenant_id' => $tenant->id]);
// 4. 设置一个全局的SQL作用域(可选,但推荐)
// 所有模型自动加上 WHERE tenant_id = ? 条件
// 详见步骤3
return $next($request);
}
}
强制数据隔离(最关键部分)
使用全局作用域(Global Scope) 来强制开发者不会忘记加 where 条件。
Laravel 示例:
<?php
// app/Models/Scopes/TenantScope.php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Support\Facades\Config;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model)
{
// 从配置或服务容器中获取当前租户ID
$tenantId = config('app.tenant_id');
if ($tenantId) {
$builder->where($model->getQualifiedTenantColumnName(), $tenantId);
}
}
// 解决 IDE 和代码提示问题(可选)
public function extend(Builder $builder)
{
// 添加一个 withoutTenant() 方法用于特殊场景(如后台全局管理员)
$builder->macro('withoutTenant', function (Builder $builder) {
return $builder->withoutGlobalScope($this);
});
}
}
// 在基础模型 Trait 中使用(所有业务模型引入)
trait HasTenant
{
public static function bootHasTenant()
{
static::addGlobalScope(new TenantScope());
}
// 定义租户字段名,便于重写
public function getQualifiedTenantColumnName()
{
return 'tenant_id';
}
}
// 使用示例(在Order模型中):
class Order extends Model
{
use HasTenant;
// 所有 Order::query() 自动带上 WHERE tenant_id = ?
}
// 特殊场景:超级管理员跨租户查询
// Order::withoutTenant()->where('created_at', '>', now()->subDay())->get();
创建数据时自动填充
确保新创建的数据自动带上当前租户ID,避免忘记。
// 在模型 Trait 中
public static function bootHasTenant()
{
// ... 全局作用域 ...
static::creating(function ($model) {
if (empty($model->tenant_id)) {
$model->tenant_id = config('app.tenant_id');
}
});
}
高级优化与坑点
连接池与并发
- 共享表方案:数据库连接是共享的,没有额外开销。
- 分库方案:每个租户一个连接池,很容易打满数据库连接,解决方案:使用
ProxySQL或pgbouncer等中间件管理连接池,PHP端不直接连接数据库,而是连接中间件。
缓存策略
- 租户级缓存:缓存KEY必须包含
tenant_id,避免不同租户的数据串了。order_list:{tenant_id}:{user_id}。 - 共享缓存:配置、模板等全局资源可以共用。
跨租户操作(如全局搜索、报表)
- 使用无租户作用域的专用模型或查询构建器。
- 建议放在独立的管理员后台,而不是SaaS前台,并且必须记录操作日志。
数据库迁移
- 共享表方案:所有租户的表和结构完全一致,开发最方便。
- 分库方案:需要为每个租户的数据库单独执行迁移,可以使用
php artisan tenants:migrate(Laravel有stancl/tenancy包支持)。
错误处理
- 租户解析失败(域名不存在)时,返回
404而不是500。 - 租户状态为
suspended时,返回403并引导用户联系客服。
扩展阅读:不同PHP框架的成熟实现
不建议重复造轮子,除非是极简场景,以下是社区最佳实践:
- Laravel:
stancl/tenancy是目前最流行的包,它支持 分库 和 共享表 两种模式,内置了队列、事件、迁移等全面的解决方案。 - Symfony:
Dockware\TenantBundle或Parthenon。 - ThinkPHP / Yii: 通常需要手动实现,但可以参考
stancl/tenancy的设计思路。
给你的建议
- 初期(MVP阶段): 采用共享数据库、共享表,通过中间件+全局作用域实现隔离,开发快,成本低。
- 中期(用户数增长,对数据安全有要求): 切换到 共享数据库、独立Schema,在现有表上加
tenant_id索引,并准备数据迁移脚本。 - 大型(超大规模,金融级合规): 使用 独立数据库,并借助
stancl/tenancy这样的成熟包来管理连接、迁移和跨租户任务。
最重要的提醒: 无论如何设计,安全是第一位的,所有 ORM 查询必须强制经过 全局作用域 过滤,严禁在业务代码中拼接 WHERE tenant_id = {用户输入},防止 SQL 注入 导致的数据泄露。