更多关于MongoDB的技术分享请关注我的公众号:mongodb_side
github版本 - 89f702135d6060ced78ab998ae0708ca62f5aafc
根据2014-08-13官方文档快照翻译(v2.6.4)
翻译 shingo(6623662005@163.com)
简介
本篇文档提供了一个使用二阶段提交将数据写入多个文档的方法来处理多文档更新或“多文档事务”。在此基础上,你可以扩展实现类似数据回滚的功能。
背景
在MongoDB中,作用于单个document的操作总是原子性的;但是,涉及到多个document的操作,也就是我们常说的“多文档事务”,是非原子性的。由于document可以设计的非常复杂并且能包含多个“内嵌”document,因此单文档原子性对很多实际场景提供了必要的支持。(译者注:比如你要批量更新某批商品的出厂日期,可以将这些商品信息放在同一个document中做内嵌。但是我几乎没有使用过这种方法,会有很多额外的问题,比如频繁操作会导致document move。)
尽管单文档原子操作能满足不少需求,但是在很多场景下仍然需要多文档事务的支持。当执行一个由几个顺序操作组成的事务时,可能会出现某些问题,例如:
-
原子性:如果某个操作失败了,同一个事务内发生在它之前的所有操作必须“回滚”到最初的状态(即“要么全OK,要么什么也不做”)。
-
一致性:如果发生了严重故障将事务中断(网络、硬件故障),数据库必须恢复到一致的状态。
对于需要多文档事务的场景,你可以在应用中实现二阶段提交来提供支持。二阶段提交可以保证数据的一致性,如果发生错误,事务前的状态是可恢复的。在事务执行过程中,无论发生什么情况都可以还原到数据和状态的准备阶段。
注意: 因为在MongoDB中只有单文档操作是原子性的,二阶段提交只能提供类似事务的语义。在二阶段提交或回滚进行中,应用程序可以返回任意步骤点的中间数据。 |
执行二阶段提交
概述
考虑这样一个场景,你想从账户A转账给账户B。在关系型数据库系统中,你可以在单个多语句事务中先减少账户A的资金然后为账户B增加资金。在MongoDB中,你可以模拟实现一个二阶段提交得到同样的结果。
本节中的所有示例使用下面两个集合:
1. 集合accounts保存账户信息。
2. 集合transactions保存转账事务信息。
初始化源账户和目标账户
将账户A和账户B的信息写入到集合accounts。
db.accounts.insert( [ { _id:"A",balance:1000,pendingTransactions: [] }, { _id:"B",balance:1000,pendingTransactions: [] } ] ) |
上面的语句返回一个BulkWriteResult()对象,包含了本次操作的状态信息。如果成功写入,BulkWriteResult()对象中的 nInserted的值为2。(译者注:在2.6版本后写操作都会返回WriteResult对象,批量写会返回BulkWriteResult,具体请见相关章节)
初始化转帐数据
将每笔转账信息写入到transactions表,转账数据包含以下字段:
-
source 和 destination字段,指向accounts集合中的_id值
-
value字段,表示转账金额,影响源账户和目标账户的余额
-
state 字段,表示转账操作当前状态,state字段可选值范围为initial, pending, applied, done, canceling和 canceled
-
lastModified 字段,表示最后更新时间
将账户A向账户B转账100的操作信息初始化到transactions集合, state字段值为"initial", lastModified字段值设为当前时间:
db.transactions.insert( { _id:1,source:"A",destination:"B",value:100, state:"initial", lastModified:newDate() } ) |
上面的语句返回一个WriteResult()对象,包含了本次操作的状态信息,如果写入成功,WriteResult()对象的nInserted值为1。
使用二阶段提交转账
1. 获取transaction集合的数据
从transactions集合查找一条state字段值为initial的数据。当前transactions集合中只有一条数据,也就是说我们在上文初始化转账数据 这个步骤只写入了一条数据。如果集合中有另外的数据,下面的查询会返回任意state字段为initial的数据,除非你附加一些别的查询条件。
var t =db.transactions.findOne( { state:"initial" } ) |
在 mongo shell中定义变量t来打印返回的内容。上边的语句会得到如下输出:
{ "_id":1, "source":"A", "destination":"B", "value":100, "state":"initial", "lastModified":ISODate("2014-07-11T20:39:26.345Z") } |
2. 将transaction数据的state字段设为pending
将transaction数据的state字段从initial设为pending,并用$currentDate操作将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. 对账户进行转账
如果账户不包含transaction信息,用update()方法更新帐户信息,在更新条件中带有pendingTransactions: {$ne: t._id },这是为了避免重复同一次转账。
同时更新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 值,并将transaction 的_id写入到pendingTransactions字段的数组中。
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 值设为applied,并更新lastModified字段值为当前时间:
db.transactions.update( { _id:t._id, state:"pending" }, { $set: {state:"applied" }, $currentDate: {lastModified:true } } ) |
操作成功后,方法会返回 WriteResult()对象, nMatched 和nModified 值为1。
5. 将transaction 数据的_id值从两个账户的pendingTransactions字段中移除
从两个账户中的pendingTransactions 字段中移除state值为applied的 transaction数据的 _id值。
更新源账户
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字段做为指标来决定pending状态的事务是否需要进行恢复;再具体点,如果pending 或 applied 状态的事务在30分钟内未更新过,恢复程序会认为这些事务需要进行恢复。你可以用不同的条件来决定事务是否需要恢复。
max-width: