PHP 多租户方案设计

wen PHP项目 2

PHP 多租户方案设计

多租户架构是指一个应用实例同时为多个租户(客户/组织)提供服务,每个租户拥有独立的数据和配置,本文将探讨PHP实现多租户的三种主要方案。

PHP 多租户方案设计

架构方案对比

独立数据库

每个租户拥有独立的数据库。

优点:

  • 数据隔离性最强
  • 便于单独备份和恢复
  • 可根据租户需求定制功能

缺点:

  • 成本高(数据库连接多)
  • 维护复杂(迁移、升级需同步)
  • 连接管理开销大

共享数据库,独立Schema

每个租户拥有独立的表空间(Schema)。

优点:

  • 数据隔离性较好
  • 便于数据迁移
  • 共享部分连接资源

缺点:

  • 维护成本较高
  • Schema数量过多时管理困难

共享数据库,共享Schema,共享表

所有租户共用同一套表,通过租户ID区分。

优点:

  • 成本最低
  • 维护简单
  • 扩展性好

缺点:

  • 数据隔离性较弱
  • 需要严格的权限控制

方案三详细设计(推荐)

1 数据库设计

-- 租户表
CREATE TABLE tenants (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    domain VARCHAR(100) UNIQUE,
    status TINYINT DEFAULT 1,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 业务数据表(带租户ID)
CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    tenant_id BIGINT NOT NULL,
    order_no VARCHAR(50) NOT NULL,
    amount DECIMAL(10,2),
    status TINYINT,
    created_at TIMESTAMP,
    INDEX idx_tenant_id (tenant_id),
    UNIQUE KEY uk_order_tenant (tenant_id, order_no)
);

2 租户识别与配置

<?php
class TenantContext
{
    private static $tenantId;
    private static $tenantConfig;
    public static function setTenant($tenantId)
    {
        self::$tenantId = $tenantId;
    }
    public static function getTenantId()
    {
        return self::$tenantId;
    }
    public static function getTenantConfig()
    {
        return self::$tenantConfig;
    }
}
// 租户识别逻辑
class TenantResolver
{
    public static function resolve(Request $request)
    {
        // 1. 域名识别
        $domain = $request->getHost();
        // 2. 子域名识别
        $subdomain = explode('.', $domain)[0];
        // 3. URL路径识别
        $path = $request->getPathInfo();
        // 4. Header/Token识别
        $tenantId = $request->headers->get('X-Tenant-Id');
        // 5. 从数据库获取租户信息
        $tenant = Tenant::findByDomain($domain);
        if ($tenant) {
            TenantContext::setTenant($tenant->id);
            return true;
        }
        return false;
    }
}

3 数据库连接管理

<?php
class DatabaseManager
{
    private static $connections = [];
    public static function getConnection($tenantId = null)
    {
        $tenantId = $tenantId ?: TenantContext::getTenantId();
        $config = self::getTenantDatabaseConfig($tenantId);
        $key = md5($tenantId . json_encode($config));
        if (!isset(self::$connections[$key])) {
            self::$connections[$key] = new PDO(
                "mysql:host={$config['host']};dbname={$config['database']}",
                $config['username'],
                $config['password']
            );
        }
        return self::$connections[$key];
    }
    private static function getTenantDatabaseConfig($tenantId)
    {
        // 方案一:所有租户共享配置
        $baseConfig = [
            'host' => getenv('DB_HOST'),
            'database' => getenv('DB_DATABASE'),
            'username' => getenv('DB_USERNAME'),
            'password' => getenv('DB_PASSWORD')
        ];
        // 方案二:根据租户获取独立配置
        if (getenv('TENANT_DB_MODE') === 'separate') {
            $tenant = Tenant::find($tenantId);
            return [
                'host' => $tenant->db_host,
                'database' => $tenant->db_name,
                'username' => $tenant->db_user,
                'password' => $tenant->db_password
            ];
        }
        return $baseConfig;
    }
}

4 数据访问层封装

<?php
abstract class BaseModel
{
    protected $table;
    protected $connection;
    protected $tenantScoped = true;
    public function __construct()
    {
        $this->connection = DatabaseManager::getConnection();
    }
    protected function applyTenantScope($query)
    {
        if ($this->tenantScoped) {
            $query->where('tenant_id', TenantContext::getTenantId());
        }
        return $query;
    }
    public function findAll()
    {
        $sql = "SELECT * FROM {$this->table} WHERE tenant_id = ?";
        $stmt = $this->connection->prepare($sql);
        $stmt->execute([TenantContext::getTenantId()]);
        return $stmt->fetchAll();
    }
    public function create($data)
    {
        $data['tenant_id'] = TenantContext::getTenantId();
        // 插入逻辑...
    }
    public function delete($id)
    {
        $sql = "DELETE FROM {$this->table} WHERE id = ? AND tenant_id = ?";
        $stmt = $this->connection->prepare($sql);
        $stmt->execute([$id, TenantContext::getTenantId()]);
    }
}
class Order extends BaseModel
{
    protected $table = 'orders';
    public function getOrderByNumber($orderNo)
    {
        $sql = "SELECT * FROM {$this->table} 
                WHERE tenant_id = ? AND order_no = ?";
        $stmt = $this->connection->prepare($sql);
        $stmt->execute([TenantContext::getTenantId(), $orderNo]);
        return $stmt->fetch();
    }
}

5 中间件设计

<?php
class TenantMiddleware
{
    public function handle($request, Closure $next)
    {
        // 排除公共路由
        if ($this->isPublicRoute($request)) {
            return $next($request);
        }
        // 识别租户
        if (!TenantResolver::resolve($request)) {
            return response()->json(['error' => 'Invalid tenant'], 401);
        }
        // 设置数据访问范围
        $this->setQueryScopes();
        // 注入租户上下文
        app()->instance('tenant', TenantContext::getTenant());
        return $next($request);
    }
    private function setQueryScopes()
    {
        // 设置全局查询作用域
        Eloquent::addGlobalScope('tenant', function ($builder) {
            $builder->where('tenant_id', TenantContext::getTenantId());
        });
    }
}

6 缓存隔离

<?php
class TenantCache
{
    private static $prefix = 'tenant:';
    public static function getKey($key)
    {
        return self::$prefix . TenantContext::getTenantId() . ':' . $key;
    }
    public static function get($key)
    {
        return Cache::get(self::getKey($key));
    }
    public static function put($key, $value, $ttl = 3600)
    {
        return Cache::put(self::getKey($key), $value, $ttl);
    }
    public static function flushTenant()
    {
        $prefix = self::$prefix . TenantContext::getTenantId();
        return Cache::flush($prefix);
    }
}

7 文件存储隔离

<?php
class TenantStorage
{
    public static function getTenantPath()
    {
        $tenantId = TenantContext::getTenantId();
        return "uploads/tenant_{$tenantId}/";
    }
    public static function upload($file, $path = '')
    {
        $tenantPath = self::getTenantPath() . $path;
        // 使用数据库存储(可选)
        $cloudPath = Storage::cloud()->putFile($tenantPath, $file);
        // 保存文件记录到数据库
        FileRecord::create([
            'tenant_id' => TenantContext::getTenantId(),
            'path' => $cloudPath,
            'name' => $file->getClientOriginalName()
        ]);
        return $cloudPath;
    }
}

8 权限控制

<?php
class TenantPermissionMiddleware
{
    public function handle($request, Closure $next, $permission)
    {
        $tenant = TenantContext::getTenant();
        // 检查租户权限
        if (!$tenant->hasPermission($permission)) {
            abort(403, 'Tenant does not have permission');
        }
        // 检查用户权限
        $user = auth()->user();
        if (!$user->hasPermission($permission)) {
            abort(403, 'User does not have permission');
        }
        return $next($request);
    }
}

性能优化

1 索引优化

-- 联合索引
CREATE INDEX idx_tenant_order_date 
ON orders (tenant_id, created_at);
-- 唯一约束
CREATE UNIQUE INDEX uk_tenant_order_no 
ON orders (tenant_id, order_no);

2 分区分表

-- 按租户ID分区分表
CREATE TABLE orders_tenant_1 PARTITION BY HASH(tenant_id) PARTITIONS 10;
-- 或按时间分区
CREATE TABLE logs (
    id BIGINT,
    tenant_id BIGINT,
    log_time DATETIME
) PARTITION BY RANGE (YEAR(log_time)) (
    PARTITION p2023 VALUES LESS THAN (2024),
    PARTITION p2024 VALUES LESS THAN (2025)
);

3 缓存策略

<?php
class TenantCacheStrategy
{
    private static $strategies = [
        // 数据容易变化,低缓存
        'inventory' => 60,
        // 数据变化慢,高缓存
        'settings' => 3600,
        // 静态数据,永久缓存
        'country_codes' => 86400
    ];
    public static function getCacheTime($key)
    {
        return self::$strategies[$key] ?? 300;
    }
}

安全考虑

1 数据隔离验证

<?php
class TenantSecurity
{
    public static function validateTenantBoundary()
    {
        // 开发环境使用
        app()->after(function ($request, $response) {
            // 检查响应数据中是否包含非当前租户数据
            $tenantId = TenantContext::getTenantId();
            // 记录潜在风险查询
            $queries = DB::getQueryLog();
            foreach ($queries as $query) {
                if (!str_contains($query['query'], "tenant_id = {$tenantId}") 
                    && !str_contains($query['query'], 'tenants')) {
                    logger()->warning('Potential tenant data leak: ' . $query['query']);
                }
            }
        });
    }
}

2 SQL注入防护

<?php
class SafeQueryBuilder
{
    public static function build($table, $conditions)
    {
        $sql = "SELECT * FROM {$table} WHERE tenant_id = :tenant_id";
        $params = ['tenant_id' => TenantContext::getTenantId()];
        foreach ($conditions as $field => $value) {
            if (!self::isWhitelistedField($field)) {
                continue;
            }
            $sql .= " AND {$field} = :{$field}";
            $params[$field] = $value;
        }
        return [$sql, $params];
    }
    private static function isWhitelistedField($field)
    {
        $allowed = ['id', 'status', 'order_no', 'amount'];
        return in_array($field, $allowed);
    }
}

运维与监控

1 数据备份策略

<?php
class BackupManager
{
    public static function backupTenant($tenantId)
    {
        // 获取租户所有数据
        $tables = ['orders', 'users', 'products', 'settings'];
        // 导出为JSON/CSV
        foreach ($tables as $table) {
            $data = DB::table($table)
                ->where('tenant_id', $tenantId)
                ->get();
            // 压缩存储
            $content = json_encode($data);
            Storage::put("backups/tenant_{$tenantId}/{$table}.json", $content);
        }
        // 通知租户
        event(new TenantBackupCompleted($tenantId));
    }
    public static function restoreTenant($tenantId, $backupPath)
    {
        // 恢复逻辑...
    }
}

2 租户指标监控

<?php
class TenantMetrics
{
    public static function collect()
    {
        $tenantId = TenantContext::getTenantId();
        $metrics = [
            'requests' => self::getRequestCount($tenantId),
            'storage_usage' => self::getStorageUsage($tenantId),
            'api_calls' => self::getApiCallCount($tenantId),
            'errors' => self::getErrorCount($tenantId)
        ];
        // 存储指标
        Metrics::create([
            'tenant_id' => $tenantId,
            'data' => $metrics,
            'recorded_at' => now()
        ]);
    }
    public static function detectAnomalies($tenantId)
    {
        // 异常检测逻辑
        $metrics = self::getRecentMetrics($tenantId);
        if ($metrics['requests'] > self::threshold('requests')) {
            event(new HighTrafficAlert($tenantId));
        }
    }
}

部署与扩展

1 负载均衡

# Nginx 配置示例
upstream app_servers {
    least_conn;
    server 10.0.0.1:80 max_fails=3 fail_timeout=30s;
    server 10.0.0.2:80 max_fails=3 fail_timeout=30s;
}
server {
    listen 80;
    server_name example.com;
    location / {
        proxy_pass http://app_servers;
        proxy_set_header X-Tenant-Id $http_x_tenant_id;
    }
}

2 水平扩展

# Docker Compose 多实例部署
version: '3'
services:
  app:
    image: your-app:latest
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis
    volumes:
      - ./storage:/var/www/storage
    ports:
      - "8080:80"
    depends_on:
      - mysql
      - redis
  app2:
    image: your-app:latest
    environment:
      - DB_HOST=mysql
      - REDIS_HOST=redis
    volumes:
      - ./storage2:/var/www/storage
    ports:
      - "8081:80"

最佳实践建议

  1. 数据隔离优先:即使使用共享数据库,也要在代码层强制实施租户隔离
  2. 缓存键加租户前缀:避免缓存数据泄露
  3. 监控租户使用量:为计费和资源管理提供依据
  4. 提供租户自助服务:注册、配置、数据导出等
  5. 定期数据清理:涉及租户数据保留策略
  6. 测试租户边界:编写专门的测试用例验证数据隔离

PHP多租户方案设计需要根据业务需求、预算和技术栈选择合适方案:

  • 大型企业:独立数据库或Schema方案
  • SaaS产品:共享数据库方案(推荐)配合严格的权限控制
  • 小型应用:共享数据库,简化实现

核心原则:保证数据隔离的安全性,同时兼顾性能和可维护性

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