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

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

aliyosef.online

الاستوديو

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

البوابة

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

مصادر

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

النشرة

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

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

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

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

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

١٤ آذار ٢٠٢٦·11 دقيقة·بقلم علي يوسف

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

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

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

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

اقرأ بعدها

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

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

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

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

    Feature flags لكل tenant بدون spaghetti

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

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

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

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

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

جدول سجلّ التدقيق وحش ثقيل الكتابة، نادر القراءة، طويل الاحتفاظ. سيكون أكبر جداولك خلال سنة، أبطأها في الاستعلام، أوّلها يطلب العملاء تصفيته، وآخرها يريد أحد لمسه. أخطئ في الشكل وسيأكل قاعدة بياناتك. أصحّ الشكل ويختفي.

هذا المقال عن الشكل الذي استقررنا عليه بعد أسبوعَين من الإنتاج قضيناهما في التعافي من إخطائه.

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

ثلاثة جماهير، لكلّ منها مطالب مختلفة:

  1. الامتثال. المدقّقون يحتاجون سجلّاً مقاوماً للعبث لمن فعل ماذا. لن يستعلموه مباشرة أبداً؛ سيطلبون صادرات.
  2. الدعم. دعم العملاء يقرأ «ماذا حدث للفاتورة ٤٧٢٩ يوم الثلاثاء الماضي.» يريدون عرضاً مقصوراً بالمستأجر، مقصوراً بالكيان، بنافذة زمنيّة. خمسة استعلامات يومياً لكلّ موظّف دعم، متوقّع أن يعود في أقلّ من ثانية.
  3. التحقيق. حين يحدث خطأ، يريد مهندس «أرني كلّ إجراء قام به هذا المستخدم في الـ ٢٤ ساعة الأخيرة» أو «أرني كلّ كتابة على جدول contacts». نادر، لكن لا يغفر بطء الاستجابة عند الحاجة.

التحسين لواحد دون التفكير في الآخرَين يعطيك سجلّاً يخيب آمال الثلاثة.

ما انكسر في الشهر التاسع#

أوّل سجلّ تدقيق لنا كان جدولاً عريضاً على نفس قاعدة البيانات مع كلّ شيء آخر:

CREATE TABLE audit_logs (
    id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tenant_id    BIGINT UNSIGNED NOT NULL,
    user_id      BIGINT UNSIGNED,
    action       VARCHAR(120) NOT NULL,
    resource     VARCHAR(120) NOT NULL,
    resource_id  BIGINT UNSIGNED,
    payload      JSON,
    ip           VARCHAR(45),
    user_agent   VARCHAR(255),
    created_at   TIMESTAMP NOT NULL
);
CREATE INDEX idx_audit_tenant_created ON audit_logs (tenant_id, created_at);

عمل بشكل جميل لأوّل بضعة أشهر. ثمّ ضربت أربعة أشياء دفعة واحدة:

  1. تجاوز الجدول ٤٠٠ مليون صفّ.
  2. استعلام «أرني الأسبوع الماضي» بدأ يعمل لـ ١٢ ثانية.
  3. وظيفة صيانة «DELETE FROM audit_logs WHERE created_at < ?» التي شغّلناها أسبوعيّاً أوقفت الـ replication لأربعين دقيقة.
  4. طلب عميل أن نحذف سجلّ تدقيق مستخدم واحد (حقّ الـ GDPR في المحو). فعل ذلك تطلّب حذفاً صفّاً صفّاً قفل الجدول لستّ دقائق، أثناءها حُجبت كلّ كتابة.

احتجنا شكلاً جديداً. هذا هو.

الشكل الذي يتّسع#

ثلاثة تغييرات، كلّ منها حامل ثقل بشكل مستقلّ:

١. التقسيم حسب الشهر#

تقسيم نطاق زمنيّ، على created_at، في تقسيمات شهريّة. تقسيم Postgres التصريحيّ أو MySQL PARTITION BY RANGE.

CREATE TABLE audit_logs (
    id           BIGINT UNSIGNED AUTO_INCREMENT,
    tenant_id    BIGINT UNSIGNED NOT NULL,
    user_id      BIGINT UNSIGNED,
    action       VARCHAR(120) NOT NULL,
    resource     VARCHAR(120) NOT NULL,
    resource_id  BIGINT UNSIGNED,
    payload      JSON,
    ip           VARCHAR(45),
    user_agent   VARCHAR(255),
    created_at   TIMESTAMP NOT NULL,
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (UNIX_TIMESTAMP(created_at)) (
    PARTITION p_2026_05 VALUES LESS THAN (UNIX_TIMESTAMP('2026-06-01')),
    PARTITION p_2026_06 VALUES LESS THAN (UNIX_TIMESTAMP('2026-07-01')),
    -- ...
    PARTITION p_future VALUES LESS THAN MAXVALUE
);

فوزان مباشرَين:

  • استعلامات النافذة الزمنيّة تتقلّص إلى تقسيم واحد. «الأسبوع الماضي» لم يعد يمسح ٤٠٠ مليون صفّ؛ يمسح تقسيم الشهر الماضي.
  • حذوفات الاحتفاظ تصبح ALTER TABLE DROP PARTITION p_2025_05، وهو فوريّ ولا يلمس الـ replication. انقطاع الأربعين دقيقة من قبل يصبح تغيير metadata.

٢. append-only على طبقة التطبيق#

الجدول لا يحوي UPDATE أو DELETE من كود التطبيق. أبداً. نفرض هذا بـ trigger قاعدة بيانات يرفع على أيّ عمليّة غير INSERT على جدول التدقيق:

CREATE TRIGGER audit_logs_no_updates
BEFORE UPDATE ON audit_logs
FOR EACH ROW
BEGIN
    SIGNAL SQLSTATE '45000'
    SET MESSAGE_TEXT = 'audit_logs is append-only';
END;

هذا ليس مسرحاً. الـ trigger التقط hotfix لـ «أصلح user_id» مرّتين ونداء update() منسيّ في خدمة ثلاث مرّات. كلّ منها كان قريباً من كارثة امتثال.

٣. كتابة async عبر queue مُخزَّن مؤقّتاً#

في النسخة المكسورة، كلّ كتابة كانت إدراجاً متزامناً في نفس transaction الإجراء الأصليّ. حتّى عند ١ مللي ثانية لكلّ إدراج، على المستأجرين عالي الـ throughput تراكم هذا. انتقلنا إلى:

class AuditLogger {
    public function log(string $action, $resource, array $payload = []): void {
        $entry = [
            'tenant_id'  => app(TenantContext::class)->id(),
            'user_id'    => auth()->id(),
            'action'     => $action,
            'resource'   => is_object($resource) ? class_basename($resource) : $resource,
            'resource_id'=> is_object($resource) ? $resource->id : null,
            'payload'    => json_encode($payload),
            'ip'         => request()->ip(),
            'user_agent' => substr((string) request()->userAgent(), 0, 255),
            'created_at' => now()->toDateTimeString(),
        ];
        $this->queue->push($entry);
    }
}

الـ queue هو Redis list يُفرَّغ كلّ ٥٠٠ مللي ثانية أو كلّ ١٠٠٠ إدخال (أيّهما أوّل) بواسطة عامل يقوم بإدراج متعدّد الصفوف واحد. زمن الاستجابة على المسار الحارّ ينخفض إلى ميكروثوانٍ. throughput الإدراج يرتفع بمقدار رتبة لأنّنا نُجمّع.

الـ buffer قرار متانة

الكتابات async المُخزَّنة مؤقّتاً يمكن أن تفقد إدخالات على انهيار بين الدفع والتفريغ. لسجلّات تدقيق تغذّي الامتثال، هذا غير مقبول؛ نستخدم Redis مع AOF persistence و fsync ١ ثانية، ومسار «إعادة من سجلّ الطلبات» للتعافي. تحقّق من أنّ نظام الامتثال لديك يتسامح مع الـ buffer قبل شحنه؛ إن لم يفعل، تراجع إلى الكتابة المتزامنة في transaction.

الاستعلام عنه دون إذابته#

ثلاثة indexes، كلّها تقود بـ tenant_id لأنّ قراءات التدقيق دائماً مقصورة بمستأجر:

-- البحث لكلّ instance من resource (استعلام الدعم)
CREATE INDEX idx_audit_tenant_resource ON audit_logs
  (tenant_id, resource, resource_id, created_at);

-- البحث لكلّ مستخدم (استعلام التحقيق)
CREATE INDEX idx_audit_tenant_user ON audit_logs
  (tenant_id, user_id, created_at);

ملاحظة: لا يوجد full-text index على payload. لا نستعلم محتويات JSON. إن احتجنا يوماً («أيّ حمولات احتوت بريد العميل هذا»)، نشحن ETL منفصلاً إلى search index. خلط تخزين التدقيق مع البحث على نفس الجدول يحرق الميزانيّة لكليهما.

ماذا تعني «حذف GDPR» هنا#

حقّ المحو على سجلّ append-only محرج. لا نحذف الصفوف. نحجبها: جدول منفصل audit_log_redactions يسجّل (audit_log_id, redacted_at, reason). حمولة الصفّ المحجوب تُستبدل بـ {"redacted": true} عبر وظيفة صيانة لقطة واحدة تعمل على الـ id المحدّد، على التقسيم المحدّد، في أوقات حركة منخفضة. ثبات الـ append-only ينجو لأنّنا لا نحذف؛ نُعيد كتابة حمولة صفّ نعرف أنّه موجود.

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

كيف تبدو لوحة المعلومات#

العرضالزمنماذا يفعل
نشاط الفاتورة ٤٧٢٩< ٢٠٠ مللي ثانيةقراءة لكلّ resource مقابل idx_audit_tenant_resource
آخر ٢٤ ساعة للمستخدم ١١٧< ٣٠٠ مللي ثانيةقراءة لكلّ مستخدم مقابل idx_audit_tenant_user
كلّ الأسبوع الماضي، هذا المستأجر< ٨٠٠ مللي ثانيةمسح تقسيم واحد
صادرات المدقّق، الربع الماضي< ٩٠ ثتيّار ثلاثة تقسيمات إلى S3
إسقاط احتفاظ، > ١٨ شهراً< ١ ثALTER TABLE DROP PARTITION

هذه الأرقام مع حوالي ٨٠٠ مليون صفّ إجمالاً، عبر ١٤ تقسيم شهريّ. الشكل، لا استراتيجيّة الـ index، هو ما يجعله سريعاً.

ما التالي#

الجزء التالي من سلسلة تعدّد المستأجرين يقفز إلى طبقة التطبيق: كيف نهيكل فحوص الأذونات بحيث لا يمكن لـ @can('edit', $resource) مفقود أن يدع مستأجراً بهدوء يحرّر بيانات مستأجر آخر. صفحة السلسلة تتابع البقيّة.