לדלג לתוכן

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

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

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


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

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

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

Write   →  [Primary DB]  →  replication  →  [Replica 1]  ← Read
                        →  replication  →  [Replica 2]  ← Read
                        →  replication  →  [Replica 3]  ← Read
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):
        """Always write to primary"""
        return self.primary

    def get_read_connection(self):
        """Read from a replica using 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"
    ]
)

# Write
with router.get_write_connection() as db:
    db.execute("INSERT INTO tasks ...")

# Read
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 מיליארד שורות - queries איטיות, הכנסת נתונים איטית.

הפתרון: פיצול הנתונים לכמה מסדי נתונים ("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()

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

חסרון: הquery על כל המשתמשים (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

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

-- Common query: "All tasks of a specific user, sorted by date"
SELECT * FROM tasks WHERE owner_id=? ORDER BY created_at DESC LIMIT 20;

-- An index that covers this query
CREATE INDEX idx_tasks_owner_created ON tasks(owner_id, created_at DESC);

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

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

Denormalization - מלאכת פשט

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

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

-- Normal: needs a JOIN between 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;

-- Denormalized: we saved the project name directly in the tasks table
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 מהירות queries בינונית
Denormalization מהירות queries מורכבות בינונית