本文目录导读:

设计一个健壮的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 注入 导致的数据泄露。
版权声明:NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播