本文目录导读:

- 核心设计原则
- 方案一:行级隔离 + EAV/Json字段(适合小型项目,灵活但查询复杂)
- 方案二:Schema/数据库隔离 + 动态Schema变更(适合中型项目,强隔离性)
- 方案三:纯JSON/NoSQL模式(适合极高灵活性,但需牺牲数据库强约束)
- 前端配合:动态表单生成
- 最佳实践与注意事项
- 总结建议
这是一个关于多租户架构的复杂问题,在PHP项目中实现“用户自定义多租户”(通常意味着租户可以自定义功能、字段或视图),需要从数据隔离、Schema扩展和配置管理三个核心维度来设计。
下面我提供一个从基础到进阶的完整实现方案,重点在于让租户能灵活定制自身行为。
核心设计原则
- 租户识别:通过域名、子域名或请求头参数(
X-Tenant-ID)识别。 - 数据隔离:选择数据库(Database)隔离、Schema 隔离或行级(Row-Level/
tenant_id字段)隔离。 - 自定义能力:通过“元数据驱动”而非硬编码字段来实现自定义。
- 配置缓存:针对每个租户的自定义配置进行高性能缓存。
行级隔离 + EAV/Json字段(适合小型项目,灵活但查询复杂)
这种模式下,所有租户共享同一张表,通过 tenant_id 行级字段隔离,自定义字段通过 EAV 或 JSON 实现。
数据库结构 (MySQL为例)
-- 核心租户表
CREATE TABLE tenants (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
domain VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255),
settings JSON COMMENT '租户自定义全局设置(如主题色、Logo)',
status ENUM('active', 'suspended') DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 用户表(行级隔离)
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
name VARCHAR(255),
email VARCHAR(255),
-- 标准字段
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
-- 方案A: 自定义字段表 (EAV模式 - 适用于字段极少且查询简单)
CREATE TABLE user_custom_fields (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
field_key VARCHAR(100), -- 如: 'department', 'employee_number'
field_value TEXT,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE KEY unique_field (tenant_id, user_id, field_key)
);
-- 方案B: 使用JSON列(推荐,现代MySQL/MariaDB支持)
ALTER TABLE users ADD COLUMN custom_fields JSON AFTER email;
-- 示例JSON: {"department": "IT", "employee_number": "EMP001"}
-- 租户自定义字段定义表(定义哪些字段可用)
CREATE TABLE tenant_custom_field_definitions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
field_key VARCHAR(100) NOT NULL,
field_label VARCHAR(255),
field_type ENUM('text', 'number', 'date', 'select', 'multiselect'),
field_options JSON COMMENT 'select/multiselect的选项',
is_required BOOLEAN DEFAULT FALSE,
sort_order INT DEFAULT 0,
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
PHP后端关键代码 (封装在TenantService中)
<?php
class TenantService {
private $currentTenantId;
public function initialize() {
// 根据域名或Header获取当前租户
$domain = $_SERVER['HTTP_HOST'] ?? '';
$tenant = $this->getTenantByDomain($domain);
if (!$tenant || $tenant['status'] !== 'active') {
throw new \Exception('Tenant not found or inactive');
}
$this->currentTenantId = $tenant['id'];
// 将租户设置注入到应用上下文
app()->instance('tenant', $tenant);
app()->instance('tenantSettings', json_decode($tenant['settings'], true));
}
// 核心:获取租户自定义字段定义
public function getCustomFieldDefinitions() {
$cacheKey = "tenant:{$this->currentTenantId}:custom_fields";
return cache()->remember($cacheKey, 3600, function() {
return DB::table('tenant_custom_field_definitions')
->where('tenant_id', $this->currentTenantId)
->orderBy('sort_order')
->get()
->toArray();
});
}
// 查询用户时,自动处理JSON字段
public function getUserWithCustomFields($userId) {
$user = DB::table('users')
->where('tenant_id', $this->currentTenantId)
->where('id', $userId)
->first();
if ($user && $user->custom_fields) {
$customFields = json_decode($user->custom_fields, true);
$definitions = $this->getCustomFieldDefinitions();
// 将字段定义和实际值合并,返回给前端渲染
foreach ($definitions as $def) {
$user->custom_fields_display[] = [
'key' => $def->field_key,
'label' => $def->field_label,
'type' => $def->field_type,
'value' => $customFields[$def->field_key] ?? null,
'options' => $def->field_options ? json_decode($def->field_options, true) : null,
];
}
}
return $user;
}
private function getTenantByDomain($domain) {
return DB::table('tenants')->where('domain', $domain)->first();
}
public function getCurrentTenantId() {
return $this->currentTenantId;
}
}
中间件自动注入租户ID
<?php
// Laravel风格中间件
namespace App\Http\Middleware;
use Closure;
use App\Services\TenantService;
class TenantMiddleware {
public function handle($request, Closure $next) {
try {
$tenantService = app(TenantService::class);
$tenantService->initialize();
// 自动绑定到查询作用域(使用Laravel的Global Scope)
// 或手动在每次模型查询时加 where('tenant_id', $tenantId)
$request->attributes->set('current_tenant_id', $tenantService->getCurrentTenantId());
} catch (\Exception $e) {
return response('Tenant error: ' . $e->getMessage(), 403);
}
return $next($request);
}
}
Schema/数据库隔离 + 动态Schema变更(适合中型项目,强隔离性)
每个租户拥有独立的数据库或Schema,自定义字段通过动态建表实现。
架构设计
- 路由层:中间件根据租户ID动态切换数据库连接。
- 元数据表:
system_db.tenant_customizations记录每个租户的自定义表结构变化。 - DDL执行器:安全的SQL生成器,根据租户请求动态添加列。
动态数据库连接切换
<?php
// config/database.php 配置
return [
'default' => env('DB_CONNECTION', 'mysql'),
'connections' => [
'tenant' => [], // 占位符,实际动态生成
'system' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'database' => 'system_db', // 存放租户元数据的库
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
],
],
];
// PHP动态切换
<?php
class DynamicDatabase {
public static function switchToTenant($tenantId) {
$tenant = DB::connection('system')
->table('tenants')
->where('id', $tenantId)
->first();
$connectionName = "tenant_{$tenantId}";
config(["database.connections.{$connectionName}" => [
'driver' => 'mysql',
'host' => $tenant->db_host,
'port' => $tenant->db_port ?? 3306,
'database' => "tenant_{$tenantId}", // 每个租户一个数据库
'username' => $tenant->db_username,
'password' => decrypt($tenant->db_password),
'charset' => 'utf8mb4',
'prefix' => '',
]]);
DB::purge($connectionName);
DB::setDefaultConnection($connectionName);
}
}
动态字段管理系统
<?php
class TenantSchemaManager {
/**
* 为租户添加自定义字段(动态改表结构)
*/
public function addCustomField($tenantId, $tableName, $fieldKey, $fieldType, $fieldLength = null) {
// 1. 检查表是否存在
$hasTable = DB::statement("SELECT 1 FROM information_schema.tables
WHERE table_schema = ? AND table_name = ?", ["tenant_{$tenantId}", $tableName]);
if (!$hasTable) {
throw new \Exception("Table {$tableName} does not exist");
}
// 2. 使用安全的表名和字段名(防止注入)
$safeTable = preg_replace('/[^a-zA-Z0-9_]/', '', $tableName);
$safeField = 'custom_' . preg_replace('/[^a-zA-Z0-9_]/', '', $fieldKey);
// 3. 决定SQL类型
$sqlType = match ($fieldType) {
'text' => 'VARCHAR(255)',
'longtext' => 'TEXT',
'number' => 'INT',
'decimal' => 'DECIMAL(10,2)',
'date' => 'DATE',
'boolean' => 'TINYINT(1)',
default => 'VARCHAR(255)'
};
if ($fieldLength && $fieldType === 'text') {
$sqlType = "VARCHAR({$fieldLength})";
}
// 4. 执行DDL(使用参数化绑定无法保护表名/字段名,但已做了清理)
DB::statement("ALTER TABLE `tenant_{$tenantId}`.`{$safeTable}`
ADD COLUMN `{$safeField}` {$sqlType} NULL");
// 5. 记录元数据
DB::connection('system')->table('custom_field_metadata')->insert([
'tenant_id' => $tenantId,
'table_name' => $tableName,
'field_key' => $fieldKey,
'field_type' => $fieldType,
'column_name' => $safeField,
'created_at' => now(),
]);
}
/**
* 动态查询:根据请求的字段列表构建SELECT
*/
public function queryWithCustomFields($tenantId, $tableName, array $customFieldsToSelect) {
// 获取该租户在此表上注册的自定义字段列名映射
$columns = DB::connection('system')
->table('custom_field_metadata')
->where('tenant_id', $tenantId)
->where('table_name', $tableName)
->whereIn('field_key', $customFieldsToSelect)
->pluck('column_name', 'field_key');
// 构建SELECT语句
$selectFields = ['id', 'name']; // 基础字段
foreach ($columns as $fieldKey => $columnName) {
$selectFields[] = "{$columnName} as custom_{$fieldKey}";
}
return DB::table("tenant_{$tenantId}.{$tableName}")
->select($selectFields);
}
}
纯JSON/NoSQL模式(适合极高灵活性,但需牺牲数据库强约束)
几乎所有数据都以JSON存入单个或少量表,利用MongoDB或PostgreSQL的JSONB。
-- PostgreSQL示例
CREATE TABLE entity_data (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id BIGINT NOT NULL,
entity_type VARCHAR(50) NOT NULL, -- 'user', 'product', 'order'
data JSONB NOT NULL, -- 完全灵活的租户自定义数据
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
-- 常用查询需要建立GIN索引
CREATE INDEX idx_entity_data_gin ON entity_data USING gin (data jsonb_path_ops);
CREATE INDEX idx_tenant_entity ON entity_data (tenant_id, entity_type);
-- 查询示例: 查找所有 custom_field.department = 'IT' 的用户
SELECT * FROM entity_data
WHERE tenant_id = 1
AND entity_type = 'user'
AND data @> '{"department": "IT"}';
前端配合:动态表单生成
实现“用户自定义”时,前端需要根据后端返回的字段定义渲染表单。
// 基于字段定义渲染动态表单
function renderCustomForm(fieldDefinitions) {
const form = document.getElementById('custom-fields-form');
form.innerHTML = '';
fieldDefinitions.forEach(field => {
const wrapper = document.createElement('div');
wrapper.className = 'form-group';
const label = document.createElement('label');
label.textContent = field.label;
wrapper.appendChild(label);
let input;
switch (field.type) {
case 'text':
input = `<input type="text" name="custom_${field.key}" value="${field.value || ''}" ${field.is_required ? 'required' : ''}>`;
break;
case 'select':
const options = field.options.map(opt =>
`<option value="${opt.value}" ${opt.value === field.value ? 'selected' : ''}>${opt.label}</option>`
).join('');
input = `<select name="custom_${field.key}">${options}</select>`;
break;
case 'date':
input = `<input type="date" name="custom_${field.key}" value="${field.value || ''}">`;
break;
}
wrapper.innerHTML += input;
form.appendChild(wrapper);
});
}
最佳实践与注意事项
-
缓存策略:
- 租户的自定义字段定义(
field_definitions)应缓存在Redis/Memcached中。 - 缓存Key示例:
tenant:{id}:schema:{entity_type}。 - 当租户更新自定义字段时,主动清除缓存。
- 租户的自定义字段定义(
-
搜索与排序:
方案一(JSON):使用MySQL的JSON_EXTRACT或->>运算符,但索引复杂。方案二(独立列):可以直接在动态添加的列上建立索引,性能最好。- 对于大数据量,考虑将常用自定义字段提升为真实列,其余保留为JSON。
-
审计日志:
记录每个租户对元数据(字段定义)的修改,防止误操作。
-
成本控制:
方案二(Schema/DB隔离)当租户数量很大(>1000)时,数据库连接池和运维成本激增。方案一(行级)更经济,但需要谨慎设计索引,避免跨租户的慢查询。
-
安全底线:
- 所有用户输入的表名、字段名,必须经过白名单验证或正则清洗(
preg_replace('/[^a-zA-Z0-9_]/', '', $input))。 - 动态生成的SQL(如
ALTER TABLE中的列名)绝对不能直接拼接用户输入。
- 所有用户输入的表名、字段名,必须经过白名单验证或正则清洗(
总结建议
- 小型/快速验证:推荐 方案一 的
JSON列 + 字段定义表组合,配合Laravel的JSON casts使用起来非常流畅。 - 中型/性能要求高/强隔离:推荐 方案二 的
数据库隔离 + 动态DDL,并增加一个“元数据管理后台”让租户管理员操作。 - 大型/SaaS平台:通常采用 混合模式:基础表用行级隔离,自定义字段用动态列(方案二),高频查询字段物化为索引列,低频查询留在JSON中。
实现用户自定义多租户的关键不在于技术难度,而在于“定义好自定义的边界”——即:租户可以改什么,不可以改什么?这通常需要和产品经理共同制定规则。