[MongoDB] 學習筆記(三) - NoSQL表格設計relationship,Index的建立、類別

Ben Hsu
28 min readJan 10, 2021

--

這篇文章說明三個項目,第一個是relationship,NoSQL是非關聯式資料庫,但實際上會建立relationship,這裡會簡要說明不同的relationship會如何提升效率及優缺點。第二個是說明explain()這個函數,目的是要在介紹Index時,查看Index的執行細節。第三個則是Collection中Index的建立,Index的目的是增加query執行的效率,當你沒有在collection建立index時,query執行時會scan整個Collection,建立Index後,查詢時會先Scan index的表格,再進一步搜尋Collection中對應的項目,所以有助於效能提升。

本章使用的資料集movies_full在這裡

Mongo版本:v4.4.1

mongoimport C:/Users/user/Downloads/movies_full.json -d demo -c movies — jsonArray — drop

目錄

  • Relationships - One-to-One Relationships
  • Relationships - One-To-Many Relationships
  • Relationships - Many-To-Many Relationships
  • Query執行資訊 - explain()
  • Index - Index的查看getIndexKeys、創建createIndex、刪除dropindex
  • Index - Index的類型 Single Field Indexes
  • Index - Index的類型 Compound Field Indexes
  • Index - Index的類型 Text Indexes
  • Index - Index的類型 Wildcard Indexes
  • Index - Index的特性 TTL Indexes
  • Index - Index的效能問題
  • 結論

Relationships

Relationships - One-to-One Relationships

下圖左有兩個json,分別是個人訊息與地址,這兩個json是有關連的,第一個json中address的代號,可以連結到第二個json的_id ,而當我們要將這個顧客建檔時,有兩種方式。第一種,是將兩個json分別建立兩個collection, peopleaddress;第二種只建立一個collection people_include_address,將document設計成一個巢型的狀態,如下圖右,而這種將所有資訊放在同一個collection的方式,就是Ono-to-One的relationships。

這兩種設計都是可以的,但由於每個人的address是唯一的,不太會跟其他人重複,所以可能更傾向於設計成圖1右的形式,更有助於我們搜尋資訊。如果我們的address是不唯一的,例如說是福建圍樓,一棟建築幾百個人,這時候如果設計成圖1右的形式,資料庫會有非常多重複資訊,以Ono-to-One的形式就會非常不利。

另外一種會分成兩個collection的可能情況是,第二層collection的field中的項目太多了,因為單一一個collection的大小是有限制的,太深(非常多巢型)、太廣都是不行的,限制的條件如下:

  • The maximum BSON document size is 16 megabytes.
  • MongoDB supports no more than 100 levels of nesting for BSON documents.

Relationships - One-To-Many Relationships

如上一節所言,圖右是One-To-One的設計,而圖左就是One-To-Many的relationships。用One-To-One的好處是,當查詢一個User的住址時,你可以直接以一個find就找到住址,如:

db.people_include_address.findOne({name:"Benjimin"}).address

但是如果寫成One-To-Many時,你就得這樣做:

var use_index = db.people.findOne({name:"Benjimin"}).address
db.address.findOne({_id:use_index})

所以相對而言,One-To-One可能是較容易查詢的。但是如果你今天要針對address內的資訊進行統計,像是我想要統計有多少人在台北有房子

One-To-One時,你會這樣做:

db.people_include_address.find({"address.city":"Taipei"}).count()

One-To-Many時:

db.address.find({city: "Taipei"}).count()

這時One-To-Many就會簡單一些,如果今天要查詢的是,有多少房子在台北。這時跟個人資訊完全沒關係,在One-To-Many的查詢方式不會改變,但如果是One-To-One的設計,就會變得異常困難。

Relationships - Many-To-Many Relationships

舉例而言,一個商場情境如下圖,這個商場的表格分為三個階層,會員資訊、消費紀錄與產品清單。其中每個會員與其消費紀錄是一對一的,不會與其他會員混淆;但在消費紀錄中,是每次消費的產品,也會出現在其他人的消費中,此時如果將產品清單與消費紀錄設計成One-To-Many的形式,在消費紀錄就會出現非常多的重複資訊,此時就會將產品清單作為獨立的Collection,並且與不同消費紀錄間產生關係,所以他將會是一個Many-To-Many的關係。

這樣的設計除了有助於我們簡約DB的儲存空間,以及查詢的效率之外,也對於更新表格有影響。舉個例子,我們還有第四個階層,是每批產品的資訊,像是牛奶的製造日期、有效日期等等,如果有一種情況,是製造日期錯了,出現了未來牛奶。此時如果是以One-To-One的設計,我就要針對每一個會員,他的某一次消費紀錄,的那個批次的牛奶進行資訊修改,這很困難;但如果是以One-To-Many或是Many-To-Many的設計方式,就只要針對一個collection的一個document進行修改,可以提高效率。

Query執行資訊 - explain()

介紹這個函數主要是因為後面查看Index的執行的細節,可以更詳細的了解Index創建前後的影響。

如果要回傳query執行的資訊,例如執行時間、scan的方式、scan了多少collection等等,可以使用 explain() 的指令。例如如果想知道find的執行資訊,就是db.<collection>.explain().find({});如果是想知道update的執行資訊,就是db.<collection>.explain().update({})。詳細可用explain查詢的method列表可見。explain的輸出可以有幾個不同的模式如下:

queryPlanner Mode

為預設模式,當explain沒有輸入任何參數時,就是queryPlanner Mode。內容包含詳細的 query optimizer,和被拒絕的plan。因為我們可以對field設定不同的Index,以加速query的執行,這裡的query optimizer就是指Mongo在執行query時,最終選擇了哪個計畫,並且會說明那些計畫被拒絕了,執行結果如下圖。

可以看到explain吐出來的結果在第一個filed "queryPlanner"中,包含了winningPlan與rejectedPleans,此時因為我們沒有對任何的field設定加速的Index,所以計畫只會有一個,使得rejectedPleans是空的。

db.movies.explain('queryPlanner').find({imdb_score: {$gt: 8}})

executionStats Mode

這個模式是針對plan列出詳細的結果,可以看到下圖執行結果多出現了executionStats,其中executionTimeMillis是指執行時間,這裡因為這個Collection的數量很少,所以執行時間是0毫秒;而totalDocsExamined則表示了scan過的document數量,因為這裡沒有設定Index,所以是掃過整個Collection中的100個document。而之後會使用這個模式來判斷是否合適。

Index - Index的查看getIndexKeys、創建createIndex、刪除dropindex

在沒有建立Index的情況下,Mongo會使用Default的Index_id,而這個 _id 是一個不重複的Index,此時的query就會掃過整個collection,被稱為Collection scan

getIndexKeys()

若取得Collection中的Index,可以使用 db.<collection_name>.getIndexKeys(),執行結果如下,可以看到一開始使用db.movies_full.getIndexKeys() 時,結果僅出現預設的[{"_id": 1}] ,而在創建Index後,就是可以搜尋到創建的結果[{"_id": 1}, {'year': 1}]

createIndex()

想要創建Index時,可以使用db.<collection_name>.createIndex({<keys>: <value>}),當對指定field建立Index後,之後涉及該field的query,都會使用Index scan。以下比較創建Index前後對query執行的影響:

下圖,是創建Index前的query執行結果,可以看到使用Collection Scan,掃秒了所有的documents,花費15毫秒。

db.movies_full.getIndexKeys()
db.movies_full.explain("executionStats").find({year: 2000})

而在創建Index後,可以發現"winningPlan"的地方改成了Index Scan。並且在相同的query條件下,花費時間變為0毫秒。

db.movies_full.getIndexKeys()
db.movies_full.createIndex({year: 1})
db.movies_full.explain("executionStats").find({year: 2000})

另外,在創建Index時,如果document的數量較多,創建時間可能會很長,此時可以使用背景執行,db.movies_full.createIndex({year: 1}, {background: true}),此時建立的作業會在後台執行,此時就可以執行其他query作業。

dropIndex()

當需要去除Index時,就可以使用db.collection_name>.dropIndex({<keys>: <value>}) ,簡單示例如下。要注意的是,因為Index建立時,有分value值為1的ascending index,與value值為-1的descending index,所以需要先以getIndexKeys()查看設定的Index。

/// 測試刪除 {year: 1}
db.movies_full.getIndexKeys()
db.movies_full.createIndex({year: 1})
db.movies_full.getIndexKeys()
db.movies_full.dropIndex({'year':1})
db.movies_full.getIndexKeys()
/// 測試刪除 {year: -1}
db.movies_full.createIndex({year: -1})
db.movies_full.getIndexKeys()
db.movies_full.dropIndex({'year':1})
db.movies_full.dropIndex({'year':-1})db.movies_full.getIndexKeys()

Index - Index的類型 Single Field Indexes

在使用createIndex()時,可以創建多種不同類型的Index,最基本的是Single Field Indexes,就是單一field的index;而對應的,就是Compound Indexes,同時針對多個field設定Index。以下針對不同的Index進行說明:

Single Field Indexes

前一節設立的都是Single Field Index,而除了針對field外,如果今天field中的是一個object,這個object又包含了兩個name,如果我們query的對象,其實是object中的子項,對object建立index就無效。

舉例而言,下面得document,有一個名為 location 的 field,內容有兩個子項分別為state與city,如果此時我想搜尋的是location中的state,就必須針對location.state建立Index,而針對location是沒有用的。

{
title: "I Have a Dream",
year: 1963,
"location": {
state: "State of Washington",
city: "Olympia"
}
}

執行結果如下圖,圖左是針對location進行query,與我們建立的Index符合,所以為Index scan;但右圖query的對象為location.state,Mongo就使用Collection scan。

db.test.drop()
db.test.insert({title: "I Have a Dream", year: 1963, "location": { state: "State of Washington", city: "Olympia" }})
db.test.createIndex({"location": 1})
db.test.explain().find({"location": 1})
db.test.explain().find({"location.state": 1})

下面先以location.state建立Index,可以看到此時篩選location.state使用的策略為Index scan。

db.test.createIndex({"location.state": 1})
db.test.explain().find({"location.state": 1})

Index - Index的類型 Compound Field Indexes

當我們同時篩選兩個field時,Signal Field的Index是會發生作用的,但是如果我們想要更快速的篩選,可以同時對兩個field建立Index。但要注意的是,field擺放的順序對Index是有影響的。寫法如下:

db.<collection_name>.createIndex( { <field1>: <type>, <field2>: <type2>, ... } )

這裡以aggregate執行兩個query做範例,query_stage的篩選同時包含兩個field,而query_stage2、query_stage3則只有一個field。不過因為範例Collection太小,執行時間沒有明顯差異,不過如果是query_stage的狀況,Compound Field Indexe會比會Single Field Indexes有優勢。

query_stage = [
{$match: {year: {$gte: 2012}}},
{$match: {cast: {$eq: 1}}}
]
query_stage2 = [
{$match: {year: {$gte: 2012}}}
]
query_stage3 = [
{$match: {cast: {$eq: 1}}}
]

篩選2個field時,Single或Compound Index的作用

下圖左是建立{year: 1}為Index的情況下,執行query_stage,結果仍是使用Index Scan(year_1)。下圖右則是在建立{year: 1}為Index後,再建立{year: 1, cast: 1} 的Compound Index (year_1_cast_1),此時再執行query_stage,會發現Mongo使用了Compound Index作為執行策略。

篩選1個field時,Compound Index的作用

下圖左是執行query_stage2的結果,如果我的Index只有year_1_cast_1的Compound Index,僅篩選year仍然是Index Scan;而query_stage3是針對cast進行篩選,而Collection中,雖然Index year_1_cast_1的第二個階層是有cast的,但因為這個cast是建立在year之下,所以是使用Collection scan。

Index - Index的類型 Text Indexes

Text Index是針對field屬性為文字時使用的,大致有幾個特性

  • 建立方式為 db.<collection_name>.createIndex( { field: “text” } )
  • 同一個Collection建立兩個以上的Text Index須使用{ field_1: “text”, field_2: “text”, … }
  • 觸發Text Index須使用operation $text,否則為Collection Scan

先插入一個document,裡面有兩個屬性為Text的field,subject與author

db.articles.drop({})
db.articles.insert(
[
{ subject: "coffee", author: "xyz", views: 50 },
{ subject: "Coffee Shopping", author: "efg", views: 5 },
{ subject: "Baking a cake", author: "abc", views: 90 },
{ subject: "baking", author: "xyz", views: 100 },
{ subject: "Café Con Leche", author: "abc", views: 200 },
{ subject: "Сырники", author: "jkl", views: 80 },
{ subject: "coffee and cream", author: "efg", views: 10 },
{ subject: "Cafe con Leche", author: "xyz", views: 10 }
]
)

Text Indexes 的建立

這時,我先針對author建立Index,可以看到Index name 為 { “_fts” : “text”, “_ftsx” : 1 };這時當我刪除Index,再以subject建立Index時,Index name也是{ “_fts” : “text”, “_ftsx” : 1 };而當我同時針對兩個field建立text Index時,Index name也是相同的。

雖然這三者的Index Name是相同的,但可以篩選的對象並不同,Single field text Index僅能對自己建立的field進行篩選,而Compound field text Index 則對兩者都有作用。並且,因為Index Name都相同,當想要建立Text Index時,需要將其她Index Name去除,否則將沒有作用。

db.articles.createIndex( { author: "text" } )
db.articles.getIndexKeys()
db.articles.dropIndex({ "_fts" : "text", "_ftsx" : 1 })
db.articles.createIndex( { subject: "text" } )
db.articles.getIndexKeys()
db.articles.dropIndex({ "_fts" : "text", "_ftsx" : 1 })
db.articles.createIndex( { author: "text", subject: "text"} )
db.articles.getIndexKeys()
db.articles.dropIndex({ "_fts" : "text", "_ftsx" : 1 })

Text Indexes 的篩選

下面我想針對Collection中的文字進行篩選,其中coffee是field subject中的字串,而efg是field author中的字串。下面先針對author建立Text Index,可以看到,以operation $text 進行篩選,可以篩選出efg,但沒有辦法篩選出coffee。

此時我直接建立subject的Text Index,可以看到建立失敗。所以我先篩除前面建立的Text Index{“_fts”: text, “_frsx”: 1},此時再建立subject的Text Index就成功了。

db.articles.getIndexKeys()
db.articles.createIndex( { author: "text" } )
db.articles.getIndexKeys()
db.articles.findOne( { $text: { $search: "coffee" } })
db.articles.findOne( { $text: { $search: "efg" } } )
db.articles.createIndex( { subject: "text" } )
db.articles.dropIndex({ "_fts" : "text", "_ftsx" : 1 })
db.articles.createIndex( { subject: "text" } )
db.articles.findOne( { $text: { $search: "coffee" } })

下面建立Compound Text Index,一樣先砍了先前的Index,此時再建立Text Index,就能同時對兩個field均進行篩選。

db.articles.dropIndex({ "_fts" : "text", "_ftsx" : 1 })
db.articles.createIndex( { author: "text", subject: "text"} )
db.articles.getIndexKeys()
db.articles.find( { $text: { $search: "coffee" } }, {_id: 0} )
db.articles.find( { $text: { $search: "efg" } }, {_id: 0} )

字串篩選的Collection Scan 與 Index Scan

下左圖,先建立了Text Index,並且針對subject的文字"coffee"進行篩選,但沒有以$text 進行篩選,此時可以看到query的執行是以Collection Scan;右圖使用$text 進行篩選,可以看到query的執行轉為Index Scan。所以如果要發揮Text Index的效果,需要搭配$text

而實際上$text 是只有在Collection有Text Index時才可以使用的,如果Collection沒有Text Index時,執行$text,會回報錯誤。

db.articles.dropIndex({ "_fts" : "text", "_ftsx" : 1 })
db.articles.createIndex( { author: "text", subject: "text"} )
db.articles.find({ subject: "coffee"} ).explain()
db.articles.find( { $text: { $search: "coffee" } } ).explain()

Index - Index的類型 Wildcard Indexes

Wildcard Indexes是一個特別的Index,他可以整對一群field建立index,並且,如果你的field中是Embedded的,他對底下的所有field也會建立Index。

舉個例子,下面插入一筆資料,包含兩個document,兩個document都有product_attributes,這個field有幾個特性

  1. 他是Embedded的,object中有其他的field
  2. 兩個document中product_attributes包含的field的屬性是不一樣的
db.test2.insert([{
"product_name" : "Spy Coat",
"product_attributes" : {
"material" : [ "Tweed", "Wool", "Leather" ],
"size" : {
"length" : 72,
"units" : "inches"
}
}
},
{
"product_name" : "Spy Pen",
"product_attributes" : {
"colors" : [ "Blue", "Black" ],
"secret_feature" : {1
"name" : "laser",
"power" : "1000",
"units" : "watts",
}
}
}])

因為他的field中的屬性多且雜,要對逐一建立Index是有些困難的,但如果使用Wildcard Indexes,將可以將product_attributes以及底下所有的field均建立Index。

執行結果見下圖,可以看到,在以product_attributes建立Index後,底下的Embedded field也都建立了Index。

db.test2.createIndex({"$**": 1})
db.test2.getIndexKeys()
db.test2.dropIndex({"$**": 1})
db.test2.createIndex({"product_attributes.$**": 1})
db.test2.getIndexKeys()
function myFunction(object) {
var result1 = object[0]['queryPlanner']['winningPlan']['inputStage']['stage']
var result2 = object[0]['queryPlanner']['winningPlan']['inputStage']['indexName']
return [result1, result2]
}
myFunction([db.test2.find({'product_attributes': 1}).explain()])
myFunction([db.test2.find({'product_attributes.material': 1}).explain()])
myFunction([db.test2.find({'product_attributes.colors': 1}).explain()])
myFunction([db.test2.find({'product_attributes.size.length': 1}).explain()])

雖然Wildcard Index可以一次建立所有Index,但當底下field屬性為text時,並不會建立Text Index。所以使用$text 是沒有效果的,此時建立Wildcard Text Index。

執行結果如下,可以看到建立Wildcard Text Index後,可以針對field,以及Embedding field進行篩選。

Index - Index的特性 TTL Indexes

在我們建立Index時,是可以針對Index賦予一些特殊的屬性,不同類型的Index可以賦予不同的屬性,這裡說明TTL的Index。

TTL Index是一個具有時效性的Index,她可以讓插入的document在一段時間後消失,所以當資料有時效性時,就可以透過TTL Index讓他自動蒸發。這個設定將為mongo的background task,每60秒會會確認一次。

而創建的方法如下,其中time_field是Collection中的一個field,但他需要紀錄該document創建的時間,而expireAfterSeconds 則是這個document可以存活多久。

db.<Collection_name>.createIndex( { <“time_field”>: 1 }, { expireAfterSeconds: value})

簡單做個測試,下面我插入一個document,並建立TTL Index,設定1秒後消失,但由於Mongo每60秒才會掃過一次TTL task,所以可以看到時間在28秒、54秒時,document均存在,但到了02秒時,document就消失。

Index - Index的效能問題

文章開頭時有提到,建立Index的目的是提升query執行的效率,其有效的原因在於,沒有建立Index時,主要使用的是Collection Scan,在每次的搜尋中,會掃過Collection中所有的document;但當建立Index後,mongo會針對該field建立一個排序後的Index table,搜尋會先掃過Index table,找到對應目標的document,此時才會去找document中對應的資訊。

但建立Index也是有可能降低速度的,建立Index後反而變慢的情形:

Index目標對象的document,約等於Collection中document

這個情形的主要原因是,Index Scan需要搜尋兩個table,但如果在搜尋完Index table後,再回去搜尋目標的document時,數量沒有下降很多的話,那其實是白做工。因為此時,我直接用Collection Scan搜尋,還只要搜尋一個表格就好。

舉例如下,我對movies_full進行年份篩選,這個Collection中,年份的最小值為1900,此時我篩選比他大的document,就必須得掃過所有document。下左圖為沒有建立Index時,此時搜尋花費15毫秒;右圖建立了year的Index,winningPlan為Index Scan,但是搜尋時間花費37毫秒。建立Index反而花費較長時間。

1

插入、更新document時速度降低

如果建立Index在多數的狀況之下,都能夠加速查詢的效率,那對所有的field都建立Index不就完美了? 在查詢上可能是這樣,但因為每建立一個Index就需要對應建立Index Table,使得我們在插入一個新的document時,或者更新一個field中的數值時,Mongo需要重新整理Index table,此時如果一個field連結的Index越多,那插入、更新的速度就會越慢。所以基本上,在建立Index時,還是需要挑選常用的篩選對象建立field,才能確實提高效率。

結論

這篇文章大體的說明了資料庫的連結,以及Index的建立、查詢、刪除、類型、屬性,以及執行時的策略優先選擇,執行時間差異等。希望有時間時能再整理得有系統些。

--

--