原文地址


https://docs.mongodb.com/manual/tutorial/perform-two-phase-commits/

译者注


mongodb只能对保证单个文档操作的原子性,无法支持传统数据库的事务,但是有时候又需要保证多个文档的操作的原子性,这就比较蛋疼了,官方文档以转账为例给出了一个叫做”两阶段提交”(Two Phase Commits)的transaction-like的解决方案,翻译如下。

摘要


这篇文章将会说明如何使用”两阶段提交”的模式来解决多文档更新问题(多文档事务)。另外,这个过程中还可以添加类似于回滚(rollback-like)的功能。

译注:在MongoDB中,一条数据记录(类似于mysql中的一行)叫做一个文档

背景


对于MongoDB数据库来说,单文档的操作是能够保证原子性的;但是设计多个文档的操作(即多文档事务)却不是原子性的。其实单文档操作的原子性已经可以给大多数实际用例(译注:用例是指软件开发中用户的某一个需求)提供足够的支持,因为单个文档的结构可以很复杂甚至包括很多内嵌的文档。

虽然单文档原子操作的功能已经很强大了,但是仍然会有些情况需要多文档事务。当执行一个由一系列操作组成的事务时,某些问题就产生了,比如:

在需要多文档事务的情况下,你可以在你的应用中实现两阶段提交来给这些多文档更新提供支持。两阶段提交能够保证数据的一致性,并且在出现错误的情况下,可以将数据恢复到事务之前的状态。

注意:因为MongoDB中只支持单文档原子操作,所以两阶段提交只能提供类似于事务的语义(transaction-like semantics)。应用还是可以在两阶段提交或者回滚的中间点上返回中间数据。

模式


概述

试想一下这样的情景,你想从账户A转账给账户B。在传统的关系数据库中,你可以在一个事务中从账户A上减去金额并且在账户B上增加相应金额。在MongoDB中,你可以通过两阶段提交实现类似的效果。

这个例子使用如下两个集合(译注:在MongoDB中集合的概念类似于mysql中的一张表):

  1. 一个叫做accounts的集合用于存储账户信息。
  2. 一个叫做transactions的集合用于存储转账事务相关的信息

初始化源账户与目标账户

在accounts集合中插入一个文档代表账户A,再插入一个文档代表账户B。

db.accounts.insert(
   [
     { _id: "A", balance: 1000, pendingTransactions: [] },
     { _id: "B", balance: 1000, pendingTransactions: [] }
   ]
)

这个操作会返回一个代表操作状态的BultWriteResult()对象。如果插入成功的话,这个对象的nInserted字段的值为2

初始化转账记录

对于每一笔转账,在transactions集合中插入一个包含转账信息的文档。这个文档包含如下字段:

为了初始化从账户A到账户B的转账,在transactions集合中插入一个包含转账信息的文档,其中state字段为initial,lastModified字段为当前日期:

db.transactions.insert(
    { _id: 1, source: "A", destination: "B", value: 100, state: "initial", lastModified: new Date() }
)

这个操作会返回一个WriteResult()对象。在插入成功的情况下,WriteResult()对象的nInserted字段的值为1

使用两阶段提交在账户之间转账

1.检索要开始的事务

从transactions集合中找到一个state为initial的transaction(文档)。当前在transactions集合中只有一个文档,即我们刚刚插入进去的那个。如果集合中还包含额外的文档,那么这个查询会返回任一个state为initial的transaction除非你指定其他的查询条件。

var t = db.transactions.findOne( { state: "initial" } )

在mongo的shell客户端中输入变量t打印出它的内容。你会得到与下面类似的文档除了lastModified的值会是你之前插入做的时间:

{ "_id" : 1, "source" : "A", "destination" : "B", "value" : 100, "state" : "initial", "lastModified" : ISODate("2014-07-11T20:39:26.345Z") }

2.将tracsaction的state更新为pending

将transaction中的state从initial修改为pending并且使用$currentData操作符将lastModified字段设置为当前时间。

db.transactions.update(
    { _id: t._id, state: "initial" },
    {
      $set: { state: "pending" },
      $currentDate: { lastModified: true }
    }
)

这个操作返回一个WriteResult()对象,它代表操作的状态。如果更新成功的话,它的nMatched和nModified字段都为1

在上面的代码中,state:"initial"条件是为了保证没有其他进程已经修改该条记录。如果nMatched和nModified都是0的话,则返回第一步得到一条不同的事务并且重新开始这个步骤。

3.在accounts上执行transaction

如果transaction还没有被执行的话,则使用update()方法执行事务。在update条件中,要记得包括pendingTransaction:{$ne:t._id}条件,这么做的目的是为了避免因为意外情况重复执行同一个事务。

在执行事务时,要同时更新account文档的balance字段和pendingTransactions字段。

更新源账户,要从它的balance字段中减去transaction的value字段,并且将transaction的_id字段添加到账户的pendingTransactions数组字段中。

db.accounts.update(
   { _id: t.source, pendingTransactions: { $ne: t._id } },
   { $inc: { balance: -t.value }, $push: { pendingTransactions: t._id } }
)

更新成功之后,方法会返回WriteResult()对象,它的nMatched与nModified字段都为1

用类似的方式更新目标账户(只不过此时balance是加上transaction的value):

db.accounts.update(
   { _id: t.destination, pendingTransactions: { $ne: t._id } },
   { $inc: { balance: t.value }, $push: { pendingTransactions: t._id } }
)

更新成功之后,方法会返回WriteResult()对象,它的nMatched与nModified字段都为1

4.将transaction的state更新为applied

使用下面的update()操作更新transaction的state字段与lastModified字段:

db.transactions.update(
   { _id: t._id, state: "pending" },
   {
     $set: { state: "applied" },
     $currentDate: { lastModified: true }
   }
)

更新成功之后,方法会返回WriteResult()对象,它的nMatched与nModified字段都为1

5.更新源账户和目标账户的pendingTransactions数组

将transaction的_id从源账户和目标账户的pendingTransactions数组中移除。

更新源账户:

db.accounts.update(
   { _id: t.source, pendingTransactions: t._id },
   { $pull: { pendingTransactions: t._id } }
)

更新成功之后,方法会返回WriteResult()对象,它的nMatched与nModified字段都为1

更新目标账户:

db.accounts.update(
   { _id: t.destination, pendingTransactions: t._id },
   { $pull: { pendingTransactions: t._id } }
)

更新成功之后,方法会返回WriteResult()对象,它的nMatched与nModified字段都为1

6.将transaction的state更新为done

将transaction的state设置为done并且更新lastModified字段,事务完成。

db.transactions.update(
   { _id: t._id, state: "applied" },
   {
     $set: { state: "done" },
     $currentDate: { lastModified: true }
   }
)

更新成功之后,方法会返回WriteResult()对象,它的nMatched与nModified字段都为1

从故障中恢复


事务程序中最重要的部分不是上面的原型示例,而是当是诶执行失败时,能够从各种各样的故障中恢复。这一部分展示了几个可能的故障以及从这些故障中恢复的步骤。

恢复操作

在两阶段提交的模式下,应用程序可以通过一系列的步骤恢复事务,达到一致的状态。在应用启动时运行恢复操作,也可以选择隔一段时间运行一次,这样可以弥补任何没能完成的事务。

到达一致性状态所需的时间取决于应用恢复每个事务的时间。

下面的恢复程序使用lastModified字段作为一个transaction是否需要恢复的依据。具体来说,如果一个状态为pending或者applied的transaction在30s内还没有被更新,则程序认为它需要恢复。你也可以使用其他条件来确定一个事务是否需要恢复。

处于pending状态的事务

假设有一个故障发生在”将transaction的state更新为pending”步骤之后,在”将transaction的state更新为applied”之前。为了将事务从这个故障中恢复,先将它从transactions集合中检索出来,然后恢复:

var dateThreshold = new Date();
dateThreshold.setMinutes(dateThreshold.getMinutes() - 30);

var t = db.transactions.findOne( { state: "pending", lastModified: { $lt: dateThreshold } } );

然后从步骤”在accounts上执行transaction”开始恢复。

处于applied状态的transaction

假设一个故障发生在步骤”将transaction的state更新为applied”之后,在”将transaction的state更新为done”之前。为了将事务从这个故障中恢复,先将它从transactions集合中检索出来,然后恢复:

var dateThreshold = new Date();
dateThreshold.setMinutes(dateThreshold.getMinutes() - 30);

var t = db.transactions.findOne( { state: "applied", lastModified: { $lt: dateThreshold } } );

然后从步骤”更新源账户和目标账户的pendingTransactions数组”开始恢复。

回滚操作

在一些情况下,你需要回滚或者说撤销事务。举个例子,在事务期间如果其中一个账户不存在或者被停用了,这个时候就只能取消事务了。

处于applied状态的transaction

在”将transaction的state更新为applied”步骤之后,就不应该再回滚事务了。相反,让这个事务完成并且创建一个新的事务来抵消掉原事务。

处于pending状态的事务

在”将transaction的state更新为pending”步骤之后,但是在”将transaction的state更新为applied”步骤之前,你可以使用下面的步骤回滚事务:

1.将transaction的state更新为canceling

更新transaction的state从pending更新为canceling。

db.transactions.update(
   { _id: t._id, state: "pending" },
   {
     $set: { state: "canceling" },
     $currentDate: { lastModified: true }
   }
)

更新成功之后,方法会返回WriteResult()对象,它的nMatched与nModified字段都为1

2.在源账户与目标账户上撤销事务

如果事务已经被执行的话,在两个账户上反过来执行事务。更新条件要记得包含pendingTransaction:t._id,这样做是为了保证只有被执行过事务的账户被更新。

更新目标账户,从它的balance字段中减去transaction的value字段并且从pendingTransactions数组中移除事务的_id:

db.accounts.update(
   { _id: t.destination, pendingTransactions: t._id },
   {
     $inc: { balance: -t.value },
     $pull: { pendingTransactions: t._id }
   }
)

更新成功之后,方法会返回WriteResult()对象,它的nMatched与nModified字段都为1。如果处在pending状态的transaction还没有在这个账户上执行,那么就没有文档能够满足更新条件,此时nMatched和nModified的值就是0.

源账户也按同样的方式更新,只不过此时balance是加上transaction的value:

db.accounts.update(
   { _id: t.source, pendingTransactions: t._id },
   {
     $inc: { balance: t.value},
     $pull: { pendingTransactions: t._id }
   }
)

更新成功之后,方法会返回WriteResult()对象,它的nMatched与nModified字段都为1。如果处在pending状态的transaction还没有在这个账户上执行,那么就没有文档能够满足更新条件,此时nMatched和nModified的值就是0.

3.将transaction的state更新为canceled

将transaction的state从canceling更新为cancelled,完成回滚。

db.transactions.update(
   { _id: t._id, state: "canceling" },
   {
     $set: { state: "cancelled" },
     $currentDate: { lastModified: true }
   }
)

更新成功之后,方法会返回WriteResult()对象,它的nMatched与nModified字段都为1

多个应用


事务能够保证多个应用在创建与运行操作时不会导致数据的不一致与冲突。在我们的程序中,在更新与检索transaction文档时总是会加上state字段来防止事务被多个应用重复执行。

举个例子,应用App1与App2都想获得同一个状态为initial的transaction。App1在App2启动之前就执行完了整个事务。当App2试着执行”将transaction的state更新为pending”操作时就会因为state:intial条件而无法检索到任何文档,并且nMatched和nModified会返回为0.这个结果会让App2返回到第一步重新检索一条事务。

当多个应用在运行时,要保证在任何时间点上都只有一个应用在处理给定事务。这样的话,在更新条件中除了要指定state以外,还可以在transaction文档中创建一个标识来表明哪一个应用在处理该事务。使用findAndModify()方法在一步中修改transaction并且得到它:

t = db.transactions.findAndModify(
       {
         query: { state: "initial", application: { $exists: false } },
         update:
           {
             $set: { state: "pending", application: "App1" },
             $currentDate: { lastModified: true }
           },
         new: true
       }
    )

这样就保证了只有唯一一个满足application字段标识的应用才可以执行事务。

如果App1在执行事务的过程中失败了,你可以使用恢复程序,但是应用在恢复事务之前应该确保他啊“拥有”该事务。举个例子,为了找到并且恢复一个处于pending状态的事务,使用类似如下的查询:

var dateThreshold = new Date();
dateThreshold.setMinutes(dateThreshold.getMinutes() - 30);

db.transactions.find(
   {
     application: "App1",
     state: "pending",
     lastModified: { $lt: dateThreshold }
   }
)

在生产中使用两阶段提交


上面的事务的例子被有意地构造得很简单。举个例子,它假定了关于账户的操作总是可以回滚的并且账户的余额可以为负值。

生产中的实现将会复杂得多。比如账户往往需要余额,信用预支(pending credits)与待提款(pending debits)的信息。

对于所有的事务,你要确保在部署中使用了合适的write concern等级。