发布日期:2024年1月 | 作者:ThinkPHP架构师
多租户SaaS系统架构概述
多租户架构是SaaS系统的核心设计模式,允许多个客户共享同一套应用程序实例,同时保持数据隔离和个性化配置。
三种多租户数据隔离方案
- 独立数据库:每个租户拥有独立的数据库,安全性最高
- 共享数据库独立Schema:同一数据库,不同数据表结构
- 共享数据库共享Schema:同一数据库和表结构,通过tenant_id区分
系统架构设计
目录结构设计
app/
├── controller/
│ ├── Tenant.php # 租户基类控制器
│ └── admin/
│ └── TenantManage.php # 租户管理
├── middleware/
│ └── TenantAuth.php # 租户身份验证
├── service/
│ └── TenantService.php # 租户业务逻辑
├── model/
│ ├── Tenant.php # 租户模型
│ └── trait/
│ └── TenantScope.php # 租户数据范围
└── common.php # 公共函数
数据库设计
-- 主数据库:系统管理表
CREATE TABLE `tenants` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL COMMENT '租户名称',
`subdomain` varchar(50) UNIQUE NOT NULL COMMENT '子域名',
`database_name` varchar(50) NOT NULL COMMENT '租户数据库名',
`status` tinyint(1) DEFAULT 1 COMMENT '状态',
`created_at` datetime DEFAULT NULL,
`expires_at` datetime DEFAULT NULL COMMENT '过期时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 租户数据库:业务数据表
CREATE TABLE `tenant_users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`tenant_id` int(11) NOT NULL,
`name` varchar(50) NOT NULL,
`email` varchar(100) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_tenant` (`tenant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
核心功能实现
1. 租户识别中间件
<?php
namespace appmiddleware;
class TenantAuth
{
public function handle($request, Closure $next)
{
// 通过子域名识别租户
$subdomain = $this->getSubdomain($request->host());
if (empty($subdomain) || $subdomain === 'www') {
// 主域名访问,跳转到租户注册或管理页面
return redirect('/admin/tenant');
}
// 查询租户信息
$tenant = appmodelTenant::where('subdomain', $subdomain)
->where('status', 1)
->find();
if (!$tenant) {
throw new thinkexceptionHttpException(404, '租户不存在');
}
// 设置当前租户上下文
appserviceTenantService::setCurrentTenant($tenant);
// 切换数据库连接(如果是独立数据库模式)
$this->switchDatabase($tenant);
return $next($request);
}
private function getSubdomain($host)
{
$parts = explode('.', $host);
if (count($parts) > 2) {
return $parts[0];
}
return '';
}
private function switchDatabase($tenant)
{
$config = [
'connections' => [
'tenant_db' => [
'type' => 'mysql',
'hostname' => env('database.hostname'),
'database' => $tenant->database_name,
'username' => env('database.username'),
'password' => env('database.password'),
'charset' => 'utf8mb4',
'prefix' => '',
]
]
];
thinkfacadeDb::config($config);
thinkfacadeDb::connect('tenant_db');
}
}
2. 租户服务类
<?php
namespace appservice;
class TenantService
{
protected static $currentTenant = null;
public static function setCurrentTenant($tenant)
{
self::$currentTenant = $tenant;
}
public static function getCurrentTenant()
{
return self::$currentTenant;
}
public static function getTenantId()
{
return self::$currentTenant ? self::$currentTenant->id : 0;
}
/**
* 创建新租户
*/
public function createTenant($data)
{
// 验证子域名唯一性
$exists = appmodelTenant::where('subdomain', $data['subdomain'])->find();
if ($exists) {
throw new Exception('子域名已被占用');
}
// 生成数据库名
$databaseName = 'tenant_' . $data['subdomain'] . '_' . time();
// 创建租户记录
$tenant = new appmodelTenant();
$tenant->save([
'name' => $data['name'],
'subdomain' => $data['subdomain'],
'database_name' => $databaseName,
'created_at' => date('Y-m-d H:i:s'),
'expires_at' => date('Y-m-d H:i:s', strtotime('+1 year'))
]);
// 创建租户数据库
$this->createTenantDatabase($databaseName);
// 初始化租户数据表
$this->initTenantTables($databaseName);
return $tenant;
}
private function createTenantDatabase($databaseName)
{
$sql = "CREATE DATABASE IF NOT EXISTS `{$databaseName}`
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci";
thinkfacadeDb::execute($sql);
}
private function initTenantTables($databaseName)
{
// 切换到租户数据库执行建表语句
$originalConfig = thinkfacadeDb::getConfig();
$config = array_merge($originalConfig, ['database' => $databaseName]);
thinkfacadeDb::connect($config);
// 执行租户业务表创建
$tableScript = file_get_contents(app()->getRootPath() . 'database/tenant_tables.sql');
thinkfacadeDb::execute($tableScript);
// 恢复默认数据库连接
thinkfacadeDb::connect($originalConfig);
}
}
3. 租户数据范围Traits
<?php
namespace appmodeltrait;
trait TenantScope
{
/**
* 自动添加租户ID过滤
*/
public static function onBeforeInsert($model)
{
$tenantId = appserviceTenantService::getTenantId();
if ($tenantId) {
$model->tenant_id = $tenantId;
}
}
/**
* 自动限制租户数据范围
*/
public function scopeTenant($query)
{
$tenantId = appserviceTenantService::getTenantId();
if ($tenantId) {
$query->where('tenant_id', $tenantId);
}
return $query;
}
}
// 在模型中使用
namespace appmodel;
use thinkModel;
use appmodeltraitTenantScope;
class TenantUser extends Model
{
use TenantScope;
// 自动写入时间戳
protected $autoWriteTimestamp = true;
// 定义租户ID字段
protected $tenantIdField = 'tenant_id';
/**
* 查询租户用户列表(自动应用租户范围)
*/
public function getTenantUsers()
{
return $this->tenant()->select();
}
}
高级特性实现
1. 多数据库连接管理
<?php
namespace appservice;
class DatabaseManager
{
/**
* 动态切换数据库连接
*/
public static function switchToTenantDatabase($tenant)
{
$config = [
'type' => 'mysql',
'hostname' => env('database.hostname'),
'database' => $tenant->database_name,
'username' => env('database.username'),
'password' => env('database.password'),
'charset' => 'utf8mb4',
'prefix' => '',
];
// 注册新的数据库连接
thinkfacadeDb::connect($config, 'tenant_connection');
return 'tenant_connection';
}
/**
* 跨数据库查询(主库与租户库)
*/
public static function crossDatabaseQuery()
{
// 主数据库查询
$tenants = thinkfacadeDb::connect('default')
->name('tenants')
->where('status', 1)
->select();
$results = [];
foreach ($tenants as $tenant) {
// 切换到租户数据库
$connectionName = self::switchToTenantDatabase($tenant);
// 租户数据库查询
$userCount = thinkfacadeDb::connect($connectionName)
->name('tenant_users')
->count();
$results[] = [
'tenant' => $tenant->name,
'user_count' => $userCount
];
}
return $results;
}
}
2. 租户自定义配置
<?php
namespace appservice;
class TenantConfig
{
/**
* 获取租户自定义配置
*/
public static function getConfig($key = null, $default = null)
{
$tenantId = TenantService::getTenantId();
if (!$tenantId) {
return $default;
}
// 从缓存或数据库获取配置
$cacheKey = "tenant_config_{$tenantId}";
$config = cache($cacheKey);
if (!$config) {
$config = thinkfacadeDb::connect('default')
->name('tenant_configs')
->where('tenant_id', $tenantId)
->column('value', 'key');
cache($cacheKey, $config, 3600);
}
if ($key) {
return $config[$key] ?? $default;
}
return $config;
}
/**
* 设置租户配置
*/
public static function setConfig($key, $value)
{
$tenantId = TenantService::getTenantId();
if (!$tenantId) {
return false;
}
$data = [
'tenant_id' => $tenantId,
'key' => $key,
'value' => $value,
'updated_at' => date('Y-m-d H:i:s')
];
// 更新或插入配置
thinkfacadeDb::connect('default')
->name('tenant_configs')
->insert($data, true);
// 清除缓存
cache("tenant_config_{$tenantId}", null);
return true;
}
}
部署与运维方案
Nginx子域名配置
server {
listen 80;
server_name ~^(?<subdomain>.+).yourdomain.com$;
root /path/to/your/project/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ .php$ {
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
# 传递子域名信息
fastcgi_param HTTP_X_TENANT_SUBDOMAIN $subdomain;
}
}
数据库备份策略
<?php
namespace appcommand;
use thinkconsoleCommand;
use thinkconsoleInput;
use thinkconsoleOutput;
class TenantBackup extends Command
{
protected function configure()
{
$this->setName('tenant:backup')
->setDescription('备份所有租户数据库');
}
protected function execute(Input $input, Output $output)
{
$tenants = appmodelTenant::where('status', 1)->select();
foreach ($tenants as $tenant) {
$this->backupTenantDatabase($tenant, $output);
}
$output->writeln('所有租户数据库备份完成');
}
private function backupTenantDatabase($tenant, $output)
{
$backupDir = runtime_path('backup/' . date('Y-m-d'));
if (!is_dir($backupDir)) {
mkdir($backupDir, 0755, true);
}
$backupFile = $backupDir . '/tenant_' . $tenant->subdomain . '.sql';
$command = sprintf(
'mysqldump -h%s -u%s -p%s %s > %s',
env('database.hostname'),
env('database.username'),
env('database.password'),
$tenant->database_name,
$backupFile
);
exec($command, $result, $status);
if ($status === 0) {
$output->writeln("租户 {$tenant->name} 备份成功: {$backupFile}");
} else {
$output->writeln("租户 {$tenant->name} 备份失败");
}
}
}
document.addEventListener(‘DOMContentLoaded’, function() {
const codeBlocks = document.querySelectorAll(‘pre code’);
codeBlocks.forEach(block => {
block.addEventListener(‘click’, function() {
const textArea = document.createElement(‘textarea’);
textArea.value = this.textContent;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand(‘copy’);
console.log(‘代码已复制到剪贴板’);
} catch (err) {
console.error(‘复制失败:’, err);
}
document.body.removeChild(textArea);
});
block.title = ‘点击复制代码’;
});
});