Database Migration ללא Downtime: השיטה של Expand-and-Contract

מאת צוות מדיה דיל · 08.08.2026 · DevOps · 8 דק׳

מדריך מעשי לביצוע migrations על מסד נתונים בפרודקשן בלי downtime — expand-and-contract, backfill, locks ו-migrations הפיכים.

הבעיה שכל צוות עם מסד נתונים בפרודקשן נתקל בה מוקדם או מאוחר: איך משנים סכימה — מוסיפים עמודה, משנים טיפוס, מפצלים טבלה — בלי לעצור את המערכת, ובלי לשבור קוד שרץ במקביל בגרסה ישנה וחדשה. migration נאיבי (ALTER TABLE אחד שרץ בזמן דיפלוי) עלול לנעול טבלה שלמה לדקות ארוכות בטבלאות גדולות, ולגרום לכל בקשת כתיבה להיתקע בתור. הפתרון הסטנדרטי בתעשייה הוא expand-and-contract — פירוק כל שינוי סכימה לשלבים נפרדים ובטוחים. במאמר הזה נפרק את השיטה לעומק, כולל המלכודות הטכניות שהופכות migration "פשוט" לתקרית פרודקשן.

הבעיה הבסיסית: שתי גרסאות קוד, סכימה אחת

בכל דיפלוי מודרני (rolling, blue-green, canary), לזמן מה רצות שתי גרסאות קוד במקביל מול אותו מסד נתונים. אם migration משנה סכימה בצורה לא-תואמת-אחורה — מסיר עמודה, משנה טיפוס, מוסיף עמודה NOT NULL בלי default — הגרסה הישנה תקרוס ברגע שהיא תיתקל בשורה שנוצרה או עודכנה על ידי הגרסה החדשה, או להפך. ההבנה הזו היא הבסיס לכל מה שבא אחריה: migration ו-deploy קוד הם שני אירועים נפרדים בזמן, אבל צריכים לשמור על תאימות הדדית לאורך כל החלון שבו שתי הגרסאות חיות יחד.

Expand-and-Contract: שלב אחר שלב

השיטה מפרקת כל שינוי לשלושה שלבים נפרדים, שכל אחד הוא דיפלוי משלו: Expand — הוספת המבנה החדש (עמודה, טבלה, אינדקס) בלי לגעת בישן. הקוד עדיין לא משתמש בו. Migrate — פריסת קוד שכותב לשני המקומות (dual write) וקורא מהחדש עם fallback לישן, יחד עם backfill שממלא נתונים היסטוריים. Contract — אחרי שכל instance רץ על הקוד החדש ואומת שהנתונים מלאים ותקינים, מסירים את המבנה הישן. הפרדת השלבים היא מה שמאפשר rollback בטוח בכל נקודה — אם שלב ה-migrate מתגלה כבעייתי, אפשר לחזור לקוד הקודם בלי לאבד נתונים, כי המבנה הישן עדיין קיים ותקין.

Backfill: המילכוד של נתונים היסטוריים

הוספת עמודה חדשה לטבלה עם מיליוני שורות קיימות דורשת backfill — מילוי הערך עבור כל השורות הקיימות. הרצת UPDATE יחיד על כל הטבלה נועלת אותה ומייצרת transaction ענק שעלול לגלוש ל-WAL (Write-Ahead Log) ולגרום ל-replication lag חמור. הפתרון הנכון הוא batched backfill — עדכון במנות קטנות (1000-10000 שורות בכל פעם) עם השהיה קצרה ביניהן, שמאפשרת ל-replication ו-locks אחרים "לנשום" בין batch ל-batch. כלים כמו pt-online-schema-change ל-MySQL או pg-osc/pgroll ל-PostgreSQL מיישמים את זה אוטומטית, אבל גם סקריפט פשוט בלולאה עם LIMIT ו-sleep קצר בין איטרציות עושה את העבודה במקרים פשוטים יותר.

Locks: איזה סוג migration נועל את הטבלה

לא כל שינוי סכימה שווה מבחינת locking. הוספת עמודה NULLABLE ללא default היא לרוב מטא-דאטה בלבד (מהיר, כמעט ללא lock ב-PostgreSQL מודרני). לעומת זאת, הוספת עמודה עם default value לא-קבוע, שינוי טיפוס עמודה, או הוספת אינדקס ללא CONCURRENTLY — כל אלה דורשים כתיבה מחדש של הטבלה כולה ונועלים אותה למשך הפעולה. ב-PostgreSQL, CREATE INDEX CONCURRENTLY בונה אינדקס בלי לנעול כתיבות (במחיר זמן ארוך יותר ואפשרות כישלון שדורש ניקוי ידני), בעוד CREATE INDEX רגיל נועל את הטבלה לכתיבה לכל משך הבנייה — על טבלה גדולה זה יכול להיות דקות ארוכות של חסימה מוחלטת.

Dual Write ובעיית העקביות

שלב ה-migrate שבו הקוד כותב לשני המקומות בו-זמנית (עמודה ישנה וחדשה) חושף בעיה עדינה: מה קורה אם הכתיבה למקום החדש נכשלת אחרי שהכתיבה לישן הצליחה? ללא עטיפה בטרנזקציה אחת, נוצר חוסר עקביות בין השדות. הפתרון הנכון הוא לוודא ששני הכתיבות קורות באותה טרנזקציית מסד נתונים (אם שניהם באותו DB) — ואם הם במערכות שונות (למשל migration בין מסדי נתונים שונים לגמרי), יש צורך במנגנון idempotency ו-reconciliation job שרץ מאוחר יותר ומזהה ומתקן פערים, כי אין דרך לקבל אטומיות אמיתית בין שני מסדי נתונים נפרדים.

כלים אוטומטיים: gh-ost, pt-osc, pgroll

עבור MySQL, כלים כמו gh-ost (GitHub) ו-pt-online-schema-change (Percona) פותרים חלק גדול מהמורכבות אוטומטית: הם יוצרים טבלת צל (shadow table) עם הסכימה החדשה, מעתיקים נתונים בהדרגה תוך מעקב אחרי שינויים חיים דרך binlog, ובסוף מבצעים RENAME אטומי שמחליף את הטבלה הישנה בחדשה תוך שברירי שנייה בלבד. עבור PostgreSQL, כלים כמו pgroll מיישמים גישה דומה עם views שמאפשרים לשתי גרסאות הסכימה להתקיים זו לצד זו בו-זמנית, כל אחת דרך view משלה, מה שמבטל את הצורך ב-dual write ידני ברמת האפליקציה. הבחירה בין מימוש ידני של expand-and-contract לבין כלי אוטומטי היא שאלה של בשלות ארגונית — כלים אוטומטיים חוסכים קוד משמעותי אך מוסיפים תלות בכלי חיצוני שצריך להכיר לעומק כדי לאבחן כשל.

בדיקת Migrations לפני פרודקשן

migration שנבדק רק על מסד נתונים ריק בסביבת פיתוח לא חושף את הבעיות האמיתיות — נעילות, זמן ריצה, עומס על replication — שמתבטאות רק בקנה מידה של פרודקשן. הגישה המקצועית היא הרצת ה-migration על עותק (snapshot) עדכני של מסד הנתונים בגודל דומה לפרודקשן, במדידת זמן ריצה ובדיקת lock contention בפועל. חלק מהארגונים הבשלים משלבים את זה כשלב אוטומטי ב-CI: כל migration חדש רץ קודם על snapshot בסביבת staging עם מדדי ביצועים, ורק migration שעובר סף זמן מוגדר מאושר להמשך לפרודקשן. זה חושף מראש migrations בעייתיים במקום לגלות אותם בזמן אמת כשהם כבר נועלים טבלה בפרודקשן.

Multi-Tenant ומסדי נתונים מבוזרים

בארכיטקטורת multi-tenant עם מסד נתונים משותף לכל הלקוחות, migration על טבלה גדולה משפיע על כולם בו-זמנית — אין דרך לבודד לקוח אחד מהעומס שהמיגרציה יוצרת. לעומת זאת, בארכיטקטורת multi-tenant עם מסד נתונים נפרד לכל לקוח (database-per-tenant), אותו migration צריך לרוץ פעמים רבות — פעם לכל tenant — מה שדורש אורקסטרציה משלו (תור migrations, מעקב אחר אילו tenants כבר עודכנו, טיפול בכישלון חלקי שבו רק חלק מה-tenants הצליחו). ארגונים עם עשרות או מאות tenants בדרך כלל בונים pipeline ייעודי להרצת migrations בהדרגה על תת-קבוצות, ולא מריצים את כולם במקביל כדי להימנע מעומס רוחבי על תשתית משותפת (connection pools, מוניטורינג).

Trade-offs: מהירות מול בטיחות

expand-and-contract איטי משמעותית מ-migration ישיר — במקום דיפלוי אחד יש שלושה או ארבעה, כל אחד דורש בדיקה נפרדת, ותהליך שלם יכול לקחת ימים במקום דקות. עבור טבלאות קטנות (אלפי שורות) בסביבות עם downtime מתוזמן, זה overkill מיותר — migration ישיר בחלון תחזוקה מהיר וזול יותר. עבור טבלאות גדולות בפרודקשן חי, expand-and-contract הוא לא "best practice" אלא הכרח טכני — migration ישיר על טבלה עם מיליארד שורות פשוט לא יסתיים בזמן סביר בלי לנעול את המערכת.

Migrations הפיכים ו-Rollback

כלל הזהב שלעיתים קרובות מופר: כל migration חייב rollback path ברור. עבור expand זה כמעט תמיד טריוויאלי (מחיקת המבנה החדש שאף אחד עוד לא תלוי בו). עבור contract זה מסוכן יותר — אחרי שהעמודה הישנה נמחקה, rollback אמיתי דורש שחזור מ-backup, לא סתם הרצת migration הפוך. לכן השלב הקריטי ביותר לבדיקה הוא בדיוק לפני ה-contract: יש לוודא שהקוד החדש רץ יציב בפרודקשן זמן מספיק (ימים, לא שעות) לפני שמסירים כל אפשרות חזרה.

טעויות נפוצות בפרודקשן

הטעות הראשונה היא הרצת migration כחלק מ-startup script של כל instance — מרוץ בין processes מקבילים שמנסים לשנות סכימה בו-זמנית. השנייה היא הוספת עמודה NOT NULL בלי default value על טבלה לא ריקה — זה נכשל מיידית או נועל את הטבלה כולה בזמן backfill סינכרוני. השלישית היא הזנחת מוניטורינג replication lag בזמן backfill — batch גדול מדי יכול לגרום ל-replica lag שקורא queries מיושנים לזמן ארוך. הרביעית היא מחיקת המבנה הישן (contract) מוקדם מדי, לפני שווידאו שאין עוד קוד (כולל jobs ישנים, cron scripts, dashboards חיצוניים) שעדיין תלוי בו.

טעות חמישית, נפוצה בייחוד בצוותים שממהרים: הרצת ה-backfill במהירות המקסימלית האפשרית כדי "לסיים מהר", בלי לקחת בחשבון שהוא מתחרה על אותם משאבי I/O עם תעבורת production חיה. batch size גדול מדי או השהיה קצרה מדי בין batches מנצחים במרוץ המשאבים על חשבון בקשות משתמשים אמיתיות, מה שגורם ל-latency spike בדיוק בזמן שהצוות חושב שהוא "פותר בעיה בזהירות". הפתרון הוא ניטור מדדי latency בזמן אמת תוך כדי ה-backfill, עם יכולת להאט או לעצור אוטומטית אם latency חורג מסף.

טעות שישית היא היעדר idempotency ב-migration scripts עצמם — script שנכשל באמצע ריצה (בגלל timeout, ניתוק רשת, או restart של ה-runner) חייב להיות ניתן להרצה חוזרת בלי לגרום לכפילות נתונים או לשגיאה על מבנה שכבר נוצר. שימוש בביטויים כמו CREATE INDEX IF NOT EXISTS ובדיקת מצב לפני כל שלב backfill (למשל שמירת "checkpoint" של השורה האחרונה שעובדה) הופכים migration לניתן להתאוששות מכל נקודת כשל, במקום לדרוש ניקוי ידני מורכב.

Observability של Migrations בזמן ריצה

מעבר לניטור latency ו-replication lag, migration ארוך זקוק ל-observability ייעודי משלו: dashboard שמראה כמה שורות עובדו מתוך הסך הכולל, קצב עדכון נוכחי, והערכת זמן לסיום. בלי זה, מהנדס תורן שמתעורר לתקרית באמצע הלילה לא יודע אם ה-migration תקוע, איטי אך מתקדם, או כבר הסתיים — וההחלטה אם להתערב תלויה בדיוק בהבדל הזה. מימוש פשוט של זה הוא כתיבת checkpoint (השורה האחרונה שעובדה, timestamp) לטבלת מטא-דאטה ייעודית בכל batch, שמאפשרת גם resume אחרי כשל וגם תצוגת התקדמות בזמן אמת דרך שאילתה פשוטה על אותה טבלה.

מתי אפשר לוותר על expand-and-contract

עבור מערכות פנימיות עם חלון תחזוקה מוסכם, סביבות staging, או טבלאות קטנות שבהן migration ישיר לוקח שניות, expand-and-contract הוא overhead מיותר. הכלל המעשי: אם ה-migration יכול להסתיים בפחות משנייה ואין תעבורה חיה בזמן הריצה, migration ישיר עדיף בפשטותו. ברגע שיש תעבורה חיה ו/או הטבלה גדולה מספיק שה-migration עלול לקחת יותר מכמה שניות, יש לעבור לגישה ההדרגתית. שילוב עם אסטרטגיית zero-downtime כללית הוא חובה — migration בטוח לא שווה הרבה אם שכבות אחרות בדיפלוי כבר שוברות תעבורה.

סיכום

Database migration ללא downtime הוא לא טריק בודד אלא משמעת: פירוק כל שינוי סכימה לשלבים בטוחים, backfill במנות קטנות עם ניטור latency בזמן אמת, הבנה מדויקת של אילו פעולות נועלות טבלאות, וטיפול זהיר בעקביות בזמן dual write. הצוותים שמצליחים בזה הם אלה שמתייחסים לכל migration כאל שרשרת דיפלויים נפרדים עם בדיקה מוקדמת על נתונים בגודל ריאלי, ולא כאל שינוי סכימה חד פעמי שרץ "מתישהו בין push ל-deploy" ומקווים לטוב.

תגיות: Database Migration · Zero-Downtime · PostgreSQL · Schema Change · Expand and Contract · DevOps · backfill

← חזרה לבלוג · צור קשר