MongoDB的事务、ACID和一致性

在前一篇《理解数据库的事务,ACID,CAP和一致性》我已经将数据库的一些基本概念包括事务,ACID,CAP,一致性,隔离性等都深入的介绍了一遍,而此篇主要是针对MongoDB数据库系统做一下深入的了解,主要希望弄清楚如下几个问题:

  • MongoDB是如何实现事务的?有哪些局限?
  • MongoDB的一致性是如何保证的?

MongoDB的事务

首先我们需要知道MongoDB是有多种存储引擎的,不同的存储引擎在实现ACID的时候,使用不同的机制。而Mongodb从3.0开始默认使用的是WiredTiger引擎,本文后续所有文字均是针对WiredTiger引擎。
WiredTiger引擎可以针对单个文档来保证ACID特性,但是当需要操作多个文档的时候无法保证ACID,也即无法提供事务支持。但是,我们是否就无法实现事务呢?实际上,MongoDB本身虽然不支持跨文档的事务,但是我们依然可以可以在应用层来获取类似事务的支持。这其中有很多方式,MongoDB公司的Antoine Girbal曾经撰写过文章详细阐释了五种方式来支持事务,可以参考Reference中的链接。不过在此之前,让我们先了解下MongoDB在单文档上是如何实现ACID特性的。

单文档的ACID是如何实现的?

MongoDB在更新单个文档时,会对该文档加锁,而要理解MongoDB的锁机制,需要先了解以下几个概念:

  • Intent Lock(我把它翻译为意图锁): 意图锁表明读写方(reader-writer)意图针对更细粒度的资源进行读取或写入操作。比如:如果当某个Collection被加了intent lock,那么说明读写方意图针对该Collection中的某个文档进行读或写的操作。如下图所示:

    图1:MongoDB Lock

    上图展示了当reader or writer需要操作文档时,相对更高的层级都需要加intent lock.

  • Multiple granularity locking (我把它翻译为多粒度锁机制): MongoDB采用的是所谓的MGL多粒度锁机制,具体可以参考文末的wiki链接。简单来说就是结合了多种不同粒度的锁,包括S锁(Shared lock),X锁(Exclusive lock), IS锁(Intent Share lock), IX(Intent Exclusive lock),这几种锁的互斥关系如下表所示:

    lockrelation.png

下面,我用一个例子来简单说明下。假设我要更改name为Jim的document

db.user_collection.update({'name': 'Jim'}, {$set: {'age': 26, 'score': 50}})

此时,如图1所示,MongoDB会为name为Jim的document加上X锁,同时为包含该document的Collection,Database和instance都加上IX锁,这时,针对该文档的操作就保证了原子性。
需要注意的是:

  • 如果当age修改成功,而score没有修改成功时,MongoDB会自动回滚,因此我们可以说针对单个文档,MongoDB是支持事务,保证ACID的(严格来说,要想保证Durability,需要在写操作时使用特殊的write concern,这个后边再谈)
  • 所有的锁都是平等的,它们是排在一个队列里,符合FIFO原则。但是,MongoDB做了优化,即当一个锁被采用时,所有与它兼容的锁(即上表为yes的锁)都会被采纳,从而可以并发操作。举个例子,当你针对Collection A中的Document a使用S锁时,其它reader可以同时使用S锁来读取该Document a,也可以同时读取同一个Collection的Document b.因为所有的S锁都是兼容的。那么,如果此时针对Collection A中的Document c进行写操作是否可以呢?显然需要为Document c赋予x锁,此时Collection A就需要IX锁,而由于IX和IS是兼容的,所以没有问题。简单来说,只要不是同一个Document,读写操作是可以并发的;如果是同一个Document,读可以并发,但写不可以。
  • WiredTiger针对global, db, collection level只能使用intent lock。另外,针对冲突的情况,WiredTiger会自动重试。

跨文档的事务支持

前面已经说过,针对多文档,MongoDB是不支持事务的,但是我们的应用却可以自己去实现类事务的功能,这里只针对其中最常用的两步提交方式来做详细阐释。
假设我们有两个账户A和B,现在我们要让账户A转账100元给账户B,我们需要将整个过程放在一个事务当中,来保证数据的一致性。在这个应用模拟的事务当中,需要涉及两个Collection,一个是accounts collection,另一个是transaction collection(用于存储交易的信息和状态)。
先来看下transaction最终成功的大体流程:如图2所示


图2 success transaction

伪代码如下:

initial accounts

bulk_result = db.accounts.insert(
   [
     { _id: "A", balance: 1000, pendingTransactions: [] },
     { _id: "B", balance: 1000, pendingTransactions: [] }
   ]
)
if bulk_result.nInserted != 2:
   print "insert account failed."
  return False

add a transaction

write_result = db.transactions.insert(
    { _id: 1, source: "A", destination: "B", value: 100, state: "initial", lastModified: new Date() }
)
if write_result.nInserted != 1:
  print "transaction failed"
  return False

update transaction to pending

t = db.transactions.findOne( { state: "initial" } )
result = db.transactions.update(
    { _id: t._id, state: "initial" },
    {
      $set: { state: "pending" },
      $currentDate: { lastModified: true }
    }
)
if result.nModified != 1:
  print "transaction failed"
  return False

update accounts & push transaction id

result_source = db.accounts.update(
   { _id: t.source, pendingTransactions: { $ne: t._id } },
   { $inc: { balance: -t.value }, $push: { pendingTransactions: t._id } }
)
result_destination = db.accounts.update(
   { _id: t.destination, pendingTransactions: { $ne: t._id } },
   { $inc: { balance: t.value }, $push: { pendingTransactions: t._id } }
)
if result_source.nModified != 1 or result_destination.nModified != 1:
   # 进入回滚的流程
  ...
   return False

update transaction to applied

result = db.transactions.update(
   { _id: t._id, state: "pending" },
   {
     $set: { state: "applied" },
     $currentDate: { lastModified: true }
   }
)
if result.nModified != 1:
  # 重新update accounts & push transaction id
  # 注意:如果上一步是成功的,pendingTransactions列表中会有相应的Transaction,那么就不会重复更新账户
  ...

pull transaction id

result_source = db.accounts.update(
   { _id: t.source, pendingTransactions: t._id },
   { $pull: { pendingTransactions: t._id } }
)
result_destination = db.accounts.update(
   { _id: t.destination, pendingTransactions: t._id },
   { $pull: { pendingTransactions: t._id } }
)
if result_source.nModified != 1 or result_destination.nModified != 1:
  # 重新执行pull transaction id
  ...

update transaction to done

result = db.transactions.update(
   { _id: t._id, state: "applied" },
   {
     $set: { state: "done" },
     $currentDate: { lastModified: true }
   }
)
if result.nModified != 1:
  # 重新从pull transaction id执行
  ...

包含回滚和失败的整体流程如图3:


图3 whole transaction procedure

从上图可以看出,任何一步失败都有相应的应对措施来保证事务或者执行完毕或者回滚。当然所有的实现都需要应用程序自己实现,更何况如果涉及多个应用并发的情况时,会更加复杂,如何保证多个事务不互相影响,又会进一步增加复杂度,这也就是为什么如果需要此类跨文档事务支持的时候推荐使用关系数据库。

MongoDB的一致性

当我们说外部一致性时,是针对分布式系统所说的CAP理论中的一致性,简单来说就是如何使得多台机器的副本保持一致,实际上Mongodb只能做到最终一致性,总会有“不一致时间窗口”,这是由于Mongodb在更新操作的时候,需要将同样的更新复制到副本节点当中,而这段时间无法保证reader读到的一定是最新数据,即使ReadConcern设置为majority,也只能保证返回目前大多数节点的所持有的数据,而不一定是最新的数据(比如,只有primary节点更新完成,其它所有secondary节点都还没有更新完成)。
当我们说内部一致性时,是针对ACID中的一致性,这里主要针对如何避免脏读,当Mongodb无法在大多数节点成功的更新操作时,会导致回滚操作,这时如果Reader已经读取了更改后的数据,就会产生脏读现象。而避免脏读,可以通过设置Read Concern和Write Concer来实现,关于Write Concern和Read Concern,请参考Reference中链接,已经讲的很详细了,不再赘述。当我们设置Read Concern为majority时,可以保证返回的数据是大多数节点所持有的数据,这种情况是不会发生回滚的,也就避免了脏读。还有一种情况可能出现脏读,就是当writer写数据时,虽然已经写入到了内存当中,但是并没有写入到磁盘中,这时reader读取到了更新后的数据,但当Mongodb将内存中的数据写入磁盘时可能会产生错误,从而导致磁盘写入失败,这时就可能导致该数据丢失,这种情况下也会产生脏读,而为了避免这种情况,我们需要在Write Concern设置的时候使用j:1,这样实际是在写入journal之后才返回写入成功,保证不会出现上述的脏读现象。当然这种情况下,性能势必会受到影响。所以还是要根据业务情况来决定,非关键业务不需要很强的一致性的情况下,也不需要此种设置。

Reference

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342