לדלג לתוכן

4.5 פיד חברתי הרצאה

מקרה בוחן: עיצוב פיד חברתי

פיד חברתי - כמו ב-Twitter/X, Instagram, או LinkedIn - הוא אחד האתגרים האדריכליים המעניינים ביותר. נעצב מערכת שבה משתמשים עוקבים אחד אחרי השני ורואים פוסטים.


שלב 1: הגדרת דרישות

פונקציונליות:
- משתמשים יכולים לפרסם פוסטים (טקסט + תמונה)
- משתמשים יכולים לעקוב אחרי משתמשים אחרים
- כל משתמש רואה פיד מהמשתמשים שהוא עוקב אחריהם
- הפיד ממויין לפי זמן (חדש ראשון)

לא-פונקציונלי:
- 10 מיליון משתמשים פעילים ביום
- 50 מיליון פוסטים ביום
- כל משתמש עוקב אחרי 500 משתמשים בממוצע
- זמן טעינת פיד: < 200ms
- זמינות: 99.99%


שלב 2: הערכת נפח

כתיבות:
50 מיליון פוסטים ביום = ~580 פוסטים בשנייה

קריאות:
10 מיליון משתמשים פעילים × 10 פעמים טעינת פיד ביום = 100 מיליון קריאות
= ~1,200 קריאות בשנייה

יחס: 1 כתיבה : 2 קריאות - לא כמו בURL shortener שם הקריאות הרבה יותר.


שלב 3: האתגר המרכזי - Fan-out

כשמשתמש A עם 1 מיליון עוקבים מפרסם פוסט, מי צריך לראות אותו?
מיליון משתמשים. מה עושים?

גישה 1: Fan-out on Write (Push Model)

כשמשתמש מפרסם - מעתיקים את הפוסט לתיבת הדואר של כל עוקב.

def publish_post(user_id: int, content: str):
    # שמור את הפוסט
    post = post_db.save({"author_id": user_id, "content": content})

    # קבל כל העוקבים
    followers = follower_db.get_followers(user_id)

    # דחף לפיד של כל עוקב
    for follower_id in followers:
        feed_cache.lpush(f"feed:{follower_id}", post["id"])
        feed_cache.ltrim(f"feed:{follower_id}", 0, 999)  # שמור רק 1000 אחרונים

קריאת פיד (מהירה מאוד):

def get_feed(user_id: int) -> list:
    post_ids = feed_cache.lrange(f"feed:{user_id}", 0, 19)  # 20 פוסטים
    return [post_db.get(post_id) for post_id in post_ids]

יתרון: קריאת פיד מאוד מהירה - רק Redis.

חסרון: פוסט ממשתמש עם מיליון עוקבים = מיליון כתיבות ל-Redis. "Thundering herd".

גישה 2: Fan-out on Read (Pull Model)

כשמשתמש פותח פיד - שולפים פוסטים מכל המשתמשים שעוקבים אחריהם.

def get_feed(user_id: int) -> list:
    # קבל כל מי שעוקב אחריהם
    following = follower_db.get_following(user_id)

    # שלוף פוסטים אחרונים מכל אחד
    all_posts = []
    for followed_id in following:
        posts = post_db.get_recent_by_user(followed_id, limit=20)
        all_posts.extend(posts)

    # מיין ותחזיר
    return sorted(all_posts, key=lambda p: p["created_at"], reverse=True)[:20]

יתרון: כתיבה פשוטה וזולה.

חסרון: קריאת פיד של מישהו שעוקב אחרי 500 איש = 500 שאילתות! איטי מאוד.

גישה 3: Hybrid (מה Twitter עושה)

משתמשים רגילים: Fan-out on Write (push)
"Celebrity" עם >מיליון עוקבים: Fan-out on Read (pull)

CELEBRITY_THRESHOLD = 1_000_000


def publish_post(user_id: int, content: str):
    post = post_db.save({"author_id": user_id, "content": content})

    if get_follower_count(user_id) < CELEBRITY_THRESHOLD:
        # push לכל עוקב
        for follower_id in follower_db.get_followers(user_id):
            feed_cache.lpush(f"feed:{follower_id}", post["id"])
    # celebrities - pull בזמן קריאה


def get_feed(user_id: int) -> list:
    # 1. קרא פיד מוכן מcache (מהמשתמשים הרגילים)
    cached_post_ids = feed_cache.lrange(f"feed:{user_id}", 0, 99)
    posts = [post_db.get(pid) for pid in cached_post_ids]

    # 2. הוסף פוסטים של celebrities שעוקב אחריהם
    following = follower_db.get_following(user_id)
    for followed_id in following:
        if get_follower_count(followed_id) >= CELEBRITY_THRESHOLD:
            celebrity_posts = post_db.get_recent_by_user(followed_id, limit=5)
            posts.extend(celebrity_posts)

    # 3. מיין ונקה כפילויות
    seen = set()
    unique = []
    for post in sorted(posts, key=lambda p: p["created_at"], reverse=True):
        if post["id"] not in seen:
            seen.add(post["id"])
            unique.append(post)

    return unique[:20]

שלב 4: Caching Strategy

פוסטים חמים: Cache את הפוסטים הנצפים ביותר.

def get_post(post_id: int) -> dict:
    # Cache first
    cached = redis.get(f"post:{post_id}")
    if cached:
        return json.loads(cached)

    post = post_db.find_by_id(post_id)
    redis.setex(f"post:{post_id}", 3600, json.dumps(post))  # cache שעה
    return post

User profiles: cache גם פרטי משתמשים - שם, avatar - כדי שלא צריך DB לכל פוסט בפיד.


שלב 5: עדכונים בזמן אמת

כשמשתמש פתוח אפליקציה ומישהו שהוא עוקב מפרסם - רוצים שיראה מיד.

Polling: בדיקה כל 30 שניות. פשוט, אבל לא realtime ובזבזן.

WebSocket: חיבור פתוח מהדפדפן לשרת. כשיש פוסט חדש - שולחים דרך החיבור.

Server-Sent Events (SSE): חיבור חד-כיווני מהשרת ללקוח. פשוט יותר מWebSocket.

@app.get("/feed/stream")
async def feed_stream(user_id: int):
    async def event_generator():
        last_check = datetime.now()
        while True:
            new_posts = get_new_posts_since(user_id, last_check)
            if new_posts:
                last_check = datetime.now()
                yield f"data: {json.dumps(new_posts)}\n\n"
            await asyncio.sleep(5)

    return StreamingResponse(event_generator(), media_type="text/event-stream")

ארכיטקטורה סופית

לקוחות → Load Balancer → API Servers
                    ┌────────┼────────┐
                    ↓        ↓        ↓
                 [Redis   [Post    [Follower
                  Feed    Service   Service]
                  Cache]    ↓
                          [Post DB]
                          [Replicas]

רכיבים:
- Redis: feed lists לכל משתמש, post cache, user profile cache
- Post DB: PostgreSQL עם sharding לפי user_id
- Follower Service: גרף עוקבים (מתאים ל-graph DB כמו Neo4j)
- Message Queue (Kafka): async fan-out לa - לא blocking