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