讀古今文學網 > MongoDB實戰 > 附錄B 設計模式 >

附錄B 設計模式

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 論壇裡的帖子

讓我們看看這些帖子是如何通過具化路徑組織起來的。首先看到的是根級文檔,所以pathnull

{ _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_idpath字段能加上索引,因為總是會基於其中某一個字段進行查詢:

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)。statecreated上的復合索引正好合適:

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指向一個子文檔數組,每個子文檔都有兩個值nv,分別對應了動態屬性的名字和取值。這種正規化表述讓你能通過一個復合索引來索引這些屬性:

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裡的BEGINCOMMITROLLBACK語義等價的東西。需要這些特性時,就換個數據庫吧(可以針對需要適當事務保障的數據部分,也可以把應用程序的數據庫整個換了)。不過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。

  1. 原子性地修改文檔狀態。

  2. 執行一些操作,可能包含對其他文檔的原子性修改。

  3. 確保整個系統(所有涉及的文檔)都處於有效狀態。如果情況如此,標記事務完成;否則將每個文檔都改回事務前的狀態。

值得注意的是,補償驅動策略幾乎是長時間多步事務所必不可少的,授權、送貨及取消訂單的過程只是一個例子。對於這些場景,就算是有完整事務語義的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是針對特定年份的。

這為應用程序帶來了良好的局部性。在查詢最近的訪問情況時,只需要查詢一個集合,比起整個分析歷史數據,這數量就小多了。如果需要刪除數據,可以刪掉某個時間段的集合,而不是從較大的集合裡刪除文檔的子集。後者通常會造成磁盤碎片。

實踐中的另一條原則是預計算。有時,在每個月開頭時,你需要插入一個模板文檔,其中每一天都是零值。因此,在增加計數器時文檔大小不會改變,因為並沒有增加字段,只是原地改變了它們的值。這一點很重要,因為在寫操作時,這能避免對文檔重新進行磁盤分配。重新分配很慢,通常也會造成碎片。