有一个工具你肯定不想错过,那就是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 itemend
仔细查看每个条目,会发现它们的状态都是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.sizeend
为了变更状态,每个选择器都有一个额外的条件{: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}} ) endend
我们使用$pullAll
操作符删除了刚才添加到订单item_ids
数组里的所有ID。然后遍历库存项ID列表,将每项的状态改回原来的样子。
可以将transition_state
方法作为其他变更库存项状态方法的基础,要将其整合进在上一节里构建的订单流转系统应该并不困难。这就作为练习留给读者了。
你可能会问:该系统是否足够强健,能够用于生产环境?在没有了解更多详情之前,无法轻易得出结论,但可以肯定的是MongoDB提供了足够的特性,在需要类似事务的行为时,能有一个可用的解决方案。当然,没人会用MongoDB构建一个银行系统。但如果只需要某类事务行为,可以考虑使用MongoDB,尤其是想让整个应用程序运行在一个数据库上的时候。