Laravel 13Node 23Redpanda (Kafka API)Redis 7PostgreSQL 17

Stock Alert Pipeline

إيه اللي بيحصل جوه السيستم خطوة بخطوة: من لحظة ما المستخدم يعمل تنبيه، لحد ما السعر يوصل والتنبيه يتنفذ. اختار سيناريو، ودوس تشغيل أو امشي خطوة خطوة. وتحت، القرارات وليه.

github.com/abo3ish/stock-alerts · الكود كامل، والـ README بيشرح إزاي تشغّله.

شكل الكاش في Redis:
HTTP 1 transaction poll 200ms write produce consume query publish consume UPDATE planned User app web · mobile Alert API api.alerts.test PostgreSQL table: alerts table: alert_outbox (empty) Outbox relay worker · Docker alert-consumer Docker · rdkafka · group alert-api Notifications planned Redpanda · Kafka API alert-triggered · 3 partitions · key = alert id stock-prices · 12 partitions · key = symbol Simulator 2,000 ticks/s Evaluator group evaluator Redis
كل لون = نوع رحلة: alert writes price ticks triggered alert failure

    
          
        

    قبل وبعد الـ Sorted Sets

    من ./bench.sh 60 2000 على نفس المليون تنبيه (AAPL عليه 100,001). الأرقام من القياس الفعلي.

    Hash (before)Sorted Sets (now)
    الأسعار اللي اتعالجت في الثانية1,6791,998
    التنبيهات اللي اتفحصت في الثانية336,2650
    الـ evaluator مشغول99%50%
    الـ lag في آخر ثانية10.5 s31 ms
    أقصى lag في الـ 60 ثانية18.4 s171 ms
    رسايل متأخرة في Kafka في الآخر24,9600

    القرارات وليه

    كل قرار في السيستم ده كان ليه بديل، والانترفيور غالباً هيسأل عنه. كل سؤال تحته: اخترنا إيه، وليه، وإمتى البديل يبقى هو الصح.

    الرسايل والـ streaming

    ليه Kafka مش RabbitMQ؟Kafka
    • الترتيب لكل سهم. الـ key هو الـ symbol، فكل أسعار AAPL بتنزل على نفس الـ partition وبتتقري بالترتيب. في RabbitMQ، لو كذا consumer بيسحبوا من نفس الـ queue، الترتيب بيضيع، وممكن سعر قديم يتعالج بعد سعر أحدث منه.
    • الرسالة مابتتمسحش لما تتقري. Kafka عبارة عن log. تقدر ترجّع الـ consumer لورا وتعيد المعالجة (عملنا كده في bench.sh بـ rpk group seek). RabbitMQ بيمسح الرسالة بعد الـ ack.
    • الـ scaling بالـ consumer groups. زوّد evaluators، وKafka بيوزع الـ 12 partition عليهم لوحده. وأي خدمة جديدة (analytics مثلاً) تقرا نفس الأسعار بـ group تاني، من غير ما تأثر على حد.
    • الحجم. 2,000 رسالة في الثانية رقم صغير على Kafka، والـ batching معمول عشان الـ throughput العالي.

    إمتى RabbitMQ؟ للـ task queues: إرسال الإشعارات نفسها، لما كل رسالة محتاجة retry لوحدها، أو priority، أو routing معقد، ومش فارق الترتيب.

    ليه Redpanda مش Kafka نفسه؟Redpanda
    • نفس الـ Kafka API بالظبط. الكود بيستخدم kafkajs و rdkafka العاديين، ومش عارف إنه بيكلم Redpanda.
    • binary واحد من غير JVM ولا ZooKeeper، فبيقوم في ثواني على اللابتوب، وبياكل رامات أقل، ومعاه Console جاهز.

    في الـ production؟ الاتنين ينفعوا، أو Kafka managed زي MSK أو Confluent. التغيير هيبقى في KAFKA_BROKERS بس.

    ليه partition بالـ symbol؟ وليه 12 بالذات؟key = symbol
    • نفس السهم دايماً بيروح لنفس الـ partition، فبيروح لنفس الـ evaluator. ده بيضمن إن الأسعار بتتعالج بترتيبها، ومحدش بيقيّم سعر قديم بعد سعر جديد.
    • عدد الـ partitions هو أقصى عدد evaluators يشتغلوا مع بعض. 12 بيدينا مساحة نكبر فيها.
    • بنختار الرقم من الأول، لأن زيادة partitions بعدين بتغيّر السهم ده هيروح على أنهي partition، وفي اللحظة دي الترتيب ممكن يتلخبط.

    العيب: السهم الواحد مابيتقسمش. لو سهم واحد بقى سخن جداً (hot partition)، زيادة الـ consumers مش هتفيده. الحل وقتها تقسيم تنبيهات السهم نفسه، مش زيادة partitions.

    ليه الـ evaluator بيبعت لـ Kafka بدل ما يحدّث الداتابيز بنفسه؟alert-triggered topic
    • الـ evaluator هو المسار السخن. لو استنى Postgres مع كل تنبيه بيتنفذ، هيتأخر في الأسعار اللي جاية وراه.
    • الـ event ممكن يتقري من أكتر من مكان: Laravel بيحدّث الحالة، وخدمة الإشعارات بتبعت push، من غير ما الـ evaluator يعرف أي حاجة عن الاتنين.
    • لو Laravel وقع، الـ events بتستناه في Kafka، ومابتضيعش.

    التخزين

    ليه PostgreSQL مش Cassandra؟PostgreSQL
    • الحجم مش محتاج. 5 مليون مستخدم في كام تنبيه لكل واحد، يعني عشرات الملايين من الصفوف. ده Postgres واحد يشيله بسهولة. وكتابة التنبيهات بتحصل من المستخدمين، مش من الأسعار، فهي قليلة.
    • الـ Outbox محتاج transaction. التنبيه وسطر الـ outbox لازم يتكتبوا مع بعض أو مايتكتبوش خالص. Cassandra مافيهاش transaction بين صفين في partitions مختلفة.
    • الـ idempotency محتاجة conditional update. UPDATE ... WHERE status = 'active' رخيصة في Postgres. في Cassandra المقابل ليها (lightweight transactions) غالي.
    • استعلامات متنوعة. تنبيهات المستخدم، والتنبيهات النشطة لسهم، وإعادة بناء الكاش. Cassandra بتتصمم على استعلام واحد معروف من الأول.

    إمتى Cassandra؟ لو هنخزن كل الأسعار (time series بمليارات الصفوف وكتابة مستمرة)، أو محتاجين multi-region writes. وقتها Cassandra أو TimescaleDB تبقى منطقية.

    ليه Redis؟ ما Postgres عنده index ويقدر يعمل range query.Redis
    • صح، index على (symbol, target_price) كان هيجاوب نفس السؤال. بس ده هيبقى 2,000 query في الثانية على نفس الداتابيز اللي بتخدم الـ API.
    • Redis بيرد في أقل من ملّي ثانية من الذاكرة، وبيعزل مسار القراءة السخن عن مصدر الحقيقة. لو الـ evaluators زادت، Postgres مش هيحس.
    • لو Redis اتمسح، Postgres لسه فيه كل حاجة، و alerts:rebuild-cache بيرجّعه.

    العيب: بقى عندنا نسختين من البيانات، ومحتاجين حاجة تخليهم متطابقين. وده سبب الـ Outbox.

    ليه Sorted Set مش Hash زي الانترفيو؟ZSET + HASH
    • الـ Hash بيدي O(1) في الإضافة والحذف، بس ده تحسين لمسار الكتابة. القراءة مع كل سعر كانت بتجيب كل تنبيهات السهم: 100 ألف لأبل مع كل tick.
    • في الـ Sorted Set، الـ score هو السعر المستهدف، فـ ZRANGEBYSCORE بترجّع بس التنبيهات اللي السعر حققها، بـ O(log n + k).
    • القياس: التأخير نزل من 18 ثانية لـ 31ms، والـ evaluator بقى مشغول 50% بدل 99%.

    التمن: الذاكرة زادت من 81MB لـ 114MB، وكل تنبيه بقى مكتوب في مفتاحين بدل واحد.

    الـ API والخدمات

    ليه REST مش GraphQL أو gRPC؟REST
    • الـ API هنا CRUD بسيط على resource واحد: اعمل تنبيه، عدّله، امسحه، اعرض تنبيهاتي. ده بالظبط اللي REST معمول له.
    • أي client يقدر يكلمه: موبايل، أو ويب، أو curl. ومن غير schema ولا code generation.
    • GraphQL بيفيد لما الـ client محتاج يجمّع بيانات من أشكال كتير في request واحد. وده مش موجود هنا.
    • gRPC بيفيد بين الخدمات وبعضها. والخدمات عندنا مابتكلمش بعض مباشرة أصلاً، بتكلم بعض عن طريق Kafka.

    والإشعار نفسه؟ مش REST. السيرفر هو اللي بيبدأ، فبيوصل بـ push notification أو WebSocket أو SSE.

    ليه لغتين، Laravel وNode، مش لغة واحدة؟Laravel + Node
    • Laravel للـ API: validation وmigrations وORM وtests جاهزين. وده الجزء اللي فيه business logic.
    • Node للـ evaluator: process واحد شغال على طول، بيقرا stream ويعمل I/O كتير مع Redis وKafka، والـ event loop مناسب لده.
    • العقد بينهم واضح ومكتوب: شكل مفاتيح Redis، وشكل الـ event. وفيه tests على الناحيتين.

    بصراحة: لغة واحدة كانت هتنفع. اللغتين هنا قرار تعليمي، ولما الفريق صغير، لغة واحدة غالباً أوفر.

    الاعتمادية

    ليه Outbox مش Dual Write؟Transactional Outbox
    • مافيش transaction واحدة بين Postgres وRedis. في الـ Dual Write، لو Redis وقع بعد ما Postgres اتكتب، التنبيه بيتسجل ومابيوصلش الكاش أبداً. جربناها: 500 للمستخدم، وصفوف مكررة لما عمل retry.
    • بالـ Outbox، الـ request بيكتب في Postgres بس (التنبيه + سطر outbox في نفس الـ transaction)، والـ relay بيوصّل لـ Redis بعدين. Redis واقع؟ التنبيه بيتأخر، بس مابيضيعش.

    التمن: eventual consistency. التنبيه بيوصل Redis بعد ما يتعمل بحوالي 200ms.

    ليه relay بيعمل polling مش CDC بـ Debezium؟polling 200ms
    • الـ polling مش محتاج أي infrastructure زيادة: query على جدول كل 200ms، و FOR UPDATE SKIP LOCKED.
    • 200ms تأخير على تنبيه المستخدم لسه عامله مش هتفرق معاه.

    إمتى CDC؟ لو حجم التغييرات كبر، أو التأخير بقى مهم. Debezium بيقرا الـ WAL بتاع Postgres مباشرة، من غير polling ومن غير جدول outbox بيكبر. أو كخطوة وسط: LISTEN/NOTIFY عشان الـ relay يصحى فوراً.

    ليه relay واحد بس؟1 replica
    • لو اتنين relays أخدوا تغييرين لنفس التنبيه، ممكن القديم يتكتب في Redis بعد الجديد.
    • Relay واحد بيوصّل مئات التغييرات في الثانية، وده أكتر بكتير من سرعة المستخدمين في إنهم يعملوا تنبيهات.

    لو احتجنا نكبر: نقسّم الشغل بـ alert_id % N، فكل تنبيه يبقى ليه relay واحد بس مسؤول عنه.

    ليه at-least-once مش exactly-once؟idempotent consumer
    • Exactly-once في Kafka موجود، بس بيشتغل جوه Kafka بس. أول ما تكتب في داتابيز برّه، رجعنا لنفس المشكلة.
    • الأرخص إن الـ consumer يبقى idempotent: UPDATE ... WHERE status = 'active'. لو الـ event اتكرر، المرة التانية بتغيّر صفر صفوف ومابتبعتش حاجة.
    • الـ offset بيتعمله commit بعد المعالجة، مش قبلها. فلو الـ consumer وقع في النص، الرسالة بتتعاد، ومابتضيعش.
    ليه الشرط "السعر فوق الهدف" مش "السعر عدّى الهدف"؟state, not crossing
    • "عدّى" محتاجة نعرف السعر اللي قبله، يعني state لكل سهم في الـ evaluator، ولازم تتحفظ لو الـ evaluator اتنقل لجهاز تاني.
    • "فوق" بيحتاج السعر الحالي بس. ولو التنبيه وصل Redis متأخر 200ms والسعر لسه فوق، أول tick بعدها هيشغّله.

    العيب: لو المستخدم عمل تنبيه "فوق 190" والسعر أصلاً 195، هيتنفذ على طول. ممكن الـ API يرفضه أو ينبّه المستخدم.

    ليه الـ workers في Docker والـ API في Herd؟Docker workers
    • الـ consumer محتاج extension اسمه rdkafka، والـ PHP بتاع Herd مابيقبلش extensions زيادة. فالـ image بتاعة الـ worker فيها PHP 8.4 + rdkafka.
    • Docker هو الـ supervisor: بيشغّل الـ relay والـ consumer مع docker compose up، ويعيد تشغيلهم لو وقعوا. وفيه SIGTERM handling عشان يقفلوا بنظافة ومايعملوش rebalance بياخد 45 ثانية.