انتقل للمحتوى
علي هيثم·TECH·يردّ خلال أقل من ساعة
  • الرئيسيةابدأ من هنا
  • الأعمالدراسات حالة، مشاريع
  • الخدماتما الذي سأبنيه لك
  • المدونةمقالات، سلاسل
  • التدريبدورات، مختبرات، دورات
  • عنّيالمهندس وراء هذا الموقع
تسجيل الدخولابدأ مشروع
علي هيثم · TECH
  • الرئيسية↗
  • الأعمال↗
  • الخدمات↗
  • المدونة↗
  • التدريب↗
  • عنّي↗
تسجيل الدخولابدأ مشروع
متاح
علي هيثم·TECH

استوديو هندسي. دمشق، GMT+3.

aliyosef.online

الاستوديو

  • الأعمال
  • المدونة
  • التدريب
  • عنّي
  • تواصل معي

البوابة

  • تسجيل الدخول
  • فتح تذكرة دعم
  • متابعة مشروع
  • حسابي

مصادر

  • الوثائق
  • حالة الأنظمة
  • سجلّ التغييرات
  • حزمة العلامة
  • الخصوصية
  • الشروط

النشرة

ملاحظات تقنية وأخبار، أسبوعياً. بدون حشو.

مجاني. إلغاء الاشتراك متى ما حبيت.

تابعني على
© 2026 علي هيثم يوسف. جميع الحقوق محفوظة.مبنيّ يدويًا بـ React 19.آخر نشر · 2026-05-08كل الأنظمة تعمل
دورةLaravel متعدّد المستأجرين↗
  1. الرئيسية/
  2. المدوّنة/
  3. تعدد المستأجرين
تعدد المستأجرين

Laravel متعدد المستأجرين، الجزء الأول: خيارات الـ schema

افتتاح السلسلة. Schema-per-tenant مقابل row-per-tenant، مسار الـ migration، والـ queries اللي انكسرت.

٨ نيسان ٢٠٢٦·12 دقيقة·بقلم علي يوسف
جزء منmulti-tenant-laravel→

جاري تحميل المقال…

شاركXLinkedInRSS
في هذه السلسلةالجزء 2 من 2
←السابقLaravel متعدد المستأجرين، الجزء الثاني: queues لكل tenantنظرة عامة على السلسلة→
استلم المقال التالي في بريدك.مقال جديد كل أحد. هندسة بلغة واضحة. مجاني، يمكنك إلغاء الاشتراك في أي وقت.اشترك→
AY
علي هيثم يوسف

مهندس وكاتب. أبني منصات متعدّدة المستأجرين، محرّكات مزامنة، وأنظمة موثوقة بهدوء. أكثر من ٦٠٠ مشروع مُسلَّم منذ ٢٠١٥.

اشتركابدأ مشروعاً→

اقرأ بعدها

  • M2
    Series
    ٢٠ تشرين الثاني ٢٠٢٥·تعدد المستأجرين

    Laravel متعدد المستأجرين، الجزء الثاني: queues لكل tenant

    كيف Horizon بيتعامل مع عدالة الـ tenants لما واحد بحاول يستحوذ على الـ workers.

    11 دقيقة
  • FF
    ٣٠ آذار ٢٠٢٦·تعدد المستأجرين

    Feature flags لكل tenant بدون spaghetti

    ثلاث سنين تشغيل flags. البنية اللي قبلت توسّع. والبنية اللي ما قبلت.

    9 دقائق
  • TD
    ٢٢ آذار ٢٠٢٦·تعدد المستأجرين

    تصدير بيانات المستأجر بدون كسر الـ DB

    تصدير بمستوى GDPR على بنية مشتركة. الـ query plan، الـ throttle، والإيصال.

    8 دقائق
←العودة إلى كل المقالات

هناك طريقتان لإضافة تعدّد المستأجرين إلى تطبيق Laravel، والاختيار الذي تتّخذه في الأسبوع الأوّل يُشكّل السنوات الثلاث القادمة. هذا هو الجزء الأوّل من أربعة عن تشغيل Laravel متعدّد المستأجرين بصدق. نبدأ بالـ schema لأنّ الـ schema يقرّر كلّ شيء آخر تقريباً: كيف تعمل الترحيلات، كيف تعمل النسخ الاحتياطيّة، كيف يقاس الأداء، كيف يُمنع التسرّب.

المدرستان#

الأدبيّات تعطي إجابتَين، ومعظم الفرق تختار واحدة دون أن تدرك أنّها اختارت.

Schema لكلّ مستأجر (قاعدة بيانات أو schema منفصلة)#

كلّ مستأجر يحصل على قاعدة بياناته الخاصّة (أو، في Postgres، schema خاصّ به داخل قاعدة بيانات مشتركة). النماذج تشير إلى connection يُختار وقت الطلب بناء على المستأجر النشط.

class TenantManager {
    public function bootForTenant(Tenant $tenant): void {
        config(['database.connections.tenant.database' => $tenant->database_name]);
        DB::purge('tenant');
        DB::reconnect('tenant');
    }
}

النتيجة: جدار صلب بين بيانات المستأجرين. أيّ استعلام في هذا الطلب يرى صفوف ذلك المستأجر فقط. النسخ الاحتياطيّة لكلّ مستأجر. القياس لكلّ مستأجر. لا يوجد عمود tenant_id على أيّ شيء.

صفّ لكلّ مستأجر (قاعدة بيانات مشتركة، مقصور بعمود)#

كلّ المستأجرين يتشاركون قاعدة بيانات واحدة. كلّ جدول لديه عمود tenant_id. كلّ استعلام مقصور بها. النماذج لديها global scope:

class TenantScope implements Scope {
    public function apply(Builder $builder, Model $model): void {
        $builder->where('tenant_id', auth()->user()->tenant_id);
    }
}

abstract class TenantModel extends Model {
    protected static function booted(): void {
        static::addGlobalScope(new TenantScope());
        static::creating(function ($model) {
            $model->tenant_id ??= auth()->user()->tenant_id;
        });
    }
}

النتيجة: قاعدة بيانات واحدة لإدارتها، لكنّ سلامة النظام تعتمد على مرور كلّ استعلام عبر ذلك الـ scope. اتّصال واحد مفقود بـ withoutGlobalScopes() ومستأجر يرى بيانات مستأجر آخر.

المقايضة الصادقة#

Schema لكلّ مستأجر له قصّة عزل نظيفة. لديه أيضاً مشكلة لا يحذّرك أحد منها: كلفة التشغيل تتراكم. إن كان لديك مئة مستأجر، لديك مئة قاعدة بيانات. كلّ منها يحتاج:

  • ترحيلاته الخاصّة لتُشغَّل (متزامنة، بشكل مثاليّ)
  • نسخه الاحتياطيّة الخاصّة
  • خانته الخاصّة في connection pool في عمليّات الـ workers
  • مراقبته الخاصّة
  • فساده المحتمل الخاصّ ليُعالَج

حين ينكسر schema لكلّ مستأجر، ينكسر لمستأجر واحد في كلّ مرّة، بينما يستمرّ الجميع. هذا جيّد. حين تنكسر الترحيلات، تنكسر لمستأجر واحد فقط، بينما تقضي اليوم التالي تُصالح. هذا سيّئ.

صفّ لكلّ مستأجر لا يوجد لديه تراكم تشغيليّ. هناك قاعدة بيانات واحدة، مجموعة ترحيلات واحدة، سياسة نسخ احتياطيّ واحدة. المشكلة هي أنّ كلّ استعلام يبعد scope مفقود واحد عن تسرّب، وعلى نطاق ينتهي tenant_id في كلّ index، كلّ where، كلّ join.

حيث تنتهي معظم الفرق

الوسط البراغماتيّ هو: schema مشترك، مقصور بعمود، مع tenant_id على كلّ جدول وكلّ index، بالإضافة إلى مجموعة اختبارات مخصّصة تُفشل الـ CI إن كان أيّ نموذج يفتقد global scope. كلفة التشغيل تبقى منخفضة، خطر التسرّب محتوى بالأدوات.

لماذا اخترنا صفّ لكلّ مستأجر#

لمنتج محاسبة يُشحَن للشركات الصغيرة والمتوسّطة، عدد المستأجرين بالمئات وينمو. عمليّات لكلّ مستأجر كانت ستأكل مهندساً بدوام كامل بحلول السنة الثانية. خطر التسرّب حقيقيّ لكنّه ميكانيكيّ، والمخاطر الميكانيكيّة يمكن تخفيفها ميكانيكيّاً:

  1. كلّ نموذج مقصور بمستأجر يمتدّ من TenantModel، و TenantModel يضيف الـ global scope و hook الـ creating تلقائيّاً.
  2. CI يشغّل فحص تحليل ساكن يُعلِّم أيّ نموذج لا يمتدّ من TenantModel ويستخدم جدولاً مرتبطاً بمستأجر. هذا الفحص هو الشيء الوحيد الذي يلتقط بشكل ثابت النماذج الجديدة التي نسيت الفئة الأمّ.
  3. كلّ اختبار API يعمل مقابل fixture بمستأجرَين ويؤكّد أنّ المستأجر A لا يستطيع رؤية صفوف المستأجر B. لا استثناءات.

فحص التحليل الساكن هو الأكثر تقديراً ناقصاً من الثلاثة. مراجعة الكود ستفوّت هذا؛ الاختبارات ستفوّت هذا إن كان كاتب الاختبار قد ارتكب الخطأ أيضاً. الـ linter لا يفعل.

ما يذهب على كلّ جدول#

ثلاثة أعمدة، كلّ مرّة:

ALTER TABLE invoices
  ADD COLUMN tenant_id BIGINT UNSIGNED NOT NULL,
  ADD COLUMN created_by BIGINT UNSIGNED,
  ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
    ON UPDATE CURRENT_TIMESTAMP;

CREATE INDEX invoices_tenant_id_idx ON invoices (tenant_id);

-- كلّ index من نوع "where هذا وذاك" يحصل على tenant_id كعمود قائد
CREATE INDEX invoices_tenant_status_idx ON invoices (tenant_id, status);
CREATE INDEX invoices_tenant_due_idx ON invoices (tenant_id, due_date);

سياسة الـ index الثالثة هي التي تهمّ للأداء. index على status لا يقود بـ tenant_id لا يمكن استخدامه من قِبَل استعلام مقصور بمستأجر بدون خطوة فرز. أخطئ في هذا على جدول حارّ وستنهار خطط استعلامك حول الشهر التاسع، حين لا يكون أيّ مستأجر فرديّ ضخماً لكنّ مجموع عدد الصفوف ضخم.

الترحيلات التي كسرتنا#

ثلاثة أنماط ظللنا نصطدم بها:

إضافة NOT NULL بدون backfill#

إضافة عمود غير قابل للـ null إلى جدول مرتبط بمستأجر على قاعدة بيانات مشتركة يقفل الجدول طوال إعادة الكتابة. مع مئة مستأجر، يشعر بهذا القفل الجميع. الإصلاح هو رقصة expand-then-contract: شحن العمود قابل للـ null، backfill على دفعات، ثمّ إضافة القيد.

إعادة تسمية عمود على مسار حارّ#

إعادة تسمية عمود على جدول مشترك هي إعادة هيكلة منسّقة عبر كلّ مستأجر في وقت واحد. النمط هو إضافة العمود الجديد، الكتابة المزدوجة من التطبيق لإصدار واحد، backfill، ثمّ إسقاط القديم. ثلاث عمليّات نشر، بدون downtime.

إسقاط عمود «لا يستخدمه أحد»#

لا أحد يستخدمه على المستأجر ٤١ لأنّ المستأجر ٤١ صغير. المستأجر ٧ لديه استعلام لوحة معلومات يلمسه. الترحيل الذي يُسقطه يُسقط المستأجر ٧. الدرس: لا تنظّف «أعمدة غير مستخدمة» على schemas مشتركة، أبداً، دون البحث في سجلّ استعلامات كلّ مستأجر الأخير.

ما التالي#

الجزء الثاني من هذه السلسلة عن الـ queues. تحديداً، ما يحدث حين يكون لمستأجر استيراد بمليون صفّ والـ ٩٩ الآخرون يريدون فقط أن تنتهي مزامنتهم اليوميّة في أقلّ من ثانية.

صفحة السلسلة تتابع البقيّة.