B.1 模式
雖然不明顯,但本書前面幾章裡有倡導大家使用一些設計模式。本附錄中,我將總結那些模式,再補充一些沒有提到的模式。
B.1.1 內嵌與引用
假設你在構建一個簡單的應用程序,用MongoDB保存博客的文章和評論。該如何表示這些數據?在相應博客文章的文檔裡內嵌評論?還是說創建兩個集合,一個保存文章,另一個保存評論,通過對像ID引用來關聯評論和文章,這樣會更好?
這裡的問題是使用內嵌文檔還是引用,這常常會給MongoDB的新用戶帶來困擾。幸好有些簡單的經驗法則,適用於大多數Schema設計場景:當子對像總是出現在父對象的上下文中時,使用內嵌文檔;否則,將子對像保存在單獨的集合裡。
這對博客的文章和評論而言意味著什麼?結論取決於應用程序。如果評論總是出現在博客的文章裡,並且無需按照各種方式(根據發表日期、評論評價等)進行排序,那麼內嵌的方式會更好。但是,如果說希望能夠顯示最新的評論,不管當前顯示的是哪篇文章,那麼就該使用引用。內嵌的方式可能性能稍好,但引用的方式更加靈活。
B.1.2 一對多
正如上一節所說的,可以通過內嵌或引用來表示一對多關係。當多端對像本質上屬於它的父對象且很少修改時,應該使用內嵌。舉個指南類應用程序(how-to application)的Schema作為例子,它能很好地說明這點。每個指南中的步驟都能表示為子文檔數組,因為這些步驟是指南的固有部分,很少修改:
{ title: \"How to soft-boil an egg\", steps: [ { desc: \"Bring a pot of water to boil.\", materials: [\"water\", \"eggs\"] }, { desc: \"Gently add the eggs a cook for four minutes.\", materials: [\"egg timer\"]}, { desc: \"Cool the eggs under running water.\" }, ] }
如果兩個相關條目要獨立出現在應用程序裡,那你就會想進行關聯了。很多MongoDB的文章都建議在博客的文章裡內嵌評論,認為這是一個好主意,但是關聯會更靈活。如此一來,你可以方便地向用戶顯示他們的所有評論,還可以顯示所有文章裡的最新評論。這些特性對於大多數站點而言是必不可少的,但此時此刻卻無法用內嵌文檔來實現。1通常都會使用對像ID來關聯文檔,以下是一個示例文章對像:
1. 有一個很熱門的虛擬集合(virtual collection)特性請求,對兩者都有很好的支持。請訪問http://jira.mongodb.org/browse/SERVER-142瞭解這一特性的最新進展。
{ _id: ObjectId(\"4d650d4cf32639266022018d\"), title: \"Cultivating herbs\", text: \"Herbs require occasional watering...\" }
下面是評論,通過post_id
字段進行關聯:
{ _id: ObjectId(\"4d650d4cf32639266022ac01\"), post_id: ObjectId(\"4d650d4cf32639266022018d\"), username: \"zjones\", text: \"Indeed, basil is a hearty herb!\" }
文章和評論都放在各自的集合裡,需要用兩個查詢來顯示文章及其評論。因為會基於post_id
字段查詢評論,所以你希望為其添加一個索引:
db.comments.ensureIndex({post_id: 1})
我們在第4章、第5章和第6章中廣泛使用了一對多模式,其中有更多例子可供參考。
B.1.3 多對多
在RDBMS裡會使用聯結表來表示多對多關係;在MongoDB裡,則是使用數組鍵(array key)。本書先前的內容裡就有該技術的示例,其中對產品和分類進行了關聯。每個產品都包含一個分類ID的數組,產品與分類都有自己的集合。假設你有兩個簡單的分類文檔:
{ _id: ObjectId(\"4d6574baa6b804ea563c132a\"), title: \"Epiphytes\" } { _id: ObjectId(\"4d6574baa6b804ea563c459d\"), title: \"Greenhouse flowers\" }
同時屬於這兩個分類的文檔看起來會像下面這樣:
{ _id: ObjectId(\"4d6574baa6b804ea563ca982\"), name: \"Dragon Orchid\", category_ids: [ ObjectId(\"4d6574baa6b804ea563c132a\"), ObjectId(\"4d6574baa6b804ea563c459d\") ] }
為了提高查詢效率,應該為分類ID增加索引:
db.products.ensureIndex({category_ids: 1})
之後,查找Epiphytes分類裡的所有產品,就是簡單地匹配category_id
字段:
db.products.find({category_id: ObjectId(\"4d6574baa6b804ea563c132a\")})
要返回所有與Dragon Orchid產品相關的分類文檔,先獲取該產品的分類ID列表:
product = db.products.findOne({_id: ObjectId(\"4d6574baa6b804ea563c132a\")})
然後使用$in
操作符查詢categories
集合:
db.categories.find({_id: {$in: product[\'category_ids\']}})
你會注意到,查詢分類要求兩次查詢,而查詢產品只需要一次。這是針對常見場景的優化,因為比起其他場景,查詢某個分類裡的產品可能性更大。
B.1.4 樹
和大多數RDBMS一樣,MongoDB沒有內置表示和遍歷樹的機制。因此,如果你需要樹的行為,就只有自己想辦法了。我在第5章和第6章裡給出了一種分類層級問題的解決方案,該策略是在每個分類文檔裡保存一份分類祖先的快照。這種去正規化讓更新操作變複雜了,但是極大地簡化了讀操作。
可惜,去正規化祖先的方式並非適用於所有問題。另一個場景是在線論壇,成百上千的帖子通常層層嵌套,層次很深。對於祖先方式而言,這裡的嵌套實在太多了,數據也太多了。有一個不錯的解決方法——具化路徑(materialized path)。
根據具化路徑模式,樹中的每個節點都要包含一個path
字段,該字段具體保存了每個節點祖先的ID,根級節點有一個空path
,因為它們沒有祖先。讓我們通過一個例子進一步瞭解該模式。首先,看看圖B-1中的論壇帖子,其中是關於希臘歷史的問題與回答。
圖B-1 論壇裡的帖子
讓我們看看這些帖子是如何通過具化路徑組織起來的。首先看到的是根級文檔,所以path
是null
:
{ _id: ObjectId(\"4d692b5d59e212384d95001\"), depth: 0, path: null, created: ISODate(\"2011-02-26T17:18:01.251Z\"), username: \"plotinus\", body: \"Who was Alexander the Great\'s teacher?\", thread_id: ObjectId(\"4d692b5d59e212384d95223a\") }
其他的根級文檔,即用戶seuclid提的問題,也有相同的結構。更能說明問題的是後續與亞歷山大大帝(Alexander the Great)的老師相關的討論。查看其中的第一個文檔,我們注意到path中包含上級父文檔的_id
:
{ _id: ObjectId(\"4d692b5d59e212384d951002\"), depth: 1, path: \"4d692b5d59e212384d95001\", created: ISODate(\"2011-02-26T17:21:01.251Z\"), username: \"asophist\", body: \"It was definitely Socrates.\", thread_id: ObjectId(\"4d692b5d59e212384d95223a\") }
下一個更深的文檔裡,path
包含了根級文檔和上級父文檔的ID,依次用分號分隔:
{ _id: ObjectId(\"4d692b5d59e212384d95003\"), depth: 2, path: \"4d692b5d59e212384d95001:4d692b5d59e212384d951002\", created: ISODate(\"2011-02-26T17:21:01.251Z\"), username: \"daletheia\", body: \"Oh you sophist...It was actually Aristotle!\", thread_id: ObjectId(\"4d692b5d59e212384d95223a\") }
最起碼,你希望thread_id
和path
字段能加上索引,因為總是會基於其中某一個字段進行查詢:
db.comments.ensureIndex({thread_id: 1}) db.comments.ensureIndex({path: 1})
現在的問題是如何查詢並顯示樹。具化路徑模式的好處之一是無論是要展現完整的帖子,還是其中的一棵子樹,都只需查詢一次數據庫。前者的查詢很簡單:
db.comments.find({thread_id: ObjectId(\"4d692b5d59e212384d95223a\")})
針對特定子樹的查詢稍微複雜一點,因為其中用到了前綴查詢:
db.comments.find({path: /^4d692b5d59e212384d95001/})
該查詢會返回擁有指定字符串開頭路徑的所有帖子。該字符串表示了用戶名為kbanker
的討論的_id
,如果查看每個子項的path
字段,很容易發現它們都滿足該查詢。這種查詢執行速度很快,因為這些前綴查詢都能利用path
上的索引。
獲得帖子列表是很容易的事,因為它只需要一次數據庫查詢。但是顯示就有點麻煩了,因為顯示的列表中要保留帖子的順序,這要在客戶端做些處理——可以用以下Ruby方法實現。2第一個方法threaded_list
構建了所有根級帖子的列表,還有一個Map,將父ID映射到子節點:
2. 本書的源代碼中包含了完整示例,其中實現了具化路徑模式,並且用到了此處的顯示方法。
def threaded_list(cursor, opts={}) list = child_map = {} start_depth = opts[:start_depth] || 0 cursor.each do |comment| if comment[\'depth\'] == start_depth list.push(comment) else matches = comment[\'path\'].match(/([d|w]+)$/ immediate_parent_id = matches[1] if immediate_parent_id child_map[immediate_parent_id] ||= child_map[immediate_parent_id] << comment end end end assemble(list, child_map) end
assemble
方法接受根節點列表和子節點Map,按照顯示順序構建一個新的列表:
def assemble(comments, map) list = comments.each do |comment| list.push(comment) child_comments = map[comment[\'_id\'].to_s] if child_comments list.concat(assemble(child_comments, map)) end end list end
到了真正顯示的時候,只需迭代這個列表,根據每個討論的深度適當縮進就行了:
def print_threaded_list(cursor, opts={}) threaded_list(cursor, opts).each do |item| indent = \" \" * item[\'depth\'] puts indent + item[\'body\'] + \" #{item[\'path\']}\" end end
此時,查詢並顯示討論的代碼就很簡單了:
cursor = @comments.find.sort(\"created\") print_threaded_list(cursor)
B.1.5 工作隊列
你可以使用標準集合或者固定集合在MongoDB裡實現工作隊列。無論使用哪種集合,findAndModify
命令都能讓你原子地處理隊列項。
隊列項要求有一個狀態字段(state
)和一個時間戳字段(timestamp
),剩下的字段用來包含其承載的內容。狀態可以編碼為字符串,但是整數更省空間。我們將用0和1來分別表示未處理和已處理。時間戳是標準的BSON日期。此處承載的內容就是一個簡單的純文本消息,它原則上可以是任何東西。
{ state: 0, created: ISODate(\"2011-02-24T16:29:36.697Z\") message: \"hello world\" }
你需要聲明一個索引,這樣才能高效地獲取最老的未處理項(FIFO)。state
和created
上的復合索引正好合適:
db.queue.ensureIndex({state: 1, created: 1})
隨後使用findAndModify
返回下一項,並將其標記為已處理:
q = {state: 0} s = {created: 1} u = {$set: {state: 1}} db.queue.findAndModify({query: q, sort: s, update: u})
如果使用的是標準集合,需要確保會刪除老的隊列項。可以在處理時使用findAndModify
的{remove: true}
選項來移除它們。但是有些應用程序希望處理完成之後,過一段時間再進行刪除操作。
固定集合也能作為工作隊列的基礎。沒有_id
上的默認索引,固定集合在插入時速度更快,但是這一差別對於大多數應用程序而言都可以忽略不計。另一個潛在的優勢是自動刪除特性,但這一特性是一把雙刃劍:你要確保集合足夠大,避免未處理的隊列項被擠出隊列。因此,如果使用固定集合,要讓它足夠大,理想的集合大小取決於隊列的寫吞吐量和平均載荷內容大小。
一旦決定了固定集合的大小,Schema、索引和findAndModify
的使用都和剛才介紹的標準集合一樣。
B.1.6 動態屬性
MongoDB的文檔數據模型在表示屬性會有變化的條目時非常有用。產品就是一個公認的例子,在本書先前的部分裡你已經看到過此類建模方法了。將此類屬性置於子文檔之中,就是一種行之有效的建模方法。在一個products
集合中,可以保存完全不同的產品類型,你可以保存一副耳機:
{ _id: ObjectId(\"4d669c225d3a52568ce07646\") sku: \"ebd-123\" name: \"Hi-Fi Earbuds\", type: \"Headphone\", attrs: { color: \"silver\", freq_low: 20, freq_hi: 22000, weight: 0.5 } }
和一塊SSD硬盤:
{ _id: ObjectId(\"4d669c225d3a52568ce07646\") sku: \"ssd-456\" name: \"Mini SSD Drive\", type: \"Hard Drive\", attrs: { interface: \"SATA\", capacity: 1.2 * 1024 * 1024 * 1024, rotation: 7200, form_factor: 2.5 } }
如果需要頻繁地查詢這些屬性,可以為它們建立稀疏索引。例如,可以為常用的耳機範圍查詢進行優化:
db.products.ensureIndex({\"attrs.freq_low\": 1, \"attrs.freq_hi\": 1}, {sparse: true})
還可以通過以下索引,根據轉速高效地查詢硬盤:
db.products.ensureIndex({\"attrs.rotation\": 1}, {sparse: true})
此處的整體策略是為了提高可讀性和應用可發現性(discoverability)而將屬性圈在一個範圍裡,通過稀疏索引將空值排除在索引之外。
如果屬性是完全不可預測的,那就無法為每個屬性構建單獨的索引。這就必須使用不同的策略了,就像下面這個示例文檔所示:
{ _id: ObjectId(\"4d669c225d3a52568ce07646\") sku: \"ebd-123\" name: \"Hi-Fi Earbuds\", type: \"Headphone\", attrs: [ {n: \"color\", v: \"silver\"}, {n: \"freq_low\", v: 20}, {n: \"freq_hi\", v: 22000}, {n: \"weight\", v: 0.5} ] }
這裡的attrs
指向一個子文檔數組,每個子文檔都有兩個值n
和v
,分別對應了動態屬性的名字和取值。這種正規化表述讓你能通過一個復合索引來索引這些屬性:
db.products.ensureIndex({\"attrs.n\": 1, \"attrs.v\": 1})
隨後就能用這些屬性進行查詢了,但是必須使用$elemMatch
查詢操作符:
db.products.find({attrs: {$elemMatch: {n: \"color\", v: \"silver\"}}})
請注意,這種策略會帶來不少開銷,因為它要在索引裡保存鍵名。在用於生產環境之前,使用有代表性的數據集進行性能測試是很重要的。
B.1.7 事務
MongoDB不會為一系列操作提供ACID保障,也不存在與RDBMS裡的BEGIN
、COMMIT
和ROLLBACK
語義等價的東西。需要這些特性時,就換個數據庫吧(可以針對需要適當事務保障的數據部分,也可以把應用程序的數據庫整個換了)。不過MongoDB支持單個文檔的原子性、持久化更新,還有一致性讀,這些特性雖然原始,但能在應用程序裡實現類似事務的作用。
第6章在處理訂單授權與庫存管理時已經有一個很好的例子了。本附錄前面實現的工作隊列也能方便地添加回滾支持。這兩個例子裡,功能強大的findAndModify
命令是實現類似事務行為的基礎,可以用來操作一個或多個文檔的state
字段。
所有這些案例裡用到的事務策略都能描述為補償驅動(compensation-driven)3。抽像後的補償過程如下。
3. 有兩個涉及補償驅動事務的文獻值得一讀。最初由Garcia-Molina和Salem所著的「Sagas」(http://mng.bz/73is)。另一篇不太正式,但同樣有趣,見「Your Coffee Shop Doesn』t Use Two-Phase Commit」(http://mng.bz/kpAq),作者是Gregor Hohpe。
原子性地修改文檔狀態。
執行一些操作,可能包含對其他文檔的原子性修改。
確保整個系統(所有涉及的文檔)都處於有效狀態。如果情況如此,標記事務完成;否則將每個文檔都改回事務前的狀態。
值得注意的是,補償驅動策略幾乎是長時間多步事務所必不可少的,授權、送貨及取消訂單的過程只是一個例子。對於這些場景,就算是有完整事務語義的RDBMS也必須實現一套類似的策略。
也許沒辦法避開某些應用程序對多對像ACID事務的需求。但是只要有正確的模式,MongoDB也能提供一些事務保障,可以支持應用程序所需的事務性語義。
B.1.8 局部性與預計算
MongoDB經常被冠以分析數據庫(analytics database)之名,大量用戶在MongoDB之中保存分析數據。原子增加與富文檔的結合看上去很棒。例如,下面這個文檔表示了一個月中每一天的總頁面訪問量,還帶有該月的總訪問量。簡單起見,以下文檔只包含該月頭五天的數據:
{ base: \"org.mongodb\", path: \"/\", total: 99234, days: { \"1\": 4500, \"2\": 4324, \"3\": 2700, \"4\": 2300, \"5\": 0 } }
可以使用$inc
操作符進行簡單的針對性更新,以修改某一天或這個月的訪問量:
use stats-2011 db.sites-nov.update({ base: \"org.mongodb\", path: \"/\" }, $inc: {total: 1, \"days.5\": 1 });
稍微關注一下集合與數據庫的名字,集合sites-nov
是針對某一月份的,而數據庫stats-2011
是針對特定年份的。
這為應用程序帶來了良好的局部性。在查詢最近的訪問情況時,只需要查詢一個集合,比起整個分析歷史數據,這數量就小多了。如果需要刪除數據,可以刪掉某個時間段的集合,而不是從較大的集合裡刪除文檔的子集。後者通常會造成磁盤碎片。
實踐中的另一條原則是預計算。有時,在每個月開頭時,你需要插入一個模板文檔,其中每一天都是零值。因此,在增加計數器時文檔大小不會改變,因為並沒有增加字段,只是原地改變了它們的值。這一點很重要,因為在寫操作時,這能避免對文檔重新進行磁盤分配。重新分配很慢,通常也會造成碎片。