دراسات حالة واقعية لتصميم الأنظمة

تصميم نظام مشاركة الركوب

18 دقيقة الدرس 7 من 10

تصميم نظام مشاركة الركوب

تعالج منصات مثل Uber وLyft وDiDi مئات الآلاف من طلبات الرحلات في الدقيقة، وتطابق الراكبين مع السائقين في أقل من ثانيتين، وتتابع ملايين المركبات المتحركة في الوقت الفعلي. يرتكز كل نظام مشاركة ركوب على مشكلتين بالغتي الصعوبة: الفهرسة الجغرافية المكانية (أين جميع السائقين الآن؟) والمطابقة الفورية (أي سائق يُعيَّن لهذا الراكب؟). يشرح هذا الدرس كلتيهما.

المتطلبات والحجم

تقدير مبدئي قبل البدء بالتصميم:

  • الراكبون النشطون يومياً: 10 ملايين
  • السائقون النشطون يومياً: مليون — يرسلون كلهم نبضات GPS كل 4 ثوانٍ
  • طلبات الرحلات في ذروة الاستخدام: ~100,000 في الدقيقة (~1700 طلب/ثانية)
  • عمليات كتابة مواقع السائقين: 1,000,000 ÷ 4 = 250,000 كتابة/ثانية (الرقم الأصعب)
  • اتفاقية مستوى خدمة المطابقة: أقل من ثانيتين من الطلب إلى التعيين
  • دقة الموقع: دقة 10–50 متراً كافية للمطابقة؛ الملاحة المفصلة تُعالج بخدمة خرائط منفصلة
250,000 كتابة موقع في الثانية هو نقطة الضغط التصميمية الحاسمة. لا يستطيع أي قاعدة بيانات علائقية تقليدية استيعاب هذا الحجم. كل قرار معماري ينبثق من هذا القيد.

الفهرسة الجغرافية المكانية — المشكلة الجوهرية

عندما يطلب راكب رحلة من الإحداثيات (lat, lng)، يجب أن يجيب النظام على: ابحث عن جميع السائقين في نطاق 5 كم مرتبين حسب وقت الوصول. فحص كل سجل سائق في قاعدة البيانات هو عملية O(n) على مليون صف — بطيء جداً. تحتاج إلى فهرس مكاني.

النهج الأول — Geohash

يُرمِّز Geohash زوجاً (lat, lng) إلى سلسلة أحرف وأرقام قصيرة. يعتمد الترميز على منحنى Z-order (Morton) الذي يتشابك بتات خط العرض وخط الطول. الخاصية الجوهرية: كلما طال البادئة المشتركة، كلما كانت المواقع أقرب جغرافياً.

  • طول Geohash 6 ← خلية ~1.2 كم × 0.6 كم
  • طول Geohash 7 ← خلية ~153 م × 153 م
  • طول Geohash 8 ← خلية ~38 م × 19 م

سائق في وسط مدينة كبرى يُخزَّن geohash موقعه في Redis كمفتاح في hashmap: HSET drivers:geo <driver_id> "9q8yy4n". يجلب استعلام القرب الخلايا التسع المجاورة (الخلية ذاتها بالإضافة إلى الثماني المحيطة بها) ويفحص تلك المجموعة فقط — عادةً بضع مئات من الإدخالات لا مليون.

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

النهج الثاني — Quadtree

يقسِّم Quadtree الخريطة تكرارياً إلى أربعة أرباع. تحتفظ كل عقدة طرفية بقائمة السائقين في تلك الخلية. الخلايا ذات الكثافة العالية تنقسم أكثر؛ الخلايا الخفيفة تبقى كبيرة. هذا يمنح فهرساً تكيفياً — كثافة العقد تعكس كثافة السائقين.

النهج الثالث — Redis GEO

توفر Redis واجهة GEO برية (أوامر GEOADD وGEOSEARCH) مدعومة بـ sorted set تكون فيه النتيجة عدداً صحيحاً من 52 بت يمثل geohash. هذا هو الخيار العملي الحديث:

  • GEOADD drivers 37.7749 -122.4194 "driver:42" — تحديث الموقع في O(log n)
  • GEOSEARCH drivers FROMMEMBER "rider:9" BYRADIUS 5 km ASC COUNT 10 — أقرب 10 سائقين في نطاق 5 كم
  • يوزع Redis Cluster الـ sorted set أفقياً عند الحاجة
Geohash grid and proximity query Geohash Grid (precision 5) 9q8yx 9q8yy 9q8yz 9q8yr 9q8ym Rider here 9q8yt 9q8yk 9q8ys 9q8yu Query: rider cell + 8 neighbours Redis GEO Internals Sorted Set (score = geohash int) 3602321 | driver:42 ▲ GEOADD O(log n) 3602334 | driver:17 3602357 | driver:88 ← nearest 3602401 | driver:99 3602488 | driver:03 GEOSEARCH … BYRADIUS 5 km ASC Score encodes (lat, lng) as 52-bit integer — binary search finds the radius window
يسار: شبكة geohash تُظهر خلية الراكب والخلايا الثماني المجاورة التي يجب الاستعلام عنها. يمين: كيف تخزن Redis السائقين في sorted set مُرتَّبة بقيمة geohash عددية.

البنية المعمارية الشاملة

ينقسم نظام مشاركة الركوب إلى خدمات مستقلة:

  • Location Service — يستوعب 250,000 نبضة GPS/ثانية من تطبيقات السائقين، يكتب إلى Redis GEO (أساسي) ومخزن السلاسل الزمنية (للتحليلات).
  • Matching Service — عمال عديمو الحالة يستعلمون Redis عن السائقين القريبين عند طلب رحلة، يرتبونهم حسب وقت الوصول، ويعيِّنون الأفضل.
  • Trip Service — يمتلك آلة حالة دورة الرحلة (REQUESTED → ACCEPTED → EN_ROUTE → ARRIVED → IN_TRIP → COMPLETED) ويستمر في قاعدة بيانات علائقية (Postgres/MySQL مجزأة حسب المدينة).
  • ETA Service — يُغلِّف محرك التوجيه لحساب وقت القيادة بين نقطتين.
  • Notification Service — يدفع التحديثات الفورية إلى تطبيقات الراكب والسائق عبر WebSocket / APNS / FCM.
  • Payment Service — يتولى حساب الأجرة والخصم عند اكتمال الرحلة.
Ride-sharing system architecture with data flow Driver App GPS ping / 4 s Rider App Request ride API Gateway Auth / Rate-limit Location Service 250k writes/sec Matching Service Stateless workers Redis GEO Cluster, in-memory ETA Service Routing engine Trip Service State machine Trip DB Postgres (sharded) Notification Svc WebSocket / Push location request GEOADD GEOSEARCH rank ETAs assign push
البنية المعمارية الشاملة لنظام مشاركة الركوب. يستوعب Location Service ربع مليون كتابة GPS في الثانية عبر Redis GEO. يقرأ Matching Service من Redis، يرتب السائقين حسب وقت الوصول، ويعيِّن الأفضل عبر Trip Service.

خوارزمية المطابقة بين الراكب والسائق

المطابقة ليست مجرد "أقرب سائق". دالة الترتيب الحقيقية توازن عدة إشارات:

  1. وقت الوصول إلى نقطة الالتقاء (ETA) — الإشارة الأساسية. سائق على بُعد 1.2 كم لكنه عالق في الازدحام أسوأ من آخر على بُعد 2 كم في طريق سيار.
  2. تقييم السائق — خصم طفيف للسائقين ذوي التقييم المنخفض لحماية تجربة الراكب.
  3. محاذاة الاتجاه — سائق متجه نحو المنطقة العامة للراكب يتكبد تكلفة تنقل خامل أقل.
  4. عدالة الأسعار الزائدة — توزيع رحلات الضريبة العالية على السائقين الأطول انتظاراً لتفادي المجاعة.

تسير حلقة المطابقة كالتالي:

  1. يطلب الراكب رحلة من الإحداثيات (lat, lng).
  2. يستعلم Matching Service من Redis: GEOSEARCH drivers BYRADIUS 5 km ASC COUNT 30 → يُعيد 30 مرشحاً.
  3. استدعاء ETA Service دفعياً لجميع الـ 30 مرشحاً بالتوازي (fan-out).
  4. تسجيل نقاط كل مرشح. تحديد المرشح الأفضل.
  5. إرسال العرض للسائق عبر Notification Service. لدى السائق 10 ثوانٍ للقبول.
  6. إذا رفض أو انتهت المهلة، يُختار المرشح التالي وتُكرَّر العملية.
استدعاءات ETA الدفعية: إصدار 30 طلب ETA بالتوازي (fan-out) بدلاً من التسلسل يخفض الكمون من ~30 × 50 مللي ثانية = 1500 مللي ثانية إلى ~50–100 مللي ثانية (رحلة ذهاب وإياب واحدة). هذه الحيلة الأساسية التي تُبقي المطابقة تحت ثانيتين.

التعامل مع إنتاجية كتابة الموقع

250,000 عملية GEOADD في الثانية تتجاوز طاقة عقدة Redis واحدة (تبلغ ذروتها ~100,000–200,000 عملية/ثانية في ظروف حقيقية). الحلول:

  • Redis Cluster مع التجزئة الجغرافية — تقسيم keyspace حسب المدينة أو المنطقة. كل شارد يتولى سائقي مدينة واحدة، مما يخفض الحمل على كل عقدة بشكل كبير.
  • التجميع من جهة العميل — يرسل تطبيق السائق نبضة كل 4 ثوانٍ؛ يجمع Location Service نبضات من سائقين متعددين ويصدر أوامر GEOADD في pipelines بحجم 100–500 أمر، مما يُقلل تكلفة الرحلات الشبكية.
  • مخزن مؤقت للكتابة — وضع مخزن صغير في الذاكرة (موضوع Kafka أو ring buffer) أمام Redis لاستيعاب التدفقات المفاجئة دون فقدان التحديثات.
لا تستخدم قاعدة بيانات علائقية كمخزن أساسي للموقع. حتى مع امتداد PostGIS، لا تستطيع Postgres استيعاب 250,000 تحديث/ثانية بكمون منخفض دون قدر هائل من الموارد العمودية. Redis مبنية أصلاً للتحديثات العالية التردد ذات الحمولة الصغيرة المُفهرسة.

الاتساق ومعالجة الأعطال

بيانات الموقع قديمة بطبيعتها — بحلول وقت اتخاذ قرار المطابقة ربما تحرك السائق. يقبل النظام الاتساق اللحظي في الموقع. ما يجب أن يكون متسقاً تماماً هو تعيين الرحلة (لا يمكن تعيين نفس السائق لراكبين). يستخدم Trip Service قفلاً متفائلاً (تحديث CAS على عمود حالة السائق) أو قفلاً موزعاً (Redis SETNX على driver:<id>:lock) لمنع التعيين المزدوج.

إذا انهار Location Service، تنتهي صلاحية إدخالات السائقين في Redis عبر TTL (10 ثوانٍ). الإدخالات المنتهية تُستبعد تلقائياً من نتائج GEOSEARCH، لذا لن يُطابَق راكب أبداً مع سائق انقطع تطبيقه.

ملخص: قرارات التصميم الرئيسية

  • استخدم Redis GEO (أو geohash + Redis) لجميع قراءات وكتابات مواقع السائقين — إنه الخيار العملي الوحيد عند 250,000 كتابة/ثانية.
  • خدمة المطابقة عديمة الحالة — تُوسَّع أفقياً بإضافة عمال خلف موزع الأحمال.
  • استخدم استدعاءات ETA الدفعية المتوازية في حلقة المطابقة للوفاء بـ SLA الثانيتين.
  • جزِّئ Redis حسب المدينة/المنطقة للإبقاء على معدل الكتابة لكل عقدة في نطاق مُدار.
  • استخدم TTL على مفاتيح موقع السائقين لاستبعاد السائقين المنقطعين تلقائياً من مجموعة المتاحين.
  • احمِ تعيين السائق بـ قفل موزع أو تحكم التزامن المتفائل — الموقع يمكن أن يكون متسقاً في نهاية المطاف، أما التعيين فلا.