多租户架构的核心目标是让同一个应用实例服务多个不同的租户,同时保证各租户数据的独立性与安全性。基于数据库隔离租户数据是常见的实现方式,主要分为独立数据库、共享数据库独立schema、共享数据库共享schema三种模式,其中共享数据库共享schema模式在php项目中应用最为广泛,通过给数据表添加租户标识字段实现数据隔离。

多租户数据库隔离的核心思路
基于数据库隔离租户数据的核心逻辑分为两步,第一步是租户识别,第二步是数据过滤。租户识别通常通过请求头、域名、登录态等方式获取当前请求的租户唯一标识,数据过滤则是在所有数据库查询操作中自动拼接租户标识条件,确保查询到的数据仅属于当前租户。
租户标识获取方式
- 域名识别:不同租户使用不同的二级域名,比如tenant1.ippipp.com、tenant2.ippipp.com,解析域名获取租户标识
- 请求头识别:前端请求时在Header中携带租户标识字段,比如X-Tenant-Id
- 登录态识别:用户登录后,从用户关联的租户信息中获取租户标识
数据库表结构设计
采用共享数据库共享schema模式时,所有业务表都需要添加tenant_id字段,用于标记数据所属的租户。以下是用户表的示例结构:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int(11) | 用户主键 |
| username | varchar(50) | 用户名 |
| password | varchar(255) | 密码 |
| tenant_id | int(11) | 租户标识,关联租户表主键 |
租户表存储所有租户的基础信息,结构示例如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int(11) | 租户主键 |
| tenant_name | varchar(100) | 租户名称 |
| domain | varchar(100) | 租户绑定的域名 |
php实现租户识别与数据隔离
租户识别中间件实现
以Laravel框架为例,我们可以创建一个中间件来识别当前请求的租户,将租户标识存储到请求上下文中,方便后续使用。
<?php
namespace AppHttpMiddleware;
use Closure;
use IlluminateHttpRequest;
use AppModelsTenant;
class IdentifyTenant
{
public function handle(Request $request, Closure $next)
{
// 从请求头获取租户标识
$tenantId = $request->header('X-Tenant-Id');
if (!$tenantId) {
// 从域名解析租户标识
$host = $request->getHost();
$tenant = Tenant::where('domain', $host)->first();
if ($tenant) {
$tenantId = $tenant->id;
}
}
if (!$tenantId) {
return response()->json(['code' => 401, 'msg' => '未识别到租户信息'], 401);
}
// 将租户标识存入请求对象
$request->attributes->set('tenant_id', $tenantId);
return $next($request);
}
}
全局查询作用域实现数据过滤
为了避免在每个查询中都手动拼接tenant_id条件,我们可以创建一个全局作用域,自动为所有模型查询添加租户过滤条件。
<?php
namespace AppScopes;
use IlluminateDatabaseEloquentScope;
use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentBuilder;
use IlluminateHttpRequest;
class TenantScope implements Scope
{
protected $request;
public function __construct(Request $request)
{
$this->request = $request;
}
public function apply(Builder $builder, Model $model)
{
// 获取当前租户标识
$tenantId = $this->request->get('tenant_id');
if ($tenantId) {
// 自动拼接tenant_id查询条件
$builder->where('tenant_id', $tenantId);
}
}
}
然后在基础模型中注册这个全局作用域:
<?php
namespace AppModels;
use IlluminateDatabaseEloquentModel;
use AppScopesTenantScope;
use IlluminateSupportFacadesApp;
class BaseModel extends Model
{
protected static function boot()
{
parent::boot();
// 注册租户全局作用域
static::addGlobalScope(new TenantScope(App::make('request')));
}
}
数据写入时自动填充租户标识
除了查询时过滤,写入数据时也需要自动填充tenant_id字段,避免手动传入导致错误。我们可以在基础模型中重写save方法或者利用模型事件实现。
<?php
namespace AppModels;
use IlluminateDatabaseEloquentModel;
use IlluminateSupportFacadesApp;
class BaseModel extends Model
{
protected static function boot()
{
parent::boot();
// 利用creating事件自动填充tenant_id
static::creating(function ($model) {
$request = App::make('request');
$tenantId = $request->get('tenant_id');
if ($tenantId && empty($model->tenant_id)) {
$model->tenant_id = $tenantId;
}
});
}
}
注意事项
- 对于不需要租户隔离的表,比如系统配置表、公共字典表,不需要添加
tenant_id字段,也不要继承基础模型 - 如果需要跨租户查询数据,可以在查询时使用
withoutGlobalScope方法移除租户作用域 - 租户标识的获取逻辑需要根据实际业务场景调整,比如支持子租户的场景需要额外处理租户层级关系
- 数据库索引需要包含
tenant_id字段,提升查询效率,比如用户表的索引可以设置为(tenant_id, username)
基于数据库隔离租户数据的方案兼顾了安全性与实现成本,适合大多数中小型SaaS应用,开发者可以根据项目的租户规模、数据量大小选择合适的隔离模式,核心是保证租户标识的全链路传递与数据操作的自动过滤。