PHP多租户怎么设计

wen PHP项目 19

本文目录导读:

PHP多租户怎么设计

  1. 三大架构模式对比
  2. 核心实现步骤(以“共享表”方案为例)
  3. 高级优化与坑点
  4. 扩展阅读:不同PHP框架的成熟实现
  5. 给你的建议

设计一个健壮的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');
        }
    });
}

高级优化与坑点

连接池与并发

  • 共享表方案:数据库连接是共享的,没有额外开销。
  • 分库方案:每个租户一个连接池,很容易打满数据库连接,解决方案:使用 ProxySQLpgbouncer 等中间件管理连接池,PHP端不直接连接数据库,而是连接中间件。

缓存策略

  • 租户级缓存:缓存KEY必须包含 tenant_id,避免不同租户的数据串了。order_list:{tenant_id}:{user_id}
  • 共享缓存:配置、模板等全局资源可以共用。

跨租户操作(如全局搜索、报表)

  • 使用无租户作用域的专用模型或查询构建器。
  • 建议放在独立的管理员后台,而不是SaaS前台,并且必须记录操作日志。

数据库迁移

  • 共享表方案:所有租户的表和结构完全一致,开发最方便。
  • 分库方案:需要为每个租户的数据库单独执行迁移,可以使用 php artisan tenants:migrate(Laravel有 stancl/tenancy 包支持)。

错误处理

  • 租户解析失败(域名不存在)时,返回 404 而不是 500
  • 租户状态为 suspended 时,返回 403 并引导用户联系客服。

扩展阅读:不同PHP框架的成熟实现

不建议重复造轮子,除非是极简场景,以下是社区最佳实践:

  • Laravelstancl/tenancy 是目前最流行的包,它支持 分库共享表 两种模式,内置了队列、事件、迁移等全面的解决方案。
  • SymfonyDockware\TenantBundleParthenon
  • ThinkPHP / Yii: 通常需要手动实现,但可以参考 stancl/tenancy 的设计思路。

给你的建议

  1. 初期(MVP阶段): 采用共享数据库、共享表,通过中间件+全局作用域实现隔离,开发快,成本低。
  2. 中期(用户数增长,对数据安全有要求): 切换到 共享数据库、独立Schema,在现有表上加 tenant_id 索引,并准备数据迁移脚本。
  3. 大型(超大规模,金融级合规): 使用 独立数据库,并借助 stancl/tenancy 这样的成熟包来管理连接、迁移和跨租户任务。

最重要的提醒: 无论如何设计,安全是第一位的,所有 ORM 查询必须强制经过 全局作用域 过滤,严禁在业务代码中拼接 WHERE tenant_id = {用户输入},防止 SQL 注入 导致的数据泄露。

抱歉,评论功能暂时关闭!