有一個工具你肯定不想錯過,那就是MongoDB的findAndModify
命令1。該命令允許對文檔進行原子性更新,並在同一次調用中將其返回。因為它帶來了無限可能,所以非常重要。舉例來說,可以使用findAndModify
來構建任務隊列和狀態機,隨後用這些簡單的構件來實現基礎的事務語義,這在極大程度上擴展了能用MongoDB構建的應用程序範圍。有了這些與事務類似的特性,就能在MongoDB上構造出整個電子商務站點,不僅是產品內容,還有結賬和庫存管理功能。
1. 不同環境裡,該命令的標識也會有所不同。Shell輔助方法是通過db.orders.findAndMofify
這樣的駝峰式大小寫規則拼寫來調用的,而Ruby則使用下劃線:find_and_modify
。更讓人困惑的是核心服務器所接受的命令是findandmodify
。如果需要手動發起命令,則需要使用最後一種形式。
我們會通過兩個實際的findAndModify
命令的例子來做演示。首先展示如何處理購物車中的基本狀態變遷,然後看一個更進一步的例子——管理有限的庫存。
6.3.1 訂單狀態變遷
所有的狀態變遷都有兩部分:一次查詢,確保是一個合法的初始狀態;一次更新,觸發狀態的變更。讓我們跳過訂單處理裡的一些步驟,假設用戶正要單擊「現在支付」功能按鈕Pay Now來授權購買。如果要在應用程序端同步授權用戶的信用卡,則需要確保以下幾件事:
只能授權用戶在結賬頁面上看到的金額;
在授權過程中購物車的內容不能發生變化;
授權過程中發生錯誤時,要讓購物車回到前一個狀態;
如果信用卡授權成功,將支付信息提交到訂單裡,訂單的狀態變為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,尤其是想讓整個應用程序運行在一個數據庫上的時候。