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