Spring Data MongoDB - 關係建模

工程 | Christoph Strobl | 2021 年 11 月 29 日 | ...

MongoDB 的彈性 schema 允許在實體之間建立關係時使用多種模式。此外,對於許多用例來說,反正規化的資料模型(將相關資料儲存在單一文件中)可能是最佳選擇,因為所有資訊都保存在同一個位置,因此應用程式需要較少的查詢才能提取所有資料。然而,這種方法也有其缺點,例如潛在的資料重複、較大的文件以及最大文件大小。

一般來說,MongoDB 建議在嵌入的優點被重複的影響所掩蓋時,使用正規化的資料模型。在這篇部落格文章中,我們將探討使用手動參考DBRefs 連結文件時的不同可能性,以應對需要處理關係的情況。

DBRef 是 MongoDB 的原生元素,用於以明確的格式 { $db : …, $ref : …, $id : … } 表示對其他文件的參考,其中包含目標資料庫集合和參考元素的 id 值的相關資訊,最適合連結到分佈在不同集合中的文件。

另一方面,手動參考的結構更簡單(僅儲存被參考文件的 id),但因此在處理混合集合參考時不那麼靈活。

設定好術語後,讓我們介紹眾所周知的領域類型,例如 BookPublisher,以及它們之間顯而易見的關係。

class Book {
    private String isbn13;
    private String title;
    private int pages;
}

class Publisher {
    private String name;
    private String arconym;
    private int foundationYear;
}

Publisher 嵌入到每個 Book 中並不是一個吸引人的選擇,因為這會導致資料重複,並對儲存和可維護性造成不必要的負擔。

class Book {
    // ...
    private Publisher publisher;
}

雖然這種儲存格式允許原子更新,並在查詢特定屬性時提供最大的彈性,但如下面的程式碼片段所示,重複 Publisher 資訊可能不值得付出這樣的代價。

{
    "_id" : "617cfb",
    "isbn13" : "978-0345503800",
    "title" : "The Warded Man",
    "pages" : 432,
    "publisher" : {
        "name" : "Del Rey Books",
        "arconym" : "DRB",
        "foundationYear" : 1977
    }
}

Books 的集合嵌入到 Publisher 中也是如此,這會導致不必要的大型文件。正規化模型並使用連結的文件可以減輕這個問題。

第一步是確定關係的方向,以找出關係的哪一部分需要保留參考,如果不是兩者都需要的話。這個決定將影響我們稍後可用的查詢、儲存和查詢選項。

使用 DBRefs 連結

在這種情況下,Publisher 保留對相關 Books 的參考。這個想法是將這些參考儲存為 Publisher 文件中的陣列。

class Publisher {
    // ...
    @DBRef
    List<Book> books;
}

在上面的程式碼片段中,books 屬性使用 @DBRef 進行註解。這建議 Spring Data 映射層將屬性的元素儲存為 MongoDB 原生 $dbref 元素,如下所示

{
    "_id" : "833f7d",
    "name" : "Del Rey Books",
    "arconym" : "DRB",
    "foundationYear" : 1977,
    "books" : [
        {
            "$ref" : "book",
            "$id" : "617cfb"
        },
        {
            "$ref" : "book",
            "$id" : "23e78f"
        }
    ]
}

使用 @DBRef 註解讓我們可以透過不重複 Book 中的所有 Publisher 資訊來減少儲存大小,這很好。儘管如此,這種方法也有其缺點。Book 不再保留有關出版者的資訊,這可能會影響透過 Publisher 的屬性查找 Books 的查詢。從 Book 到出版者的缺乏反向參考也會影響在給定 Book 查找 Publisher 時的效能,因為我們現在必須針對 Publisher 集合發出一個查詢,該查詢將 Book.id 與出版者的 books 欄位進行比對,而不是直接前往其 *id*。此外,Publisher 中的 books 陣列使用了一個複雜的物件,該物件儲存了比必要更多的資訊,而僅使用 *id* 的手動參考就已足夠,因為所有參考物件都保存在同一個目標集合中。

幸運的是,有一些改進的方法,首先是新增一個指向 Publisher 的反向參考(例如,透過其 id

class Book {
    // …
    private String publisherId;
}

使用手動參考連結

接下來,讓我們將儲存 Book 參考的集合從DBRef 切換為手動參考。顯而易見的一步是移除 @DBRef 註解,並將 List<Book> 替換為 List<String>,如下面的程式碼片段所示

class Publisher {
    // …
    List<String> bookIds;
}

{
    …
    "bookIds" : ["617cfb", "23e78f", … ]
}

若要將新的 Book 新增到 PublisherbookIds 欄位,我們可以使用以下語句。

template.update(Publisher.class)
    .matching(where("id").is(publisher.id))
    .apply(new Update().push("bookIds", book.id))
    .first();

遵循這種方法可以最佳化儲存格式,並對領域模型和資料庫中使用的資料類型做出非常明確的聲明。然而,僅僅是 bookIds 並不能提供您在其中查找 bookIds 欄位中包含的值的集合的上下文。

使用宣告式手動參考連結

Spring Data MongoDB 3.3.0 開始,可以使用 @DocumentReference 註解以宣告方式表示手動參考

class Publisher {
    // …
    @DocumentReference
    List<Book> books;
}

預設情況下,這會告知映射層提取被參考實體的 id 值進行儲存,並在讀取時載入被參考的文件本身。

{
    …
    "books" : ["617cfb", … ]
}

由於映射層知道文件之間的連結,因此更新語句(例如先前顯示的語句)會偵測到關聯並提取 *id* 進行儲存

template.update(Publisher.class)
    .matching(where("id").is(publisher.id))
    .apply(new Update().push("books", book))
    .first();

此外,從 BookPublisher 的反向參考也可以用這種方式建模。在這種情況下,延遲檢索出版者直到第一次存取屬性,以避免過早載入延遲,可能是有意義的

class Book {
    // …
    @DocumentReference(lazy=true)
    private Publisher publisher;
}

透過使用宣告式連結,我們現在可以保留映射功能,同時最佳化儲存。儘管如此,在新增新的 Book 實例時,我們需要小心,因為這些實例也需要新增到 Publisherbooks 欄位,以建立連結

template.save(newBook);

template.update(Publisher.class)
    .matching(where("id").is(newBook.publisher.id))
    .apply(new Update().push("books", newBook))
    .first();

上面的程式碼片段很好地概述了使用文件之間的連結的非原子性,這可能需要在 交易 中執行操作。

一對多樣式參考

根據應用程式的需求,反轉 BookPublisher 之間的關係可能可行,以便連結元素僅儲存在 Book 文件中。這樣,您可以儲存 Books,而無需考慮更新 Publisher 文件,正如我們在最後一個程式碼片段中看到的。為此,我們需要做兩件事。首先,我們需要告知映射層省略儲存從 PublisherBook 的連結,其次,在檢索連結的 Books 時更新查詢查找

初始部分相當容易,將額外的 @ReadOnlyPorperty 註解應用於 books 屬性。另一部分要求我們使用自訂查詢更新 @DocumentReference 註解的 lookup 屬性

class Publisher {
    // …
    @ReadOnlyProperty
    @DocumentReference(lookup="{'publisher':?#{#self._id} }")
    List<Book> books;
}

在上面的程式碼片段中,我們利用了 Spring Data 查詢剖析器中的表達式支援。透過這樣做,我們可以透過使用 #self 屬性存取原始 Publisher 文件,並且可以提取其識別碼,以便在查詢 Book 集合以尋找比對元素時使用它。

最終結論

擁有一個包含嵌入式資料的單一聚合根有很多優點。然而,重要的是要了解,一旦這些優點被其他考量因素(例如儲存空間大小或可操作性)取代時,該如何對關係進行建模。我們已經看到,透過從嵌入式方法轉移到 DBRefs,再到手動參考,我們可以減少儲存空間大小。但是,我們必須處理其他問題,例如影響多個文件的變更以及有限的查詢選項。@DocumentReference 是一個強大的工具,可讓您表達和自訂文件之間的連結。您可以在我們的參考文檔中了解更多資訊。

儘管如此,在您離開之前,請始終牢記文件之間的連結需要額外的伺服器往返。因此,請確保有可用的索引來支援您的查找。 一組連結的文件會進行批量載入,並在您的應用程式記憶體中盡最大努力恢復排序。

此外,請始終問自己,哪種方法最適合您的應用程式?預設的嵌入方法是更好的解決方案嗎?您真的需要循環反向參考嗎?連結應該是延遲載入嗎?非原子更新將如何影響您的應用程式?最後,您需要執行哪些查詢?

取得 Spring 電子報

隨時掌握 Spring 電子報

訂閱

領先一步

VMware 提供培訓和認證,以加速您的進展。

了解更多

取得支援

Tanzu Spring 在一個簡單的訂閱中提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位檔案。

了解更多

即將舉行的活動

查看 Spring 社群中所有即將舉行的活動。

查看全部