首页 » MongoDB实战 » MongoDB实战全文在线阅读

《MongoDB实战》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 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,尤其是想让整个应用程序运行在一个数据库上的时候。