يحل Transactional Outbox مشكلة دقيقة: تحتاج الخدمة إلى تغيير قاعدة بياناتها وإعلان هذا التغيير، لكن Database وMessage Broker لا تشتركان في Atomic Transaction واحدة. اكتب Business Row وOutbox Row ثابتة في Local Transaction نفسها، نفّذ Commit مرة واحدة، ثم انشر Outbox لاحقًا عبر Polling Relay أوChange Data Capture. صمّم على أساس At-least-once: كل Event تحتاج ID ثابتة، ويجب أن يمنع Consumer التكرار، ويكون Ordering ضمن Aggregate، وتتوفر مراقبة للتأخير وإعادة المحاولة والاحتفاظ.
تم التحقق من وثائق AWS وDebezium وPostgreSQL وApache Kafka المشار إليها في 6 أكتوبر 2026. توثق هذه المصادر النمط وسلوك المنصات؛ أما Schema وبوابات الإطلاق والحدود التشغيلية أدناه فهي توصيات هندسية.
لماذا تفشل Dual Write العادية
التنفيذ المغري هو تحديث `orders` ثم Commit ثم نشر `OrderConfirmed`. إذا تعطلت العملية بعد Commit وقبل Acknowledgement من Broker، يبقى Order ولا تعرف الأنظمة اللاحقة عنه. عكس الترتيب ليس آمنًا: قد يخرج Event قبل Rollback لقاعدة البيانات. كما قد تكرر إعادة الطلب طرفًا واحدًا فقط.
تصف AWS Prescriptive Guidance ذلك بمشكلة Dual Write، وتقترح تخزين Event مع تغيير قاعدة البيانات في Transaction واحدة. الضمان ليس "Exactly Once في كل مكان". الضمان أضيق ومفيد: كل Business Change تم Commit لها Event Record قابلة للنشر لاحقًا؛ وكل Transaction تم Rollback لها لا تملك أيًا منهما.
لا تدخل Kafka Producer Idempotence أوBroker Transactions تلقائيًا في PostgreSQL Transaction. هي تحمي Writes داخل Kafka ضمن حدودها الموثقة. ما لم يملك Transaction Coordinator واحد الموردين معًا، ستظل هناك عمليتا Commit. يلغي Outbox الحاجة إلى هذه Atomicity العابرة للأنظمة.
ثبّت حقيقة واحدة دائمة
يتحقق Command Handler من الطلب، ويغير Aggregate، ويضيف Outbox Row قبل Database Commit واحدة. لا تتصل بـBroker داخل Transaction: Network Latency تطيل Locks، وفشل Broker يوقف Database Work، وTimeout غير حاسم لا يخبرك بأمان هل تم النشر.
يحمل Outbox Row عملي `event_id` و`aggregate_type` و`aggregate_id` و`event_type` وSchema Version صريحة وPayload ووقت الحدوث وTrace Context ووقت الإنشاء. تُولّد Event ID مرة قبل Insert ولا تتغير. تصبح Aggregate ID مفتاح Partition أوOrdering. والـPayload عقد منشور لا Dump لكل الأعمدة الداخلية.
BEGIN;
UPDATE orders
SET status = 'confirmed', version = version + 1
WHERE id = :order_id AND version = :expected_version;
INSERT INTO outbox_events (
event_id, aggregate_type, aggregate_id, event_type,
schema_version, payload, occurred_at, created_at
) VALUES (
:event_id, 'order', :order_id, 'order.confirmed',
1, :payload::jsonb, :occurred_at, now()
);
COMMIT;ابنِ Payload من الحالة الموثقة نفسها المكتوبة في Transaction. إذا صنع Trigger أحداثًا من Row Diffs عامة فقد يصبح Business Meaning غامضًا. إنشاء Event داخل Application أوضح غالبًا؛ ويصلح Trigger عندما يكون Enforcement على مستوى Database مقصودًا ومختبرًا.
تعامل مع Outbox Schema كعقد
استخدم Append-only Table. تعديل Payload بعد Commit يدمر Audit Trail وقد يجعل محاولتي نشر تحملان حقيقتين مختلفتين تحت ID واحدة. تتوقع وثائق Debezium Outbox Event Router Inserts أيضًا، وتكشف Event ID فريدة وAggregate Key وType وPayload. وتوضح أهمية Aggregate Key لحفظ الترتيب داخل Kafka Partitions.
Version الـEvent Envelope بصورة مستقلة عن Application Deployment. فضّل Additive Changes واجعل Consumers تتسامح مع Unknown Fields. عند Breaking Change انشر Event Version جديدة أوTopic Contract منفصلة، ثم انقل Consumers بوضوح. لا تعِد استخدام Event Name قديم بمعنى غير متوافق.
اجعل Tenant وAuthorization Boundaries صريحة. في Multi-tenant Service احمل Tenant Identifier اللازمة للتوجيه والسياسة، لكن لا تسرّب Event أسرارًا أوحقولًا لا يحق لـConsumers استلامها. شفّر Transport وStorage في Broker، واضبط Topic Access، وتعامل مع Payload Retention كقرار Data Governance.
الخيار الأول: Polling Publisher
Polling Relay أبسط تصميم عندما يكون Traffic متوسطًا وتملك PostgreSQL. تستطيع Workers متعددة Claim دفعات باستخدام `FOR UPDATE SKIP LOCKED`؛ توضح PostgreSQL أن `SKIP LOCKED` تعطي View غير متسقة للاستخدام العام لكنها مناسبة لمستهلكين متعددين يصلون إلى Queue-like Table. استخدم `ORDER BY` حتميًا وBatch صغيرة وTransactions قصيرة.
WITH claimed AS (
SELECT id
FROM outbox_events
WHERE published_at IS NULL
AND next_attempt_at <= now()
ORDER BY created_at, id
FOR UPDATE SKIP LOCKED
LIMIT 100
)
UPDATE outbox_events o
SET claimed_at = now(), claimed_by = :worker
FROM claimed
WHERE o.id = claimed.id
RETURNING o.*;لا تحتفظ بـRow Locks أثناء انتظار Broker. خذ Lease قصيرة ثم Commit، انشر، وبعدها سجّل النجاح. إذا تعطلت Worker بعد قبول Broker للرسالة وقبل تخزين `published_at`، تنتهي Lease ويُنشر Event ثانية. هذا Duplicate متوقع؛ تجعله Event ID ثابتة وConsumer Idempotent آمنًا.
استخدم Exponential Backoff مع Jitter للأخطاء المؤقتة، وحدًا لعدد المحاولات أوQuarantine للأحداث السامة، وReplay Action ظاهرة للمشغل. لا تحذف Row فاشلة لمجرد فتح Queue. قسّم أوArchive Rows المنشورة كي يبقى Index الخاص بغير المنشور صغيرًا.
الخيار الثاني: CDC مع Debezium
تقرأ CDC تغييرات Database التي تم Commit لها من Transaction Log. تستطيع Debezium التقاط Outbox Table وتحويل كل Insert عبر Outbox Event Router. يلغي ذلك Application Polling ويخفض Latency غالبًا، لكنه يضيف تشغيل Connector وReplication Slot وSchema وBroker.
تحول Logical Decoding في PostgreSQL تغييرات WAL إلى Stream مفهومة للتطبيق. تحذر الوثائق أن Slot بعد Crash قد ترجع إلى LSN أقدم وترسل تغييرات حديثة مجددًا، لذلك يجب احتمال التكرار. كما قد تحتفظ Replication Slot متوقفة بـWAL وتستهلك Storage. راقب Slot Lag بالحجم والزمن، وConnector Heartbeat، ووقت آخر Event منشورة، وDisk Headroom.
اختر CDC عندما تشغل المنصة Kafka Connect أوDebezium أصلًا، وعندما يجعل Throughput أوLatency الـPolling غير مناسب، ويستطيع الفريق امتلاك Recovery لـReplication Slot. واختر Polling عندما تكون البساطة التشغيلية أهم والأحجام محدودة ويمكن ضبط Database Queries بأمان. يطبق كلاهما العقد نفسه؛ ولا يلغي أي منهما Consumer Idempotency.
Delivery Semantics مسؤولية End-to-end
تعني At-least-once أن النظام قد يوصل Event نفسها أكثر من مرة، لكنه لا يجب أن يفقد Event تم Commit لها بصمت. أي ادعاء Exactly-once يملك نطاقًا. تمنع Kafka Producer Idempotence النسخ المكررة الناتجة من Producer Retries ضمن قواعد Kafka؛ لكنها لا تمنع تكرار Business Action داخل Database أخرى.
على كل Consumer إدخال `event_id` في Processed-events Table ضمن Local Transaction نفسها التي تنفذ Side Effect. تحوّل Unique Constraint الـDuplicate إلى No-op. في Balance Change أوInventory Movement فضّل حركة ثابتة مرتبطة بـEvent ID بدل قراءة قيمة وتنفيذ Increment أعمى.
BEGIN;
INSERT INTO processed_events (consumer, event_id, processed_at)
VALUES ('billing', :event_id, now())
ON CONFLICT DO NOTHING;
-- Continue only if one row was inserted.
UPDATE invoices SET order_status = :status WHERE order_id = :order_id;
COMMIT;يجب أن يشترك Deduplication Record وSide Effect في Transaction واحدة. Redis Key مكتوبة منفصلة قد تنتهي مبكرًا أوتنجح بينما يفشل Database Change، فتعيد Dual Write داخل Consumer. اجعل Deduplication Retention أطول على الأقل من أقصى Broker Replay وRecovery Window.
احفظ الترتيب الذي تحتاجه فقط
Global Ordering مكلف ونادرًا ما يكون ضروريًا. تحتاج معظم المجالات ترتيبًا لكل Aggregate: يجب أن تصل تغييرات Order أوShipment أوAccount واحدة بالتسلسل، بينما تعمل Aggregates غير المرتبطة بالتوازي. استخدم `aggregate_id` كمفتاح Partition وأضف Aggregate Version أوSequence إلى Payload.
على Consumer رفض أوتأجيل Version Gap، وتجاهل Version مطبقة، والتنبيه عندما لا يصل Predecessor مفقود. Timestamps وحدها ليست Sequence موثوقة بين Hosts أوTransactions متزامنة. قد يختلف Database Commit Order وOutbox Creation Order وBroker Observation Order ما لم يجعل Relay وPartitioning Contract الحدود صريحة.
لا تعد بترتيب بين Aggregate اثنتين إلا إذا احتاج Business Invariant ذلك فعلًا. إذا امتدت Workflow بين Services واحتاجت Compensating Actions فاستخدم Saga أوProcess Manager صريحًا فوق الأحداث الموثوقة؛ تنقل Outbox الحقائق لكنها لا تنسق Business Transaction كاملة.
شغّل Backlog وRecovery كسلوك منتج
قس عمر أقدم Row غير منشورة، وعدد غير المنشور، وPublish Latency Percentiles، والمحاولات حسب Error Class، والأحداث في Quarantine، وانتهاء Relay Leases، وConnector أوSlot Lag، وConsumer Lag، وDeduplication Hits. قد تبدو Queue سليمة حسب Depth بينما Event حرجة قديمة عالقة. أنشئ Alert حسب العمر وأهمية العمل لا الحجم فقط.
نفذ Failure Drills: أوقف الخدمة بعد Commit؛ اقتل Relay بعد Broker Acknowledgement؛ أوقف Broker؛ كرر Message؛ أوصل Versions خارج الترتيب؛ أوقف CDC Connector حتى ينمو WAL؛ وأعد Event من Quarantine. تأكد من عدم فقد أي Committed Event وعدم تكرار Business Side Effect.
حدد Retention منفصلًا لسجلات Unpublished وPublished وProcessed Events. لا تحذف Unpublished Rows. لا تؤرشف Published إلا بعد فهم Broker Retention ومتطلبات Recovery. احم Cleanup Job بـWatermark محافظة وDry-run Metrics وقاعدة بلا Rollback: الحذف مسموح فقط لـTerminal Published Rows الأقدم من Policy.
Anti-patterns
لا تنشر داخل Database Transaction وتفترض أن Broker Timeout يعني فشلًا. لا تسجل Outbox Row كمنشورة قبل Broker Acknowledgement. لا تولد Event ID جديدة لكل Retry. لا تعدّل Payload في مكانها. ولا تستخدم Cron Job تختار Rows بلا Lease أوRow Claim؛ ستتسابق Workers متعددة.
لا توجه كل Event إلى Partition واحدة باسم "Ordering" عندما تحتاج Per-order Sequencing فقط. ولا تستخدم Generic Envelope بعشرات Optional Fields بلا Schema Owner. ولا ترسل Sensitive Snapshots لأن Broker مريح. ولا تعامل Duplicate Delivery كحالة نادرة؛ صمّمها واختبرها كتعافٍ طبيعي.
متى لا تستخدم النمط
لا يحتاج Monolith ينفذ كل العمل المطلوب في Database Transaction واحدة إلى Outbox لاستدعاءات داخلية. وقد تحتاج Synchronous Request يجب أن تعيد نتيجة Downstream إلى API Workflow لاAsynchronous Event. وإذا وفرت Source Datastore أصلًا Durable Change Stream بالدلالات المطلوبة فقد تكون Outbox Table إضافية تكرارًا للبنية.
لا تستخدم Outbox كبديل لـEvent Sourcing. تنشر Outbox Integration Events، وليست غالبًا Source of Truth لإعادة بناء Aggregate كلها. ولا تضف Kafka وCDC وSchema Registry إلى نظام منخفض الحجم لمجرد الموضة؛ قد تكون Polling Relay بفهرسة جيدة أكثر موثوقية للفريق الذي يشغلها.
قرار الإنتاج
تستبدل Transactional Outbox وعدًا مستحيلًا بين نظامين بعقدين محليين دائمين: تكتب الخدمة State وEvent معًا، وتستمر Delivery Pipeline بالمحاولة حتى تصبح معالجة Downstream آمنة. اختر Polling أوCDC حسب القدرة التشغيلية لا الأيديولوجيا.
استخدم Event IDs ثابتة وOrdering ضمن Aggregate وPayloads ثابتة ذات Version وConsumers Idempotent وLeases صريحة أوReplication-slot Monitoring وإجراءات Replay مختبرة. اربط النمط بـهندسة الأنظمة الخلفية وتكاملات API ودليل معالجة Webhooks الموثوقة؛ تحتاج الأحداث الصادرة وWebhooks الواردة الانضباط نفسه حول التكرار وإعادة المحاولة والتعافي المرئي.
المراجع الرسمية
تدعم هذه المراجع سلوك الأدوات المذكورة. الأمثلة وقرارات التصميم توضيحية، ويجب تكييفها مع متطلبات المشروع وإصداراته.
إعداد: Noor Yasser
تعمل على تحدٍ تقني مشابه؟
أساعد الفرق على تحويل القرار المعماري إلى نطاق واضح وتنفيذ يمكن تشغيله ومراجعته بثقة.




