ההבדלים בין Embedded ל-Reference ב-MongoDB ומתי לבחור בכל אחד

סער טויטו

סער טויטו

Software & Web Development


TL;DR
  • Embedded Document שמירת נתונים מקושרים בתוך אותו document, כמו אובייקט מקונן ב-JavaScript (למשל הכתובת בתוך המשתמש). מהיר לקריאה - שאילתה אחת מחזירה הכל.
  • Reference Document שמירת נתונים תחת collections שונים וקישור ביניהם דרך ObjectId; הקריאה מבוצעת דרך populate (Mongoose) או $lookup (MongoDB Aggregation).
  • ההבדל המעשי Embedded מהיר יותר לקריאה אבל יוצר כפילויות; Reference חוסך כפילויות ומקל על עדכון נתונים שמופיעים במקומות רבים, אבל דורש שאילתה נוספת.
  • כלל אצבע Embedded לנתונים שנקראים יחד תכופות ומשתנים נדיר; Reference לנתונים שגדלים ללא הגבלה, מתעדכנים תכופות, או חורגים מ-16MB (המגבלה של document ב-MongoDB).

ההבדלים בין Embedded ל-Reference

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

Embedded שומר את הנתונים בתוך אותו document - כמו אובייקט מקונן ב-JavaScript - ומאפשר שליפה של כל המידע בשאילתה אחת (מהיר). Reference שומר את הנתונים ב-collection נפרד וקושר אליהם דרך ObjectId שמתפקד כ-foreign key (חוסך כפילויות אבל דורש שאילתה נוספת דרך populate או $lookup). הכלל המעשי: Embedded לנתונים שנקראים יחד תכופות ומשתנים נדיר; Reference לנתונים שמתעדכנים תכופות או גדלים ללא הגבלה.

במאמר הזה נחקור איך עדיף לשמור את כתובת המגורים של האזרחים ביחד עם הנתונים האישיים שלהם. אם ניקח דוגמה, בישראל יש כ-10 מיליון תושבים, ברמת גן מתגוררים 174,974 תושבים וברחוב מסוים יכולים להתגורר מאות ואלפים של אנשים. איך אתם הייתם שומרים מידע כזה - ב-Reference או ב-Embedded?

מה זה Embedded Documents?

זו טכניקה שבה המידע נשמר בתוך השני ממש כמו אובייקט ב-JavaScript, הנה דוגמה ל-Embedded Documents בו אני שומר את הנתונים של המשתמש ובנוסף אובייקט שמאגד את כל הנתונים של מיקום המגורים של המשתמש:

{
  "_id": ObjectId("507f191e810c19729de860ea"),
  "name": "John Doe",
  "address": {
    "street": "123 Park Ave",
    "city": "New York",
    "state": "NY"
  }
}

מה זה Reference Documents?

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

// Document in users collection
{
  "_id": ObjectId("507f191e810c19729de860ea"),
  "name": "John Doe",
  "address_id": ObjectId("507f1f77bcf86cd799439011")
}

// Document in addresses collection
{
  "_id": ObjectId("507f1f77bcf86cd799439011"),
  "street": "123 Park Ave",
  "city": "New York",
  "state": "NY"
}

הבנת נתונים סטטיים בסכמות MongoDB

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

אבל מצד שני יהיו לנו כפילויות שזה פחות טוב, ואז היתרונות של Reference (מניעת כפילויות, ואחסון טוב יותר) אולי יהיו עדיפים. אז איך אנחנו יכולים לבחור מה עדיף? אנחנו, בתור Database Engineers או Backend Developers, צריכים לקחת את כל האפשריות כדי לנסות לעצב את הסכמה והמבנה הנכונים ביותר.

נתונים סטטיים עם Reference

הפניה (Reference), כוללת יצירת מסמכים (documents) נפרדים עבור ישויות שונות, והקישור בניהם מתבצע על ידי foreign keys. אומנם המונח foreign keys מתייחס יותר למסדי נתונים מבוססי SQL, אבל הוא מתאר בדיוק את אותו הדבר גם ב-NoSQL כמו MongoDB.

כדי ליצור הפניות (References) ב-MongoDB, ניתן להשתמש בשתי שיטות עיקריות:

  1. השיטה הראשונה היא ()populate ששייכת ל-Mongoose (ORM עבור Node.js) ומאפשרת לשלוף נתונים מקושרים בצורה פשוטה ונוחה. כדי להשתמש ב-()populate, חובה להגדיר סכמות (Schemas) ומודלים (Models) ב-Mongoose. זה מאפשר ל-Mongoose להבין את הקשרים בין הנתונים.

שימוש ב-()populate (מחייב עבודה עם Mongoose)

const mongoose = require('mongoose');

const AddressSchema = new mongoose.Schema({
  street: String,
  city: String,
  state: String
});

const UserSchema = new mongoose.Schema({
  name: String,
  address_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Address' }
});

const Address = mongoose.model('Address', AddressSchema);
const User = mongoose.model('User', UserSchema);

היישום של ()populate

User.findOne({ name: 'John Doe' })
  .populate('address_id') // מציין את שם השדה שצריך לקשר
  .exec((err, user) => {
    console.log(user);
  });

התוצאה

{
  "_id": "507f191e810c19729de860ea",
  "name": "John Doe",
  "address_id": {
    "_id": "507f1f77bcf86cd799439011",
    "street": "123 Park Ave",
    "city": "New York",
    "state": "NY"
  }
}
  1. השיטה השנייה היא lookup$ שמשמשת במסגרת Aggregation Framework של MongoDB ומאפשרת לקשר בין מסמכים ב-collections שונים בצורה דומה ל-joins במסדי נתונים רלציוניים. ב-lookup$ אנחנו לא צריכים להגדיר סכמות או מודלים.

    MongoDB מבצע את הקישור בין ה-collections ישירות, אבל אתם כמובן יכולים להשתמש ב-lookup$ גם אם יש לכם סכמות ומודלים ב-Mongoose. למעשה, Mongoose תומך ב-Aggregation Framework של MongoDB.

שימוש ב-lookup$ (MongoDB ישירות, לא תלוי ב-Mongoose)

db.users.aggregate([
  {
    $lookup: {
      from: "addresses", // שם ה-(collection)
      localField: "address_id", // השדה ב-document הנוכחי
      foreignField: "_id", // השדה ב-document היעד
      as: "address" // שם השדה החדש שבו התוצאה תשמר
    }
  },
  {
    $unwind: "$address" // פותח את המערך לתוך אובייקט יחיד
  }
]);

התוצאה

{
  "_id": "507f191e810c19729de860ea",
  "name": "John Doe",
  "address_id": "507f1f77bcf86cd799439011",
  "address": {
    "_id": "507f1f77bcf86cd799439011",
    "street": "123 Park Ave",
    "city": "New York",
    "state": "NY"
  }
}
  • Data integrity & maintenance

    תחשבו שיש לנו document אחד של מדינה ישראל ויש בתוכו רק filed אחד - 'countryName', וביצענו הפנייה מה-document הזה, לכל אזרחי ישראל במאגר שלנו (10 מיליון). עכשיו תחשבו שיש משימה חדשה, להוסיף לאותו document גם מידע היסטורי ונתונים דמוגרפיים.

    במקרה הזה אנחנו לא צריכים לשנות 10 מיליון documents של אזרחים, אנחנו פשוט משנים במקום אחד, ב-collection של countries ב-document של מדינת ישראל, וזהו, אין כפילויות וחסכנו מקום ב-database.

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

  • Modular & Separation of concerns

    אז נמשיך עם הדוגמה של מדינת ישראל ו-10 מיליון אזרחים. אם נשתמש בהפניה (Reference Document) אנחנו נוסיף את השדות (מידע היסטורי ונתונים דמוגרפיים) רק על document אחד בלבד - (מדינת ישראל) ולא על 10 מיליון documents (אזרחים). כלומר השיטה הזו עוזרת לנו לשמר עקביות על הנתונים וגם מאגדת במקום אחד את כל הנתונים של המדינה (Separation of concerns).

נתונים סטטיים עם Embedded

אז אחרי שניתחנו את הסיטואציה שלנו עם Reference, הגיע הזמן לנתח את האפשרות של Embedded.

  • Query performance

    טכניקת Embedded נחשבת למהירה יותר מאשר טכניקת Reference, מכיוון שבעת שימוש ב-Reference מתבצע למעשה תהליך הדומה ל-Join במסדי נתונים רלציוניים כמו SQL. בתהליך זה, יש לפנות תחילה ל-collection אחד, לאחר מכן ל-collection נוסף, לחבר את המידע משניהם, ורק אז להציג את התוצאה הסופית. תהליך זה כבד יותר מבחינת משאבים וזמן.

    לעומת זאת, Embedded Document מאפשר שליפה מהירה יותר של נתונים, מכיוון שכל המידע מאורגן במקום אחד – בתוך document יחיד – במקום במספר מקומות שונים. כך, השאילתה לדאטהבייס דורשת פעולה אחת בלבד כדי להחזיר את כל המידע, כמו במקרה של שליפת נתוני אזרח הכוללים את כתובת המגורים שלו.

  • Minimized Document Growth

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

ההחלטה בין Embedded לבין Reference אינה חד-משמעית, ואתם צריכים להבין את המטרות של המערכת שלכם, ולכן הכנתי עבורכם כללי אצבע שתוכלו ללכת לפיהם:

מתי להשתמש ב-Embedded Document?

  • Frequent Read Together

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

  • Rarely Changes

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

  • Query Performance

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

  • Document Size

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

מתי להשתמש ב-Reference Document?

  • Independent Update Patterns

    אם הנתונים משתנים לעיתים קרובות ואתם לא קוראים את המידע של ה-document שמבצע את ההפניה בתדירות גבוהה ואתם מעדיפים לשמור על סדר - Separation of concerns, השימוש ב-Reference Document יהיה מתאים יותר.

  • Reduce Duplication

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

  • Large, Variable Data Sets

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

ניתוח Dataset של 500 מיליון אזרחים

בואו נבחן את האסטרטגיות לניתוח Dataset של 500 מיליון אזרחים, תוך דיון ביתרונות וחסרונות של שימוש ב-Reference Document Embedded Documen ב-MongoDB.

  • Duplication Concerns

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

  • Update Management

    אם נשתמש ב-Embedded, כל עדכון לפרטי מידע משוכפל (כמו שינוי שם מדינה) ידרוש עדכון של 500 מיליון documents, אחד לכל אזרח. אם נשתמש ב-Reference, אנחנו נשנה את אותו מסמך (document) של אותה מדינה, כלומר פעם אחת בלבד, והעדכון יכלול על כל ה-500 מיליון documents. אבל בגלל שהסיכוי ששם המדינה או העיר או הרחוב ישתנו הוא אפסי, ובהנחה שהמהירות חשובה לנו ויש לנו את המשאבים, אולי זה עדיף.

  • Frequent Read Together

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

סיכום

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

שאלות נפוצות

מה המגבלה של 16MB ב-MongoDB ולמה היא חשובה

MongoDB מגביל כל document בודד ל-16 מגה-בייט. המגבלה הזו חשובה במיוחד בהחלטה בין Embedded ל-Reference: אם תכננתם לשמור Embedded ויש סיכוי שה-document יגדל מעבר לגבול (למשל מאות אלפי תגובות בתוך פוסט), חייבים לעבור ל-Reference. בפועל, רוב ה-documents קטנים בהרבה, אבל בנתונים שגדלים ללא הגבלה (logs, comments, history), Reference הוא חובה.

האם populate מאט את הביצועים

כן, יש עלות. populate מבצע בפועל שאילתה נוספת אל ה-collection המקושר אחרי השאילתה הראשית - כלומר במקום שאילתה אחת לקבלת המידע יש שתיים (או יותר אם populate נוסף). אם אתם מבצעים populate בלולאה על מאות documents, הביצועים יורדים משמעותית. הפתרון: שימוש ב-$lookup של Aggregation שמבצע את הקישור בצד השרת בפעולה אחת, או שימוש ב-Embedded אם הנתונים נקראים יחד תכופות.

מה ההבדל בין populate ל-$lookup

populate הוא פיצ'ר של Mongoose (ORM ל-Node.js) שמבצע אוטומטית שאילתה נוספת ל-collection המקושר ומחבר את התוצאות בקוד JavaScript. $lookup הוא חלק מ-Aggregation Framework של MongoDB עצמו ומבצע את הקישור (join) בצד השרת בפעולה אחת. $lookup מהיר יותר ל-datasets גדולים, אבל הסינטקס מורכב יותר. populate נוח יותר לפעולות פשוטות עם schemas מוגדרים.

האם אפשר לשלב Embedded ו-Reference באותו schema

בהחלט - וזו הגישה המומלצת ברוב המקרים. דוגמה טיפוסית: ב-document של פוסט, נשמור את שם המחבר ותמונת הפרופיל שלו כ-Embedded (קל לקריאה, נדיר שמשתנים), אבל את רשימת התגובות נשמור כ-Reference ל-collection נפרד (גדל ללא הגבלה, נדרשים עדכונים תכופים). העיצוב הנכון משלב את שתי השיטות לפי דפוסי הקריאה והעדכון של כל שדה.

איך לבחור בין Embedded ל-Reference במצב של רבים-לרבים

ביחסי רבים-לרבים (many-to-many) - למשל סטודנטים וקורסים - Reference הוא כמעט תמיד הבחירה הנכונה. אם תשמרו את הקורסים כ-Embedded בתוך כל סטודנט, תקבלו כפילויות עצומות (כל קורס משוכפל בכל סטודנט שרשום אליו). הגישה המקובלת: collection נפרד לסטודנטים, נפרד לקורסים, ו-collection-קישור (enrollments) שמכיל זוגות של ObjectId. אפשר גם לשמור מערך של ObjectIds בתוך אחד הצדדים, אבל זה פחות גמיש.