بناء نظام إشعارات لحظية (Push Notification) موثوق ومراعٍ للمناطق الزمنية
احتاج تطبيق للصحة والعافية إلى إرسال تنبيهات يومية مخصصة — تذكيرات بالوجبات، تسجيلات الحالة المزاجية، تنبيهات الترطيب، إشارات النوم، وتذكيرات مخصصة — إلى آلاف المستخدمين حول العالم. الشرط الأساسي: كان على كل إشعار أن يصل في التوقيت المحلي الصحيح، مرة واحدة بالضبط، وألا يصل أبدًا إلى جهاز غير نشط. قمنا بتصميم وبناء المسار الموزع (distributed pipeline) الذي يحقق ذلك.
التحدي
- صحة المنطقة الزمنية على نطاق واسع. تذكير "الإفطار الساعة 9 صباحًا" يعني شيئًا مختلفًا لكل مستخدم. إرسال الإشعار بتوقيت الخادم يعني وصوله لشخص في سيدني الساعة 3 صباحًا. يجب أن يصل كل إشعار في اللحظة المحلية للمستخدم.
- لا رسائل غير مرغوب فيها، لا تكرار. جدولة الـ crons تتداخل وتُعاد تشغيلها حتمًا. بدون ضمانات صارمة، قد يتلقى المستخدم الواحد نفس تنبيه "حان وقت الغداء 🥗" مرتين أو ثلاث مرات — وهذا طريق سريع لإلغاء التثبيت.
- الأجهزة غير موثوقة. يقوم المستخدمون بإلغاء تثبيت التطبيقات، وسحب الأذونات، وتدوير الـ push tokens باستمرار. إرسال الإشعارات بشكل أعمى إلى الـ tokens القديمة يهدر الموارد ويفسد مقاييس التسليم.
- توقيت دقيق بدون جدولة بقوة غاشمة. توصيل مئات الإشعارات في دقائق محددة — دون أن يقوم cron بضرب قاعدة البيانات كل 60 ثانية — تطلب آلية أذكى من الاستقصاء الساذج (naive polling).
حلنا
لقد بنينا مسارًا ثلاثي المراحل (pipeline) يفصل بوضوح ما يجب إرساله، متى يجب إرساله، وإرساله فعليًا — بحيث يمكن لكل مرحلة أن تفشل وتتعافى بشكل مستقل. قاعدة البيانات هي مصدر الحقيقة، وقائمة انتظار الرسائل (message queue) تتعامل مع التوقيت الدقيق، وطبقة عامل واحدة (worker layer) تتحدث مع مزود الإشعارات اللحظية (push provider).

البنية المعمارية
- Expo-notifications هو عميل React Native بقنوات أصلية، وأصوات فريدة، وروابط عميقة (deep links) يستخدم تنسيق token واحد وواجهة API للتسليم لكل من iOS و Android.
- واجهة خلفية (backend) باستخدام NestJS مع expo-server-sdk كتجريد موحد (unified push abstraction) لإشعارات Push فوق FCM و APNs.
- MongoDB كمصدر للحقيقة — مجموعات NotificationMessage، NotificationToken، و NotificationCounter.
- قوائم انتظار التأخير (delay queues) في ActiveMQ (STOMP)، واحدة لكل فئة (وجبة، مزاج، نشاط، أمان، تذكيرات)، للتسليم المجدول الدقيق.
- crons المنشئة التي تولد سجلات إشعارات محسوبة بناءً على المنطقة الزمنية لكل مستخدم.
- العاملون المستهلكون (Consumer workers) الذين يشتركون في كل قائمة انتظار ويجرون التحقق النهائي قبل الإرسال.
- AWS ECS Fargate يقوم بتشغيل الـ crons والمستهلكين؛ و ActiveMQ على مثيل EC2 مخصص.
الميزات الرئيسية
- الجدولة المراعية للمناطق الزمنية. باستخدام date-fns-tz، يتم حساب وقت الإرسال المحلي لكل مستخدم، وتحويله مرة أخرى إلى UTC للتخزين، وتقييده بنوافذ تاريخ UTC لضمان تنبيه واحد يوميًا.
- تكرار العملية (idempotency) المفروض على مستوى قاعدة البيانات. يؤدي الفهرس الفريد الجزئي (partial unique index) على الرسائل المعلقة إلى جعل إنشاء النسخ المكررة أمرًا مستحيلاً — حتى عندما يعمل cron مرتين:
| // فريد فقط عندما تكون الرسالة لا تزال معلقة (PENDING) ولم يتم حذفها schema.index( { userId: 1, notificationTokenId: 1, category: 1, label: 1, scheduledAt: 1 }, { unique: true, partialFilterExpression: { status: 'pending', isDeleted: false } } ); |
3. التسليم الدقيق عبر قوائم انتظار التأخير (delay queues). بدلاً من cron لكل دقيقة، يقوم المجدول (scheduler) بوضع الرسائل في قائمة الانتظار (enqueues) الآن ولكنه يؤجل التسليم إلى الدقيقة المستحقة بالضبط باستخدام عنوان scheduled-delay الخاص بـ ActiveMQ:
| client.send(`/queue/${queueName}`, { persistent: 'true', 'AMQ_SCHEDULED_DELAY': String(delayMs), // يتم التسليم بالضبط عند الاستحقاق }, JSON.stringify(message)); |
4. token نشط واحد لكل جهاز. يضمن الفهرس الفريد الجزئي (partial unique index) وجود token نشط واحد بالضبط لكل جهاز؛ تسجيلات الدخول الجديدة تقوم بإلغاء تنشيط الـ token القديم بشكل نظيف، مع إعادة المحاولة بالاسترجاع الأسي (exponential-backoff retries) للبقاء على قيد الحياة عند تسجيلات الدخول المتزامنة.
5. التحقق من الإيصال (Receipt checking) + التنظيف التلقائي. بعد الإرسال، نقوم باستقصاء إيصالات Expo (Expo receipts). استجابة DeviceNotRegistered تقوم بإلغاء تنشيط الـ token غير النشط على الفور حتى لا نضيع إرسالًا عليه مرة أخرى أبدًا.
| f (receipt.status === 'error' && receipt.details?.error === 'DeviceNotRegistered') { await this.deactivateToken(token); // إيقاف الإرسال إلى الأجهزة غير النشطة } |
6. سقف الفشل. لكل جهاز عداد إعادة محاولة؛ بعد 3 إخفاقات متتالية، يتم إلغاء تنشيط الـ token تلقائيًا — لا توجد حلقات لا نهائية، ولا توجد tokens معطلة.
7. احترام نية المستخدم وقت الإرسال. تتم إعادة التحقق من تفضيلات الإشعارات من قبل المستهلك عند التسليم، وليس فقط عند الجدولة — لذا فإن المستخدم الذي يلغي الاشتراك قبل ساعة من التنبيه لن يتلقاه أبدًا. تصل الرسائل إلى حالاتها النهائية الصريحة: success، failed، أو is_missed.
النتائج
- يتلقى كل مستخدم التنبيهات في التوقيت المحلي الصحيح، في جميع أنحاء العالم — صفر إشعارات خارج ساعات العمل.
- تم التخلص من الإشعارات المكررة بالكامل من خلال idempotency على مستوى قاعدة البيانات.
- يتم اكتشاف tokens الجهاز المعطلة والقديمة وإلغاء تنشيطها تلقائيًا، مما يحافظ على نظافة التسليم.
- تمت زيادة الموثوقية وتقليل حمل قاعدة البيانات من خلال التسليم الدقيق والمحدد بالدقيقة دون جدولة بقوة غاشمة.
مجموعة التقنيات (Technology Stack)
React Native · Expo Notifications · NestJS · TypeScript · MongoDB · ActiveMQ (STOMP) · expo-server-sdk · date-fns-tz · AWS ECS Fargate

