انتقل للمحتوى
DjangoCeleryElasticsearchArchitecture

دروس في الـ architecture من بناء الـ backend بتاع Twiscope

17 أكتوبر 2026مدة القراءة: 8 دقايقعبدالرحمن سعيد

سنتين كمطور الـ backend الوحيد على منصة للرصد والتحليل الإعلامي فيها كذا منتج، ومع توسّع الشغل تولّيت قيادة الفريق كمان. اللي أثبت نفسه: شغل async من أول endpoint، واستراتيجية جمع واحدة لكذا نطاق في المنتج، و chords عشان الـ scores تبقى قابلة للمقارنة، ومنع التكرار (deduplication) جوه قاعدة البيانات.

المقال ده اتكتب من جديد في أكتوبر 2026. نسخة أقدم منه كانت بتذكر أرقام لحجم الـ events، وتقليل في الـ latency، وتوقيتات لتنبيهات real-time، وده كله مش مدعوم لا بالكود ولا بالقياسات اللي أقدر أشاركها. النسخة دي ملتزمة باللي النظام بيعمله فعلًا.

Twiscope منصة بالعربي والإنجليزي للرصد والتحليل الإعلامي ودعم القرار. بدأت مطور الـ backend الوحيد فيها في أكتوبر 2023، وتولّيت قيادة الفريق في أغسطس 2024، مع استمراري مطور الـ backend الوحيد لحد أكتوبر 2025. في الفترة اللي بيغطيها المقال، كان الـ backend المشترك بـ Django/DRF و Celery بيدعم خمسة نطاقات: تقارير عند الطلب (Deep-In)، ومتابعة المؤثرين، ولوحات تحكم لاتخاذ القرار بتقيّم البراندات قصاد المنافسين (Sadeed / Decision Hub)، ومحرك تنبيهات، وخدمة إشعارات. مجموعة المنتجات اللي بتظهر للناس استمرت تتطور بعد الفترة دي.

دي القرارات اللي أثبتت نفسها، والخطوات اللي كنت أفضّل أبدأ بيها بدري.

شغل async من أول endpoint

جمع البوستات من الـ X API وتحليلها بياخد من ثواني لدقايق، عشان كده مفيش ولا endpoint للتقارير بيعمل الشغل ده جوه الـ request نفسه. الـ request بيتحقق من المدخلات، ويعمل تقرير بحالة pending، ويحط Celery task في الـ queue؛ والـ task بتتأكد من الـ quota بتاعة الاشتراك، وتجمع، وتحلل، وتحفظ، وتحاسب على الاستهلاك.

python
# Simplified
from django.db import transaction

class KeywordReportSerializer(serializers.ModelSerializer):
    def create(self, validated_data):
        report = KeywordReport.objects.create(status="pending", **validated_data)
        transaction.on_commit(
            lambda: keyword_analysis.delay(report.id)
        )                                          # enqueue after commit
        return report


@shared_task
def keyword_analysis(report_id):
    report = KeywordReport.objects.get(pk=report_id)
    ensure_quota(report.owner, platform="x")       # subscription gate
    posts = collect(report.query, strategy="report")
    save_results(report, analyse(posts))
    charge_usage(report.owner, len(posts))

إرسال الـ task بعد الـ commit مهم لو إنشاء التقرير جوه transaction؛ غير كده الـ worker ممكن يبدأ بسرعة ويحاول يقرأ التقرير قبل ما يظهر له في قاعدة البيانات.

استراتيجية جمع واحدة، وأكتر من consumer

التقارير والتنبيهات و Decision Hub كلهم محتاجين بوستات من X، بس كل واحد فيهم محتاج حاجة مختلفة بعد كده. بدل تلات نسخ من كود الجمع، فيه استراتيجية واحدة بتتحدد حسب الـ caller: التقارير بتحفظ كل حاجة، والتنبيهات بتجيب البيانات وتقيّمها من غير ما تحفظها، و Decision Hub بيجمع في حدود ميزانية الـ reply enrichment الخاصة بيه. وأي إصلاح في التعامل مع الـ rate limits أو الـ pagination بيتعمل في مكان واحد.

الـ rate limits من مدخلات التصميم

الـ X API عليه rate limits وبيتدفع على كل request، وده بيحدد شكل كل حاجة مبنية فوقه. الـ client بيلتزم بالـ rate-limit reset header اللي بيرجع من الـ API، وكـ fallback بيستخدم exponential backoff ليه حد أقصى؛ والاشتراكات فيها quotas يومية وشهرية لكل منتج ولكل منصة؛ وكل run في Decision Hub ليه ميزانية جمع خاصة بيه. التنبيهات بتتقيّم على مواعيد محددة (كل 15 دقيقة افتراضيًا) مش بالـ streaming، وده بيخلّي التكلفة متوقعة، والتمن إنها مش real-time.

الـ chords لما الـ scores لازم تبقى قابلة للمقارنة

الـ centre في Decision Hub بيقارن براند بمنافسينه. لو بيانات كل entity اتجمعت واتحسب لها score في معاد مختلف، الـ scores هتوصف فترات زمنية مختلفة. عشان كده كل run بيعمل fan-out للجمع على كل الـ entities بالتوازي، وبيحسب الـ scores بتاعتهم كلهم في callback واحد، في نفس اللحظة.

python
from celery import chord

def run_center(center_id):
    entity_ids = Entity.objects.filter(center_id=center_id).values_list("id", flat=True)
    chord(collect_entity.s(eid) for eid in entity_ids)(aggregate_center.s(center_id))

الـ scores نفسها معادلات deterministic بأوزان، مبنية على components متعمل لها normalization — sentiment share، authority، volume، engagement، momentum — مش موديل متدرّب. لما component مينفعش يتحسب، بيتسجّل على إنه غير متاح، والأوزان الباقية بيتعمل لها normalization من جديد، بدل ما يتحسب صفر من غير ما حد ياخد باله.

منع التكرار في قاعدة البيانات

الـ scans بتاعة التنبيهات بتتداخل مع بعض عن قصد، فممكن نفس البوست يطابق شروط التنبيه أكتر من مرة. الـ deduplication هنا عبارة عن unique constraint على (alert, item) في PostgreSQL، وكل insert بيتعمل في savepoint خاص بيه، فالـ insert المكرر بيبقى no-op تكلفته قليلة. بعد كده الـ events بتتعمل لها index في Elasticsearch بالـ id بتاعها في قاعدة البيانات، وده بيخلّي الـ re-indexing عملية idempotent، وفيه repair command بيعمل re-index لأي حاجة الكتابة بتاعتها في Elasticsearch فشلت.

الـ cache على حسب البيانات مسموح لها تبقى قديمة قد إيه

أبطأ الشاشات كانت لوحات تحكم المؤثرين اللي بتعمل aggregation لـ categories كاملة. الحل كان بسيط وفعّال: composite indexes اتبنت concurrently، و response cache في Redis لكل مستخدم بـ TTLs مدتها 5–15 دقيقة للـ aggregates اللي مبتتغيرش غير لما تقارير جديدة توصل، و burst throttle، و metrics للـ cache hit/miss عشان نقدر نقيس فايدة الـ cache.

اللي كنت هعمله بشكل مختلف

  • أخلّي نجاح اختبارات التطبيق شرط واضح للنشر من أول شهر. الـ workflows اللي اتراجعت بتفرض الفحص الأمني على نفس الـ commit اللي هينزل؛ واختبارات التطبيق جزء من عملية التسليم الأوسع، لكن ما اتأكدش هنا بشكل مستقل إنها بوابة إلزامية للنشر.
  • أدّي الـ queues التقيلة workers خاصة بيها من بدري. لسه فيه worker container واحد بيستهلك من كل الـ queues الرئيسية، فأي burst في نوع واحد من الشغل ممكن يأخّر الباقي.
  • أحدد من بدري المؤشرات اللي محتاج أتابعها، وأجهّز الـ instrumentation لقياسها، عشان تقييم النتايج يبقى مبني على بيانات أقدر أرجع لها.
  • أعمل signing للـ webhooks الطالعة من البداية، بدل ما أضيفه بعدين.

تفاصيل تصميم الـ gateway والبنية التحتية على AWS في دراسة الحالة: /ar/case-studies/twiscope

كتبه

عبدالرحمن سعيد

قائد فريق برمجيات | مهندس برمجيات أول متخصص في الـ Backend