讀古今文學網 > MongoDB實戰 > 6.2 電子商務數據模型中的更新 >

6.2 電子商務數據模型中的更新

在股票的例子裡更新MongoDB文檔中的這個或那個屬性是很容易的。但生產環境中的數據模型和真實的應用程序中,會出現不少困難,對指定屬性的更新可能不再是簡單的一行語句。在後續的內容裡,我會使用上兩章裡出現的電子商務數據模型作為示例,演示在生產環境中的電子商務網站裡可能看到的更新。其中某些更新很直觀,而另一些則不那麼直觀。但總的來說,我們會對第4章中設計的Schema有更深入的認識,對MongoDB更新語言的特性和限制有更進一步的理解。

6.2.1 產品與分類

本節你將看到一些實際的針對性更新的例子,首先瞭解如何計算產品平均評分,隨後是更複雜的維護分類層級的任務。

1. 產品平均評分

產品可以運用很多更新策略。假設管理員有一個界面可用於編輯產品信息,最簡單的更新涉及獲取當前產品文檔,將其與用戶編輯的文檔進行合併,執行一次文檔替換。有時候,可能只需更新幾個值,顯然這時針對性更新是更好的選擇。計算產品平均評分就是這種情況。因為用戶會基於平均評分對產品列表排序,可以將該評分保存在產品文檔中,在添加或刪除評論時進行更新。

執行此類更新的方式很多,下面就是其中之一:

average = 0.0
count = 0
total = 0
cursor = @reviews.find({:product_id => product_id}, :fields => [\"rating\"])
while cursor.has_next? && review = cursor.next
  total += review[\'rating\']
  count += 1
end

average = total / count

@products.update({:_id => BSON::ObjectId(\"4c4b1476238d3b4dd5003981\")},
   {\'$set\' => {:total_reviews => count, :average_review => average}})
  

這段代碼聚合併處理了每條產品評論中的rating字段,然後計算了平均值。實際上,我們迭代了每個評分,借此計算產品的總評分,這節省了一次額外的count函數調用。有了評論的總條數和平均評分之後,在代碼中使用$set執行一次針對性更新。

關注性能的用戶可能會盡量避免每次更新時重新聚合所有產品評論這種做法。此處提供的方法雖然保守,但在大多數情況下還是可以接受的。也會有其他策略,舉例來說,可以在產品文檔中保存額外的字段來緩存評論的總評分。在插入一條新評論後,查詢產品以獲得當前評論總數和總評分,隨後計算平均值,用如下選擇器發起一次更新:

{\'$set\' => {:average_review => average, :ratings_total => total},
 \'$inc\' => {:total_reviews => 1}})
  

只有使用典型數據對系統進行評測之後才能確定哪種方式更好。但這個例子恰恰說明了MongoDB通常提供多種可選方法,應用程序的需求可用於幫助確定哪種方法是最好的。

2. 分類層級

在很多數據庫中都沒有簡單的方法來表示分類層級,雖然文檔結構對此有所幫助,但MongoDB裡的情況也差不多。文檔可以針對讀取進行優化,因為每個分類都能包含其祖先的列表。唯一麻煩的要求是始終保持最新的祖先列表。讓我們來看一個例子。

首先需要一個通用的方法更新任意給定分類的祖先列表。下面是一個可行方案:

def generate_ancestors(_id, parent_id)
  ancestor_list = 
  while parent = @categories.find_one(:_id => parent_id) do
    ancestor_list.unshift(parent)
    parent_id = parent[\'parent_id\']
  end

  @categories.update({:_id => _id},
    {\"$set\" {:ancestors => ancestor_list}})
end
  

該方法回溯了分類層級,連續查詢每個節點的parent_id屬性,直到根節點(parent_idnil的節點)為止。總之,它構建了一個有序的祖先列表,保存在ancestor_list數組裡。最後,使用$set更新分類的ancestors屬性。

既然已經有了基本的構建模塊,那就讓我們來看看插入新分類的過程吧。假設有一個簡單的分類層級,如圖6-1所示。

圖6-1 初始的分類層級

假設想在Home分類下添加一個名為Gardening的新分類,插入新分類文檔後運行方法來生成它的祖先列表:

category = {
  :parent_id => parent_id,
  :slug => \"gardening\",
  :name => \"Gardening\",
  :description => \"All gardening implements, tools, seeds, and soil.\"
}
gardening_id = @categories.insert(category)
generate_ancestors(gardening_id, parent_id)
  

圖6-2顯示了更新後的樹。

圖6-2 添加Gardening分類

這太簡單了。但如果現在想把Outdoors分類放在Gardening下面又會怎麼樣呢?這就有點複雜了,因為要修改很多分類的祖先列表。可以從把Outdoors的 parent_id修改為Gardening的_id開始做起,這還不是很困難:

@categories.update({:_id => outdoors_id},
                   {\'$set\' => {:parent_id => gardening_id}})
  

因為移動了Outdoors分類,所以其所有後代的祖先列表都無效了。可以查詢所有祖先列表裡有Outdoors的分類,隨後重新生成它們的祖先列表。MongoDB可深入數組進行查詢,因而能輕而易舉地完成這項工作:

@categories.find({\'ancestors.id\' => outdoors_id}).each do |category|
  generate_ancestors(category[\'_id\'], outdoors_id)
end
  

這就是一個處理分類parent_id屬性的更新的方法,圖6-3顯示了變更後的分類排列方式。

圖6-3 最終狀態的分類樹

要是想要修改分類名稱又會怎麼樣呢?如果將Outdoors分類的名稱改為The Great Outdoors,那麼還必須修改其他祖先列表中出現Outdoors的分類。這時你會想「看到沒?這種情況下去正規化就麻煩了」。但瞭解到不用重新計算祖先列表就能執行這個更新後,你應該會感覺好多了。方法如下:

doc = @categories.find_one({:_id => outdoors_id})
doc[\'name\'] = \"The Great Outdoors\"
@categories.update({:_id => outdoors_id}, doc)
@categories.update({\'ancestors.id\' => outdoors_id},
  {\'$set\' => {\'ancestors.$\'=> doc}}, :multi => true)
  

我們先取得了Outdoors文檔,在本地修改它的name屬性,隨後通過替換進行更新,最後再用修改後的Outdoors文檔來替換多個祖先列表中的舊文檔。我們通過位置操作符和多項更新實現了這個操作。多項更新很容易理解;回憶一下,如果希望修改能作用於所有選擇器匹配到的文檔,需要指定:multi => true。此處,我們想更新所有祖先列表中有Outdoors的分類。

位置操作符更巧妙一些。假設無從獲知Outdoors分類會出現在給定分類祖先列表中的什麼地方,此時就需要更新操作符針對任意文檔動態定位Outdoors分類在數組中的位置。說到位置操作符,即ancestors.$中的$,代替了查詢選擇器匹配到的數組下標,這才使這個更新操作成為可能。

因為需要更新數組中單獨的子文檔,總是會用到位置操作符。總的來說,在要處理子文檔數組時,這些更新分類層級的技術都能適用。

6.2.2 評論

評論並不是完全「平等」的,這就是應用程序會允許用戶對評論進行投票的原因。投票很簡單,它們指出了哪些評論是有用的。我們已經對評論做了建模,其中能緩存總投票數以及投票者ID的列表。評論文檔中相關的部分看起來是這樣的:

{helpful_votes: 3,
voter_ids: [ ObjectId(\"4c4b1476238d3b4dd5000041\"),
             ObjectId(\"7a4f0376238d3b4dd5000003\"),
             ObjectId(\"92c21476238d3b4dd5000032\")
           ]}
  

可以通過針對性更新來記錄用戶投票。使用$push操作符將投票者的ID添加到列表裡,使用$inc操作符來增加總投票數,這兩個操作都在同一個更新操作裡:

db.reviews.update({_id: ObjectId(\"4c4b1476238d3b4dd5000041\")},
  {$push: {voter_ids: ObjectId(\"4c4b1476238d3b4dd5000001\")},
 $inc: {helpful_votes: 1}
})
  

大多數情況下這個更新沒有問題,但我們需要確保僅當正在投票的用戶尚未對該評論投過票時才能進行更新。因此要修改此處的查詢選擇器,只匹配voter_ids數組中不包含要添加的ID的情況。使用$ne查詢操作符就能輕鬆實現了:

query_selector = {_id: ObjectId(\"4c4b1476238d3b4dd5000041\"),
  voter_ids: {$ne: ObjectId(\"4c4b1476238d3b4dd5000001\")}}
db.reviews.update(query_selector,
  {$push: {voter_ids: ObjectId(\"4c4b1476238d3b4dd5000001\")},
   $inc : {helpful_votes: 1}
  })
  

這是一個很強大的示例,演示了MongoDB的更新機制以及如何將其用於面向文檔的Schema。本例中的投票既是原子操作,又有很高的效率。原子性保證了即使在高並發環境下,也沒人能投兩次票。高效是因為對投票者身份的判斷、更新計數器和投票者列表的操作都是在同一個服務器請求內完成的。

現在,如果最終確定使用該技術來保存投票信息,請務必保證其他對評論文檔的更新都是針對性更新,因為替換更新的方式一定會導致不一致性。想像一下,假設用戶通過替換更新來修改評論的內容,先要查詢希望修改的文檔,在查詢評論和更新之間,另一個用戶很有可能在為該評論投票。圖6-4中就描述了這個事件序列。

圖6-4 通過針對性更新和替換更新並發地修改評論時會丟失數據

很明顯,T3時刻的文檔替換會覆蓋T2時刻發生的投票更新。使用之前描述的樂觀鎖技術是可以避免這種情況的,但確保本例中所有的更新都是針對性更新似乎更容易一些。

6.2.3 訂單

在評論中看到的更新操作的原子性和高效性也能被運用在訂單上。接下來,我們會看到如何使用針對性更新實現「添加到購物車」功能(Add to Cart)。這個過程有兩步:第一步,構建一個產品文檔,用來保存訂單條目數組;第二步,發起一次針對性更新,標明這是一次upsert ——如果要更新的文檔不存在則插入一個新文檔的更新操作。(在下一節裡我會詳細描述upsert的。)如果訂單對像不存在,該操作會創建一個新的訂單對象,無縫地處理初始化以及後續「添加到購物車」的動作。1

1. 我交換使用購物車和訂單這兩個詞,因為它們都是使用同一個文檔來表示的。兩者僅在文檔的state字段上有所不同(文檔狀態是CART的表示購物車)。

我們先構建一個要添加到購物車中的示例文檔:

cart_item = {
  _id: ObjectId(\"4c4b1476238d3b4dd5003981\"),
  slug: \"wheel-barrow-9092\",
  sku: \"9092\",

  name: \"Extra Large Wheel Barrow\",
  pricing: {
    retail: 589700,
    sale: 489700
  }
}
  

構建該文檔時,很可能就是查詢products集合,隨後抽取出需要保存為訂單條目的字段。產品中的_idskuslugnameprice字段應該就夠了2。有了購物車明細文檔,就可以把它upsert進訂單集合了:

2. 在實際的電子商務應用程序中,會需要在結賬時驗證一下價格是否發生變化。

selector = {user_id: ObjectId(\"4c4b1476238d3b4dd5000001\"),
            state: \'CART\',
            \'line_items.id\':
              {\'$ne\': ObjectId(\"4c4b1476238d3b4dd5003981\")}
           }
update = {\'$push\': {\'line_items\': cart_item}}
db.orders.update(selector, update, true, false)
  

為了讓代碼更清晰一點,我分別構造了查詢選擇器和更新文檔。更新文檔將購物車明細文檔塞進訂單條目數組裡。查詢選擇器中指出僅在數組中不存在特定訂單條目時,更新才會成功。當然,用戶第一次執行「添加到購物車」功能時,根本就沒有購物車。這就是此處使用upsert的原因。upsert會根據查詢選擇器和更新文檔裡的鍵和值構建文檔。因此,初始的upsert會產生如下訂單文檔:

{
user_id: ObjectId(\"4c4b1476238d3b4dd5000001\"),
state: \'CART\',
line_items: [{
    _id: ObjectId(\"4c4b1476238d3b4dd5003981\"),
    slug: \"wheel-barrow-9092\",
    sku: \"9092\",

    name: \"Extra Large Wheel Barrow\",

    pricing: {
      retail: 589700,
      sale: 489700
    }
  }]
}
  

隨後需要再發起一次針對性更新,確保明細數量和訂單小計的正確性:

selector = {user_id: ObjectId(\"4c4b1476238d3b4dd5000001\"),
            state: \"CART\",
            \'line_items.id\': ObjectId(\"4c4b1476238d3b4dd5003981\")}

update = {$inc:
            {\'line_items.$.qty\': 1,
             sub_total: cart_item[\'pricing\'][\'sale\']
            }
         }
db.orders.update(selector, update)
  

請注意,這裡使用了$inc操作符來更新訂單小計和單獨條目的數量。第二條更新使用了上一節介紹的位置操作符($),方便了不少。需要第二條更新的主要原因是要處理用戶單擊添加到購物車的東西已經存在於購物車中的情況。針對這種情況,第一條更新不會成功,但仍然需要調整數量和小計。因此,在兩次單擊手推車的「添加到購物車」功能按鈕後,購物車看起來應該是這樣的:

{
\'user_id\': ObjectId(\"4c4b1476238d3b4dd5000001\"),
\'state\' : \'CART\',
\'line_items\': [{
    _id: ObjectId(\"4c4b1476238d3b4dd5003981\"),
    qty: 2,
    slug: \"wheel-barrow-9092\",
    sku: \"9092\",

    name: \"Extra Large Wheel Barrow\",
    pricing: {
      retail: 589700,
      sale: 489700
    }
  }],
  subtotal: 979400
}
  

現在購物車裡有兩部手推車了,小計上也有所體現。

還需要更多操作才能完整實現一個購物車,其中大多數都能通過一個或者多個針對性更新來實現,例如從購物車中刪除一項,或者清空購物車。如果這還不明顯,接下來的小節中會描述每個查詢操作符,應該會讓一切都清晰明瞭的。在實際的訂單處理中,可以通過推進訂單狀態以及應用每個狀態的處理邏輯來處理訂單。下一節會演示這些內容,而且我還會解釋原子文檔處理和findAndModify命令。