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

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

aliyosef.online

الاستوديو

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

البوابة

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

مصادر

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

النشرة

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

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

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

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

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

٢٢ آذار ٢٠٢٦·8 دقائق·بقلم علي يوسف

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

شاركXLinkedInRSS
استلم المقال التالي في بريدك.مقال جديد كل أحد. هندسة بلغة واضحة. مجاني، يمكنك إلغاء الاشتراك في أي وقت.اشترك→
AY
علي هيثم يوسف

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

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

اقرأ بعدها

  • MT
    Series
    ٨ نيسان ٢٠٢٦·تعدد المستأجرين

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

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

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

    Feature flags لكل tenant بدون spaghetti

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

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

    Audit logs اللي بتتوسّع فعلياً

    Append-only، partitioned، queryable. الـ schema اللي وقفنا عندها بعد ما خسرنا أسبوعين بالإنتاج.

    11 دقيقة
←العودة إلى كل المقالات

يضغط مستأجر «صدّر كلّ بياناتي». بعد ثلاث دقائق، كلّ مستأجر آخر على نفس قاعدة البيانات يحصل على timeout. خطّة الاستعلام السذجة فتحت transaction، مسحت كلّ جدول ذي صلة، وحملت أقفالاً مشتركة طوال المسير. كلّما طال تشغيل الصادرات، تكدّست استعلامات أكثر خلفها.

الصادرات تكسر قواعد البيانات المشتركة أكثر ممّا يظنّ الناس. هذه هي البنية التي لا تفعل.

ماذا تعني «صدّر كلّ شيء» فعلاً#

«أعطِ المستخدم كلّ بياناته» على نمط GDPR يتطلّب:

  1. كلّ صفّ، عبر كلّ جدول مرتبط بمستأجر، ينتمي لهذا المستأجر.
  2. مرتبط أو غير مُطبَّع كي تكون الصادرة مفهومة بدون معرفة الـ schema.
  3. يُسلَّم كملفّ قابل للتنزيل (CSV لكلّ جدول، عادة مضغوط، أو حزمة JSON).
  4. خلال نافذة وقت «معقولة» (نَعِدُ العملاء بـ ٢٤ ساعة؛ عملياً ينتهي في أقلّ من ساعة لـ ٩٩٪ من المستأجرين).

ما يفوّته الناس هو أنّ النقطتَين ١ و٤ تتعارضان على قاعدة بيانات مشتركة. النقطة ١ هي «صفوف كثيرة»؛ النقطة ٤ هي «صفوف كثيرة، بسرعة، بدون قفل الجدول». هذه هي المشكلة كلّها.

النمط الذي انكسر#

أوّل وظيفة صادرات شحنّاها فعلت الأمر البديهيّ:

DB::transaction(function () use ($tenant) {
    $bundle = [];
    foreach (TENANT_TABLES as $table) {
        $bundle[$table] = DB::table($table)
            ->where('tenant_id', $tenant->id)
            ->get();
    }
    return $bundle;
});

ثلاث مشاكل، بترتيب الشدّة:

  1. transaction واحدة. كلّ الاستعلامات تعمل داخل transaction واحدة كي تكون اللقطة متّسقة. تلك الـ transaction تمسك أقفالاً على نسخ صفوف InnoDB طوال الصادرات. صادرات طويلة الأمد تُعطّل كتّاباً آخرين.
  2. بدون تقطيع. كلّ get() يُجسِّد الجدول بأكمله لذلك المستأجر في الذاكرة. مستأجر بـ ٢٠٠٠٠٠ صفّ فاتورة يُفجّر ذاكرة العامل.
  3. بدون throttle. الصادرات تقرأ بأسرع ما تستطيع قاعدة البيانات خدمته، تتنافس مع حركة المستأجرين الحيّة على خانات الاتّصال و IO القرص.

الإصلاح يتضمّن التخلّي عن كلّ من تلك الثلاثة.

الشكل الذي نشحنه الآن#

class TenantExport implements ShouldQueue {
    public function handle(): void {
        $bundle = new ZipArchive();
        $bundle->open($this->path, ZipArchive::CREATE);

        foreach ($this->config->tables as $table => $cfg) {
            $this->dumpTable($table, $cfg, $bundle);
        }

        $bundle->close();
        Notification::route('mail', $this->tenant->email)
            ->notify(new ExportReady($this->path));
    }

    private function dumpTable(string $table, array $cfg, ZipArchive $zip): void {
        $stream = fopen('php://memory', 'r+');
        $writer = $this->csvWriter($stream, $cfg['columns']);

        DB::table($table)
            ->where('tenant_id', $this->tenant->id)
            ->orderBy('id')
            ->chunkById(2000, function ($rows) use ($writer) {
                foreach ($rows as $row) {
                    $writer->insertOne((array) $row);
                }
                usleep(200_000); // ٢٠٠ مللي ثانية تنفّس، كلّ chunk
            });

        rewind($stream);
        $zip->addFromString("{$table}.csv", stream_get_contents($stream));
        fclose($stream);
    }
}

خمسة تغييرات من النسخة المكسورة:

بدون transaction خارجيّة#

الحزمة متّسقة في النهاية. صفّ يُنشَأ خلال الصادرات قد يكون أو لا يكون فيها، حسب التوقيت. نقبل هذا. العملاء يهتمّون بالصادرات خلال ٢٤ ساعة؛ لا يهتمّون بأنّ اللقطة بدقّة الثانية. النصّ القانونيّ في سياسة الخصوصيّة لدينا يقول «الحالة الحاليّة وقت الصادرات، قد لا تتضمّن العمليّات من الستّين ثانية الأخيرة».

chunkById، لا chunk#

chunk في Laravel يستخدم OFFSET ... LIMIT، الذي يصبح أبطأ بشكل تربيعيّ على الجداول الكبيرة. chunkById يمشي WHERE id > last_seen ORDER BY id LIMIT N. وقت خطّيّ، خطّة متوقّعة، يستخدم الـ primary index.

كتابات CSV streaming#

league/csv يكتب في memory stream، ثمّ يُفرَّغ إلى الـ zip. على المستأجرين الكبار جدّاً نبدّل إلى ملفّ على القرص (tmpfile())؛ بقيّة الكود لا يتغيّر.

usleep(200ms) لكلّ chunk#

هذا هو الـ throttle. بدونه، الصادرات تحرق قاعدة البيانات. معه، الصادرات تستخدم تقريباً ١/٦ من IO المتاح في الذروة. وصلنا إلى ٢٠٠ مللي ثانية بالتنصيف حتّى توقّف المستأجرون الآخرون عن الإبلاغ عن بطء خلال الصادرات.

إيصال في النهاية#

وظيفة الصادرات تكتب صفّها الخاصّ إلى جدول tenant_exports: tenant_id، path، row_count، started_at، finished_at، requested_by. الإيصال هو ما يعرضه دعم العملاء حين يدّعي مستأجر أنّ صادراته لم تكتمل. بدونه، أنت تتجادل من الذاكرة.

throttle في العامل، لا في قاعدة البيانات

ستُغرى باستخدام throttle على مستوى قاعدة البيانات (read replicas، أدوار للقراءة فقط، أولويّة الاستعلام). هذه تحلّ مشكلة مختلفة. usleep لكلّ chunk غبيّ وفعّال: يُبقي الـ throughput متوقّعاً بغضّ النظر عن عدد الصادرات المتزامنة الجارية، لأنّ كلّ منها يُبطئ نفسه.

ماذا نضع في الحزمة#

الجداول في الحزمة ليست schema الخامّ. نُسقط إلى «schema صادرات مستقرّ» يُحفَظ بجانب الجدول:

const EXPORT_TABLES = [
    'invoices' => [
        'columns' => ['id', 'number', 'created_at', 'amount', 'status', 'notes'],
        'transform' => InvoiceExportTransform::class,
    ],
    // ...
];

الـ transform يملأ الحقول غير المُطبَّعة. للفواتير يربط اسم جهة الاتّصال، اسم الشركة، البنود كعمود JSON. للعميل الذي ينزّل الحزمة، «فاتورة ٤٧٢٩ لـ Acme Corp بـ ١٤٥٠ ر.س. في ٢٠٢٦-٠٥-٠٨» أكثر فائدة من «صفّ في invoices، صفّ في contacts، صفّ في line_items، ابحث عنه بنفسك».

ما لا نصدّره عمداً#

ثلاث فئات:

  1. بيانات الاعتماد المُهَشَّمة والمملّحة. المستخدم لديه؛ لسنا بحاجة لإظهارها.
  2. سجلّ تدقيق المستخدمين الآخرين. صادرات المستخدم تحتوي على تدقيقه الخاصّ، لا الشركة. وإلّا تسرّب صادرات المسؤول الموظّفين.
  3. التقارير المحسوبة. يمكن إعادة اشتقاقها من الجداول الأساسيّة؛ تضمينها يُضاعف حجم الصادرات بدون فائدة.

وثّق هذا في نصّ الخصوصيّة. العملاء الذين يسألون لماذا لا تحوي صادراتهم العنصر X أسهل في التعامل حين يكون X في قائمة منشورة.

ما اشتراه لنا هذا#

وظيفة الصادرات، على مستأجر بـ ٥٠٠٠٠ فاتورة، تستغرق حوالي ٤ دقائق، تذروة عند ١٨٪ CPU قاعدة البيانات، ولا تؤثّر على عمليّات أيّ مستأجر آخر. شغّلنا ستّ صادرات متزامنة عبر مستأجرين مختلفين بدون تدهور قابل للقياس على مسار القراءة الحيّ.

الجزء التالي من سلسلة تعدّد المستأجرين عن سجلّات تدقيق تتّسع فعلاً، وهي مشكلة متعلّقة بشدّة بقيود مختلفة جدّاً. صفحة السلسلة تتابع البقيّة.