Laravel وKafka بلا أحداث مفقودة: Outbox والمعالجة الآمنة عند التكرار مع PostgreSQL

حفظ الطلب في قاعدة البيانات ونشر الحدث إلى Kafka عمليتان منفصلتان. تعلّم كيف يحمي Outbox من فقد الحدث وكيف يمنع المستهلك آثار التكرار.
قد تبدو الشيفرة التالية صحيحة:
$order = Order::create($data);
$kafka->publish('orders.placed.v1', $order->toArray());
لكن ماذا لو حُفظ الطلب وفشل النشر؟ يوجد طلب لا تعلم عنه خدمة المخزون. وعكس الترتيب قد ينشر حدثاً قبل أن تنجح معاملة قاعدة البيانات. حتى استدعاء Kafka داخل DB::transaction() لا يجعل Kafka جزءاً من معاملة PostgreSQL.
نمط transactional outbox يحفظ الحدث والبيانات التجارية في معاملة قاعدة بيانات واحدة، ثم ينشره عامل مستقل. يحل مشكلة الفقد عند الحد الفاصل بين قاعدة البيانات والناشر، لكنه لا يضمن أن كل نظام خارجي سيرى الحدث مرة واحدة فقط. لذلك نحتاج أيضاً إلى مستهلك يمنع تكرار الأثر.
1. حدّد معنى الحدث
{
"eventId": "evt_01JQ8X8F",
"eventType": "OrderPlaced",
"schemaVersion": 1,
"occurredAt": "2026-09-29T10:30:00Z",
"orderId": "ord_782",
"customerId": "cus_17",
"totalMinor": 12900,
"currency": "USD"
}
يعني OrderPlaced أن الطلب تم حفظه بالفعل. eventId ثابت عند إعادة النشر، ويستخدمه المستهلك لمنع التكرار. مفتاح Kafka هو orderId لحفظ ترتيب أحداث الطلب داخل القسم وفق سياسة توزيع مستقرة. لا تنشر نموذج Eloquent كاملاً؛ أسماء الأعمدة والعلاقات قد تتغير، وقد تحمل بيانات داخلية أو شخصية لا يحتاجها المستهلك.
وثّق مالك الموضوع orders.placed.v1، والحقول، ومدة الاحتفاظ، والتوافق بين الإصدارات، والمستهلكين المقصودين. اسم الموضوع ليس بديلاً عن مخطط بيانات واضح.
2. جدول Outbox ومعاملة واحدة
يمكن البدء بتصميم مبسط في PostgreSQL:
CREATE TABLE outbox_events (
id uuid PRIMARY KEY,
aggregate_id text NOT NULL,
topic text NOT NULL,
event_key text NOT NULL,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL,
published_at timestamptz NULL,
attempts integer NOT NULL DEFAULT 0
);
CREATE INDEX outbox_pending_idx ON outbox_events (occurred_at, id)
WHERE published_at IS NULL;
داخل DB::transaction() أنشئ الطلب وصف outbox بالـ eventId نفسه. إذا تراجعت المعاملة يختفي الاثنان، وإذا نجحت يبقى الحدث بانتظار عامل النشر حتى لو ماتت عملية الويب مباشرة. احتفظ بالمعاملة قصيرة؛ لا تنتظر شبكة Kafka وأنت تمسك أقفال قاعدة البيانات.
هذا مخطط تنفيذي مبسط، لا حزمة Laravel جاهزة:
DB::transaction(function () use ($input) {
$order = Order::create($input);
$eventId = (string) Str::uuid();
DB::table('outbox_events')->insert([
'id' => $eventId,
'aggregate_id' => (string) $order->id,
'topic' => 'orders.placed.v1',
'event_key' => (string) $order->id,
'payload' => json_encode([
'eventId' => $eventId,
'eventType' => 'OrderPlaced',
'schemaVersion' => 1,
'orderId' => (string) $order->id,
], JSON_THROW_ON_ERROR),
'occurred_at' => now(),
]);
});
في مشروع حقيقي تحقق من المدخلات وحدد أنواع الأعمدة وحماية البيانات بوضوح. اجعل بنية الحدث مستقلة عن صف قاعدة البيانات.
3. عامل النشر وحدود الضمان
يختار العامل مجموعة صغيرة من الصفوف غير المنشورة، يحجزها بطريقة آمنة بين عدة عمّال، يرسل كل سجل بمفتاحه إلى Kafka، ينتظر إقرار المنتج المناسب، ثم يضع published_at. يمكن استخدام مطالبة قصيرة أو lease مع استعادة المطالبات المنتهية. لا تمسك قفل صف أثناء انتظار وسيط بطيء.
قد يقبل Kafka الحدث ثم يتوقف العامل قبل تحديث published_at. سيرسله عامل آخر مرة ثانية. لذا يحقق Outbox نشراً مرة واحدة على الأقل، لا مرة واحدة تماماً. لا تنشئ eventId جديداً عند إعادة المحاولة. تساعد خاصية المنتج idempotent مع تكرار الإرسال إلى Kafka، لكنها لا تجعل تحديث PostgreSQL وإقرار Kafka معاملة ذرية واحدة.
يمكن تنفيذ الناشر كأمر Laravel أو عامل أو خط CDC؛ اختيار العميل يتطلب مراجعة التوافق والأمن والتشغيل. طوابير Laravel المدمجة تشمل Redis وSQS وقاعدة بيانات، لكن Kafka لا يصبح driver مدمجاً بمجرد تغيير QUEUE_CONNECTION.
4. مستهلك لا يحجز المخزون مرتين
قد يحجز مستهلك المخزون الكمية ثم يتوقف قبل تأكيد offset. بعد إعادة التشغيل يقرأ الحدث نفسه. اجعل أثر العمل محمياً بقيد فريد:
CREATE TABLE processed_events (
consumer_name text NOT NULL,
event_id text NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (consumer_name, event_id)
);
داخل معاملة قاعدة بيانات المخزون أدخل (inventory-reservation, eventId) ونفذ الحجز. إذا فشل الإدخال بسبب التكرار، فتجاوز الأثر السابق. بعد نجاح المعاملة فقط أكّد offset. إن نجحت المعاملة ثم ضاع تأكيد offset، تمنع قاعدة البيانات الحجز الثاني. وإن تراجعت، ينبغي ألا يتقدم offset كأن العمل تم.
البريد والدفع يقعان خارج معاملة هذه القاعدة. استخدم مفتاح منع تكرار لدى المزود إن توفر، أو Outbox آخر، أو آلية تعويض. لا تعمم عبارة «مرة واحدة تماماً» على كل الأنظمة؛ اشرح ضمان كل أثر جانبي.
5. الأخطاء والـ replay وتطور العقد
عطل قاعدة بيانات عابر يحتاج إعادة محاولة بتأخير. حدث بتنسيق خاطئ يحتاج قراراً: إصلاح أو عزل أو تخطٍ موثق. موضوع dead-letter خيار للعزل، لكنه يحتاج سياسة تنبيه واحتفاظ وإعادة تشغيل، كما أن النشر إليه وتأكيد offset الأصلي لهما نافذة فشل. لا تبتلع الاستثناء ثم تؤكد offset.
سجّل eventId والموضوع والقسم وoffset والمجموعة وفئة الخطأ دون تسريب حمولة حساسة. احتفظ بتاريخ منع التكرار بما يغطي مدة الاحتفاظ وإعادة القراءة الموعودة. أضف حقولاً اختيارية عند التطور، واختبر نسخ الأحداث القديمة؛ تغيير معنى حقل قائم يحتاج إصداراً أو ترحيلاً واضحاً.
اختبر نوافذ الفشل
اختبر تراجع معاملة الطلب، ونجاحها مع توقف الناشر، ونجاح النشر مع توقف العامل قبل published_at، وتسليم الحدث مرتين للمخزون، والتوقف بعد حجز المخزون وقبل offset، والطلبات المتزامنة. راقب عمر أقدم صف outbox، وعدد المعلق، وأخطاء النشر، وتأخر المستهلك، وحجم الأحداث المعزولة.
لرسالة بريد خلفية واحدة قد تكفي طوابير Laravel المعتادة. يصبح Outbox مع Kafka مفيداً حين يجب أن تصل حقيقة الطلب إلى أنظمة مستقلة مع قابلية إعادة القراءة وكلفة حقيقية للفقد أو التكرار.
مراجع
مقالات مختارة

Kafka في الإنتاج: استراتيجية الأقسام وتأخر المستهلكين والاعتمادية وخطة الحوادث
نجاح تجربة إرسال واستقبال حدث لا يكفي للإنتاج. صمّم الأقسام والسعة والاحتفاظ والمراقبة وخطة تعافٍ قابلة للتنفيذ.

شرح Apache Kafka: الموضوعات والأقسام ومجموعات المستهلكين وأول تدفق أحداث
اتبع حدث طلب من المنتج إلى عدة مستهلكين، ثم جرّب Kafka محلياً وافهم كيف تعمل الأقسام والترتيب وإعادة القراءة.

كيف تصمّم واجهات API يثق بها العملاء: دليل عملي يبدأ بالعقد
واجهة API الجيدة تجعل الخطوة التالية متوقعة. صمّم عملية تسجيل في دورة انطلاقاً من حاجة العميل، مع إعادة محاولة آمنة وأخطاء مفيدة وخطة للتغيير.
التعليقات
0 تعليقاتلا توجد تعليقات معتمدة بعد. قد تنتظر الردود الجديدة المراجعة.