MongoDB Schema Design Anti Pattern

Unbounded Arrays

當 array 的 element 數量無止盡的增加,除了會造成 query 效能變差以外,還可能超過 document size 16 MB 的限制

如何避免?

  1. Only store data together if it is queried together.
  2. 元素數量龐大的 array 不應該被 embed

範例

book document 中有大量的 reviews,這是一個 unbounded array 我們不想看到,常用 subset patterns 或 extended reference pattern 處理,實際要 by case 判斷用什麼方式解決

// books collections
[
{
book_id:
title:
author:
publisher:
product_type:
reviews: [
{
user_id: 1,
text: '...'
},
{
user_id: 2,
text: '...'
},
...
]
},
{
...
}
]

Solution

extended reference pattern (這個 case 不是最佳解)

會將 book document 的 reviews field 移除並新增一個 reviews collection,並且在每個 review document 內依照實際使用場景加上 query 需要的資訊

// books collection
[
{
book_id:
title:
author:
publisher:
product_type:
},
{
...
}
]

// reviews collection
[
{
book_id:
title:
author:
publisher:
product_type:
user_id: 1,
text: '...'
},
{
book_id:
title:
author:
publisher:
product_type:
user_id: 2,
text: '...'
},
...
]

因為 review document 含有太多與 book document 重複的 field,所以並不是一個很好的方式

subset pattern

將最常用的三個 review 留在 book document 裏,並新增 reviews collection 將所有 review document 放進去,這樣做重複的資料最多也就是每本書裡面最常用的三個 review

// books collections
[
{
book_id:
title:
author:
publisher:
product_type:
reviews: [
{
user_id: 1,
text: '...'
},
{
user_id: 2,
text: '...'
},
{
user_id: 3,
text: '...'
}
]
},
{
...
}
]

// reviews collection
[
{
book_id:
user_id: 1,
text: '...'
},
{
book_id:
user_id: 2,
text: '...'
},
{
book_id:
user_id: 3,
text: '...'
},
{
book_id:
user_id: 4,
text: '...'
},
]

因為在原本的 book document 保留了最常用的三筆 review,在取得 review 時也大幅減少 $lookup 或是 multiple query 的場景


Bloated Documents

為了遵守黃金守則 data is accessed together should be stored together,在初期設計會將整個 document 塞滿必須的資訊,但當產品開始成長且使用場景增加了,容易造成同一個 document 儲存了可以分開讀取的資料,而在某些場景拖垮了效能

以 Book detail collection 舉例來說,在初期只需在 detail 頁面顯示每本書的資訊,但隨著產品發展多了一個需求是在首頁顯示所有書的 summary,也就是只需要顯示 document 中部分 field 的資訊,但現行的架構為了拿首頁需要的資訊需要連同其他資訊一起拿,導致了效能瓶頸

{
// summary 需要的資訊只有 title, author,卻連同其他 field 一起被讀取了
title:
author:
genre:
year:
pages:
price:
description:
...
}

一個簡單的評估方式是,MongoDB 的預設 storage engine Wired Tiger 會將常使用的資料儲存在 memory 中,也就是 Wired Tiger Internal Cache,儲存的資訊包含 documents 和 index 等等,而儲存的上限粗估為 500 MB 上下

另外配合兩個指令來評估目前使用的 memory size

// documents 數量
db.collection.stats().count

// 每個 document 平均大小
db.collection.stats().avgObjSize

兩者相乘,要是超過現有的 memory size,可以考慮兩種解法

  1. 增加 cluster 記憶體
  2. 修改 schema

為了幫公司省錢成為優質的工程師,從第二點下手,將原本的 detail collection 另外切出一個 summary collection 僅儲存 summary 必要的資訊

// summary collection
{
title:
author:
}

而當首頁讀取 summary 資訊時就只需要從 summary collection 來讀取即可解決效能的問題

Massive number of collections

MongoDB 雖然沒有限制 collection 的數量,但超過一定數量依舊會影響效能,這取決於硬體的限制

MongoDB 的預設儲存引擎 WiredTiger 會將每個 collection 及對應的 index 分開儲存,M10 cluster 的用戶大概儲存 5000 個 collection 就會開始感受到效能阻礙,M20/M30 大概是 10000 個,Sharded Cluster 也大概是 10000 個/shard

解決方式

  1. 定期刪減 collections
  2. 重新設計 schema 設計的方式
  3. 做 sharding

定期檢查不常使用的 collection,將它們刪除或是做 archive,而當做完這些事情後 collection 數量依舊太多就要考慮重新思考 schema 設計的方式

以 book store 舉例來說:想追蹤每本書的觀看次數,將每本書的 view 儲存在各自的 collection 就容易造成 Massive number of collections

// book1_views collection
[
{
user_id:
book_id:1
timestamp:
},
{
user_id:
book_id:1
timestamp:
},
...
]

// book2_views collection
[
{
user_id:
book_id: 2
timestamp:
},
{
user_id:
book_id: 2
timestamp:
},
...
]

// ...

這個 case 來說,會想要修改設計的方式將所有 view 存在同一個 collection 中已降低 collection 數量

// book_views
[
{
user_id:
book_id:1
timestamp:
},
{
user_id:
book_id:1
timestamp:
},
{
user_id:
book_id: 2
timestamp:
},
{
user_id:
book_id: 2
timestamp:
},
...
]

Unnecessary Indexes

雖然 MongoDB 給每個 collection 最多的 index 數量可以到 64 個,但仍然建議使用越少 index 越好,過多的 index 容易造成效能瓶頸,可以從幾個面向觀察並減少 index 數量

  1. 未使用的 index
  2. 很少用的 index
  3. 被其他 index 涵蓋的 index

不恰當使用 index 有幾個可能會拖垮效能的地方

  1. index 被使用時會佔據記憶體用量
  2. 佔據的空間隨著 document 數量增加而變大
  3. create, update, delete document 時都會需要更新 index

可以利用 pipeline 檢查目前 collection 中 index 的使用狀態

const sortIndexesByAccessOpsPipeline = [
{ $indexStats: {} },
{ $project:
{
_id: 0,
name: "$name",
ops: "$accesses.ops", // 使用次數
since: "$accesses.since"
}
},
{ $sort: { ops: 1 } } // 最少被使用的次數會排最前面
]

或在 MongoDB Atlas 中的 Data Explorer 或 Performance Advisor 中觀察 index 的使用狀況。

而當初 index 在建立時都會有原因,建議在刪除 index 之前先用 hideIndex() 的方式觀察一陣子後再刪除。

Data Normalization

在規劃 schema 時為了效能或減少資料重複為考量,有時會很直覺的將資料分組並存放在不同處,但有時這樣做會造成經常一起被 query 的資料分散在各處,導致需要多個 query 或必須用 $lookup 在不同資料分組間查詢,反而造成效能瓶頸。

Data Normalization 指的就是上述的情況。

例如一開始在分組時直覺地將 books collectionreviews collection 分開存放,但後來發現這些資料通常會一起被 query。與此同時也不可能將 reviews 直接 embed 進 books,因為容易造成 unbounded array。

經常被考量的解決方案有 subset pattern 或 extended reference pattern

subset pattern

當每本書一開始僅需要顯示前三筆 review 資訊,就可以將前三筆資訊直接放在 book documents 中

// books collection
[
{
title:
author:
publisher:
product_type:
reviews: [
{
user_id:1,
review_text: '...'
},
{
user_id: 2,
review_text: '...'
},
{
user_id: 3,
review_text: '...'
}
]
}
]

// reviews collection
[
{
user_id:1,
review_text: '...'
},
{
user_id: 2,
review_text: '...'
},
{
user_id: 3,
review_text: '...'
},
{
user_id: 4,
review_text: '...'
}
]

結果是

  1. 依舊能達到將資料分成不同的 collection 來呈現
  2. 造成了部分 review 資料重複在不同的 collection,但不再需要多個 query 或 $lookup 來查找資料

但也須注意 review 變更時,需要同時更新 books collection 與 reviews collection 裡 review 資訊的策略

extended reference pattern

如果需求改為需要顯示同一本書的所有 review,那麽 extended reference pattern 將會是比較好的選擇

在每個 review document 內存放部分的 book 資訊

// reviews collection
[
{
user_id:1,
review_text: '...',
book: {
title:
author:
}
},
{
user_id: 2,
review_text: '...',
book: {
title:
author:
}
},
{
user_id: 3,
review_text: '...',
book: {
title:
author:
}
},
{
user_id: 4,
review_text: '...',
book: {
title:
author:
}
}
]

結果也是

  1. 依舊能達到將資料分成不同的 collection 來呈現
  2. 造成了部分 review 資料重複在不同的 collection,但不再需要多個 query 或 $lookup 來查找資料

而且因為 book title 與 author 的資料幾乎不可能變更,這個場景下反而不用花太多心力考慮同個資料變更時要去更新所有 reference 的策略

Case-Sensitivity

MongoDB 的 query 預設是 case sensitive 的,Case-Sensitivity 指的是套用預設設定卻預期 case insensitive,除了會得到非預期的結果也可能影響效能。

可以利用 Collation 達成 case insensitive。

Collation 定義了在特定語言中如何比較與排序的規則,需要指定語言 ex: ‘en’ 以及 strength,strength 預設是 3,這個值只會做 case sensitive 的查詢,想要達成 case insensitive 必須設定為 1 或 2

來看一下用法

db.Books.find({ author: 'Paul Done' }).collation({locale: 'en', strength: 2})

在 query 之中指定了 collation,這次的查詢會是不區分大小寫的

有兩個常見的方法來解決 anti pattern

方法一

指定帶有 collation 設定的 index 並且在 query 時使用相同設定的 collation,例如

// 建立 index
db.Books.createIndex(
{ author: 1 },
{
collation: {
locale: 'en',
strength: 2
}
}
)

// 執行相同 collation 設定的 query
db.Books.find({ author: 'Paul Done' }).collation({locale: 'en', strength: 2})

不確定目前 index 是否有相同 collation 可以用 db.collection.getIndexes() 方法確認,如果目前的 index 沒有設定 collation 必須 dropIndex() 之後重新建立對應 collation 的 index

方法二

在建立 collection 時指定預設的 collation,此時

  1. 預設會套用在所有的 index 中而不須在建立 index 時額外只定
  2. 所有對於這個 collection 的 query 預設也會使用相同設定的 collation,可以在 query 時另外指定 collation 去覆寫,但不太建議

但要注意的是一旦 collection 設定好預設值後就無法改變

有時候也會使用 $regex 搭配 /i option 來做 case insensitive 的查詢,但這麼做效能不好所以不推薦

如果有更進階的 query 需求可以查詢 MongoDB 的 Atlas Search