تصميم أنظمة الخدمات الخلفية

مسائل تصميم الخدمات الخلفية الكلاسيكية

5 دقيقة للقراءة

يتناول هذا الدرس أربعة أنظمة تظهر بشكل متكرر في مقابلات الخدمات الخلفية. لكل نظام، نتبع الإطار من 4 خطوات: المتطلبات، والتقدير، والتصميم عالي المستوى، والتعمق في المكونات الأكثر إثارة.

1. مُختصر الروابط (TinyURL)

هذا هو أكثر أسئلة تصميم الأنظمة شيوعًا لمهندسي الخدمات الخلفية متوسطي المستوى. يختبر تصميم قواعد البيانات والترميز والتخزين المؤقت والتحليلات.

المتطلبات

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

غير وظيفية: زمن إعادة توجيه منخفض (أقل من 50 مللي ثانية)، توفر عالٍ (99.99%)، الروابط القصيرة يجب ألا تكون قابلة للتخمين.

التقدير

الكتابة: 100 مليون رابط جديد/شهر ≈ 40 رابط/ثانية
القراءة: نسبة قراءة:كتابة 10:1 ← 400 إعادة توجيه/ثانية
الذروة: 400 x 3 = 1,200 إعادة توجيه/ثانية

التخزين لكل رابط: ~500 بايت (الكود القصير + الرابط الطويل + البيانات الوصفية)
تخزين 5 سنوات: 100M x 12 x 5 x 500B = 3 TB

الكاش: أعلى 20% من الروابط تتعامل مع 80% من حركة المرور (باريتو)
حجم الكاش: 400 طلب/ثانية x 86,400 ثانية x 500B x 0.2 ≈ 3.5 GB/يوم

التصميم عالي المستوى

  العميل                                          مستهلك
    │                                             التحليلات
    ▼                                                ▲
┌────────┐    ┌──────────┐    ┌──────────┐    ┌────────┴─────┐
│  موزع  │───▶│  خادم   │───▶│  كاش    │    │   Kafka      │
│الأحمال │    │  API    │    │  Redis  │    │(سجلات النقر)│
└────────┘    └────┬─────┘    └──────────┘    └──────────────┘
                   │                ▲
                   ▼                │ فقدان الكاش
              ┌──────────┐         │
              │  قاعدة  │─────────┘
              │ البيانات │
              │(Postgres)│
              └──────────┘

تعمق: ترميز Base62

يستخدم Base62 الأحرف a-z, A-Z, 0-9 (62 حرفًا). كود من 7 أحرف يعطي:

62^7 = 3,521,614,606,208 ≈ 3.5 تريليون رابط فريد

النهج 1: التجزئة والاقتطاع

import hashlib

def shorten(long_url: str) -> str:
    # MD5 ينتج تجزئة 128-بت ← نأخذ أول 43 بت لـ 7 أحرف Base62
    hash_hex = hashlib.md5(long_url.encode()).hexdigest()
    hash_int = int(hash_hex, 16)

    chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
    code = ""
    for _ in range(7):
        code += chars[hash_int % 62]
        hash_int //= 62

    return code

النهج 2: خدمة المفاتيح المُولّدة مسبقًا (المفضل على نطاق واسع)

خدمة Key Generation Service (KGS) منفصلة تُولّد مسبقًا ملايين الأكواد الفريدة من 7 أحرف وتخزنها في قاعدة بيانات. عندما يحتاج خادم API لكود جديد، يأخذ واحدًا من المجموعة بشكل ذري. هذا يلغي فحص التصادمات تمامًا.

تعمق: تخزين الروابط الشائعة مؤقتًا

def redirect(short_code: str) -> str:
    # الخطوة 1: فحص كاش Redis (أقل من مللي ثانية)
    long_url = redis.get(f"url:{short_code}")
    if long_url:
        # إرسال حدث تحليلات بدون انتظار
        kafka.send("clicks", {"code": short_code, "timestamp": now()})
        return long_url

    # الخطوة 2: فقدان الكاش — استعلام قاعدة البيانات
    long_url = db.query(
        "SELECT long_url FROM urls WHERE short_code = %s", short_code
    )
    if not long_url:
        raise NotFoundError("الرابط القصير غير موجود")

    # الخطوة 3: ملء الكاش بـ TTL 24 ساعة
    redis.setex(f"url:{short_code}", 86400, long_url)

    kafka.send("clicks", {"code": short_code, "timestamp": now()})
    return long_url

2. مُحدد المعدل (Rate Limiter)

يحمي مُحدد المعدل واجهات API من سوء الاستخدام ويضمن الاستخدام العادل. يختبر هذا السؤال التفكير الخوارزمي ومعرفة الأنظمة الموزعة.

المتطلبات

وظيفية: تحديد الطلبات لكل مفتاح API/مستخدم/IP، إرجاع HTTP 429 عند تجاوز الحد، قواعد قابلة للتهيئة لكل نقطة نهاية.

غير وظيفية: حمل زمني منخفض (أقل من 1 مللي ثانية لكل فحص)، توفر عالٍ، الاتساق النهائي عبر العقد الموزعة مقبول.

خوارزمية دلو الرموز (Token Bucket)

أكثر خوارزميات تحديد المعدل شيوعًا. لكل مستخدم دلو يُملأ بالرموز بمعدل ثابت ويُفرغ رمزًا واحدًا لكل طلب.

سعة الدلو: 10 رموز (حد الاندفاع)
معدل إعادة التعبئة: 1 رمز/ثانية (المعدل المستدام)

الجدول الزمني:
  t=0:  الدلو=10  ← طلب يصل ← الدلو=9   ✓
  t=0:  الدلو=9   ← طلب يصل ← الدلو=8   ✓
  ...
  t=0:  الدلو=1   ← طلب يصل ← الدلو=0   ✓
  t=0:  الدلو=0   ← طلب يصل ← رفض       ✗ (429)
  t=1:  الدلو=1   ← (رمز واحد أُعيد تعبئته)

التنفيذ مع Redis (عملية ذرية):

import time
import redis

def is_allowed(user_id: str, capacity: int, refill_rate: float) -> bool:
    """مُحدد معدل بخوارزمية دلو الرموز باستخدام Redis."""
    key = f"ratelimit:{user_id}"
    now = time.time()

    # سكربت Lua للذرية — يعمل بالكامل على خادم Redis
    lua_script = """
    local key = KEYS[1]
    local capacity = tonumber(ARGV[1])
    local refill_rate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])

    local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
    local tokens = tonumber(bucket[1]) or capacity
    local last_refill = tonumber(bucket[2]) or now

    -- إعادة تعبئة الرموز بناءً على الوقت المنقضي
    local elapsed = now - last_refill
    tokens = math.min(capacity, tokens + elapsed * refill_rate)

    if tokens >= 1 then
        tokens = tokens - 1
        redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
        redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) * 2)
        return 1  -- مسموح
    else
        redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
        redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) * 2)
        return 0  -- مرفوض
    end
    """
    result = redis.eval(lua_script, 1, key, capacity, refill_rate, now)
    return result == 1

مقارنة الخوارزميات

الخوارزمية الدقة الذاكرة التعامل مع الاندفاع التعقيد
دلو الرموز جيدة منخفضة (قيمتان لكل مستخدم) يسمح باندفاع محكوم بسيط
سجل النافذة المنزلقة دقيقة عالية (تخزن كل طابع زمني) بدون اندفاع متوسط
عداد النافذة المنزلقة تقريبية منخفضة (عدّادان لكل مستخدم) تقريب مرجّح بسيط
عداد النافذة الثابتة تقريبية منخفضة جدًا (عداد واحد) اندفاع مضاعف عند حافة النافذة الأبسط

تحديد المعدل الموزع

لعمليات النشر متعددة المناطق، كل منطقة تحتفظ بعداد Redis محلي وتُزامن دوريًا إلى عدد عالمي. اقبل عدم دقة طفيفة فوق الحد (مثلاً السماح بـ 105 طلبات بدلاً من 100 بالضبط) مقابل زمن استجابة أقل عبر المناطق.


3. خدمة الإشعارات

يختبر هذا قدرتك على التعامل مع التسليم متعدد القنوات والمعالجة غير المتزامنة والموثوقية على نطاق واسع.

المتطلبات

وظيفية: إرسال إشعارات دفع، وبريد إلكتروني، و SMS، ورسائل داخل التطبيق. دعم القوالب والجدولة وتفضيلات المستخدم ومستويات الأولوية.

غير وظيفية: تسليم مرة واحدة على الأقل، أقل من 30 ثانية للإشعارات العاجلة، معالجة أكثر من مليون إشعار/دقيقة.

التصميم عالي المستوى

┌───────────┐     ┌──────────────┐     ┌───────────────────┐
│  API /    │────▶│  التحقق     │────▶│  موجّه الأولويات │
│ المُحفزات│     │  والقوالب   │     │                   │
└───────────┘     └──────────────┘     └─────┬────┬────┬───┘
                                             │    │    │
                              ┌──────────────┘    │    └──────────────┐
                              ▼                   ▼                   ▼
                     ┌──────────────┐   ┌──────────────┐   ┌──────────────┐
                     │ قائمة عاجلة │   │ قائمة عادية │   │ قائمة دفعية │
                     │ (< 30 ثانية)│   │ (< 5 دقائق)│   │ (< ساعة)    │
                     └──────┬───────┘   └──────┬───────┘   └──────┬───────┘
                            │                  │                   │
                            ▼                  ▼                   ▼
                     ┌─────────────────────────────────────────────────┐
                     │              عمال القنوات                       │
                     │  ┌──────┐  ┌───────┐  ┌─────┐  ┌──────────┐  │
                     │  │ دفع  │  │ بريد  │  │ SMS │  │ داخل    │  │
                     │  │(APNs/│  │(SES/  │  │(Twi-│  │التطبيق  │  │
                     │  │ FCM) │  │Mailgun│  │lio) │  │(WebSocket│  │
                     │  └──────┘  └───────┘  └─────┘  │/ SSE)   │  │
                     │                                  └──────────┘  │
                     └─────────────────────────────────────────────────┘
                               ┌──────────────┐
                               │  متتبع      │
                               │  التسليم DB │
                               └──────────────┘

تعمق: إعادة المحاولة مع التراجع الأسي

import time
import random

def send_with_retry(channel: str, payload: dict, max_retries: int = 5):
    """إرسال إشعار مع تراجع أسي وارتجاج عشوائي."""
    for attempt in range(max_retries):
        try:
            response = channel_clients[channel].send(payload)
            # تسجيل التسليم الناجح
            db.update_delivery_status(payload["id"], "delivered")
            return response
        except TemporaryError:
            if attempt == max_retries - 1:
                # نقل إلى قائمة الرسائل الميتة بعد الفشل النهائي
                dead_letter_queue.send(payload)
                db.update_delivery_status(payload["id"], "failed")
                raise

            # تراجع أسي: 1 ثانية، 2 ثانية، 4 ثوانٍ، 8 ثوانٍ، 16 ثانية
            base_delay = 2 ** attempt
            # إضافة ارتجاج لمنع تأثير القطيع المتهافت
            jitter = random.uniform(0, base_delay * 0.5)
            time.sleep(base_delay + jitter)

تعمق: منع التكرار

منع إرسال نفس الإشعار مرتين باستخدام مفتاح عدم التكرار:

def process_notification(notification: dict):
    idempotency_key = f"notif:{notification['user_id']}:{notification['template_id']}:{notification['dedup_window']}"

    # فحص إذا أُرسل مسبقًا (Redis SET مع NX و TTL)
    already_sent = redis.set(idempotency_key, "1", nx=True, ex=3600)
    if not already_sent:
        return  # مكرر — تخطي بصمت

    # متابعة التسليم
    route_to_channels(notification)

4. نظام الدردشة (فردية وجماعية)

يختبر هذا إدارة WebSocket وترتيب الرسائل والحضور ومقايضات التخزين.

المتطلبات

وظيفية: رسائل فردية وجماعية، حالة الحضور (متصل/غير متصل)، إيصالات القراءة، سجل الرسائل، مشاركة الوسائط.

غير وظيفية: تسليم الرسائل أقل من 200 مللي ثانية للمستخدمين المتصلين، تسليم 100% (بدون رسائل مفقودة)، ترتيب الرسائل ضمن المحادثة.

التصميم عالي المستوى

┌────────┐   WebSocket   ┌──────────────┐    ┌──────────────┐
│العميل A│───────────────▶│   البوابة    │───▶│ خدمة الدردشة│
└────────┘               │  (إدارة WS)  │    │              │
                         └──────┬───────┘    └──────┬───────┘
┌────────┐   WebSocket          │                   │
│العميل B│──────────────────────┘                   │
└────────┘                                          │
                              ┌─────────────────────┤
                              │                     │
                              ▼                     ▼
                     ┌──────────────┐      ┌──────────────┐
                     │   خدمة     │      │   مخزن      │
                     │   الحضور   │      │   الرسائل   │
                     │  (Redis)   │      │              │
                     └──────────────┘      │الحديثة:Redis│
                                           │التاريخية:   │
                                           │ Cassandra   │
                                           └──────────────┘

تعمق: إدارة اتصالات WebSocket

# سجل الاتصالات (في Redis للنشر الموزع)
class ConnectionManager:
    def __init__(self):
        self.redis = redis.Redis()

    def register(self, user_id: str, server_id: str, ws_id: str):
        """تسجيل اتصال WebSocket لمستخدم."""
        self.redis.hset(f"connections:{user_id}", ws_id, server_id)
        self.redis.expire(f"connections:{user_id}", 300)  # TTL 5 دقائق

    def heartbeat(self, user_id: str, ws_id: str):
        """تجديد TTL الاتصال عند نبض القلب (كل 30 ثانية)."""
        self.redis.expire(f"connections:{user_id}", 300)

    def get_servers(self, user_id: str) -> list:
        """إيجاد الخوادم التي تحتفظ باتصالات لمستخدم."""
        return self.redis.hvals(f"connections:{user_id}")

    def unregister(self, user_id: str, ws_id: str):
        """إزالة الاتصال عند قطع الاتصال."""
        self.redis.hdel(f"connections:{user_id}", ws_id)

تعمق: ترتيب الرسائل

كل محادثة تحتفظ برقم تسلسل متزايد بشكل رتيب. الخادم يُعيّن الرقم التسلسلي وليس العميل، لمنع التعارضات.

def send_message(conversation_id: str, sender_id: str, content: str):
    # الزيادة الذرية تضمن تسلسلًا فريدًا ومرتبًا لكل محادثة
    seq = redis.incr(f"seq:{conversation_id}")

    message = {
        "id": generate_uuid(),
        "conversation_id": conversation_id,
        "sender_id": sender_id,
        "content": content,
        "sequence": seq,
        "timestamp": datetime.utcnow().isoformat(),
        "status": "sent"
    }

    # الكتابة في كاش الرسائل الحديثة (مجموعة Redis مرتبة بالتسلسل)
    redis.zadd(
        f"messages:{conversation_id}",
        {json.dumps(message): seq}
    )

    # كتابة غير متزامنة للتخزين الدائم (Cassandra)
    kafka.send("message-persist", message)

    # التسليم لجميع المشاركين المتصلين
    deliver_to_recipients(conversation_id, sender_id, message)

    return message

تعمق: خدمة الحضور

اكتشاف الاتصال/عدم الاتصال:

1. العميل يتصل        ← الحضور = "متصل"
2. نبض قلب كل 30 ثانية ← تجديد TTL "متصل"
3. فقدان نبضتين (60 ثانية)   ← الحضور = "بعيد"
4. فقدان 3 نبضات (90 ثانية)  ← الحضور = "غير متصل"
5. قطع اتصال صريح     ← الحضور = "غير متصل" فورًا

الدردشة الجماعية: استراتيجيات التوزيع

الاستراتيجية كيف تعمل المزايا العيوب
التوزيع عند الكتابة عند إرسال رسالة، اكتب في صندوق كل مستقبل قراءات سريعة (الصندوق جاهز) كتابات مكلفة للمجموعات الكبيرة
التوزيع عند القراءة خزّن الرسالة مرة واحدة، المستقبلون يستعلمون عند التحميل كتابات رخيصة قراءات أبطأ، استعلامات معقدة
هجين توزيع عند الكتابة للمجموعات الصغيرة (< 100)، توزيع عند القراءة للمجموعات الكبيرة/القنوات متوازن منطق أكثر تعقيدًا

نصيحة للمقابلة: معظم أنظمة الدردشة الواقعية (WhatsApp، Slack) تستخدم النهج الهجين. الدردشات الجماعية الصغيرة تستخدم التوزيع عند الكتابة للتسليم الفوري. القنوات التي تضم آلاف الأعضاء تستخدم التوزيع عند القراءة لتجنب تضخم الكتابة.

التالي: سنغطي أنماط التوسع وتجزئة قواعد البيانات والبنية التحتية للبحث وتصميم خطوط أنابيب البيانات للتعامل مع النمو. :::

اختبار

اختبار الوحدة 4: تصميم أنظمة الخدمات الخلفية

خذ الاختبار