لماذا Multi-Tenancy؟
Multi-Tenancy هو نمط معماري تقوم فيه نسخة واحدة من التطبيق بخدمة عملاء (مستأجرين) متعددين، ولكل منهم بياناته المعزولة. تخيّل الأمر كمبنى سكني — مبنى واحد، شقق متعددة، ولكل شقة قفلها ومفتاحها الخاص.
إذا كنت تبني منتج SaaS، ستواجه حتماً هذا القرار: كيف أعزل بيانات كل مستأجر؟ الإجابة لها تأثيرات كبيرة على الأمان والأداء والتكلفة والتعقيد.
الاستراتيجيات الثلاث
1. قاعدة بيانات واحدة، مخطط مشترك
كل جدول يحتوي على عمود tenant_id. هذا هو الأسلوب الأبسط لكنه يتطلب انضباطاً — إذا نسيت جملة where واحدة فقد تتسرب البيانات.
// A global scope handles tenant filtering automatically
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if (auth()->check()) {
$builder->where($model->getTable() . '.tenant_id', auth()->user()->tenant_id);
}
}
}
// Apply it via a trait
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope(new TenantScope);
static::creating(function (Model $model) {
if (auth()->check()) {
$model->tenant_id = auth()->user()->tenant_id;
}
});
}
}
// Usage is invisible to the developer
class Invoice extends Model
{
use BelongsToTenant;
}
// This query is automatically scoped
Invoice::all(); // SELECT * FROM invoices WHERE tenant_id = 1
المزايا: نشر بسيط، نسخ احتياطي سهل، تكلفة منخفضة.
العيوب: خطر تسرب البيانات، صعوبة توسيع نطاق المستأجرين بشكل فردي، مشكلة الجار المزعج.
2. قاعدة بيانات واحدة، مخططات منفصلة
يحصل كل مستأجر على مخطط قاعدة بيانات خاص به (أو بادئة). هذا يوفر عزلاً أفضل دون تحمّل عبء قواعد بيانات متعددة.
// Dynamically set the table prefix per tenant
class TenantDatabaseManager
{
public function connect(Tenant $tenant): void
{
config(['database.connections.tenant.prefix' => "tenant_{$tenant->id}_"]);
DB::purge('tenant');
DB::reconnect('tenant');
}
}
3. قاعدة بيانات لكل مستأجر
يحصل كل مستأجر على قاعدة بيانات خاصة به. أقصى درجات العزل، لكن أعلى تعقيد تشغيلي.
// config/database.php — the tenant connection is dynamic
'tenant' => [
'driver' => 'mysql',
'database' => '', // Set at runtime
'host' => env('DB_HOST', '127.0.0.1'),
// ...
],
// Tenant middleware resolves and connects
class IdentifyTenant
{
public function handle(Request $request, Closure $next)
{
$tenant = Tenant::where('domain', $request->getHost())->firstOrFail();
config([
'database.connections.tenant.database' => "tenant_{$tenant->id}",
]);
DB::purge('tenant');
app()->instance(Tenant::class, $tenant);
return $next($request);
}
}
استراتيجيات تحديد المستأجر
كيف تعرف أي مستأجر ينتمي إليه الطلب؟ الأساليب الشائعة:
- النطاق الفرعي:
acme.yourapp.com— أنيق، احترافي، سهل التحليل - بادئة المسار:
yourapp.com/acme/dashboard— إعداد DNS أبسط - نطاق مخصص:
app.acme.com— يتطلب شهادة SSL بحرف بدل أو شهادات لكل نطاق - ترويسة الطلب:
X-Tenant-ID: acme— شائع في واجهات API
// Subdomain resolver
class SubdomainTenantResolver implements TenantResolver
{
public function resolve(Request $request): ?Tenant
{
$subdomain = explode('.', $request->getHost())[0];
return Tenant::where('slug', $subdomain)->first();
}
}
إدارة عمليات الترحيل
مع نمط قاعدة بيانات لكل مستأجر، تحتاج إلى تشغيل عمليات الترحيل عبر جميع قواعد بيانات المستأجرين:
// artisan command: php artisan tenants:migrate
class TenantMigrate extends Command
{
protected $signature = 'tenants:migrate {--tenant=}';
public function handle(): void
{
$tenants = $this->option('tenant')
? Tenant::where('id', $this->option('tenant'))->get()
: Tenant::all();
$tenants->each(function (Tenant $tenant) {
$this->info("Migrating tenant: {$tenant->name}");
config(['database.connections.tenant.database' => "tenant_{$tenant->id}"]);
DB::purge('tenant');
Artisan::call('migrate', [
'--database' => 'tenant',
'--path' => 'database/migrations/tenant',
'--force' => true,
]);
$this->info(Artisan::output());
});
}
}
أي استراتيجية يجب أن تختار؟
| العامل | مخطط مشترك | مخطط منفصل | قاعدة بيانات منفصلة |
|---|---|---|---|
| العزل | منخفض | متوسط | عالٍ |
| التعقيد | منخفض | متوسط | عالٍ |
| التكلفة | منخفضة | متوسطة | عالية |
| عدد المستأجرين | آلاف | مئات | عشرات إلى مئات |
| الامتثال (GDPR) | أصعب | أسهل | الأسهل |
ابدأ بأسلوب المخطط المشترك. انتقل إلى قواعد بيانات منفصلة فقط عندما تتطلب المتطلبات التنظيمية ذلك أو يطلبه مستأجر ذو قيمة عالية. العزل المبكر هو شكل من أشكال الإفراط في الهندسة.
النقاط الرئيسية
- استخدم Global Scopes لفرض عزل المستأجرين تلقائياً
- اكتب دائماً اختبارات تتحقق من عدم إمكانية تسرب البيانات بين المستأجرين
- فكّر في استخدام حزم مثل
stancl/tenancyلإعدادات قاعدة بيانات لكل مستأجر - خزّن نتيجة تحديد المستأجر مؤقتاً — فهي تحدث مع كل طلب
- خطط لاستراتيجية الترحيل قبل أن يصل عدد المستأجرين إلى 100