كل تطبيق يكبر يصل إلى نفس اللحظة: رمز التحقق يرسل برسالة SMS، وتأكيد الطلب بالبريد، وتحديث الشحنة بإشعار على الهاتف، وتنبيه أمني عند دخول من جهاز جديد، ثم يطلب فريق التسويق نشرة أسبوعية لكل المستخدمين. وقد يبدو هذا من الوهلة الأولى دالة واحدة اسمها send() تستدعيها من أي مكان، لكن في الواقع فهذه الدالة تتحول بعد أشهر إلى مصدر شكاوى من كل الجهات: رمز تحقق يصل بعد انتهاء صلاحيته لأنه وقف خلف نصف مليون رسالة تسويقية، ومستخدم وصلته نفس الرسالة ثلاث مرات، وآخر استيقظ في الثالثة فجراً على عرض لا يهمه، وفاتورة SMS تضاعفت دون سبب واضح، ومزود رسائل توقف ساعة فتوقفت معه عمليات الدخول.
وهذا المقال من قسم دراسات الحالة في خارطة الطريق، وطريقة عمل كل قناة بمفردها شرحناها في مقالات مستقلة، فالإشعارات الفورية وخدمات Apple وGoogle في كيف تعمل الإشعارات الفورية، والبريد الذي ترسله التطبيقات في البريد التفاعلي، والطوابير وإعادة المحاولة في مفاهيم الطوابير، فلن نكررها هنا، وسوف نركز على النظام الذي يجمعها ويقرر ماذا يرسل ومتى ولمن.
دليلكيف تعمل الإشعارات الفورية Push Notifications؟كيف يصل الإشعار إلى تطبيق مغلق عبر خدمات Apple وGoogle، وما الذي تراه هذه الخدمات من بيانات مستخدميك.
دليلإشعارات فورية من سيرفرك إلى هاتفك مع ntfyكيف تشغل سيرفر ntfy خاصاً، وتصلك على هاتفك رسالة عندما يمتلئ القرص أو تفشل النسخة الاحتياطية أو ينتهي النشر.
دليلTransactional Email: كيف تصل رسائل تطبيقك في وقتهالماذا تفصل رسائل تطبيقك مثل استعادة كلمة المرور عن النشرات البريدية، وكيف ترسلها بحيث تصل في وقتها ولا تذهب إلى Spam.
دليلالطابور Queue للمبتدئين: لماذا تحتاجه وكيف يعمللماذا تضع العمل البطيء في طابور بدل أن ينتظره المستخدم، وكيف تضمن ألا تضيع الرسالة إذا توقف ال Worker فجأة.وسوف نناقش في هذا المقال ما يلي:
- القنوات الأربع وما يناسب كل واحدة منها، ولماذا تحتاج خدمة مستقلة للإشعارات.
- المعمارية الكاملة في مخطط واحد، مع الأدوات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو وما يحدث عندما يتعطل كل جزء.
- الأولويات والطوابير المنفصلة، حتى لا ينتظر رمز التحقق خلف النشرة التسويقية.
- القوالب واللغة، وتكلفة الرسالة العربية في SMS، واتجاه RTL في البريد.
- تفضيلات المستخدم وساعات الهدوء Quiet Hours، ومنع التكرار بمفتاح Idempotency Key، وحدود الإرسال لكل مستخدم.
- إعادة المحاولة بانتظار متزايد، والانتقال إلى مزود احتياطي Failover، و Dead Letter Queue، ومتابعة حالة كل إشعار.
- ما حدث في هاواي سنة 2018 عندما أرسل نظام إنذار رسالة خاطئة لكل الهواتف.
- تجربة عملية على Valkey Streams: اثنان من ال Workers ومزود رسائل يتعطل، مع أرقام حقيقية لكل ما سبق، ثم نوقف أحد ال Workers فجأة.
القنوات الأربع، ولماذا تحتاج خدمة مستقلة؟
لكل قناة طبيعة مختلفة، ومعرفتها تحدد أين ترسل كل نوع من الرسائل:
- الإشعار الفوري Push: مجاني تقريباً وسريع، لكنه يحتاج تطبيقاً مثبتاً وإذناً من المستخدم، ولا يضمن الوصول، فخدمة Apple أو Google قد تؤخره أو تدمجه مع غيره. ولمن يريد إشعارات لفريقه أو سيرفراته دون تطبيق خاص فهناك ntfy.
- الرسائل القصيرة SMS: تصل إلى أي هاتف دون تطبيق أو إنترنت، لذلك هي القناة المعتادة لرموز التحقق OTP، لكنها تكلف مالاً على كل رسالة، ومحدودة الطول، وهي هدف مفضل للاحتيال كما سيأتي.
- البريد الإلكتروني Email: مناسب للمحتوى الطويل والإيصالات والفواتير، رخيص، لكنه بطيء نسبياً وقد يذهب إلى الرسائل غير المرغوب فيها إذا لم تضبط سمعة الإرسال، وقد شرحنا ذلك في SPF وDKIM وDMARC.
- الصندوق داخل التطبيق In-app Inbox: قائمة الإشعارات التي يراها المستخدم عند فتح التطبيق، وهي الوحيدة التي تملكها بالكامل، وتحفظ السجل حتى لو لم يصل الإشعار الفوري، وتوصل لحظياً بنفس طريقة تطبيق المحادثة في مقال تطبيق المحادثة.
والحل البسيط أن يستدعي كل فريق مزود الرسائل مباشرة من خدمته، فخدمة الطلبات ترسل بريدها، وخدمة الدخول ترسل رسائلها، والتسويق يرسل نشرته، وهذا يعمل في البداية، لكنه يعني أن لا أحد يرى الصورة كاملة: لا توجد حدود مشتركة لعدد ما يصل للمستخدم في اليوم، ولا تفضيلات موحدة، وكل فريق يكتب منطق إعادة المحاولة بطريقته، وتغيير مزود SMS يعني تعديل خمس خدمات. والطريقة الصحيحة خدمة إشعارات مستقلة تستقبل من الجميع طلباً واحداً بسيطاً، مثل «أرسل للمستخدم u123 إشعار shipped بهذه البيانات»، وتقرر هي القناة واللغة والتوقيت والمزود، والصورة التالية تبين مكوناتها:

المعمارية الكاملة: كيف تتصل الأجزاء ببعضها؟
الصورة السابقة تبين الطريق الذي يسلكه الإشعار، أما الصورة التالية فتبين النظام كاملاً بالأجزاء التي تشغلها فعلاً: بوابة API أمام الخدمة، وقاعدة بيانات للتفضيلات والقوالب والحالة، وValkey للمفاتيح السريعة والطوابير، وال Workers، والمزودين لكل قناة، ثم إشعارات التسليم التي تعود من المزودين. والخط المتصل فيها طلب ينتظر رداً Sync، والخط المتقطع حدث يكتب في طابور أو يرسل ولا ينتظر Async:

وفي التجربة العملية وضعنا كل شيء في Valkey، حتى التفضيلات وسجل الحالة، والمزودون مزيفون داخل الكود، أما في الإنتاج فتنتقل البيانات التي يجب ألا تضيع، مثل التفضيلات والقوالب وسجل كل إشعار، إلى PostgreSQL، ويبقى في Valkey ما يعيش ثوان أو ساعات، مثل مفاتيح منع التكرار والعدادات والطوابير.
المكونات والأدوات المقترحة
| المكون | مسؤوليته | أداة مفتوحة المصدر مقترحة | لماذا هي |
|---|---|---|---|
| بوابة API Gateway | TLS والتحقق من توكن كل خدمة منتجة وحد الطلبات لكل منها | Traefik (MIT) | يقرأ إعداده من Docker، وفيه حد للطلبات Rate Limit ومصادقة جاهزة، فلا تكتبها في كل خدمة |
| Notification API | منع التكرار والتفضيلات وساعات الهدوء والحدود واختيار القالب والطابور | كودك أنت، كما في الدالة notify في التجربة | هنا قواعد منتجك، وهي التي تتغير كل أسبوع |
| قاعدة البيانات | التفضيلات والقوالب وسجل كل إشعار وحالته | PostgreSQL (PostgreSQL License) | قيد فريد على مفتاح Idempotency Key يمنع التكرار حتى لو ضاع Valkey، واستعلامات الدعم والتقارير بلغة SQL |
| الطوابير والمفاتيح السريعة | طابوران للأولويات وطابور الانتظار وDead Letter Queue والعدادات وقاطع الدائرة | Valkey Streams (BSD-3-Clause) | مجموعات المستهلكين والقائمة المعلقة وXAUTOCLAIM هي ما أنقذ الرسائل في التجربة، ومعها العدادات بمهلة في نفس الأداة |
| ال Workers | قراءة العالي أولاً، والإرسال، وإعادة المحاولة، والانتقال إلى المزود الاحتياطي | كودك أنت، كما في الدالة worker | كل مزود له أخطاؤه وحدوده، والقرار بين المؤقت والدائم يخص نظامك |
| الإشعار الفوري | الإرسال إلى APNs وFCM وإبلاغك بالتوكنات الملغاة | Gorush (MIT) | سيرفر جاهز للخدمتين، فلا تكتب بروتوكوليهما بنفسك |
| البريد التفاعلي | إرسال البريد من نطاقك ومتابعة الارتداد Bounce | Postal (MIT) | يرسل من سيرفرك ويبلغ عن حالة كل رسالة عبر Webhook، وقد شرحنا تثبيته |
| SMS | رموز التحقق والتنبيهات المهمة | مزودان تجاريان بعقدين منفصلين | الإرسال إلى شبكات الاتصال يحتاج اتفاقاً مع مزود، والمهم أن يكون لديك اثنان خلف قاطع الدائرة |
| الصندوق داخل التطبيق | قائمة الإشعارات وتوصيلها لحظياً للمتصلين | جدول في PostgreSQL مع WebSocket Gateway كما في مقال تطبيق المحادثة | هذه القناة الوحيدة التي تملكها بالكامل، فتحفظ فيها السجل حتى لو لم يصل الإشعار الفوري |
| النشرات البريدية | الحملات التسويقية الكبيرة وإلغاء الاشتراك | listmonk (AGPL-3.0) | النشرة الأسبوعية لا تحتاج أن تمر بطوابير الإشعارات الفردية |
| المراقبة | زمن التسليم لكل أولوية، ونسبة الفشل لكل مزود، وحجم Dead Letter Queue | Prometheus (Apache-2.0) مع Grafana (AGPL-3.0) | تنبيه عندما يرتفع زمن رموز التحقق أو تفشل رسائل مزود قبل أن يشتكي أحد |
نموذج البيانات
في التجربة تحمل المفاتيح status:<id> وprefs:<user> وidem:<key> كل شيء، والجداول التالية هي نفس الحقول في PostgreSQL، ومعها المفاتيح التي تبقى في Valkey:
-- one row per notification (lab: the hash status:<id>)
CREATE TABLE notifications (
id text PRIMARY KEY, -- lab: the 10-character nid
idem_key text NOT NULL UNIQUE, -- 'order-5012-shipped' (lab: idem:<key>, 24 h)
user_id text NOT NULL,
kind text NOT NULL, -- otp, shipped, promo
channel text NOT NULL CHECK (channel IN ('push', 'sms', 'email', 'inapp')),
priority text NOT NULL CHECK (priority IN ('high', 'low')),
state text NOT NULL, -- queued, retrying, sent, delivered, failed, dead-letter
provider text, -- sms-a, sms-b, email, push
provider_ref text, -- the id the provider returned, matched by its webhook
attempts int NOT NULL DEFAULT 0,
last_error text,
sms_encoding text, sms_segments int, -- GSM-7 or UCS-2, and how many parts were billed
created_at timestamptz NOT NULL DEFAULT now(),
sent_at timestamptz, delivered_at timestamptz
);
CREATE INDEX ON notifications (user_id, created_at DESC); -- "did the code arrive?" from support
CREATE INDEX ON notifications (provider, provider_ref); -- delivery receipts
CREATE TABLE preferences ( -- lab: prefs:<user> with off=email
user_id text, kind text, channel text, enabled boolean NOT NULL,
PRIMARY KEY (user_id, kind, channel)
);
CREATE TABLE contacts ( -- lab: quiet=on and the local hour
user_id text PRIMARY KEY, phone text, email text, lang text NOT NULL DEFAULT 'ar',
timezone text NOT NULL DEFAULT 'Asia/Riyadh', quiet_from time, quiet_to time
);
CREATE TABLE templates ( -- lab: the TEMPLATES dictionary
kind text, lang text, channel text, version int, body text NOT NULL, approved_by text,
PRIMARY KEY (kind, lang, channel, version)
);
-- Valkey:
-- notify:high, notify:low streams, consumer group "workers"
-- notify:retry sorted set, score = when to try again
-- notify:dlq stream of messages that failed every attempt
-- otp:<user>, rate:<user>:<minute>, circuit:sms-a, fails:sms-a counters with a TTLولاحظ أن القيد UNIQUE على idem_key يجعل منع التكرار مضموناً حتى بعد أن تنتهي مهلة المفتاح في Valkey، فالمفتاح في Valkey يمنع التكرار بسرعة، والقيد في PostgreSQL هو الضمان الأخير.
الواجهات الرئيسية
| الطريقة | المسار | ماذا يفعل |
|---|---|---|
| POST | /v1/notifications | طلب إشعار: المستخدم واسم القالب وبياناته ومفتاح Idempotency-Key في الترويسة، ويرد 202 برقم الإشعار، أو نفس الرقم السابق إذا تكرر المفتاح. ويحمل توكن الخدمة المنتجة، وكل خدمة مسموح لها بأنواع محددة فقط، فخدمة التسويق لا ترسل رموز تحقق |
| GET | /v1/notifications/{id} | حالة الإشعار ومحاولاته وآخر خطأ، وهي الشاشة التي يفتحها الدعم |
| GET | /v1/users/{id}/notifications?since=… | الصندوق داخل التطبيق، ويحتاج توكن المستخدم نفسه |
| PUT | /v1/users/{id}/preferences | القنوات لكل نوع وساعات الهدوء والمنطقة الزمنية، ورسائل الأمان لا يمكن إيقافها |
| POST | /v1/broadcasts ثم /v1/broadcasts/{id}/approve | إرسال جماعي لا يبدأ حتى يوافق عليه شخص ثان غير الذي أنشأه، كما تعلمت هاواي |
| POST | /v1/webhooks/{provider} | إيصالات التسليم من المزودين، ويتحقق من توقيع المزود قبل أن يحدث الحالة، والإيصال المكرر لا يغير شيئاً |
تقدير السعة على ظهر ورقة
الأرقام التالية تقدير لنظام افتراضي، والمهم طريقة الحساب، وقد شرحنا هذا النوع من التقدير في مدخل إلى تصميم الأنظمة. نفترض ما يلي، وكلها افتراضات:
- خمسة ملايين مستخدم، يصل كل منهم في المتوسط ثلاثة إشعارات في اليوم.
- ساعة الذروة للإشعارات العادية أعلى من المتوسط بأربع مرات، والنشرة التسويقية تذهب لكل المستخدمين خلال ساعة واحدة.
- 500 ألف رمز تحقق برسالة SMS في اليوم، وسجل الإشعار مع فهارسه 500 بايت، ونحفظ السجل 90 يوماً.
notifications per day = 5,000,000 x 3 = 15,000,000
average per second = 15,000,000 / 86,400 ~ 174/s
peak per second = 174 x 4 ~ 700/s (high + low together)
one broadcast = 5,000,000 / 3,600 s ~ 1,390/s through notify:low, for one hour
writes per notification = insert 1 + status updates ~3 = 4
peak writes on PostgreSQL = 700 x 4 ~ 2,800/s
status log = 15,000,000 x 500 B = 7.5 GB/day ~ 2.7 TB/year
kept 90 days = 7.5 GB x 90 ~ 675 GB
idempotency keys = 15,000,000 x ~100 B, TTL 24 h ~ 1.5 GB of Valkey memory
OTP by SMS = 500,000/day
billed SMS parts = 500,000 x 1 = 500,000 if the template fits 70 Arabic characters
= 500,000 x 2 = 1,000,000 if it is 71 to 134 charactersوفي الحساب أعلاه لاحظ التالي:
- النشرة التسويقية الواحدة تساوي ضعف ذروة اليوم كله، لذلك لها طابورها المنخفض، وتحدد سرعتها بحدود المزود وليس بسرعة ال Workers، فإذا كان حد المزود 500 في الثانية فالنشرة تأخذ ساعتين ونصفاً وليس ساعة.
- سجل الحالة هو أكبر ما تخزنه، وتكلفته تأتي من مدة الحفظ، فقسم الجدول بالشهر Partitioning كما شرحنا في PostgreSQL للجداول الضخمة، واحذف الشهر القديم كاملاً بدل حذف الصفوف واحداً بعد الآخر.
- طول القالب العربي يغير فاتورة SMS أكثر من أي قرار تقني آخر هنا: حرف واحد بعد السبعين يضاعف عدد الأجزاء المدفوعة، كما رأيت في حساب الأجزاء في التجربة العملية.
مراحل النمو
| الجزء | البداية MVP (سيرفر أو اثنان) | النمو (آلاف الإشعارات في الدقيقة) | الحجم الكبير (ملايين في الساعة) |
|---|---|---|---|
| الاستقبال | دالة notify داخل التطبيق نفسه مع مفتاح Idempotency Key | خدمة إشعارات مستقلة خلف بوابة API | عدة نسخ من الخدمة بلا حالة خلف موزع حمل، وتوكن لكل خدمة منتجة |
| الطوابير | جدول في PostgreSQL مع FOR UPDATE SKIP LOCKED أو Valkey Streams | طابوران للأولوية وطابور انتظار وDead Letter Queue | طوابير لكل قناة وأولوية، أو Apache Kafka مقسم بالمستخدم حتى يبقى ترتيب إشعاراته |
| ال Workers | Worker واحد يقرأ العالي أولاً | مجموعة لكل أولوية، وقاطع دائرة لكل مزود | مجموعة لكل قناة ومزود، وحد إرسال مشترك لكل مزود، وWorkers في أكثر من منطقة |
| المزودون | مزود SMS واحد وخدمة بريد واحدة | مزود SMS احتياطي وGorush للإشعار الفوري | مزودون حسب الدولة والتكلفة، وتوجيه رموز التحقق إلى الأرخص الذي ينجح |
| التتبع | عمود الحالة في نفس الجدول | Webhooks من المزودين ولوحة للدعم | جدول أحداث مقسم بالشهر، أو قاعدة تحليلية منفصلة للتقارير |
ولا تقم في اليوم الأول ببناء توجيه ذكي بين مزودين حسب الدولة، أو اتصال مباشر بشبكات الاتصال، أو Kafka، والسبب أن الحجم الذي يحتاجها بعيد، وما يجب أن يكون موجوداً من اليوم الأول قليل ورخيص: مفتاح Idempotency Key يصف الحدث، وطابوران منفصلان للأولوية، وحد لرموز التحقق لكل رقم، وسجل حالة لكل إشعار.
ماذا لو تعطل جزء؟
| ماذا لو تعطل…؟ | الأثر | الحل |
|---|---|---|
| مزود SMS الأساسي | رموز التحقق لا تصل، والدخول متوقف | قاطع الدائرة ينقل الإرسال إلى المزود الاحتياطي، وفي التجربة انقسمت الرموز بين المزودين ولم يتوقف الدخول |
| Worker في منتصف دفعة | الرسائل التي قرأها ولم يؤكدها تبقى معلقة | XAUTOCLAIM بعد مهلة أطول من أبطأ إرسال، فيأخذها Worker آخر، وهذا ما حدث في كسر التجربة دون أن تضيع رسالة |
| Valkey | لا يمكن الكتابة في الطوابير، ولا تعمل العدادات | احفظ صف الإشعار في PostgreSQL بحالة queued أولاً، ومهمة صغيرة تعيد إلى الطابور كل صف بقي في هذه الحالة، مع نسخة احتياطية ل Valkey وحفظ على القرص AOF |
| PostgreSQL | لا يمكن قراءة التفضيلات والقوالب ولا تسجيل الحالة | نسخة احتياطية تتولى العمل، والقوالب والتفضيلات في ذاكرة الخدمة لدقائق، وتحديثات الحالة تنتظر في طابور حتى تعود القاعدة |
| مستقبل ال Webhooks | الحالة تبقى «أرسل» ولا تصبح «سلم» | المزودون يعيدون الإرسال عادة، ومهمة دورية تسأل واجهة المزود عن حالة ما بقي دون إيصال |
| شخص أرسل نشرة خاطئة | رسالة خاطئة لكل المستخدمين | موافقة شخص ثان على الإرسال الجماعي، وبيئة تدريب منفصلة بوضوح، وقالب تصحيح جاهز |
الأولويات والطوابير المنفصلة
رمز التحقق الذي يصل بعد خمس دقائق لا قيمة له، فالمستخدم طلب رمزاً آخر أو ترك التطبيق، أما النشرة الأسبوعية فلا يهم إن وصلت في التاسعة أو العاشرة. ولذلك فأول قرار في التصميم هو فصل الإشعارات حسب الأولوية في طوابير مختلفة، والصورة التالية تبين السبب:

ولاحظ أن الفصل لا يكفي إذا كانت نفس ال Workers تقرأ الطابورين بالتساوي، فيجب أن تقرأ الطابور العالي أولاً في كل دورة، ولا تأخذ من المنخفض إلا عندما يكون العالي فارغاً، أو تخصص لكل طابور مجموعة Workers مستقلة. وفي التجربة العملية وصلت رموز التحقق خلال 122 ملي ثانية في المتوسط رغم أنها دخلت خلف مئات الرسائل منخفضة الأولوية التي احتاجت 709 ملي ثوان. وخدمات الإشعار نفسها لها أولويات أيضاً، فبحسب وثائق APNs فإن الإشعارات بأولوية 5 أو 1 قد تجمع وتسلم على دفعات بحسب حالة البطارية، وبحسب وثائق FCM فإن الأولوية العالية في Android مخصصة لإشعارات يراها المستخدم، وإذا لاحظت Google أن رسائلك العالية لا تنتج إشعارات ظاهرة فقد تخفض أولويتها، فلا تقم بإرسال كل شيء بأولوية عالية.
القوالب واللغة: العربية في SMS والبريد
الخدمات لا ترسل نصاً جاهزاً، وإنما اسم قالب Template وبياناته، مثل shipped مع رقم الطلب، والخدمة تختار نسخة القالب بلغة المستخدم وتملؤها، والسبب أن النص يتغير أكثر مما تظن، وأن ترجمته يجب أن تكون في مكان واحد، وأن نفس الإشعار يحتاج نصاً قصيراً للإشعار الفوري ونصاً أطول للبريد. والعربية تضيف أمرين:
- تكلفة SMS: الرسالة القصيرة بالحروف اللاتينية تستخدم ترميز GSM-7 وتتسع ل 160 حرفاً، أما أي حرف خارجه مثل العربية فيحول الرسالة كلها إلى ترميز UCS-2 الذي يتسع ل 70 حرفاً فقط، وإذا طالت الرسالة قسمت إلى أجزاء يأخذ كل منها 153 حرفاً في GSM-7 أو 67 حرفاً في UCS-2، لأن جزءاً من كل قطعة يذهب لترويسة الربط بين الأجزاء، كما تشرح وثائق Twilio، والترميز نفسه معرف في مواصفة 3GPP TS 23.038. وكل جزء يحاسب كرسالة، لذلك فإن رسالة عربية من 116 حرفاً تكلف رسالتين، ورمز تعبيري واحد في رسالة إنجليزية يضاعف تكلفتها، والصورة التالية تبين ذلك بأرقام من التجربة.
- الاتجاه في البريد: أعط قالب البريد العربي
lang="ar"وdir="rtl"صريحين في العنصرhtml، ولا تعتمد على أن برنامج البريد سوف يستنتج الاتجاه، وانتبه للأرقام وأسماء المنتجات الإنجليزية داخل النص العربي، فهي التي تكسر الترتيب عادة، وأرسل معه جزءاً نصياً Plain Text للبرامج التي لا تعرض HTML.

التفضيلات وساعات الهدوء ومنع التكرار والحدود
قبل أن يدخل أي إشعار إلى الطابور تسأل الخدمة عدة أسئلة، وكل سؤال يوقف نوعاً من الرسائل التي لا يجب أن ترسل، والصورة التالية تبين الأسئلة بالترتيب مع أرقام التجربة العملية:

- منع التكرار Deduplication: الخدمة التي تطلب الإشعار قد تعيد طلبها بعد انقطاع أو إعادة تشغيل، والطابور نفسه يوصل مرة واحدة على الأقل، لذلك يحمل كل طلب مفتاح Idempotency Key ثابتاً يصف الحدث نفسه، مثل
order-5012-shipped، وليس معرفاً عشوائياً ينشأ مع كل محاولة، والخدمة تحفظ المفاتيح التي رأتها يوماً على الأقل وترفض المكرر، وقد شرحنا الفكرة في تصميم واجهات API. - التفضيلات Preferences: المستخدم يختار القنوات لكل نوع من الإشعارات، فقد يريد تحديثات الطلبات على الهاتف والفواتير بالبريد ولا يريد التسويق أبداً، ورسائل الأمان لا يمكن إيقافها. وفي البريد التسويقي تحتاج أيضاً رابط إلغاء الاشتراك بنقرة واحدة وفق RFC 8058 كما شرحنا في مقال listmonk.
- ساعات الهدوء Quiet Hours: الإشعار غير العاجل لا يرسل ليلاً بتوقيت المستخدم وليس بتوقيت السيرفر، وإنما يؤجل إلى الصباح، والتوقيت المحلي مسألة أصعب مما تبدو كما شرحنا في التاريخ والوقت.
- حد لكل مستخدم Per-user Rate Limit: عدد أقصى من الإشعارات غير العاجلة لكل مستخدم في الساعة واليوم، فإذا تجاوزها أسقطت الخدمة الباقي أو جمعته في ملخص واحد Digest، والسبب أن المستخدم الذي يصله عشرون إشعاراً في ساعة يلغي الإشعارات كلها، وعندها لن يصله حتى المهم.
- حد لرموز التحقق: رموز OTP لا تخضع لحدود التسويق، لكن لها حد أشد لكل رقم هاتف، والسبب هجوم ضخ الرسائل SMS Pumping، حيث يطلب سكربت رموزاً لآلاف الأرقام المميزة التي يتقاسم أصحابها الأرباح مع شركات الاتصال، فتدفع أنت ثمن كل رسالة، وقد شرحنا هذا الهجوم وطرق منعه بالتفصيل في بوابة الدخول برمز التحقق.
إعادة المحاولة والمزود الاحتياطي و Dead Letter Queue
المزود الخارجي سوف يفشل حتماً، والسؤال الوحيد هو ماذا تفعل عندها في ال Workers الذين رأيتهم في المعمارية أعلاه، والصورة التالية تبين المسارات الثلاثة لأي محاولة إرسال:

- فرق بين الخطأ المؤقت والدائم: انتهاء المهلة Timeout أو الرد 429 أو 503 يعني «حاول لاحقاً»، أما رقم هاتف غير صالح أو توكن إشعار ألغي لأن المستخدم حذف التطبيق فلن ينجح أبداً، وإعادته تهدر المال وتزعج المزود، والصحيح أن توقفه وتحدث بيانات المستخدم، مثل حذف توكن الإشعار الملغى.
- الانتظار المتزايد مع العشوائية: لا تعد المحاولة فوراً ولا بعد مدة ثابتة، فإذا تعطل المزود دقيقة وعاد فإن آلاف الرسائل التي فشلت معاً سوف تعود معاً وتسقطه من جديد، والحل انتظار يتضاعف مع كل محاولة وتضاف إليه عشوائية Jitter، وقد شرحنا ذلك في الأنظمة الموزعة، والتجربة تستخدم طريقة Full Jitter التي قارنها Marc Brooker في مقاله في مدونة AWS.
- المزود الاحتياطي Failover: لأهم القنوات، أي SMS لرموز التحقق، اعقد مع مزودين اثنين، واستخدم قاطع الدائرة Circuit Breaker: عندما يفشل الأساسي عدة مرات متتالية توقف الاتصال به فترة قصيرة وترسل عبر الاحتياطي، ثم تجربه برسالة واحدة قبل أن تعيد له الحمل، والسبب أن كل محاولة على مزود متوقف تضيف ثواني إلى رمز ينتظره مستخدم أمام شاشة الدخول.
- Dead Letter Queue: الرسالة التي فشلت في كل المحاولات لا تحذف بصمت، وإنما تنتقل إلى طابور خاص يراه الفريق ويحلل سببه، كما شرحنا في مفاهيم الطوابير.
- Idempotency عند المزود أيضاً: إذا أرسل ال Worker الرسالة ثم توقف قبل أن يسجل النجاح فسوف يعيدها Worker آخر، فإذا كان المزود يقبل مفتاح Idempotency Key فمرر له معرف الإشعار، وإلا فتقبل أن هذا الاحتمال النادر يعني رسالة مكررة.
متابعة حالة كل إشعار
«هل وصل الرمز؟» هو أول سؤال يسأله فريق الدعم، ولا يمكن الإجابة عنه إذا كانت الخدمة ترسل وتنسى، لذلك يحمل كل إشعار سجلاً بحالته: في الطابور، ثم أرسل إلى المزود مع مرجعه، ثم سلم أو فشل بحسب إشعار المزود Delivery Receipt الذي يصل عبر Webhook إلى مكون Delivery webhooks في مخطط المعمارية، ثم فتح إن كانت القناة تسمح بذلك، مع عدد المحاولات وسبب آخر خطأ. ومن هذا السجل تخرج الأرقام التي تراقبها كل يوم: الزمن من الطلب حتى التسليم لكل أولوية، ونسبة الفشل لكل مزود، وعدد ما في Dead Letter Queue، وتكلفة SMS لكل نوع إشعار، وعندما يرتفع أحدها فجأة فقد بدأت المشكلة قبل أن يشتكي أحد.
عندما يكبر الحجم
الأفكار السابقة لا تتغير مع الحجم، لكن بعض الأجزاء تحتاج عناية إضافية عندما ترسل ملايين الإشعارات:
- الإرسال الجماعي Broadcast: إشعار لكل المستخدمين لا يدخل الطابور كمليون رسالة دفعة واحدة، وإنما كمهمة واحدة يقسمها Worker إلى دفعات بحسب حدود المزود في الثانية، وخدمات الإشعار الفوري توفر أيضاً الإرسال لموضوع Topic يشترك فيه المستخدمون.
- حدود المزودين: كل مزود له حد للرسائل في الثانية، فاجعل معدل ال Workers أقل منه، والسبب أن تجاوزه يعني ردود 429 تملأ طوابير إعادة المحاولة.
- الإشعارات القابلة للاستبدال: نتيجة مباراة تتحدث كل دقيقة لا تحتاج أن تصل كل نسخها، وتسمح رسائل FCM القابلة للدمج و
apns-collapse-idفي Apple بأن تستبدل الرسالة الجديدة القديمة إذا لم تكن وصلت بعد، وتذكر وثائق FCM أن Android يحفظ حتى 100 رسالة غير قابلة للدمج للجهاز غير المتصل، فإذا تجاوزها حذفها كلها وأبلغ التطبيق أن يطلب مزامنة كاملة. - الأدوات الجاهزة: قبل أن تبني كل هذا ابحث في المنصات المفتوحة التي تفعل جزءاً منه، مثل Novu الذي يجمع القنوات والتفضيلات والصندوق داخل التطبيق، وlistmonk للنشرات البريدية، وPostal لإرسال البريد من سيرفرك.
تجربة عملية: طابوران واثنان من ال Workers ومزود يتعطل
في هذه التجربة نبني نسخة مصغرة من النظام على Valkey Streams، وهي سجلات يضيف إليها المنتج بأمر XADD وتقرأ منها مجموعة مستهلكين Consumer Group بأمر XREADGROUP، فتوزع الرسائل على ال Workers، ويبقى كل ما قرأه Worker ولم يؤكده في قائمة معلقة Pending List حتى يؤكده أو يأخذه غيره، كما تشرح مقدمة Valkey Streams. والمزودون في التجربة مزيفون داخل الكود، لكن سلوكهم يشبه الحقيقة:
- مزود SMS الأساسي
sms-aيفشل في 5% من الرسائل عشوائياً، ويتوقف تماماً بخطأ 503 من الثانية 2 إلى الثانية 4 من بداية السيناريو. - المزود الاحتياطي
sms-bيفشل في 5% من الرسائل. - البريد يفشل في 10% من الرسائل، ومستخدم واحد
u050سيرفر بريده لا يرد أبداً. - الإشعار الفوري لا يفشل إلا للمستخدمين الذين تنتهي أسماؤهم بالرقم 7، ويرد عليهم بخطأ دائم كأنهم حذفوا التطبيق.
أما السيناريو فيرسل 100 تحديث شحن، ثم يعيد إرسال 20 منها بنفس المفتاح، ثم ست رسائل تسويقية لكل واحد من عشرة مستخدمين، ثم عشرة رموز لنفس الرقم، ثم رسالة لمستخدم في ساعات الهدوء، ورسالة بريد لمستخدم أوقف البريد، وأخيراً 60 رمز تحقق برسالة SMS بمعدل رمز كل 100 ملي ثانية، أي أنها تمر أثناء تعطل المزود الأساسي.
المتطلبات: جهاز عليه Docker Engine وDocker Compose، ولا تحتاج نشر أي منفذ.
ملفات التجربة
الملف compose.yaml:
name: lab-case2-notify
x-worker: &worker
build: .
image: lab-case2-notify:1
command: ["worker"]
environment: { VALKEY_URL: "redis://valkey:6379" }
depends_on: [valkey]
services:
valkey:
image: valkey/valkey:9.1.2-alpine
container_name: lab-case2-notify-valkey
worker1:
<<: *worker
container_name: lab-case2-notify-worker1
hostname: worker1
worker2:
<<: *worker
container_name: lab-case2-notify-worker2
hostname: worker2
sender:
<<: *worker
container_name: lab-case2-notify-sender
hostname: sender
command: ["send"]
profiles: ["run"]والملف Dockerfile:
FROM python:3.13.16-slim
RUN pip install --no-cache-dir "redis==8.1.0"
WORKDIR /app
COPY notify.py segments.py ./
ENTRYPOINT ["python", "-u", "notify.py"]والملف notify.py الذي فيه الجهتان: الدالة notify التي يستدعيها التطبيق، والدالة worker التي تقرأ وترسل:
"""Notification pipeline for the lab: Valkey streams, a worker with retries, dedup, rate limits and provider failover.
python notify.py worker # run a worker (start two of them)
python notify.py send # enqueue the test scenario, wait, print the report
"""
import json, os, random, sys, time, uuid
import redis
r = redis.Redis.from_url(os.environ.get("VALKEY_URL", "redis://valkey:6379"), decode_responses=True)
NAME = os.environ.get("HOSTNAME", "worker")
STREAMS = {"high": "notify:high", "low": "notify:low"} # separate queues: an OTP never waits behind a newsletter
GROUP = "workers"
MAX_ATTEMPTS = 4
def elapsed():
"""Seconds since the sender started the scenario (all containers share this clock through Valkey)."""
return time.time() - float(r.get("lab:t0") or time.time())
def log(*a):
print(f"{elapsed():6.2f}s {NAME}", *a, flush=True)
# ---------- templates and preferences ----------
TEMPLATES = {
("otp", "ar"): "رمز التحقق {code}. لا تشاركه مع أحد.",
("otp", "en"): "Your code is {code}. Do not share it.",
("shipped", "ar"): "تم شحن طلبك رقم {order}.",
("shipped", "en"): "Your order {order} has shipped.",
("promo", "ar"): "خصم {pct}% على كل شيء حتى نهاية الأسبوع.",
("promo", "en"): "{pct}% off everything until the weekend.",
}
def render(kind, lang, params):
return TEMPLATES.get((kind, lang), TEMPLATES[(kind, "en")]).format(**params)
def sms_segments(text):
"""GSM-7 fits 160 characters (153 per part); anything outside it, such as Arabic, needs UCS-2: 70 (67 per part).
Simplified: plain ASCII is treated as GSM-7, and UCS-2 length is counted in UTF-16 units (an emoji counts twice)."""
gsm = all(ord(c) < 128 for c in text)
single, part = (160, 153) if gsm else (70, 67)
units = len(text) if gsm else len(text.encode("utf-16-le")) // 2
n = 1 if units <= single else -(-units // part)
return ("GSM-7" if gsm else "UCS-2"), n
# ---------- fake providers ----------
class TransientError(Exception): pass
class PermanentError(Exception): pass
rng = random.Random(NAME)
def provider_send(provider, msg):
time.sleep(0.02)
if provider == "sms-a" and 2.0 < elapsed() < 4.0:
raise TransientError("sms-a: 503 Service Unavailable") # an outage window
if provider == "email" and msg["user"] == "u050":
raise TransientError("email: timeout from mx.slow.example") # a mail server that never answers
fail_rate = {"sms-a": 0.05, "sms-b": 0.05, "email": 0.1, "push": 0.0}[provider]
if provider == "push" and msg["user"].endswith("7"):
raise PermanentError("push: 410 Unregistered (the app was removed)")
if rng.random() < fail_rate:
raise TransientError(f"{provider}: timeout")
return f"{provider}-{uuid.uuid4().hex[:8]}"
# ---------- circuit breaker for the primary SMS provider ----------
def sms_provider():
return "sms-b" if r.exists("circuit:sms-a") else "sms-a"
def record_sms_result(provider, ok):
if provider != "sms-a":
return
if ok:
r.delete("fails:sms-a")
return
if r.incr("fails:sms-a") >= 3: # 3 failures in a row: stop calling it for 3 s
if r.set("circuit:sms-a", "open", nx=True, ex=3):
log("circuit OPEN for sms-a, failing over to sms-b")
r.delete("fails:sms-a")
# ---------- the worker ----------
def backoff(attempt):
base = 0.2 * (2 ** (attempt - 1)) # 0.2, 0.4, 0.8, 1.6 s
return rng.uniform(0, base) # "full jitter": spread the retries out
def handle(msg):
nid = msg["id"]
attempt = int(msg.get("attempt", 1))
text = render(msg["kind"], msg["lang"], json.loads(msg["params"]))
channel = msg["channel"]
provider = sms_provider() if channel == "sms" else channel
try:
ref = provider_send(provider, msg)
if channel == "sms":
record_sms_result(provider, True)
enc, parts = sms_segments(text) if channel == "sms" else ("-", 0)
r.hset(f"status:{nid}", mapping={"state": "sent", "provider": provider, "ref": ref, "attempts": attempt,
"encoding": enc, "segments": parts, "latency_ms": int((time.time() - float(msg["created"])) * 1000)})
except PermanentError as e:
r.hset(f"status:{nid}", mapping={"state": "failed", "error": str(e), "attempts": attempt})
log(f"{nid} permanent failure, not retrying: {e}")
except TransientError as e:
if channel == "sms":
record_sms_result(provider, False)
if attempt >= MAX_ATTEMPTS:
r.xadd("notify:dlq", {**msg, "error": str(e)})
r.hset(f"status:{nid}", mapping={"state": "dead-letter", "error": str(e), "attempts": attempt})
log(f"{nid} gave up after {attempt} attempts -> DLQ")
return
delay = backoff(attempt)
r.zadd("notify:retry", {json.dumps({**msg, "attempt": attempt + 1}): time.time() + delay})
log(f"{nid} attempt {attempt} failed ({e}), retry in {delay:.2f}s")
r.hset(f"status:{nid}", mapping={"state": "retrying", "attempts": attempt, "error": str(e)})
def move_due_retries():
for raw in r.zrangebyscore("notify:retry", 0, time.time(), start=0, num=50):
if r.zrem("notify:retry", raw): # only one worker wins the ZREM
m = json.loads(raw)
r.xadd(STREAMS[m["priority"]], m)
def worker():
for s in STREAMS.values():
try:
r.xgroup_create(s, GROUP, id="0", mkstream=True)
except redis.ResponseError:
pass
log("worker started")
last_claim = 0
while True:
move_due_retries()
if time.time() - last_claim > 2: # take over messages that another worker read but never acknowledged
last_claim = time.time()
for stream in STREAMS.values():
_next, claimed, _deleted = r.xautoclaim(stream, GROUP, NAME, min_idle_time=5000, start_id="0-0", count=50)
if claimed:
log(f"claimed {len(claimed)} message(s) left pending by a dead worker on {stream}")
for entry_id, msg in claimed:
handle(msg)
r.xack(stream, GROUP, entry_id)
# high priority first; only when it is empty, take low priority work
got = r.xreadgroup(GROUP, NAME, {STREAMS["high"]: ">"}, count=10)
if not got:
got = r.xreadgroup(GROUP, NAME, {STREAMS["low"]: ">"}, count=10, block=200)
for stream, entries in got or []:
for entry_id, msg in entries:
handle(msg)
r.xack(stream, GROUP, entry_id) # acknowledged after handling (or after scheduling the retry)
# ---------- the producer side: what the application calls ----------
def notify(user, kind, params, idem_key, priority, channel, lang="ar", local_hour=12):
if not r.set(f"idem:{idem_key}", "1", nx=True, ex=86400):
return "duplicate"
prefs = r.hgetall(f"prefs:{user}")
if channel in prefs.get("off", "").split(","):
return "opted-out"
if priority == "high" and kind == "otp":
n = r.incr(f"otp:{user}")
if n == 1:
r.expire(f"otp:{user}", 600)
if n > 3: # at most 3 codes per number per 10 minutes
return "otp-limit"
if priority == "low":
if prefs.get("quiet") == "on" and (local_hour >= 22 or local_hour < 7):
return "deferred-quiet-hours" # a real system schedules it for 07:00 local time
n = r.incr(f"rate:{user}:{int(time.time() // 60)}")
r.expire(f"rate:{user}:{int(time.time() // 60)}", 120)
if n > 3: # at most 3 low-priority messages per user per minute
return "rate-limited"
nid = uuid.uuid4().hex[:10]
msg = {"id": nid, "user": user, "kind": kind, "params": json.dumps(params), "priority": priority,
"channel": channel, "lang": lang, "created": time.time(), "attempt": 1}
r.hset(f"status:{nid}", mapping={"state": "queued", "user": user, "channel": channel, "priority": priority})
r.xadd(STREAMS[priority], msg)
return "queued"
def send():
r.set("lab:t0", time.time())
counts = {}
def add(result):
counts[result] = counts.get(result, 0) + 1
# 1) 100 "order shipped" by push or email (low)
for i in range(100):
add(notify(f"u{i:03d}", "shipped", {"order": 5000 + i}, f"order-{5000 + i}-shipped", "low", "push" if i % 2 else "email"))
# 2) the order service retries 20 of them (same idempotency key)
for i in range(20):
add(notify(f"u{i:03d}", "shipped", {"order": 5000 + i}, f"order-{5000 + i}-shipped", "low", "push" if i % 2 else "email"))
# 3) a marketing burst: 6 promos to each of 10 users
for u in range(10):
for k in range(6):
add(notify(f"m{u:02d}", "promo", {"pct": 20}, f"promo-w41-{u}-{k}", "low", "push"))
# 4) somebody asks for 10 codes for one number (SMS pumping looks like this)
for k in range(10):
add(notify("u999", "otp", {"code": "000000"}, f"otp-pump-{k}", "high", "sms"))
# 5) a user with quiet hours, at 23:00 his time; and a user who turned email off
r.hset("prefs:night", mapping={"quiet": "on"})
add(notify("night", "promo", {"pct": 20}, "promo-night", "low", "push", local_hour=23))
r.hset("prefs:noemail", mapping={"off": "email"})
add(notify("noemail", "shipped", {"order": 7777}, "order-7777-shipped", "low", "email"))
# 6) people logging in: 60 OTP codes by SMS (high), one every 100 ms, while the low-priority work is still queued
for i in range(60):
add(notify(f"u{i:03d}", "otp", {"code": f"{rng.randint(0, 999999):06d}"}, f"otp-{i}", "high", "sms", lang="ar" if i % 2 else "en"))
time.sleep(0.1)
print("enqueue results:", dict(sorted(counts.items())), flush=True)
while True: # wait until nothing is queued, in flight or waiting for a retry
time.sleep(0.5)
busy = r.zcard("notify:retry") + sum(int(g["pending"]) + int(g["lag"] or 0)
for s in STREAMS.values() for g in r.xinfo_groups(s))
if busy == 0:
break
report()
def report():
states, attempts, providers, enc, lat = {}, {}, {}, {}, {"high": [], "low": []}
for key in r.scan_iter("status:*"):
s = r.hgetall(key)
states[s["state"]] = states.get(s["state"], 0) + 1
attempts[s.get("attempts", "-")] = attempts.get(s.get("attempts", "-"), 0) + 1
if s["state"] == "sent":
providers[s["provider"]] = providers.get(s["provider"], 0) + 1
lat[s["priority"]].append(int(s["latency_ms"]))
if s["channel"] == "sms":
k = f'{s["encoding"]} x{s["segments"]}'
enc[k] = enc.get(k, 0) + 1
p = lambda xs, q: sorted(xs)[int(q * (len(xs) - 1))] if xs else 0
print("final states: ", dict(sorted(states.items())))
print("attempts used: ", dict(sorted(attempts.items())))
print("sent via: ", dict(sorted(providers.items())))
print("sms encoding: ", dict(sorted(enc.items())))
print("latency ms p50/p95 high:", p(lat["high"], .5), "/", p(lat["high"], .95), " low:", p(lat["low"], .5), "/", p(lat["low"], .95))
print("dead letters: ", r.xlen("notify:dlq"))
if __name__ == "__main__":
if sys.argv[1] == "worker":
worker()
else:
send()والملف الصغير segments.py الذي يحسب عدد أجزاء SMS لعدة نصوص:
# How many SMS parts does a text cost? (GSM-7: 160 / 153 per part, UCS-2: 70 / 67 per part)
from notify import sms_segments
samples = {
"English OTP": "Your code is 482913. Do not share it.",
"Arabic OTP": "رمز التحقق 482913. لا تشاركه مع أحد.",
"Arabic, 70 chars": "ا" * 70,
"Arabic, 71 chars": "ا" * 71,
"Arabic order update": "تم شحن طلبك رقم 5012 من مستودع الرياض، وسوف يصل خلال يومين عمل. تتبع الشحنة من التطبيق أو من الرابط في رسالة البريد.",
"English, 161 chars": "a" * 161,
"English shipped": "Your order 5012 has shipped and will arrive within two working days. Track it in the app.",
"Same + one emoji": "Your order 5012 has shipped and will arrive within two working days. Track it in the app. 📦",
}
for name, text in samples.items():
enc, parts = sms_segments(text)
print(f"{name:<24} {len(text):>4} chars {enc} {parts} part(s)")في الإعداد أعلاه لاحظ التالي:
- ال Worker يقرأ
notify:highدون انتظار، ولا يقرأnotify:lowإلا إذا عاد الأول فارغاً، وهذه هي الأولوية كلها في سطرين. - إعادة المحاولة لا تحجز ال Worker بأمر
sleep، وإنما تضع الرسالة في مجموعة مرتبةnotify:retryبوقت موعدها، والدالةmove_due_retriesتعيد ما حان وقته إلى الطابور، وZREMيضمن أن Worker واحداً فقط يأخذ كل رسالة. - قاطع الدائرة مشترك بين ال Workers لأنه مفتاح في Valkey بمهلة 3 ثوان، فإذا فتحه أحدهم انتقل الجميع إلى المزود الاحتياطي.
- كل ثانيتين يسأل ال Worker عن رسائل بقيت معلقة أكثر من 5 ثوان عند Worker آخر ويأخذها بأمر XAUTOCLAIM، وهذا ما ينقذ الرسائل عندما يتوقف Worker في منتصف عمله.
- الحساب في
sms_segmentsمبسط، فهو يعامل كل حروف ASCII كأنها من GSM-7، والجدول الحقيقي في المواصفة يختلف في بعض الرموز.
التشغيل والتقرير
docker compose up -d --build
docker compose run --rm senderوالمخرج سوف يكون كما يلي:
enqueue results: {'deferred-quiet-hours': 1, 'duplicate': 20, 'opted-out': 1, 'otp-limit': 7, 'queued': 193, 'rate-limited': 30}
final states: {'dead-letter': 1, 'failed': 13, 'sent': 179}
attempts used: {'1': 182, '2': 10, '4': 1}
sent via: {'email': 49, 'push': 67, 'sms-a': 31, 'sms-b': 32}
sms encoding: {'GSM-7 x1': 30, 'UCS-2 x1': 33}
latency ms p50/p95 high: 122 / 370 low: 709 / 1328
dead letters: 1في المخرج أعلاه لاحظ التالي:
- من 252 طلباً دخل الطابور 193، وأوقفت الأسئلة قبل الطابور الباقي: 20 مكرراً بنفس المفتاح، و30 رسالة تسويقية تجاوزت الحد لكل مستخدم، و7 رموز تجاوزت حد الرقم الواحد، ورسالة أجلت لساعات الهدوء، وأخرى لمستخدم أوقف البريد.
- من 193 رسالة أرسلت 179، وفشلت 13 بخطأ دائم ولم تعد أبداً، وذهبت رسالة واحدة إلى Dead Letter Queue بعد أربع محاولات.
- رموز التحقق انقسمت تقريباً بين المزودين: 31 عبر
sms-aو32 عبرsms-b، أي أن تعطل المزود الأساسي لم يوقف الدخول. - الرموز العربية والإنجليزية كلها جزء واحد لأن القالب قصير، 30 بترميز GSM-7 و33 بترميز UCS-2.
- زمن الرموز ذات الأولوية العالية 122 ملي ثانية في المنتصف و370 في النسبة 95، بينما احتاجت الرسائل المنخفضة 709 و1328، وهذه أرقام جهاز واحد في التجربة وليست قياساً لمزود حقيقي.
ماذا حدث أثناء التعطل؟
docker compose logs --no-log-prefix worker1 worker2 | sort -n | grep -v permanent -0.00s worker1 worker started
-0.00s worker2 worker started
0.10s worker1 a988ee5499 attempt 1 failed (email: timeout), retry in 0.13s
0.19s worker1 c5a14e443a attempt 1 failed (email: timeout), retry in 0.09s
0.23s worker1 ef37339954 attempt 1 failed (email: timeout), retry in 0.01s
0.66s worker1 298b622650 attempt 1 failed (email: timeout from mx.slow.example), retry in 0.04s
1.06s worker2 d759dc73fc attempt 1 failed (email: timeout), retry in 0.15s
1.68s worker1 298b622650 attempt 2 failed (email: timeout from mx.slow.example), retry in 0.28s
1.72s worker1 2749d9a129 attempt 1 failed (sms-a: timeout), retry in 0.04s
2.00s worker2 298b622650 attempt 3 failed (email: timeout from mx.slow.example), retry in 0.67s
2.02s worker2 a49c13674d attempt 1 failed (sms-a: 503 Service Unavailable), retry in 0.03s
2.30s worker1 4cdd29f992 attempt 1 failed (sms-a: 503 Service Unavailable), retry in 0.17s
2.32s worker1 circuit OPEN for sms-a, failing over to sms-b
2.33s worker1 d458c22d5a attempt 1 failed (sms-a: 503 Service Unavailable), retry in 0.18s
2.55s worker2 a8981a9c92 attempt 1 failed (sms-b: timeout), retry in 0.15s
2.81s worker1 298b622650 gave up after 4 attempts -> DLQ
5.87s worker1 3b91669f78 attempt 1 failed (sms-a: timeout), retry in 0.07sلاحظ التسلسل عند الثانية 2: فشلت رسالتان على sms-a بخطأ 503، ومعهما خطأ عشوائي قبلهما، فاكتملت ثلاثة أخطاء متتالية وفتح قاطع الدائرة عند الثانية 2.32، ومن هناك ذهبت الرموز إلى sms-b، وحتى الرموز التي فشلت أعيدت بعد 0.03 إلى 0.18 ثانية فقط، فذهبت هي أيضاً إلى المزود الاحتياطي. ولاحظ الرسالة إلى u050: أعيدت ثلاث مرات بانتظار متزايد، ثم انتقلت إلى Dead Letter Queue عند الثانية 2.81. ولاحظ أيضاً أن الانتظار الفعلي بين المحاولتين الأولى والثانية لهذه الرسالة كان قرابة ثانية رغم أن المطلوب 0.04 ثانية، والسبب أن ال Worker لا ينقل الرسائل المؤجلة إلا بين دفعتين، وكان مشغولاً بدفعات الرسائل منخفضة الأولوية، وهذا مقبول للبريد، أما في نظام حقيقي فاجعل نقل الرسائل المؤجلة مهمة مستقلة. أما الأخطاء الدائمة فكانت كلها من هذا النوع:
0.17s worker1 04ed76567b permanent failure, not retrying: push: 410 Unregistered (the app was removed)
0.17s worker2 431d507620 permanent failure, not retrying: push: 410 Unregistered (the app was removed)
0.38s worker1 c740126762 permanent failure, not retrying: push: 410 Unregistered (the app was removed)كم جزءاً تكلف الرسالة؟
docker compose run --rm --entrypoint python sender segments.pyEnglish OTP 37 chars GSM-7 1 part(s)
Arabic OTP 36 chars UCS-2 1 part(s)
Arabic, 70 chars 70 chars UCS-2 1 part(s)
Arabic, 71 chars 71 chars UCS-2 2 part(s)
Arabic order update 116 chars UCS-2 2 part(s)
English, 161 chars 161 chars GSM-7 2 part(s)
English shipped 89 chars GSM-7 1 part(s)
Same + one emoji 91 chars UCS-2 2 part(s)لاحظ السطرين الأخيرين: نفس الرسالة الإنجليزية جزء واحد، ثم صارت جزأين بعد إضافة رمز تعبيري واحد في آخرها، والسبب أن الرمز خارج GSM-7 فتحولت الرسالة كلها إلى UCS-2، وسعة الجزء الواحد نزلت من 160 إلى 70. وفي نظام يرسل مليون رسالة في الشهر فإن هذا الرمز يعني مليون رسالة إضافية في الفاتورة.
نكسر التجربة: Worker يتوقف في منتصف العمل
الآن نعيد السيناريو من البداية، ونوقف worker2 بالقوة بعد ثانيتين ونصف تقريباً من بدايته، وهو في منتصف دفعة من الرسائل:
docker compose down && docker compose up -d
docker compose run --rm sender &
sleep 2.5; docker kill lab-case2-notify-worker2; waitenqueue results: {'deferred-quiet-hours': 1, 'duplicate': 20, 'opted-out': 1, 'otp-limit': 7, 'queued': 193, 'rate-limited': 30}
final states: {'dead-letter': 1, 'failed': 13, 'sent': 179}
attempts used: {'1': 182, '2': 7, '3': 3, '4': 1}
sent via: {'email': 49, 'push': 67, 'sms-a': 31, 'sms-b': 32}
sms encoding: {'GSM-7 x1': 30, 'UCS-2 x1': 33}
latency ms p50/p95 high: 143 / 357 low: 731 / 6586
dead letters: 1docker compose logs --no-log-prefix worker1 | sort -n | grep -i "claimed\|started" -0.00s worker1 worker started
6.66s worker1 claimed 9 message(s) left pending by a dead worker on notify:low
0.87s worker2 46ab3302b0 permanent failure, not retrying: push: 410 Unregistered (the app was removed)
1.11s worker2 435cc9f1c3 permanent failure, not retrying: push: 410 Unregistered (the app was removed)النتيجة النهائية مطابقة للتشغيل الأول: 179 رسالة أرسلت دون أن يضيع شيء، والسبب أن الرسائل التسع التي قرأها worker2 ولم يؤكدها بقيت في القائمة المعلقة، فأخذها worker1 بأمر XAUTOCLAIM عند الثانية 6.66، أي بعد أن مرت 5 ثوان على قراءتها. والثمن ظهر في النسبة 95 لزمن الرسائل منخفضة الأولوية الذي قفز من 1.3 ثانية إلى 6.6 ثوان، فمهلة الأخذ قصيرة تعني إنقاذاً أسرع لكنها قد تأخذ رسالة من Worker بطيء ما زال يعمل عليها فترسل مرتين، لذلك اجعلها أطول من أبطأ إرسال تتوقعه.
حذف التجربة
docker compose down
docker image rm lab-case2-notify:1أخطاء شائعة
- طابور واحد لكل الإشعارات، فيقف رمز التحقق خلف النشرة التسويقية.
- مفتاح منع التكرار ينشأ عشوائياً مع كل محاولة، فلا يمنع شيئاً.
- إعادة كل خطأ، بما فيها الأرقام الخاطئة والتوكنات الملغاة، أو إعادته فوراً دون انتظار وعشوائية.
- الاعتماد على مزود SMS واحد لرموز الدخول.
- غياب حد لعدد الرموز لكل رقم، فتكتشف هجوم ضخ الرسائل في الفاتورة.
- قوالب SMS عربية طويلة أو فيها رموز تعبيرية دون حساب أجزائها.
- إرسال التسويق ليلاً بتوقيت السيرفر لا بتوقيت المستخدم.
- إرسال كل الإشعارات بأولوية عالية في APNs وFCM.
- الإرسال دون سجل حالة، فلا يعرف الدعم هل وصل الإشعار.
- قوالب الإنذار الجماعي دون موافقة شخص ثان ودون قالب تصحيح جاهز.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- اجعل الإشعارات خدمة مستقلة تستقبل اسم القالب وبياناته من الجميع، وتقرر هي القناة واللغة والتوقيت والمزود.
- افصل الأولويات في طوابير مختلفة، واقرأ العالي أولاً، وفي التجربة وصلت الرموز في 122 ملي ثانية رغم مئات الرسائل الأخرى.
- الرسالة العربية في SMS تتسع ل 70 حرفاً بدل 160، ورمز تعبيري واحد يضاعف تكلفة الرسالة الإنجليزية، والبريد العربي يحتاج
dir="rtl"صريحاً. - قبل الطابور: مفتاح Idempotency Key يصف الحدث، وتفضيلات المستخدم، وساعات الهدوء بتوقيته، وحد لكل مستخدم، وحد أشد لرموز التحقق ضد ضخ الرسائل.
- أعد الأخطاء المؤقتة فقط بانتظار متزايد مع عشوائية، وانقل إلى مزود احتياطي بقاطع دائرة، وضع ما فشل نهائياً في Dead Letter Queue.
- سجل حالة كل إشعار، وراقب زمن التسليم ونسبة الفشل والتكلفة لكل مزود.
- الإرسال الجماعي الخطير يحتاج موافقة شخصين وقالب تصحيح جاهزاً، كما تعلمت هاواي في 2018.
وفي مقالات هذا القسم أنظمة أخرى تستخدم نظام الإشعارات من جهتها:
دليلكيف تبني تطبيق محادثة مثل WhatsApp لملايين المستخدمينكيف تصل الرسالة من هاتف إلى آخر عبر سيرفرات مختلفة، وكيف تحفظ لمن انقطع اتصاله وتظهر علامات التسليم والقراءة.
دليلكيف تبني نظام حجز تذاكر يتحمل لحظة فتح البيعكيف تمنع بيع المقعد الواحد مرتين، وكيف يصمد نظام الحجز عندما يدخل الجميع في الدقيقة نفسها لشراء التذاكر.
دليلأنظمة الحشود الكبرى: كيف تصمد في مواسم الحج والعمرةكيف تصمم نظام حجز وتصاريح يتحمل ملايين الطلبات في مواعيد معروفة مسبقاً، وماذا تطفئ عندما يتجاوز الحمل خطتك.
دليلبوابة الدخول Captive Portal: شبكة الضيوف برمز تحققكيف تعمل بوابة الدخول في شبكة Wi-Fi الضيوف، وكيف تختار طريقة التحقق والأدوات المناسبة لمقهى أو فندق أو شركة.
دليلتثبيت listmonk لإدارة النشرات البريدية على خادمككيف ترسل نشرتك البريدية Newsletter من سيرفرك باستخدام listmonk، فتبقى قائمة المشتركين عندك ولا يرتفع السعر مع كل مشترك.
دليلالأنظمة الموزعة للمبتدئين: ماذا يحدث عندما تنقطع الشبكةلماذا تتصرف الأنظمة الموزعة بشكل غريب عند انقطاع الشبكة، وكيف تتفق السيرفرات على قرار واحد رغم ذلك.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة العملية على Docker Engine 29.8.0 وDocker Compose 5.5.1 مع Valkey 9.1.2 وPython 3.13.16 وredis-py 8.1.0.
- أكتوبر 2026: إضافة قسم المعمارية الكاملة: المخطط والمكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو.