שיפור ביצועים ב-MongoDB: מדריך מלא.

·

8 דקות קריאה

אמ;לק

ביצועים טובים ב-MongoDB לא מתקבלים במקרה. הם תוצאה של תכנון מושכל - של הסכמה, האינדקסים, אופן השאילתות, ואפילו של ארגון הנתונים הפנימי. בפוסט הזה נבין איך לקבל את המסימום מ- MongoDB - בהיבטים רבים.


רקע: בסיס נתונים ללא סכימה? לא באמת

MongoDB הפך בשנים האחרונות לבחירה פופולרית עבור מערכות מבוזרות, מערכות ניהול תוכן, אפליקציות real-time ועוד - בעיקרב בזכות הגמישות הרבה שלו כ document store. אך לגמישות הזאת יש מחיר.

העובדה שאין סכימה קשיחה לא אומרת שאין תכנון. להיפך - היא מחייבת תכנון קפדני.
בלי הכוונה מראש, פרויקט שמתחיל ״קליל״ יכול להפוך מהר מאוד לאיטי, כבד, ובלתי ניתן ל scale up.


מה משפיע על ביצועים ב-MongoDB?

נחלק את הגומרים לשישה תחומים עיקריים:

  1. תכנון סכימה - הטמעה (embedding) מול הפניה (referencing)

  2. שימוש באינדקסים - מתי, איזה ואיך?

  3. שאילתות ו-Aggregation Pipeline

  4. עיצוב מסמכים - גודל, מבנה וסוגי שדות

  5. Sharding וחלוקה ל-segments

  6. מנגנונים משלימים - read preference, bulk write, TTL ועוד.

בואו נצלול לכל אחד מהם, בצורה מסודרת.

תכנון סכימה: הטמעה או הפניה?

ב-MongoDB אנחנו מחליטים האם לכלול את המידע בתוך אותו Document (מה שנקרא Embedded Document) או לקשר ל-Document אחר (referencing). ההחלטה הזאת יכולה להיות משמעותית בסקייל גבוה.

מתי להשתמש ב-Embedding?

  • כאשר מבנה ה-Documents שלכם הוא ביחס של 1:1 או 1:מעט

  • המידע ב-document המוטמע תמיד צריך להגיע יחד עם המידע של ההורה שלו

  • המידע ב-document לא משתנה לעיתים קרובות

JSON{ "_id": "user123", "name": "John Doe", "address": { "street": "Main St", "city": "Tel Aviv", "zip": "12345" } }

{
  "_id": "user123",
  "name": "John Doe",
  "address": {
    "street": "Main St",
    "city": "Tel Aviv",
    "zip": "12345"
  }
}

בדוגמה למעלה השדה address הוא embedded בתוך המסמך של ה-user כיוון שזה לא משהו שאנחנו מצפים שישתנה יותר מדי פעמים, הוא תמיד צריך לבוא בהקשר של המשתמש שאליו הוא מחובר, ויש יחס של 1:1 בין מתשמש לבין הכתובת

מתי להשתמש ב-Referencing?

  • נתונים שגדלים עם הזמן (יחס של 1:many או many:many)

  • שיתוף נתונים בין אובייקטים

  • מידע שמשתנה לעיתים תכופות

JSON// users { "_id": "user123", "name": "John Doe" }

// articles { "_id": "article789", "author_id": "user123", "title": "Improving MongoDB Performance", "body": "...", "tags": ["mongodb", "performance"] }

// users
{
  "_id": "user123",
  "name": "John Doe"
}

// articles
{
  "_id": "article789",
  "author_id": "user123",
  "title": "Improving MongoDB Performance",
  "body": "...",
  "tags": ["mongodb", "performance"]
}

במקרה הזה אין סיבה להטמיע את כל המאמרים בתוך ה-Document של המשתמש כיוון שמספר המאמרים משתנה עם הזמן, ויש פה יחס של 1 לרבים או רבים לרבים (במקרה בו כמה אנשים כותבים מאמרים ביחד ולחוד).
אנחנו נצטרך הפניה למאמרים גם בלי קשר למשתמש שכתב אותם אם נרצה להראות מאמרים לפי תגיות.

כלל אצבע:
אם יש גידול לינארי לאורך זמן או שימוש עצמאי ב-Document - אנחנו נעדיף להשתמש ב-Referencing
אם הנתונים קטנים, לא משתנים, ותמיד משתמשים בהם ביחד - נעדיף להשתמש ב embedding.


אינדקסים: המנוע שמריץ שאילתות

בלי אינדקס מתאים, כל find() מבצע סריקה מלאה של כל הטבלה. זה יכול להיות איטי מאוד ב-collection גדול.

סוגי אינדקסים שכדאי להכיר:

שם האינדקסשימוש עיקרי
Single Field Indexאינדוקס פשוט על שדה אחד
Compound Indexמתאים לשאילתות עם מספר תנאים על שדות שונים
Multikey Indexנדרש כאשר השדה שרוצים לעשות עליו אינדקס הוא מערך
Text Indexלחיפוש טקסט חופשי
Hashed Indexלרוב משמש ב-sharding
Wildcard Indexאינדוקס של שדות דינאמיים
Partial Indexאינדקוס רק על מסמכים העונים לתנאי מסוים
TTL Indexמחיקת מסמכים אוטומטית לאחר זמן

טיפים לשימוש באינדקסים

טיפ 1: השתמשו ב-explain() כדי להבין מה קורה

כל שאילתה מורכבת - כזו שרצה בתדירות גבוהה על אוסף גדול - צריכה להיבדק בעזרת explain("executionStats"). הפקודה הזו מחזירה מידע מפורט על איך השאילתה רצה בפועל: האם היא משתמשת באינדקס, כמה מסמכים נסרקו, כמה התאימו, כמה זמן זה לקח ועוד. כך תדעו אם האינדקס שאתם סומכים עליו באמת פועל - או שאתם רק מקווים

JavaScriptdb.orders.find({ user_id: "123" }).explain("executionStats")

db.orders.find({ user_id: "123" }).explain("executionStats")

ראו עוד כאן

טיפ 2: הסדר ב-Compound Index קובע!

אינדקס מורכב (Compound Index) הוא אינדקס על יותר משדה אחד. הסדר שבו אתם מגדירים את השדות באינדקס קובע אילו שאילתות ייהנו ממנו. לדוגמה, אם יצרתם אינדקס על (status, create_at), שאילתה על status בלבד תוכל להשתמש באינדקס, אבל שאילתה על created_at בלבד לא.

JavaScriptdb.orders.createIndex({ status:1 , created_at: -1 })

db.orders.createIndex({ status:1 , created_at: -1 })

כדאי לתכנן אינדקסים מורכבים לפי השדות שמופיעים בתחילת השאילתה (prefix fields) - זה שיקול קריטי באופטימיזציה.

טיפ 3: הוסיפו אינדקסים חלקיים (Partial Indexes) כשיש תתי-קבוצות פעילות

אם רק חלק מהמסמכים שלכם נגישים בפועל - למשל "status": "active" מתוך ["active", "archived", "deleted"] - אפשר לחסוך מקום וזמן ע״י אינדוקס רק של הפעילים. אינדקס חלקי חוסך משאבים ומאיץ שאילתות נפוצות:

JavaScriptdb.users.createIndex( { email: 1 }, { partialFilterExpression: { status: "active" } } )

db.users.createIndex(
  { email: 1 },
  { partialFilterExpression: { status: "active" } }
)
טיפ 4: אל תגזימו בכמות האינדקסים - זה יפגע בכתיבה

כל אינדקס חדש הוא כמו טבלה נוספת שצריך לעדכן בכל שינוי. ככל שיש יותר אינדקסים, פעולות update, insert, ו-delete הופכות איטיות יותר. זו אחת הטעויות הנפוצות: מפתחים מוסיפים אינדקס לכל שדה שאי פעם מופיע בשאילתה - ואז מגלים שפעולות כתיבה קורסות.

פתרון: מדדו בעזרת oplog או profiler אילו אינדקסים בשימוש אמיתי - ומחקו את המיותרים.

טיפ 6: נצלו את covered queries - כשלא צריך לגשת למסמך בכלל

אם אתם שואלים רק שדות שנמצאים כבר באינדקס, MongoDB יכול להחזיר תשובה בלי לגעת בכלל במסמך. זה נקרא covered query, וזה מאוד מהיר. לשם כך תצטרכו לוודא גם שהשדות שאתם מחפשים וגם השדות שאתם מחזירים - נמצאים כולם באינדקס

JavaScriptdb.orders.createIndex({ user_id: 1, status: 1 }) db.orders.find({ user_id: "123" }, { status: 1, _id:0 })

db.orders.createIndex({ user_id: 1, status: 1 })
db.orders.find({ user_id: "123" }, { status: 1, _id:0 })

אתם יכולים לקרוא על זה כאן

Aggregation Pipeline: עיבוד נתונים רב-שלבי ב-MongoDB

מה זה Aggregation Pipeline?

Aggregation Pipeline הוא מנגנון עוצמתי ב-MongoDB שמאפשר לבצע עיבוד נתונים מתקדם על collections באמצעות רצף של שלבים (stages), שכל אחד מהם מקבל נתונים, מעבד אותם, ומעביר הלאה.
אפשר לדמות את זה ל-UNIX pipers או ל-DataFrame של pandas:
כל שלב מקבל input -> מבצע משהו -> שולח output לשלב הבא

למה find() לא מספיק?

במקרים רבעים אנחנו לא רק רוצים ״לשלוף מסמכים״, אלא:

  • לבצע סינון לפי תנאים מורכבים

  • לקבץ נתונים - group by

  • לחשב סכומים, ממוצעים, סטטיסטיקות

  • לצרף Documents מ-collection אחר (join)

  • למיין, להגביל, לפצל

לצורך זה MongoDB מציע את Aggregation Framework. עם התחביר של: db.collection.aggregate([...])

מה חשוב לדעת על ביצועי Aggregation Pipeline?

סדר השלבים קובע
  • תמיד שימו את $match כמה שיותר מוקדם - זה מצמצם את הכמות הנתונים ששאר החלקים בפייפליין צריכים לעבוד עליהם.

  • גם $project בשלב מוקדם עוזר - מפחית שדות מיותרים בזיכרון.

  • רק בסוף עושים $sort, $group, $facet.

תשתמשו באינדקסים גם בפייפליין

MongoDB יודע לנצל אינדקסים גם בתוך פייפלייין - אבל רק אם ה-$match מופיע מוקדם, ולא לאחר $project שמשנה את המבנה.

שימוש ב-$merge או $out עדיף לעיבוד כבד

אם אתם צריכים לבצע עיבוד על עשרות אלפי מסמכים ולשמור תוצאה - עדיף להשתמש ב-$merge לתוך אוסף חדש או קיים, ולא לשלוף את התוצאה ולהתמודד איתה באפליקציה.

JavaScriptdb.orders.aggregate([ { $match: { status: "completed" } }, { $group: { _id: "$customer_id", total: { $sum: "$amount" } } }, { $merge: "customer_totals" } ])

db.orders.aggregate([
  { $match: { status: "completed" } },
  { $group: { _id: "$customer_id", total: { $sum: "$amount" } } },
  { $merge: "customer_totals" }
])

מתי לא להשתמש ב-Aggergation?

  • אם אתם יכולים להשיג את מה שאתם צריכים עם find() - אז תשמשו בזה, אל תסתבכו.

  • תרחישים שבהם נדרש ביצוע בזמן אימת על collections ענקיים - שיקלו להכין את החישובים ב-offline ולא לפי בקשה.

  • במקום שבו $lookup יגרום ל-nested arrays עמוקים מדי - קשה מאוד לתחזק.

מבנה Documents: גודל, עומק וסוג שדות

שיקולים חשובים

  • גודל מסמך - MongoDB יודע להתמודד עם מסמכים בגודל של עד 16MB. שימרו את המסמכים שלכם קטנים מזה ואל תתקרבו לגודל הזה.

  • שמות השדות - השתמשו בשמות קצרים לשדות, במיוחד לשדות שחוזרים על עצמם.

  • עומק - nesting עמוק מקשה על האינדוקס.

  • סוגי שדות - העדיפו Int על פני String עבור מספרים, ו-Boolean במקום "true"/"false"

Sharding: כאשר שרת אחד כבר לא מספיק

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

איך זה עובד?

MongoDB מחלק את האוסף לפי shard key, וכל mongos מנתב שאילתות בהתאם.

איך בוחרים shard key?

  • חייב להיות בעל שונות גבוהה (high cardinality)

  • חייב להופיע בשאילתות

  • לא משתנה לעיתים קרובות

טריקים נוספים לשיפור הביצועים

  • השתמשו ב bulkWrite() במקום להריץ כמות גדולה של update() בלולאה.

  • מחיקת לוגים אוטומטית:

JavaScriptdb.sessions.createIndex({ "createdAt": 1 }, { expireAfterSeconds: 3600 })

db.sessions.createIndex({ "createdAt": 1 }, { expireAfterSeconds: 3600 })
  • החזירו רק את השדות הנדרשים:

JavaScriptdb.users.find({}, { name: 1, email: 1 })

db.users.find({}, { name: 1, email: 1 })
  • לא כל קריאה חייבת להיות מ-primary. שימוש ב-"readPreference: "nearest" יכול להפחית עומס

סיכום

  • סכמה זה חוזה, גם אם הוא גמיש. תכננו לפי דפוסי שימוש.

  • אינדקוס זה מדע, נטרו ושפרו כל הזמן.

  • בדקו כל שאילתה, ע״י explain, באופן קבוע

  • שיקלו sharding רק כשאין ברירה

קישורים להעמקה