معظم تطبيقات سجلّ التدقيق تكون بخير حتّى يتجاوز الجدول عشرة ملايين صفّ، ثمّ يتدهور كلّ شيء دورة واحدة. هذا المختبر يمشي عبر سجلّ تدقيق بشكل Laravel مُصمَّم ليبقى قابلاً للاستعلام عند ١٠٠ مليون+ صفّ على MySQL تجاريّ: كتابات append-only، أقسام شهريّة، الفهارس الصحيحة، وطبقة استعلام لا تنهار قاعدة البيانات حين يسأل المحاسب "من غيّر هذه الفاتورة".
النهج المعياريّ "ضعها في 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=InnoDBPARTITION 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);
ثلاثة أشياء تبدو صغيرة لكنّها ليست:
المفتاح الأساسيّ مركّب. (id, occurred_at). MySQL يتطلّب أن يكون كلّ عمود تقسيم في كلّ مفتاح فريد. أسقط occurred_at من الـ PK ويفشل التقسيم على ALTER.
الفهرسان (entity_type, entity_id, occurred_at) و (actor_type, actor_id, occurred_at). هذان يطابقان الاستعلامَين الحقيقيَّين الوحيدَين: "أرني تاريخ هذا الكيان" و "أرني ماذا فعل هذا المستخدم". أيّ فهرس آخر تورّم تخمينيّ.
الـ payload JSON، ليس مجموعة صفوف منمذجة من field_name/old_value/new_value. التدقيق ليس مستودع بياناتك. تكتب مرّة وتقرأ نادراً؛ JSON مُسطَّح غير منمذج هو الصحيح هنا.
وظيفة مجدولة تشغّل في الأوّل من كلّ شهر وتضيف القسم التالي قبل أن تستطيع أيّ صفوف الهبوط في 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;}
نمطان من الفشل في الصباح الباكر يستحقّ معرفتهما. أوّلاً، REORGANIZE حاجب على MySQL 5.x؛ على 8.0 مع ALGORITHM=INPLACE ليس كذلك، لكنّه يقفل metadata لحظيّاً. جدوله في نافذة الازدحام المنخفض. ثانياً، إن تخطّيت شهراً (فشل وظيفة، خادم متوقّف)، التشغيل التالي بخير: يعيد بناء p_max من أيّ حدّ هو فيه.
لاحظ دقّة 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 متعدّد المستأجرين الكاملة تغطّي الثلاثة فوق هذا الجدول بالضبط.