如何在MongoDB中設計文檔模式

作者選擇將捐贈給開放網際網路/言論自由基金,作為撰寫捐贈計劃的一部分。

引言

如果您在關聯式資料庫方面擁有豐富經驗,可能難以超越關聯模型的原則,例如以表格和關係進行思考。面向文件的資料庫MongoDB使您能夠擺脫關聯模型的僵化和限制。然而,能夠在資料庫中儲存自我描述文件所帶來的靈活性和自由也可能導致其他陷阱和困難。

本文概述了面向文件資料庫中與模式設計相關的五個常見指導原則,並強調了在資料間建立關係模型時應考慮的各種因素。本文還將介紹幾種可採用的關係建模策略,包括將文件嵌入陣列以及使用子參照和父參照,並說明何時最適合使用這些策略。

指南1 — 將需要一起存取的資料存放在一起

在典型的關聯式資料庫中,資料儲存在表格中,每個表格由固定列的欄位組成,這些欄位代表構成實體、物件或事件的各種屬性。例如,在一個代表大學學生的表格中,你可能會找到存放每位學生名字、姓氏、出生日期和唯一識別號碼的欄位。

通常,每個表格代表一個單一主題。如果你想儲存有關學生當前學習、獎學金或先前教育的資訊,將這些資料存放在與個人資訊不同的表格中可能是有意義的。然後,你可以連接這些表格,以表明每個表格中的資料之間存在關聯,表示它們包含的資訊具有有意義的聯繫。

舉例來說,描述每位學生獎學金狀態的表格可以透過學號來指涉學生,但並不直接儲存學生的姓名或地址,以避免資料重複。在這種情況下,若要取得某位學生的完整資訊,包括其社交媒體帳戶、先前教育背景及獎學金記錄,查詢時就需要同時存取多個表格,並將來自不同表格的結果整合為一。

這種透過參照來描述關係的方法被稱為正規化資料模型。以這種方式儲存資料——使用多個獨立且精簡的物件相互關聯——在文件導向資料庫中也是可行的。然而,文件模型的靈活性及其允許在單一文件中儲存嵌入式文件和陣列的自由度意味著,你可以採用與關聯式資料庫不同的方式來建模資料。

文件導向資料庫建模資料的基本理念是「將會一起被存取的資料存放在一起」。”進一步探討學生的例子,假設該校大多數學生擁有多個電子郵件地址。因此,學校希望能在每位學生的聯絡資訊中儲存多個電子郵件地址。

在這種情況下,範例文件可能具有如下結構:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "emails": [
        {
            "email": "[email protected]",
            "type": "work"
        },
        {
            "email": "[email protected]",
            "type": "home"
        }
    ]
}

請注意,此範例文件包含了一個嵌入式的電子郵件地址列表。

在一個文件中表示多於一個主題的特點,標誌著一個非正規化的數據模型。它允許應用程序一次性檢索和操作與給定對象(在此,即學生)相關的所有數據,無需訪問多個獨立的對象和集合。這樣做還保證了對這類文件操作的原子性,無需使用多文檔事務來保證完整性。

將需要一起存取的數據存儲在一起,使用嵌入式文檔,往往是表示文檔導向數據庫中數據的最佳方式。在以下指南中,您將學習如何最佳地建模文檔導向數據庫中不同對象之間的關係,例如一對一或一對多關係。

指南2 — 使用嵌入式文檔建模一對一關係

一個一對一關係代表兩個不同對象之間的關聯,其中一個對象恰好與另一種類的一個對象相連接。

延續上一節的學生例子,每位學生在任何時候都只有一張有效的學生證。一張卡片永遠不會屬於多個學生,也沒有學生可以擁有多張識別卡。如果你要將所有這些數據存儲在關聯式數據庫中,將學生記錄和學生證記錄存儲在通過引用綁定在一起的單獨表格中,這樣建模學生和其學生證之間的關係可能是有意義的。

在文檔數據庫中表示這種關係的一種常見方法是使用嵌入式文檔。例如,以下文檔描述了一個名叫Sammy的學生及其學生證:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "id_card": {
        "number": "123-1234-123",
        "issued_on": ISODate("2020-01-23"),
        "expires_on": ISODate("2020-01-23")
    }
}

請注意,這個示例文檔的id_card字段不是單一值,而是包含一個嵌入式文檔,該文檔代表學生的識別卡,由ID號碼、卡片發行日期和卡片到期日期描述。身份卡本質上成為描述學生Sammy的文檔的一部分,儘管在現實生活中它是單獨的對象。通常,像這樣構建文檔模式,以便你可以通過單一查詢檢索所有相關信息,是一個明智的選擇。

如果你遇到連接一種對象與另一種多個對象的關係,例如學生的電子郵件地址、他們參加的課程或他們在學生會留言板上發布的消息,事情就變得不那麼直觀了。在接下來的幾個指南中,你將使用這些數據示例來學習處理一對多和多對多關係的不同方法。

指南3 — 使用嵌入式文件建模一對少關係

當一種類型的對象與另一種類型的多個對象相關聯時,可以將其描述為一對多關係。一個學生可以有多個電子郵件地址,一輛車可以有許多零件,或者一個購物訂單可以包含多個商品。這些示例中的每一個都代表了一對多關係。

雖然在文檔數據庫中最常見的一對一關係表示方法是通過嵌入式文檔,但在文檔模式中有多種方式來建模一對多關係。在考慮如何最好地建模這些關係時,有三個關係的屬性您應該考慮:

  • 基數基數是給定集合中單個元素的數量。例如,如果一個班級有30名學生,您可以說該班級的基數是30。在一對多關係中,基數在每種情況下可能不同。一個學生可能有一個或多個電子郵件地址。他們可能只註冊了幾門課程,或者他們可能有完全排滿的時間表。在一對多關係中,“多”的大小將影響您如何建模數據。
  • 獨立存取:某些相關資料很少會單獨從主要對象中存取。例如,單獨檢索某個學生的電子郵件地址而不涉及其他學生詳細資訊的情況可能很罕見。另一方面,大學的課程可能需要獨立存取和更新,無論是哪位學生或哪些學生註冊參加。你是否會單獨存取相關文件也將影響你如何建模資料。
  • 資料之間的關係是否嚴格為一對多關係:以某位學生在大學所修的課程為例。從學生的角度來看,他們可以參加多門課程。表面上看,這似乎是一對多的關係。然而,大學課程很少由單一學生參加;通常情況下,多個學生會參加同一門課。在這種情況下,所討論的關係實際上不是一對多關係,而是多對多關係,因此你在建模這種關係時會採取與一對多關係不同的方法。

想象一下,你正在決定如何儲存學生的電子郵件地址。每位學生可以有多個電子郵件地址,例如一個用於工作,一個用於個人使用,以及一個由大學提供的地址。代表單一電子郵件地址的文件可能採用以下形式:

{
    "email": "[email protected]",
    "type": "work"
}

在基數方面,每個學生只會有少量的電子郵件地址,因為一個學生不太可能擁有幾十個——更不用說幾百個——電子郵件地址。因此,這種關係可以被描述為一對少的關係,這是一個有力的理由將電子郵件地址直接嵌入到學生文件中並一起存儲。你不必擔心電子郵件地址列表會無限增長,這會使文件變得龐大且使用效率低下。

注意:需要注意的是,將數據存儲在數組中存在某些陷阱。例如,單個MongoDB文檔的大小不能超過16MB。雖然使用數組字段嵌入多個文檔是可能且常見的,但如果對象列表無法控制地增長,文檔可能會迅速達到這個大小限制。此外,在嵌入的數組中存儲大量數據會對查詢性能產生重大影響。

在數組字段中嵌入多個文檔在許多情況下可能是合適的,但要知道它也不總是最好的解決方案。

關於獨立訪問,電子郵件地址很可能不會與學生分開訪問。因此,沒有明顯的動機將它們存儲為單獨的文檔在單獨的集合中。這是另一個有力的理由將它們嵌入到學生的文檔中。

最後要考慮的是,這種關係是否真的是一對多關係,而非多對多關係。由於電子郵件地址屬於單一人,將此關係描述為一對多關係(或許更準確地說,是一對少關係)而非多對多關係是合理的。

這三個假設暗示,將學生的各種電子郵件地址嵌入到描述學生本身的同一文檔中,對於存儲此類數據來說是一個不錯的選擇。一個包含嵌入式電子郵件地址的學生文檔樣本可能呈現如下形式:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "emails": [
        {
            "email": "[email protected]",
            "type": "work"
        },
        {
            "email": "[email protected]",
            "type": "home"
        }
    ]
}

利用這種結構,每次檢索學生文檔時,你也會在同一讀取操作中獲取嵌入的電子郵件地址。

如果你建模的是一對少類型的關係,其中相關文檔不需要獨立訪問,直接嵌入文檔通常是可取的,因為這可以簡化模式複雜性。

但如前所述,這樣嵌入文檔並非總是最佳解決方案。下一節將詳細說明在某些情況下為何可能不是最優,並概述如何使用子引用作為另一種在文檔數據庫中表示關係的方法。

指南 4 — 使用子引用建模一對多和多對多關係

學生與其電子郵件地址之間的關係性質,決定了如何在文檔數據庫中最佳地建模這種關係。這與學生和他們參加的課程之間的關係存在一些差異,因此,您建模學生與其課程之間關係的方式也將有所不同。

描述學生參加的單一課程的文檔可能遵循以下結構:

{
    "name": "Physics 101",
    "department": "Department of Physics",
    "points": 7
}

假設您從一開始就決定使用嵌入式文檔來存儲每位學生課程的信息,如本例所示:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "emails": [
        {
            "email": "[email protected]",
            "type": "work"
        },
        {
            "email": "[email protected]",
            "type": "home"
        }
    ],
    "courses": [
        {
            "name": "Physics 101",
            "department": "Department of Physics",
            "points": 7
        },
        {
            "name": "Introduction to Cloud Computing",
            "department": "Department of Computer Science",
            "points": 4
        }
    ]
}

這將是一個完全有效的 MongoDB 文檔,並且很可能達到目的,但請考慮您在前一指南中學到的三個關係屬性。

第一個是基數性。學生可能只會維護幾個電子郵件地址,但他們在學習期間可以參加多門課程。經過數年的參與,學生可能參加了數十門課程。此外,他們將與其他許多學生一起參加這些課程,這些學生在多年的參與中也會參加自己的一系列課程。

若決定如前例般內嵌每門課程,學生的文件將迅速變得難以管理。隨著基數增加,內嵌文件的方法變得不再那麼吸引人。

第二個考慮因素是獨立存取。與電子郵件地址不同,合理假設在某些情況下,需要單獨檢索有關大學課程的信息。例如,某人可能需要有關可用課程的信息來準備營銷手冊。此外,課程可能需要隨時間更新:教授可能變更,課程時間表可能波動,或先修要求可能需要更新。

如果將課程作為文檔存儲在學生文檔內,檢索大學提供的所有課程清單將變得麻煩。此外,每次課程需要更新時,都需要遍歷所有學生記錄並在各處更新課程信息。這兩點都是將課程單獨存儲而不是完全內嵌的好理由。

第三個考慮因素是學生與大學課程之間的關係實際上是多對一還是多對多。在此情況下,是後者,因為每門課程可以有多名學生參加。這種關係的基數和獨立存取方面表明不應內嵌每個課程文檔,主要是出於實際原因,如易於存取和更新。考慮到課程與學生之間多對多的關係,將課程文檔存儲在具有自身唯一標識的單獨集合中可能是有意義的。

這個獨立集合中代表類別的文件可能具有類似以下範例的結構:

{
    "_id": ObjectId("61741c9cbc9ec583c836170a"),
    "name": "Physics 101",
    "department": "Department of Physics",
    "points": 7
},
{
    "_id": ObjectId("61741c9cbc9ec583c836170b"),
    "name": "Introduction to Cloud Computing",
    "department": "Department of Computer Science",
    "points": 4
}

如果你決定以這種方式儲存課程資訊,你需要找到一種方法將學生與這些課程連接起來,以便你知道哪些學生參加哪些課程。在這種情況下,當相關物件的數量不是特別龐大,尤其是在多對多關係中,一種常見的做法是使用子參照

透過子參照,學生的文件將在其嵌入式陣列中參照學生參加的課程的物件識別符,如下例所示:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "emails": [
        {
            "email": "[email protected]",
            "type": "work"
        },
        {
            "email": "[email protected]",
            "type": "home"
        }
    ],
    "courses": [
        ObjectId("61741c9cbc9ec583c836170a"),
        ObjectId("61741c9cbc9ec583c836170b")
    ]
}

請注意,此範例文件仍然有一個courses欄位,這也是一個陣列,但與先前範例中嵌入完整課程文件不同,這裡僅嵌入了參照分開集合中課程文件的識別符。現在,當檢索學生文件時,課程不會立即可用,需要單獨查詢。另一方面,可以立即知道要檢索哪些課程。此外,如果需要更新任何課程的詳細信息,只需更改課程文件本身。學生與其課程之間的所有參照將保持有效。

注意:對於何時關係的基數過大,無法以這種方式嵌入子參照,並沒有嚴格的規則。你可能會在較低或較高的基數時選擇不同的方法,如果這最適合正在討論的應用程序。畢竟,你總是希望將數據結構化,以適應應用程序查詢和更新數據的方式。

如果你要建立一對多關係的模型,其中相關文件的數量在合理範圍內,且需要獨立存取相關文件,則建議將相關文件分開儲存,並透過子參考嵌入來連接它們。

現在你已經學會如何使用子參考來表示不同類型數據之間的關係,本指南將概述一個相反的概念:父參考。

指南5 — 使用父參考建模無界的一對多關係

當相關對象太多而無法直接嵌入到父文件中,但數量仍在已知範圍內時,使用子參考效果良好。然而,有些情況下,相關文件的數量可能無限制,並且會隨著時間持續增長。

舉個例子,想像大學的學生會有一個留言板,任何學生都可以在上面發布任何他們想發布的訊息,包括課程問題、旅行故事、工作招聘、學習資料,或是自由聊天。在此例中,一則樣本訊息包含主題和訊息內容:

{
    "_id": ObjectId("61741c9cbc9ec583c836174c"),
    "subject": "Books on kinematics and dynamics",
    "message": "Hello! Could you recommend good introductory books covering the topics of kinematics and dynamics? Thanks!",
    "posted_on": ISODate("2021-07-23T16:03:21Z")
}

你可以使用之前討論的兩種方法之一——嵌入和子參考——來建模這種關係。如果你決定採用嵌入方式,學生的文件可能會呈現如下形式:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "emails": [
        {
            "email": "[email protected]",
            "type": "work"
        },
        {
            "email": "[email protected]",
            "type": "home"
        }
    ],
    "courses": [
        ObjectId("61741c9cbc9ec583c836170a"),
        ObjectId("61741c9cbc9ec583c836170b")
    ],
    "message_board_messages": [
        {
            "subject": "Books on kinematics and dynamics",
            "message": "Hello! Could you recommend good introductory books covering the topics of kinematics and dynamics? Thanks!",
            "posted_on": ISODate("2021-07-23T16:03:21Z")
        },
        . . .
    ]
}

然而,若學生經常撰寫訊息,其文件將迅速變得極其冗長,並可能輕易超出16MB的大小限制,因此此關聯的基數暗示了不應採用嵌入方式。此外,訊息可能需要從學生文件中單獨存取,例如,若訊息板頁面設計為顯示學生最新發布的訊息,這也表明嵌入並非此情境下的最佳選擇。

注意:在檢索學生文件時,您亦應考慮訊息板訊息是否經常被存取。若非如此,將所有訊息嵌入該文件中將導致在檢索和操作此文件時產生效能損失,即便這些訊息列表不常被使用。相關資料的不頻繁存取通常是另一個不應嵌入文件的線索。

現在考慮使用子參照而非如前例般嵌入完整文件。個別訊息將存儲於一個獨立的集合中,而學生文件則可具有以下結構:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "emails": [
        {
            "email": "[email protected]",
            "type": "work"
        },
        {
            "email": "[email protected]",
            "type": "home"
        }
    ],
    "courses": [
        ObjectId("61741c9cbc9ec583c836170a"),
        ObjectId("61741c9cbc9ec583c836170b")
    ],
    "message_board_messages": [
        ObjectId("61741c9cbc9ec583c836174c"),
        . . .
    ]
}

在這個例子中,`message_board_messages` 欄位現在儲存了 Sammy 撰寫的所有消息的子參考。然而,改變方法只解決了前面提到的一個問題,即現在可以獨立訪問這些消息。但儘管使用子參考方法可以使學生文檔的大小增長得更慢,但由於這種關係的無界基數,對象識別符的集合也可能變得難以管理。畢竟,一個學生在其四年的學習期間很容易寫出成千上萬條消息。

在這種情況下,連接一個對象到另一個對象的常見方式是通過父參考。與前面描述的子參考不同,現在不是學生文檔引用單個消息,而是消息文檔中包含一個指向撰寫它的學生的參考。

要使用父參考,你需要修改消息文檔結構以包含一個指向消息作者學生的參考:

{
    "_id": ObjectId("61741c9cbc9ec583c836174c"),
    "subject": "Books on kinematics and dynamics",
    "message": "Hello! Could you recommend a good introductory books covering the topics of kinematics and dynamics? Thanks!",
    "posted_on": ISODate("2021-07-23T16:03:21Z"),
    "posted_by": ObjectId("612d1e835ebee16872a109a4")
}

注意新的 `posted_by` 欄位包含了學生文檔的對象識別符。現在,學生的文檔將不包含任何關於他們發布的消息的信息:

{
    "_id": ObjectId("612d1e835ebee16872a109a4"),
    "first_name": "Sammy",
    "last_name": "Shark",
    "emails": [
        {
            "email": "[email protected]",
            "type": "work"
        },
        {
            "email": "[email protected]",
            "type": "home"
        }
    ],
    "courses": [
        ObjectId("61741c9cbc9ec583c836170a"),
        ObjectId("61741c9cbc9ec583c836170b")
    ]
}

要檢索某個學生撰寫的消息列表,你將在消息集合上進行查詢,並對 `posted_by` 欄位進行過濾。將它們放在一個單獨的集合中,可以安全地讓消息列表增長,而不影響任何學生的文檔。

注意:在使用父文档引用时,为引用父文档的字段创建索引可以显著提高每次根据父文档标识符进行筛选的查询性能。

若你构建一个一对多关系,其中相关文档的数量无上限,无论这些文档是否需要独立访问,通常建议将相关文档分开存储,并使用父文档引用来连接它们与父文档。

结论

得益于面向文档数据库的灵活性,在文档数据库中确定最佳关系模型化方式相较于关系型数据库而言,更少依赖于严格的科学方法。通过阅读本文,您已熟悉了嵌入文档以及使用子文档和父文档引用来存储相关数据的方法。您了解了考虑关系基数和避免无界数组的重要性,以及是否需要单独或频繁访问文档的因素。

这些只是帮助您在MongoDB中模型化典型关系的几条指导原则,但数据库模式设计并非一成不变。始终要考虑您的应用程序及其如何使用和更新数据,在设计模式时做出相应调整。

若要深入了解結構描述設計及在 MongoDB 中儲存各類資料的常見模式,我們建議您查閱該主題的官方 MongoDB 文件

Source:
https://www.digitalocean.com/community/tutorials/how-to-design-a-document-schema-in-mongodb