MongoDB高阶特性:事务、索引

发布时间 2023-10-05 17:21:18作者: yifanSJ

一、事务

一)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中的某个文档进行读或写的操作。如下图所示:
    图片描述
    上图展示了当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),这几种锁的互斥关系如下表所示:
    图片描述

下面,我用一个例子来简单说明下。假设我要更改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所示
图片描述

伪代码如下:

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

四)MongoDB的一致性

1、外部一致性

当我们说外部一致性时,是针对分布式系统所说的CAP理论中的一致性,简单来说就是如何使得多台机器的副本保持一致,实际上Mongodb只能做到最终一致性,总会有“不一致时间窗口”,这是由于Mongodb在更新操作的时候,需要将同样的更新复制到副本节点当中,而这段时间无法保证reader读到的一定是最新数据,即使ReadConcern设置为majority,也只能保证返回目前大多数节点的所持有的数据,而不一定是最新的数据(比如,只有primary节点更新完成,其它所有secondary节点都还没有更新完成)。

db.collection.find(
   {
      // 查询条件
   },
   {
      readConcern: { level: "majority" }
   }
)

2、内部一致性

当我们说内部一致性时,是针对ACID中的一致性,可以通过设置Read Concern和Write Concer来实现

这里主要针对如何避免脏读,当Mongodb无法在大多数节点成功的更新操作时,会导致回滚操作,这时如果Reader已经读取了更改后的数据,就会产生脏读现象。而避免脏读,当我们设置Read Concern为majority时,可以保证返回的数据是大多数节点所持有的数据,这种情况是不会发生回滚的,也就避免了脏读。

还有一种情况可能出现脏读,就是当writer写数据时,虽然已经写入到了内存当中,但是并没有写入到磁盘中,这时reader读取到了更新后的数据,但当Mongodb将内存中的数据写入磁盘时可能会产生错误,从而导致磁盘写入失败,这时就可能导致该数据丢失,这种情况下也会产生脏读,而为了避免这种情况,我们需要在Write Concern设置的时候使用j:1,这样实际是在写入journal之后才返回写入成功,保证不会出现上述的脏读现象。当然这种情况下,性能势必会受到影响。所以还是要根据业务情况来决定,非关键业务不需要很强的一致性的情况下,也不需要此种设置。

3、ReadConcern(读取关注级别)

用于控制读取操作的一致性要求。它可以确保读取操作返回的数据在不同条件下的一致性。

  • local:默认级别,提供较低的一致性保证。
  • available:确保数据来自可用的节点。
  • majority:确保读取的数据已经被多数节点接受,提供较高级别的一致性。
    通过指定 readConcern 选项,可以在查询中或在读取操作中明确设置所需的一致性级别。
db.collection.find(
   {
      // 查询条件
   },
   {
      readConcern: { level: "majority" }
   }
)

4、WriteConcern(写入关注级别):

WriteConcern 用于控制写入操作的数据持久性。它确定了写入操作何时被视为成功,并返回响应。

  • acknowledged:默认级别,确保写入操作被MongoDB接收,但不一定已持久化。
  • w:1:确保写入被主节点接受。
  • w:"majority":确保写入被多数节点接受,提供较高的持久性。
    通过在写入操作中设置 writeConcern 选项,可以明确指定所需的写入关注级别。
db.collection.insertOne(
   {
      // 插入的文档数据
   },
   {
      writeConcern: { w: "majority" }
   }
)

索引