خدمات مصغّرة موجّهة بالأحداث دون فقدان الأحداث: نمط Transactional Outbox عمليًا
كيف حافظنا على اتساق الطلبات والمدفوعات والخدمات اللاحقة على Cloud Run وSpanner باستخدام نمط الـ outbox المعاملاتي وعمّال ترحيل عبر طوابير المهام — وما الذي كنا سنفعله بشكل مختلف.
فئة أخطاء الإنتاج الأبغض إلى قلبي لا تُسقط شيئًا. تُقتطع الدفعة بنجاح، ويبقى الطلب متجمّدًا، ولا ينطلق أي إنذار — النظام ببساطة يختلف مع نفسه بهدوء. وفي كل مرة طاردتُ فيها خطأً من هذا النوع تقريبًا، انتهى الأثر إلى سطرين بريئي المظهر من الكود: ثبّت الصف (commit)، ثم انشر الحدث (publish). إذا ماتت العملية بين هذين السطرين، فلن تسمع الخدمات اللاحقة بالطلب أبدًا. واقلب الترتيب، فيعني التراجع (rollback) أنها ستسمع بطلبٍ لا وجود له.
على منصة تجارة إلكترونية استهلاكية عملت عليها — خدمات Go مصغّرة على Cloud Run، وCloud Spanner قاعدةَ بيانات رئيسية، ونحو دزينة من الخدمات تنسّق تدفقات شراء متعددة الخطوات مع عدة أنظمة لاحقة بينها بوابة دفع — كانت الكتابة المزدوجة هي بالضبط نمط الفشل الذي لا يحتمله مسار المال: دفعة مقتطعة دون انتقال حالة الطلب المقابل، أو طلب عالق ينتظر حدثًا لم يُنشر قط — انحراف تكتشفه في استعلام مطابقة، لا في تنبيه.
كان الإصلاح مملًا وموثّقًا حتى الإشباع وفعّالًا تمامًا: الـ Transactional Outbox.
الفكرة الجوهرية
بدل نشر الحدث إلى ناقل الرسائل كأثر جانبي، تكتب الحدث داخل نفس معاملة قاعدة البيانات التي تحمل تغيير الحالة:
-- معاملة Spanner واحدة للقراءة والكتابة
INSERT INTO Orders (OrderId, Status, ...) VALUES (@id, 'CONFIRMED', ...);
INSERT INTO OutboxEvents (EventId, Topic, Payload, CreatedAt, PublishedAt)
VALUES (@eventId, 'order.confirmed', @payload, PENDING_COMMIT_TIMESTAMP(), NULL);
إمّا أن يوجد الصفّان معًا أو لا يوجد أيّ منهما. ذرّية قاعدة البيانات — الضمانة التي دفعت ثمنها أصلًا — صارت تغطي مراسلتك أيضًا.
بعدها يقرأ عامل ترحيل مستقل الصفوف غير المنشورة ويدفعها إلى الخارج. استخدمنا Cloud Tasks آليةً للتسليم: يضع الترحيل مهمة لكل حدث موجهة إلى نقطة نهاية المستهلك، ويعلّم الصف منشورًا، ويترك لإعادة المحاولة والتراجع الأسّي (backoff) شأن طابور المهام.
func (r *Relay) Tick(ctx context.Context) error {
events, err := r.repo.FetchUnpublished(ctx, batchSize)
if err != nil {
return err
}
for _, ev := range events {
if err := r.tasks.Enqueue(ctx, ev); err != nil {
return err // اترك الصف؛ الدورة القادمة تعيد المحاولة
}
if err := r.repo.MarkPublished(ctx, ev.ID); err != nil {
return err // قد تنطلق المهمة مرتين — على المستهلكين إزالة التكرار
}
}
return nil
}
التعليق على مسار الخطأ الأخير هو العقد كله: يمنحك الـ outbox تسليم at-least-once، وليس exactly-once أبدًا. إذا انهار الترحيل بين الإدراج في الطابور والتعليم كمنشور، يخرج الحدث مرتين. مطاردة الـ exactly-once في طبقة النقل لعبة خاسرة؛ الصواب دفع المشكلة إلى المستهلكين.
المستهلكون العديمو التأثر بالتكرار نصف النمط
لا ينجح تسليم at-least-once إلا إذا كانت إعادة التسليم غير مؤذية، وكان كل مجال (domain) يفرض ذلك بمفرداته — الترحيل يفحص علامة «منشور» قبل الإدراج ويبتلع خطأ التكرار من طابور المهام؛ ومسار الدفع يزيل التكرار بمفتاح idempotency لكل طلب. أما الشكل القابل للتعميم، والجدير بالنسخ، فهو سجل إزالة تكرار يُثبَّت مع تغيير الحالة نفسه:
// داخل معاملة المستهلك نفسه
applied, err := s.repo.TryRecordEvent(ctx, ev.ID) // بدلالة INSERT OR IGNORE
if err != nil || !applied {
return err // تكرار — أكّد الاستلام وامضِ
}
// ... طبّق تغيير الحالة في المعاملة نفسها
سجل إزالة التكرار وتغيير الحالة يثبَّتان ذرّيًا، أي أن المستهلك يملك «outbox معكوسًا» مصغّرًا خاصًا به. وحيثما التزم المستهلك بهذا الخط، صار التكرار لا-حدثًا — حرفيًا.
تنسيق التدفقات متعددة الخطوات
امتدت تدفقات الشراء عبر عدة خدمات: حجز المخزون، تفويض الدفع، تأكيد الطلب، إطلاق التنفيذ، إرسال الإشعارات. تعمّدنا ألّا نمدّ أيدينا إلى إطار عمل saga. كل خطوة كانت سلسلة «حدث ← معالج عديم التأثر بالتكرار ← حدث تالٍ»، والـ outbox يضمن ألا تسقط أي حلقة بصمت.
قاعدتا تصميم أبقتا الأمر قابلًا للإدارة:
- الأحداث تحمل وقائع، لا أوامر.
order.confirmedوليسsend_email_please. المستهلكون يقررون ماذا تعني الواقعة لهم، فيبقى المنتِج جاهلًا بجمهوره، ويمكنك إضافة مستهلكين دون لمسه. - لكل تدفق خدمة مالكة واحدة. خدمة واحدة تملك آلة حالات الطلب وهي الكاتب الوحيد لأحداث دورة حياته؛ والبقية تتفاعل. حين يسوء شيء في الثانية صباحًا، هناك مكان واحد بالضبط تسأل فيه: «ما حالة هذا الطلب فعلًا؟».
وبخصوص بوابة الدفع تحديدًا، عزلنا كل وسائل الدفع بالبطاقة والمحفظة والدفع في المتاجر خلف خدمة مخصصة لتجريد البوابة. مزوّدو الدفع الخارجيون لهم دلالات إعادة محاولة ونداءات رجوع (callbacks) ومفردات فشل خاصة بهم؛ ولو تسرّب ذلك إلى خدمة الطلبات لارتبطت آلة حالاتنا الجوهرية بغرائب طرف ثالث. خدمة البوابة ترجمت نداءات المزوّد إلى نفس الأحداث الداخلية النظيفة التي يستهلكها كل شيء آخر.
ملاحظات تشغيلية
أمور كان وزنها في الإنتاج أكبر من وزنها في وثيقة التصميم:
- نموّ جدول الـ outbox. الصفوف المنشورة تحذفها مهمة مجدولة بعد نافذة احتفاظ. يتعامل Spanner مع الجداول الكبيرة جيدًا، لكن outbox بلا حدود يبطّئ مسح
FetchUnpublished. فهرس على(PublishedAt, CreatedAt)وأبقِ مجموعة العمل صغيرة. - الترتيب. طوابير المهام لا تضمن الترتيب. حيث كان التسلسل مهمًا (انتقالات آلة الحالات)، تحقّق المستهلكون من صحة الانتقال بدل الوثوق بترتيب الوصول — حدث
order.shippedالواصل قبلorder.confirmedيُركن ويعاد لاحقًا، لا يُطبَّق. - قابلية الرصد. يصدّر الترحيل المقياس
oldest_unpublished_age. هذا المؤشر الواحد يلتقط كل أنماط الفشل تقريبًا: ترحيل عالق، طابور متكدّس، مستهلك يفشل. اربط الإنذار بالعمر، لا بعمق الطابور. - إلغاء الـ context. خطأ إنتاج خفي: كتابة cache انطلقت في goroutine ورثت سياق الطلب، فأُلغيت في منتصف الطريق لحظة عودة المعالج. الإصلاح كان التوقف عن الذكاء — نفّذ العمل تزامنيًا على مسار الطلب (التقرير الكامل).
ماذا كنت سأفعل بشكل مختلف
لو بدأت من جديد، لولّدت كود سباكة الـ outbox توليدًا. كتبنا كود الترحيل والمستودعات يدويًا لكل خدمة في البداية، وكان الانحراف بين التنفيذات مصدر معظم الاحتكاك. حين وحّدنا لاحقًا على مكتبة داخلية مشتركة ومولّد كود للـ DAO، صار تبنّي النمط شبه مجاني.
وكنت سأُدخل النمط قبل أول حادثة اتساق، لا بعدها. كلفة الـ outbox جدولٌ إضافي وعامل صغير. كلفة البديل عطلةُ نهاية أسبوع تُمضيها في مطابقة سجلات الطلبات والمدفوعات بسكربت SQL — وثقة مستخدميك في صفحة الدفع.
الكتابة المزدوجة خطأ لم تصطدم به بعد، لا أكثر. اكتب الحدث داخل المعاملة.
هذه المقالة ترسو عليها سلسلة قصيرة عن المنصة نفسها: واجهة واحدة، أربع طرق للدفع، مصادقة بين الخدمات دون service mesh، عندما تكون goroutine الأداة الخاطئة.