讀古今文學網 > MongoDB實戰 > 6.3 原子文檔處理 >

6.3 原子文檔處理

有一個工具你肯定不想錯過,那就是MongoDB的findAndModify命令1。該命令允許對文檔進行原子性更新,並在同一次調用中將其返回。因為它帶來了無限可能,所以非常重要。舉例來說,可以使用findAndModify來構建任務隊列和狀態機,隨後用這些簡單的構件來實現基礎的事務語義,這在極大程度上擴展了能用MongoDB構建的應用程序範圍。有了這些與事務類似的特性,就能在MongoDB上構造出整個電子商務站點,不僅是產品內容,還有結賬和庫存管理功能。

1. 不同環境裡,該命令的標識也會有所不同。Shell輔助方法是通過db.orders.findAndMofify這樣的駝峰式大小寫規則拼寫來調用的,而Ruby則使用下劃線:find_and_modify。更讓人困惑的是核心服務器所接受的命令是findandmodify。如果需要手動發起命令,則需要使用最後一種形式。

我們會通過兩個實際的findAndModify命令的例子來做演示。首先展示如何處理購物車中的基本狀態變遷,然後看一個更進一步的例子——管理有限的庫存。

6.3.1 訂單狀態變遷

所有的狀態變遷都有兩部分:一次查詢,確保是一個合法的初始狀態;一次更新,觸發狀態的變更。讓我們跳過訂單處理裡的一些步驟,假設用戶正要單擊「現在支付」功能按鈕Pay Now來授權購買。如果要在應用程序端同步授權用戶的信用卡,則需要確保以下幾件事:

  1. 只能授權用戶在結賬頁面上看到的金額;

  2. 在授權過程中購物車的內容不能發生變化;

  3. 授權過程中發生錯誤時,要讓購物車回到前一個狀態;

  4. 如果信用卡授權成功,將支付信息提交到訂單裡,訂單的狀態變為SHIPMENT PENDING。

第一步是讓訂單進入PRE-AUTHORIZE狀態。我們使用findAndModify查找用戶的當前訂單對象,並確保對象是CART狀態的:

db.orders.findAndModify({
  query: {user_id: ObjectId(\"4c4b1476238d3b4dd5000001\"),
            state: \"CART\" },

  update: {\"$set\": {\"state\": \"PRE-AUTHORIZE\"},
  new: true}
})
  

如果成功,findAndModify會返回狀態變遷後的訂單對象。2一旦訂單進入PRE-AUTHORIZE狀態,用戶就無法再編輯購物車的內容了,這是因為對購物車的所有更新總是確保CART狀態。現在,處於預授權狀態,我們利用返回的訂單對象,重新計算各項總計。計算完畢之後,發出新的findAndModify,當新的總計和之前的一致時,將訂單的狀態變遷為AUTHORIZING。以下是用到的findAndModify命令:

2. 默認情況下,findAndModify命令會返回更新前的文檔。要返回修改後的文檔,必須像示例中那樣指定{new: true}

db.orders.findAndModify({
  query: {user_id: ObjectId(\"4c4b1476238d3b4dd5000001\"),
             total: 99000,
             state: \"PRE-AUTHORIZE\" },

  update: {\"$set\": {\"state\": \"AUTHORIZING\"}}
})
  

如果第二個findAndModify失敗了,那麼必須將訂單的狀態退回為CART,並將更新後的總計信息告訴用戶。但如果它成功了,那麼我們就知道授權的總金額和呈現給用戶的金額是一樣的,也就是說可以繼續進行實際的授權API調用了。應用程序現在會對用戶的信用卡發起一次信用卡授權請求。如果授權失敗,和之前一樣,把失敗記錄下來,將訂單退回CART狀態。

但如果授權成功,把授權信息寫入訂單,訂單流轉到下一個狀態,兩步都在同一個findAndModify調用裡完成。下面這個例子通過一個示例文檔來表示接受到的授權信息,它會附加到原訂單上:

auth_doc = {ts: new Date,
            cc: 3432003948293040,
            id: 2923838291029384483949348,
            gateway: \"Authorize.net\"}
db.orders.findAndModify({
  query: {user_id: ObjectId(\"4c4b1476238d3b4dd5000001\"),
             state: \"AUTHORIZING\" },

  update: {\"$set\":
                {\"state\": \"PRE-SHIPPING\"},
                 \"authorization\": auth}
})
  

請注意,MongoDB的一些特性簡化了這個事務性過程。我們可以原子性地修改任意文檔,單個連接中能保證讀取的一致性。最後,文檔結構本身也允許這些操作來適應MongoDB提供的單文檔原子性。本例中,文檔結構允許將訂單條目、產品、價格和用戶身份都放進同一個文檔裡,保證只需操作一個文檔就能完成銷售。

本例應該讓你印象深刻,也會讓你感到疑惑(就像我一樣),MongoDB到底能否實現多對像事務行為呢?答案是肯定的,可以通過另一個電子商務網站功能來做演示,即庫存管理功能。

6.3.2 庫存管理

並非所有電子商務網站都需要嚴格的庫存管理,大多數商品都有充足的時間進貨,這使得訂單不用考慮當前商品的實際數量。這種情況下,管理庫存就是簡單地管理期望值;當庫存僅有少量存貨時,調整送貨預期即可。

限量商品則有不同的挑戰。假設正在銷售指定座位的音樂會門票或者手工藝術品,這些產品是不能套期保值的,用戶總是希望保證能購買到自己所選的產品。本節我將展示一種使用了MongoDB的可行解決方案。這能進一步說明findAndModify命令的創造性,以及如何明智地使用文檔模型,還能演示如何實現跨多個文檔的事務性語義。

建模庫存的最好方法就是想像一個真實的商店。如果在一家園藝商店裡,我們能看見並感受到實際庫存量;很多鏟子、耙子和剪刀在過道裡擺成一排。要是我們拿起一把鏟子放進購物車裡,對其他顧客而言就少了一把鏟子,其結果就是兩個客戶不能同時在他們的購物車裡擁有同一把鏟子。我們可以使用這個簡單的原則來建模庫存。在庫存集合中為倉庫裡的每件實際商品保存一個對應的文檔。如果倉庫裡有10把鏟子,數據庫裡就有10個鏟子文檔。每個庫存項都通過sku鏈接到產品上,並且擁有AVAILABLE (0)IN_CART (1)PRE_ORDER (2)PURCHASED (3)這四個狀態中的某個狀態。

下面的代碼插入了三把鏟子、三把耙子和三把剪刀作為可用庫存:

3.times do
  @inventory.insert({:sku => \'shovel\', :state => AVAILABLE})
  @inventory.insert({:sku => \'rake\', :state => AVAILABLE})
  @inventory.insert({:sku => \'clippers\', :state => AVAILABLE})
end
  

我們將用一個特殊的庫存獲取類來管理庫存。我們先看看它是如何工作的,然後深入其中,揭示它的實現原理。

庫存獲取器能向購物車內添加任意產品集合。此處,我們創建了一個新訂單對象與一個新的庫存獲取器。隨後用獲取器向指定訂單添加了三把鏟子和一把剪刀,訂單由傳給add_to_cart方法的訂單ID指定,另外再傳入兩個文檔指定產品和數量:

@order_id = @orders.insert({:username => \'kbanker\', :item_ids => })
@fetcher = InventoryFetcher.new(:orders => @orders,
                                :inventory => @inventory)

@fetcher.add_to_cart(@order_id,
                     {:sku => \"shovel\", :qty => 3},
                     {:sku => \"clippers\", :qty => 1})

order = @orders.find_one({\"_id\" => @order_id})
puts \"nHere\'s the order:\"
p order
  

如果某件商品添加失敗,add_to_cart方法會拋出一個異常。如果執行成功,訂單應該是這樣的:

{\"_id\" => BSON::ObjectId(\'4cdf3668238d3b6e3200000a\'),
 \"username\"=>\"kbanker\",
 \"item_ids\" => [BSON::ObjectId(\'4cdf3668238d3b6e32000001\'),
                BSON::ObjectId(\'4cdf3668238d3b6e32000004\'),
                BSON::ObjectId(\'4cdf3668238d3b6e32000007\'),
                BSON::ObjectId(\'4cdf3668238d3b6e32000009\')],
}
  

訂單文檔裡會保存每件實際庫存項的_id,可以像下面這樣查詢這些庫存項:

puts \"nHere\'s each item:\"
order[\'item_ids\'].each do |item_id|
  item = @inventory.find_one({\"_id\" => item_id})
  p item
end
  

仔細查看每個條目,會發現它們的狀態都是1,對應了IN_CART狀態,而且其中還用時間戳記錄了上次狀態改變的時間。如果商品被放入購物車的時間太長了,稍後還可以使用這個時間戳對這些商品做過期處理。舉例來說,可以規定用戶有15分鐘來完成將商品添加到購物車到結賬的整個流程:

{\"_id\" => BSON::ObjectId(\'4cdf3668238d3b6e32000001\'),
  \"sku\"=>\"shovel\", \"state\"=>1, \"ts\"=>\"Sun Nov 14 01:07:52 UTC 2010\"}

{\"_id\"=>BSON::ObjectId(\'4cdf3668238d3b6e32000004\'),
  \"sku\"=>\"shovel\", \"state\"=>1, \"ts\"=>\"Sun Nov 14 01:07:52 UTC 2010\"}

{\"_id\"=>BSON::ObjectId(\'4cdf3668238d3b6e32000007\'),
  \"sku\"=>\"shovel\", \"state\"=>1, \"ts\"=>\"Sun Nov 14 01:07:52 UTC 2010\"}

{\"_id\"=>BSON::ObjectId(\'4cdf3668238d3b6e32000009\'),
  \"sku\"=>\"clippers\", \"state\"=>1, \"ts\"=>\"Sun Nov 14 01:07:52 UTC 2010\"}
  

如果這個InventoryFetcher的API還講得過去,那麼你應該能預感到如何實現庫存管理了。findAndModify命令又在其中發揮了重要作用。本書的源代碼中包含了InventoryFetcher的完整源代碼及測試套件。此處我們不會仔細介紹每行代碼,但會著重說明其中的三個重要方法。

首先,當傳入一個要添加到購物車裡的商品列表時,庫存獲取器會嘗試將它們的狀態從AVAILABLE變更為IN_CART。如果操作中有哪一步失敗了(比如某項商品未能添加到購物車裡),那麼整個操作就會回滾。看看之前調用過的 add_to_cart方法:

def add_to_cart(order_id, *items)
  item_selectors = 
  items.each do |item|
    item[:qty].times do
      item_selectors << {:sku => item[:sku]}
    end
  end

  transition_state(order_id, item_selectors, :from => AVAILABLE,
     :to => IN_CART)
end
  

該方法並沒有完成上述功能,它只是接收要添加到購物車的具體商品並增加其數量,這樣每件實際添加到購物車裡的商品都能有一個庫存項選擇器。舉例來說,以下文檔表示想添加兩把鏟子:

 {:sku => \"shovel\", :qty => 2}
  

會變成:

[{:sku => \"shovel\"}, {:sku => \"shovel\"}]
  

針對每件要添加到購物車裡的商品,都需要一個單獨的查詢選擇器。因此,add_to_cart方法會將庫存項選擇器數組傳給一個名為transition_state的方法。例如,上述代碼指明了狀態應該從AVAILABLE變更為IN_CART

def transition_state(order_id, selectors, opts={})
  items_transitioned = 

  begin
    for selector in selectors do
      query = selector.merge(:state => opts[:from])

      physical_item = @inventory.find_and_modify(:query => query,
        :update => {\'$set\' => {:state => opts[:to], :ts => Time.now.utc}})

      if physical_item.nil?
        raise InventoryFetchFailure
      end

      items_transitioned << physical_item[\'_id\']

      @orders.update({:_id => order_id},
                     {\"$push\" => {:item_ids => physical_item[\'_id\']}})
    end

  rescue Mongo::OperationFailure, InventoryFetchFailure
    rollback(order_id, items_transitioned, opts[:from], opts[:to])
    raise InventoryFetchFailure, \"Failed to add #{selector[:sku]}\"
  end

  items_transitioned.size
end
  

為了變更狀態,每個選擇器都有一個額外的條件{:state => AVAILABLE},隨後選擇器會被傳給findAndModify,如果條件匹配,則設置時間戳和庫存項的新狀態。transition_state方法會保存變更過狀態的庫存項列表,將它們的ID更新到訂單裡。

如果findAndModify命令執行失敗並返回nil,那麼會拋出一個InventoryFetchFailure異常。如果命令由於網絡錯誤而失敗,那麼必然會有Mongo::OperationFailure異常,我們需要捕獲該異常。這兩種情況下,都要回滾之前修改過的庫存項,然後拋出一個InventoryFetchFailure,其中包含無法添加的庫存項SKU。隨後能在應用層捕獲該異常,告訴用戶操作失敗了。

現在就只剩下回滾的代碼了:

def rollback(order_id, item_ids, old_state, new_state)
  @orders.update({\"_id\" => order_id},
                 {\"$pullAll\" => {:item_ids => item_ids}})

  item_ids.each do |id|
    @inventory.find_and_modify(
      :query => {\"_id\" => id, :state => new_state},
      :update => {\"$set\" => {:state => old_state, :ts => Time.now.utc}}
    )
  end
end
  

我們使用$pullAll操作符刪除了剛才添加到訂單item_ids數組裡的所有ID。然後遍歷庫存項ID列表,將每項的狀態改回原來的樣子。

可以將transition_state方法作為其他變更庫存項狀態方法的基礎,要將其整合進在上一節裡構建的訂單流轉系統應該並不困難。這就作為練習留給讀者了。

你可能會問:該系統是否足夠強健,能夠用於生產環境?在沒有瞭解更多詳情之前,無法輕易得出結論,但可以肯定的是MongoDB提供了足夠的特性,在需要類似事務的行為時,能有一個可用的解決方案。當然,沒人會用MongoDB構建一個銀行系統。但如果只需要某類事務行為,可以考慮使用MongoDB,尤其是想讓整個應用程序運行在一個數據庫上的時候。