לדלג לתוכן

4.3 מסד נתונים בהיקף הרצאה

מסד נתונים בהיקף - Databases at Scale

שרת אחד של מסד נתונים הוא נקודת כשל יחידה, ויש לו מגבלות ברורות. בפרק זה נלמד כיצד להרחיב מסדי נתונים.


Read Replicas - רפליקות קריאה

הבעיה: 90% מהבקשות למסד נתונים הן קריאות. שרת אחד מוצף.

הפתרון: Primary server לכתיבות, כמה Replicas לקריאות.

כתיבה   →  [Primary DB]  →  replication  →  [Replica 1]  ← קריאה
                         →  replication  →  [Replica 2]  ← קריאה
                         →  replication  →  [Replica 3]  ← קריאה
class DatabaseRouter:
    def __init__(self, primary_url: str, replica_urls: list[str]):
        self.primary = connect(primary_url)
        self.replicas = [connect(url) for url in replica_urls]
        self._replica_index = 0

    def get_write_connection(self):
        """תמיד כותבים לprimary"""
        return self.primary

    def get_read_connection(self):
        """קוראים מreplica לפי Round Robin"""
        conn = self.replicas[self._replica_index % len(self.replicas)]
        self._replica_index += 1
        return conn


router = DatabaseRouter(
    primary_url="postgres://primary:5432/taskflow",
    replica_urls=[
        "postgres://replica1:5432/taskflow",
        "postgres://replica2:5432/taskflow"
    ]
)

# כתיבה
with router.get_write_connection() as db:
    db.execute("INSERT INTO tasks ...")

# קריאה
with router.get_read_connection() as db:
    tasks = db.execute("SELECT * FROM tasks WHERE owner_id=?", (1,)).fetchall()

חשוב: יש Replication Lag - עיכוב קטן בין כתיבה לprimary לבין הופעה ברeplicas. לרוב מילישניות עד שניות.


Sharding - פיצול אופקי

הבעיה: גם עם replicas, אם יש 10 מיליארד שורות - שאילתות איטיות, הכנסת נתונים איטית.

הפתרון: פיצול הנתונים לכמה מסדי נתונים ("shards") כך שכל shard מכיל רק חלק מהנתונים.

Hash Sharding

class ShardedTaskRepository:
    def __init__(self, shards: list):
        self.shards = shards
        self.num_shards = len(shards)

    def _get_shard(self, user_id: int):
        shard_index = user_id % self.num_shards
        return self.shards[shard_index]

    def save_task(self, task: dict):
        shard = self._get_shard(task["owner_id"])
        shard.execute("INSERT INTO tasks ...", task)

    def get_tasks_by_owner(self, owner_id: int) -> list:
        shard = self._get_shard(owner_id)
        return shard.execute("SELECT * FROM tasks WHERE owner_id=?", (owner_id,)).fetchall()

יתרון: כל שאילתה ידועה ל-shard אחד.

חסרון: שאילתה על כל המשתמשים (admin report) צריכה לפנות לכל ה-shards.

Range Sharding

Shard 1: user_id 1 - 1,000,000
Shard 2: user_id 1,000,001 - 2,000,000
Shard 3: user_id 2,000,001 - 3,000,000

יתרון: אפשר להוסיף shard בסוף בקלות.

חסרון: hotspot - אם כולם משתמשים חדשים, כל הכתיבות הולכות לshard האחרון.

בעיית Resharding

כשמוסיפים shard - צריך לחלק מחדש את הנתונים. קשה ויקר.

פתרון: Consistent Hashing (כמו שראינו בload balancing) מקטין את כמות הנתונים שצריך להעביר.


שיקולים נוספים בהתרחבות

Connection Pooling

פתיחת חיבור למסד נתונים יקרה. Connection Pool מנהל חיבורים פתוחים מראש.

from contextlib import contextmanager
import queue


class ConnectionPool:
    def __init__(self, db_url: str, pool_size: int = 10):
        self._pool = queue.Queue(maxsize=pool_size)
        for _ in range(pool_size):
            self._pool.put(create_connection(db_url))

    @contextmanager
    def get_connection(self):
        conn = self._pool.get()
        try:
            yield conn
        finally:
            self._pool.put(conn)


pool = ConnectionPool("postgres://localhost/taskflow", pool_size=20)

with pool.get_connection() as db:
    db.execute("SELECT * FROM tasks")

Index Design

האינדקס הנכון יכול להפוך שאילתה מ-10 שניות ל-1 מילישנייה.

-- שאילתה נפוצה: "כל המשימות של משתמש מסוים, ממויינות לפי תאריך"
SELECT * FROM tasks WHERE owner_id=? ORDER BY created_at DESC LIMIT 20;

-- אינדקס שמכסה את השאילתה הזו
CREATE INDEX idx_tasks_owner_created ON tasks(owner_id, created_at DESC);

כלל: הוסיפו אינדקס על כל עמודה שמופיעה ב-WHERE ו-ORDER BY בשאילתות נפוצות.

שימו לב: אינדקסים מאטים כתיבות (צריך לעדכן גם את האינדקס). לא הוסיפו אינדקס על כל עמודה.

Denormalization - מלאכת פשט

בDB מנורמל, נתונים מפוצלים לטבלאות רבות. שאילתות מורכבות דורשות joins.

לפעמים - בשביל ביצועים - מכפילים נתונים:

-- נורמלי: צריך JOIN בין tasks, projects, users
SELECT t.title, p.name AS project, u.username AS owner
FROM tasks t
JOIN projects p ON t.project_id = p.id
JOIN users u ON t.owner_id = u.id;

-- מלאכת פשט: שמרנו את שם הפרויקט ישירות בטבלת tasks
SELECT title, project_name, owner_username FROM tasks;

מחיר: כששם פרויקט משתנה, צריך לעדכן גם בטבלת tasks. עקביות נתונים היא האחריות שלכם.

Database per Service

במיקרוסרביסים, כל שירות אחראי על מסד הנתונים שלו בלבד.

task-service   → PostgreSQL (tasks, comments)
user-service   → PostgreSQL (users, auth)
search-service → Elasticsearch (tasks index)
notify-service → Redis (queues, preferences)

יתרון: שירות אחד יכול לשנות schema בלי להשפיע על אחרים.

חסרון: אי-אפשר לעשות JOIN בין מסדי נתונים שונים. צריך לפנות ל-API של שירות אחר.

סיכום

שיטה פותרת מורכבות
Read Replicas עומס קריאות נמוכה
Sharding כמות נתונים, עומס כתיבות גבוהה
Connection Pool overhead חיבורים נמוכה
Index Design מהירות שאילתות בינונית
Denormalization מהירות שאילתות מורכבות בינונית