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

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

aliyosef.online

الاستوديو

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

البوابة

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

مصادر

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

النشرة

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

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

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

أضف سجلّات تدقيق لتطبيق Laravel

معظم تطبيقات سجلّ التدقيق تكون بخير حتّى يتجاوز الجدول عشرة ملايين صفّ، ثمّ يتدهور كلّ شيء دورة واحدة. هذا المختبر يمشي عبر سجلّ تدقيق بشكل Laravel مُصمَّم ليبقى قابلاً للاستعلام عند ١٠٠ مليون+ صفّ على MySQL تجاريّ: كتابات append-only، أقسام شهريّة، الفهارس الصحيحة، وطبقة استعلام لا تنهار قاعدة البيانات حين يسأل المحاسب "من غيّر هذه الفاتورة".

ابدأ المختبر↓افتح المستودع↖
دقيقة
45
الـ STACK
Laravel
المستوى
متوسّط
/02 — التهيئة

ما ستبنيه

جدول سجلّ تدقيق أدنى ينجو من ١٠٠ مليون صفّ. append-only، مُقسَّم، قابل للاستعلام.

المتطلّبات

  1. 01Laravel 10 أو 11 + MySQL 8 (أو PostgreSQL 14+).
  2. 02شغّلت migrations من قبل. تعرف ماذا يفعل الفهرس.
  3. 03تطبيق اختبار به نموذج واحد على الأقلّ تريد تسجيل تغييراته.
/03 — الشرح
/04 — موارد

خذها معك.

  • ⌥
    المستودعالكود الابتدائيّ على GitHub.
  • ↓
    تحميل zipنفس الكود، بدون git.
/يُستخدم في

دورات تتزاوج مع هذا المختبر.

  • محرّكات المزامنة، من طرف لطرف→
  • Laravel متعدّد المستأجرين→
/05 — التالي
جرّب هذا تالياً٠١ابنِ queue في ٦٠ دقيقةمختبر آخر قصير ومجّانيّ.→
أو اذهب إلى العمق

Laravel متعدّد المستأجرين

الدورة الكاملة تمتدّ بنمط سجلّ التدقيق هذا إلى تقسيم لكلّ مستأجر، سياسات الاحتفاظ، وطبقة الـ broadcasting التي شُحنت في الإنتاج لنفس الـ SaaS.

شاهد الدورة←

لماذا تموت معظم جداول سجلّ التدقيق#

النهج المعياريّ "ضعها في audit_logs وأضف فهرساً" يعمل حتّى الصفّ ١٠ ملايين، ثمّ تبدأ الكتابات بحجب القراءات، يتجزّأ الفهرس، ويستغرق استعلام فريق الدعم "من غيّر هذه الفاتورة الثلاثاء الماضي" ٤٠ ثانية. الإصلاح ليس "أضف فهارس أكثر". الإصلاح هو تصميم الجدول لنمط الوصول من اليوم الأوّل: append-only، مُقسَّم بالشهر، مع فهرسَين يطابقان الاستعلامَين الوحيدَين اللذَين ستشغّلهما.

الخطوة ١: الجدول#

CREATE TABLE audit_logs (
  id           BIGINT UNSIGNED AUTO_INCREMENT,
  occurred_at  DATETIME(6) NOT NULL,
  actor_id     BIGINT UNSIGNED NULL,
  actor_type   VARCHAR(60) NOT NULL,
  entity_id    BIGINT UNSIGNED NOT NULL,
  entity_type  VARCHAR(60) NOT NULL,
  action       VARCHAR(40) NOT NULL,
  payload      JSON NULL,
  PRIMARY KEY (id, occurred_at),
  KEY idx_entity (entity_type, entity_id, occurred_at),
  KEY idx_actor  (actor_type, actor_id, occurred_at)
)
ENGINE=InnoDB
PARTITION BY RANGE (TO_DAYS(occurred_at)) (
  PARTITION p_2026_01 VALUES LESS THAN (TO_DAYS('2026-02-01')),
  PARTITION p_2026_02 VALUES LESS THAN (TO_DAYS('2026-03-01')),
  PARTITION p_max     VALUES LESS THAN MAXVALUE
);

ثلاثة أشياء تبدو صغيرة لكنّها ليست:

  1. المفتاح الأساسيّ مركّب. (id, occurred_at). MySQL يتطلّب أن يكون كلّ عمود تقسيم في كلّ مفتاح فريد. أسقط occurred_at من الـ PK ويفشل التقسيم على ALTER.
  2. الفهرسان (entity_type, entity_id, occurred_at) و (actor_type, actor_id, occurred_at). هذان يطابقان الاستعلامَين الحقيقيَّين الوحيدَين: "أرني تاريخ هذا الكيان" و "أرني ماذا فعل هذا المستخدم". أيّ فهرس آخر تورّم تخمينيّ.
  3. الـ payload JSON، ليس مجموعة صفوف منمذجة من field_name/old_value/new_value. التدقيق ليس مستودع بياناتك. تكتب مرّة وتقرأ نادراً؛ JSON مُسطَّح غير منمذج هو الصحيح هنا.

الخطوة ٢: الـ Laravel migration#

public function up(): void
{
    DB::statement(<<<'SQL'
        CREATE TABLE audit_logs (
            id           BIGINT UNSIGNED AUTO_INCREMENT,
            occurred_at  DATETIME(6) NOT NULL,
            actor_id     BIGINT UNSIGNED NULL,
            actor_type   VARCHAR(60) NOT NULL,
            entity_id    BIGINT UNSIGNED NOT NULL,
            entity_type  VARCHAR(60) NOT NULL,
            action       VARCHAR(40) NOT NULL,
            payload      JSON NULL,
            PRIMARY KEY (id, occurred_at),
            KEY idx_entity (entity_type, entity_id, occurred_at),
            KEY idx_actor  (actor_type, actor_id, occurred_at)
        )
        ENGINE=InnoDB
        PARTITION BY RANGE (TO_DAYS(occurred_at)) (
            PARTITION p_max VALUES LESS THAN MAXVALUE
        )
    SQL);
}

نبدأ بقسم وحيد p_max ونضيف أقساماً شهريّة عبر أمر مجدول (الخطوة التالية). محاولة تعريف كلّ قسم مقدّماً شكل خاطئ: ستحتاج migration كلّ شهر إلى الأبد.

الخطوة ٣: مُدوِّر الأقسام#

وظيفة مجدولة تشغّل في الأوّل من كلّ شهر وتضيف القسم التالي قبل أن تستطيع أيّ صفوف الهبوط في p_max. أدناه نسخة مدمجة تعيش في app/Console/Commands/RotateAuditPartitions.php:

public function handle(): int
{
    $next = now()->addMonth()->startOfMonth();
    $name = 'p_' . $next->format('Y_m');
    $boundary = $next->copy()->addMonth()->format('Y-m-d');

    DB::statement("ALTER TABLE audit_logs REORGANIZE PARTITION p_max INTO (
        PARTITION {$name} VALUES LESS THAN (TO_DAYS('{$boundary}')),
        PARTITION p_max   VALUES LESS THAN MAXVALUE
    )");

    return self::SUCCESS;
}

جدوله في app/Console/Kernel.php:

$schedule->command('audit:rotate-partitions')->monthlyOn(1, '02:00');

نمطان من الفشل في الصباح الباكر يستحقّ معرفتهما. أوّلاً، REORGANIZE حاجب على MySQL 5.x؛ على 8.0 مع ALGORITHM=INPLACE ليس كذلك، لكنّه يقفل metadata لحظيّاً. جدوله في نافذة الازدحام المنخفض. ثانياً، إن تخطّيت شهراً (فشل وظيفة، خادم متوقّف)، التشغيل التالي بخير: يعيد بناء p_max من أيّ حدّ هو فيه.

الخطوة ٤: الكاتب#

الكاتب مراقب Eloquent واحد بالإضافة إلى وظيفة queue. المراقب يلتقط التغيير ذرّيّاً؛ وظيفة الـ queue تستديمه. تقسيمهما يُبقي مسار الطلب سريعاً.

class AuditedObserver
{
    public function updated(Model $model): void
    {
        if (! $model->isDirty()) return;

        AuditLog::dispatch(
            actorId:    auth()->id(),
            actorType:  auth()->user() ? 'user' : 'system',
            entityId:   $model->getKey(),
            entityType: $model->getMorphClass(),
            action:     'updated',
            payload:    $model->getDirty(),
        );
    }
}

وظيفة الـ queue:

class AuditLog implements ShouldQueue
{
    public function handle(): void
    {
        DB::table('audit_logs')->insert([
            'occurred_at' => now()->format('Y-m-d H:i:s.u'),
            'actor_id'    => $this->actorId,
            'actor_type'  => $this->actorType,
            'entity_id'   => $this->entityId,
            'entity_type' => $this->entityType,
            'action'      => $this->action,
            'payload'     => json_encode($this->payload),
        ]);
    }
}

لاحظ دقّة DATETIME(6) في الطابع الزمنيّ. الميكروثواني تهمّ حين تهبط كتابتان في نفس المللي ثانية وتحتاج ترتيباً حتميّاً لعرض تاريخ الكيان.

الخطوة ٥: الاستعلامان الوحيدان اللذان ستشغّلهما على الإطلاق#

// تاريخ الكيان
AuditLog::query()
    ->where('entity_type', 'invoice')
    ->where('entity_id', $invoiceId)
    ->orderByDesc('occurred_at')
    ->limit(50)
    ->get();

// نشاط الفاعل
AuditLog::query()
    ->where('actor_type', 'user')
    ->where('actor_id', $userId)
    ->orderByDesc('occurred_at')
    ->limit(50)
    ->get();

كلاهما يجب أن يعود في مللي ثوان من خانة واحدة على أيّ مقياس. إن لم يفعلا، الفهرس خطأ، ليس التقسيم. اعمل EXPLAIN للاستعلام وأكّد ظهور Using index في عمود Extra.

ما ليس عليه هذا المختبر#

هذا الجدول والكاتب. ليس سياسة احتفاظ (حذف الأقسام الأقدم من X)، ليس تحديد نطاق لكلّ مستأجر (كلّ صفّ يحمل tenant_id ضمنيّاً في الإنتاج)، وليس طبقة broadcast (كي تتحدّث واجهة المسؤول مباشرةً حين يهبط صفّ تدقيق). دورة Laravel متعدّد المستأجرين الكاملة تغطّي الثلاثة فوق هذا الجدول بالضبط.