Audit logs اللي بتتوسّع فعلياً
Append-only، partitioned، queryable. الـ schema اللي وقفنا عندها بعد ما خسرنا أسبوعين بالإنتاج.
جاري تحميل المقال…
Append-only، partitioned، queryable. الـ schema اللي وقفنا عندها بعد ما خسرنا أسبوعين بالإنتاج.
جاري تحميل المقال…
جدول سجلّ التدقيق وحش ثقيل الكتابة، نادر القراءة، طويل الاحتفاظ. سيكون أكبر جداولك خلال سنة، أبطأها في الاستعلام، أوّلها يطلب العملاء تصفيته، وآخرها يريد أحد لمسه. أخطئ في الشكل وسيأكل قاعدة بياناتك. أصحّ الشكل ويختفي.
هذا المقال عن الشكل الذي استقررنا عليه بعد أسبوعَين من الإنتاج قضيناهما في التعافي من إخطائه.
ثلاثة جماهير، لكلّ منها مطالب مختلفة:
التحسين لواحد دون التفكير في الآخرَين يعطيك سجلّاً يخيب آمال الثلاثة.
أوّل سجلّ تدقيق لنا كان جدولاً عريضاً على نفس قاعدة البيانات مع كلّ شيء آخر:
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);عمل بشكل جميل لأوّل بضعة أشهر. ثمّ ضربت أربعة أشياء دفعة واحدة:
احتجنا شكلاً جديداً. هذا هو.
ثلاثة تغييرات، كلّ منها حامل ثقل بشكل مستقلّ:
تقسيم نطاق زمنيّ، على 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.الجدول لا يحوي 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() منسيّ في خدمة ثلاث مرّات. كلّ منها كان قريباً من
كارثة امتثال.
في النسخة المكسورة، كلّ كتابة كانت إدراجاً متزامناً في نفس 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 الإدراج يرتفع بمقدار رتبة لأنّنا نُجمّع.
ثلاثة 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. خلط تخزين التدقيق مع البحث على نفس الجدول
يحرق الميزانيّة لكليهما.
حقّ المحو على سجلّ 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) مفقود
أن يدع مستأجراً بهدوء يحرّر بيانات مستأجر آخر. صفحة السلسلة تتابع
البقيّة.