DB::olap() في Laravel: تقارير PlanetScale الكبيرة بدون حد 100 ألف صف

تتيح DB::olap() في Laravel تشغيل تقارير وتصدير ضخمة على PlanetScale دون حد 100 ألف صف، مع إعادة آمنة للوضع. إليك الشروط والفخ الأهم.

إذا كانت قاعدة بياناتك على PlanetScale (المبنية على Vitess)، فربما اصطدمت باستعلام تقرير يعمل بسلاسة على MySQL المحلي ثم ينهار في الإنتاج بسبب حدود الصفوف. الحل المعروف كان أمرًا خامًا هو set workload = 'olap' يُكتب يدويًا، لكنه يخبّئ فخّين سنشرحهما أدناه. الدالة الجديدة DB::olap() تغلّف هذا الحل في سطر واحد آمن. الدالة ظهرت في Laravel 13.36 بحسب تغطية Laravel News، وأضافها Jack Bayliss.

ما المشكلة: حدود OLTP الافتراضية في PlanetScale

PlanetScale تعمل بوضع افتراضي موجّه لمعاملات التطبيق اليومية (OLTP). بحسب صفحة حدود النظام الرسمية، الحدود الافتراضية هي:

  • 100 ألف صف كحد أقصى لكل استعلام (إرجاعًا أو تحديثًا أو حذفًا).
  • 64 MiB كحجم أقصى لمجموع نتيجة الاستعلام.
  • 900 ثانية مهلة للاستعلام الواحد في وضع autocommit، و20 ثانية للمعاملة (transaction).

عندما تبني تقرير إيرادات لثلاث سنوات أو تصديرًا كاملًا إلى CSV، تتجاوز هذه الحدود بسهولة. يوفّر Vitess وضعًا آخر هو OLAP يرفع حد الصفوف المُرجَعة، ويُفعَّل بالأمر SET workload = OLAP.

لماذا لا يكفي الأمر الخام؟

وصف كاتب الـ Pull Request مشكلتين في الحل القديم، وهو تنفيذ DB::statement("set workload = 'olap'") قبل الاستعلام ثم الرجوع في finally:

  1. الأمر يُنفَّذ على اتصال الكتابة فقط. فإن كان استعلام select سيذهب إلى replica للقراءة، فقد يبقى في وضع OLTP وتظهر الحدود نفسها.
  2. إن نسيت إعادة الوضع إلى OLTP، فسيبقى الاتصال في OLAP ويؤثر على المهمة أو الطلب التالي، لأن الـ workload إعداد على مستوى الجلسة.

كيف تستخدم DB::olap()

تأخذ الدالة callback وتُعيد ما يُعيده، وتضبط الوضع على اتصالَي الكتابة والقراءة معًا، ثم تعيده إلى OLTP في كتلة finally حتى لو حدث استثناء. المثال التالي مأخوذ من تغطية Laravel News:

use Illuminate\Support\Facades\DB;

$revenue = DB::olap(fn () => Order::query()
    ->selectRaw("customer_id, date_format(created_at, '%Y-%m') as month, sum(total) as revenue")
    ->where('created_at', '>=', now()->subYears(3))
    ->groupBy('customer_id', 'month')
    ->get());

ولاتصال غير افتراضي، تُستدعى الدالة على الاتصال نفسه ويستلم الـ callback الاتصال:

DB::connection('analytics')->olap(function ($connection) {
    return $connection->table('page_views')
        ->selectRaw('path, count(*) as views')
        ->groupBy('path')
        ->get();
});

كما أُضيفت معها الدالة isVitess() لمعرفة إن كان الاتصال على Vitess/PlanetScale:

if (DB::connection()->isVitess()) {
    // سلوك خاص بـ PlanetScale
}

بحسب الوصف، تقرأ isVitess() نسخة الخادم (كما تفعل isMaria())، أي أنها تفتح اتصالًا بالقاعدة عند أول استدعاء.

الشروط والسلوك على القواعد الأخرى

  • الدالة معرّفة على MySqlConnection فقط، فاستدعاؤها على اتصال PostgreSQL أو SQLite الافتراضي يرمي BadMethodCallException.
  • على MySQL العادي (غير Vitess) تنفّذ الدالة الـ callback دون تغيير أي شيء، فيعمل الكود نفسه محليًا وفي الـ CI.
  • تحتاج Laravel بإصدار يحوي الدالة (13.36 أو أحدث بحسب Laravel News).

الفخ الأهم: الاستعلامات الكسولة (Lazy)

الدوال cursor() وlazy() وlazyById() تُرجع LazyCollection لا تنفّذ الاستعلام إلا عند التكرار عليها. فإن كتبت:

$orders = DB::olap(fn () => Order::query()->cursor());

foreach ($orders as $order) {
    // هنا يعمل الاستعلام فعليًا، بعد أن عاد الوضع إلى OLTP
}

فالاستعلام سيعمل خارج وضع OLAP. القاعدة: كرّر على النتيجة داخل الـ callback، أو استدعِ get() هناك. هذا مثال التصدير الصحيح داخل Job:

public function handle(): void
{
    $stream = fopen('php://temp', 'w+');

    fputcsv($stream, ['id', 'customer_id', 'total', 'created_at']);

    DB::olap(function () use ($stream) {
        Order::query()
            ->select('id', 'customer_id', 'total', 'created_at')
            ->lazyById(5000)
            ->each(fn (Order $order) => fputcsv($stream, [
                $order->id, $order->customer_id, $order->total, $order->created_at,
            ]));
    });

    rewind($stream);
    Storage::put($this->path, $stream);
}

تحفظات قبل أن تعتمد عليها

  • PlanetScale تنصح بتجنب OLAP إلا عند الضرورة. وثائقها توصي بإرسال هذه الاستعلامات إلى replica عند الإمكان، وبأن تكون جاهزًا لأخطاء إن انقطع الاتصال أثناء الاستعلام.
  • الحد لا يسقط بالكامل. بحسب الصفحة الرسمية، حد الـ 100 ألف صف يبقى ساريًا على التحديث (UPDATE) والحذف (DELETE)، والرفع ينطبق على الصفوف المُرجَعة. أما بقية الحدود (حجم النتيجة والمهلات) فالصفحة لا تذكر تغيّرها في OLAP، فلا تفترض أنها اختفت.
  • الأنسب: تصدير نادر، أو backfill، أو تقرير إداري يقرأ أكثر من 100 ألف صف. أما للتقارير المتكررة فالأفضل ترقيم الصفحات (pagination) أو نسخ البيانات إلى قاعدة تحليلية مستقلة.

متى تستخدمها ومتى تتجنبها

قبل أن تلفّ كل استعلام ثقيل بـ DB::olap()، اسأل نفسك ثلاثة أسئلة:

  1. هل الاستعلام يعمل مرة واحدة أو نادرًا؟ تصدير شهري أو backfill لمرة واحدة مرشحان مناسبان. أما صفحة تقارير يفتحها المستخدمون طوال اليوم فلا.
  2. هل يمكن تقسيم العمل؟ إن أمكنك معالجة البيانات على دفعات صغيرة بـ chunkById() فلن تحتاج أصلًا إلى رفع الحد، وستبقى في الوضع الموصى به للتطبيق.
  3. هل التقرير تحليلي بطبعه؟ إن كانت الاستعلامات التحليلية جزءًا دائمًا من منتجك، فنقل البيانات إلى قاعدة تحليلية مستقلة (كما تقترح PlanetScale) حل أنظف من إبقاء ضغط OLAP على قاعدة الإنتاج.

ونصيحة عملية: اجعل الـ Job الذي يستخدم DB::olap() صغيرًا ومحصورًا في مهمة واحدة، وضع المنطق الإضافي (التنسيق، الإرسال بالبريد) خارج الـ callback، حتى لا يبقى الاتصال في وضع OLAP أطول من اللازم. وبما أن الدالة تعمل كما هي على MySQL العادي، يمكنك تغطية الـ Job باختبارات محلية دون الحاجة إلى حساب PlanetScale.

الخلاصة

إن كنت على PlanetScale فاستبدل أوامر set workload اليدوية بـ DB::olap(): تحصل على إعادة تلقائية للوضع وتغطية للـ replica بسطر واحد. تذكّر ثلاثة أشياء: كرّر على النتائج الكسولة داخل الـ callback، واستخدمها للحالات النادرة فقط، ولا تتوقع أن ترفع حدود التحديث والحذف.

المصادر