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 | מהירות שאילתות מורכבות | בינונית |