当前位置: 首页 > news >正文

MongoDB 4.x——高级特性(Change Stream和事务)

MongoDB 4.x高级特性(Change Stream和事务)

    • 1、Change Stream介绍
    • 2、Change Stream案例:数据迁移
      • 2.1、关键点
      • 2.2、实战:使用Change Stream实现增量迁移
        • 2.2.1、准备工作
        • 2.2.2、记录增量日志
        • 2.2.3、全量迁移
        • 2.2.4、增量迁移
        • 2.2.5、验证程序
      • 3、多文档事务
      • 3.1、事务简介
      • 3.2、MongoDB中的事务
        • 3.2.1、在mongo shell中使用事务
        • 3.2.2、验证隔离性
        • 3.2.3、事务超时
    • 4、基于Spring开发事务
      • 4.1、在驱动中实现事务
      • 4.2、使用Spring Data实现事务
        • 4.2.1、事务管理器
        • 4.2.2、使用TransactionTemplate
    • 5、事务实现原理
      • 5.1、MVCC与快照的一致性
      • 5.2、事务持久性
      • 5.3、读写隔离设定
    • 6、写冲突模式
    • 7、使用事务的限制

1、Change Stream介绍

Change Stream指数据的变化事件流,MongoDB从3.6版本开始提供订阅数据变更的功能。在此之前(MongoDB 3.4及以下版本)​,为了实时获得集合文档的变化事件,我们不得不使用tailing oplog(复制集日志)这一方案来完成这样的功能。

众所周知,oplog是实现副本集数据同步的核心手段,为了持续获得oplog的内容,可执行如下的命令:

>db.oplog.rs.find({fromMigrate:{$exists:false}}).addOption(DBQuery.Option.tailable).addOption(DBQuery.Option.awaitData)

其中,fromMigrate是分片迁移的标记,我们需要对这些数据均衡产生的oplog进行过滤,除此之外,对查询的Cursor还设置了tailable(自动滚动)和awaitData(自动等待新数据)两个选项。可见,构造这样的查询是比较复杂的,而且tailing oplog这种方式还存在很多弊端。

  • 解析oplog的工作相对复杂,实现者需要探究MongoDB复制的一些非公开的细节。
  • oplog是全局性的,这意味着,为了订阅某个集合的变更,需要对整个系统的oplog进行过滤,效率太低。另外,获得oplog意味着对所有集合都有变更读的权限,安全风险增加了。
  • 当主备节点发生切换时,oplog存在被回滚的风险,应用可能会获取到“脏”的变更。
  • 在分片集群环境中,你需要对每个分片(shard)进行oplog拉取,此时由于存在数据均衡,很可能会出现乱序问题。

Change Stream特性的出现恰恰解决了这些问题,使用db.collection.watch命令,你可以轻松地获得实时,且顺序一致的数据变化。而且,Change Stream会采用"readConcern:majority"这样的一致性级别,保证写入的变更不会被回滚。

在监听范围方面,Change Stream支持3种不同的级别,见表。

不同的监听级别提升了数据处理的灵活性,而且,你还可以在watch命令中添加聚合过滤器来满足自己的需求。

那么,Change Stream的实现机制和oplog有什么本质的不同吗?答案是并没有,Change Stream仍然是基于底层的oplog机制实现的,为了正常使用Change Stream,你必须使用副本集或者分片副本集架构的MongoDB集群。

执行下面的代码,即可对集合开启监听。

varwatchCursor=db.getSiblingDB("data").sensors.watch()while(!watchCursor.isExhausted()){if(watchCursor.hasNext()){printjson(watchCursor.next());}}

此时,对于sensors集合的一些数据的增加、删除、修改、查询操作,会触发相应的Change Event。通常的Change Event的形式如下:


字段说明见表。

其中,对于变更类型(operationType)的支持见表。

下面是一些样例,可以基本了解一下。

insert事件的代码如下:

delete事件的代码如下:


replace事件的代码如下:

update事件的代码如下:


drop事件的代码如下:

rename事件的代码如下:


dropDatabase事件的代码如下:

invalidate事件的代码如下:

2、Change Stream案例:数据迁移

Change Stream具有诸多优点,在项目实战中很容易找到一些合适的应用场景,例如数据迁移。

尽管现有的微服务架构带来了许多新的理念,但也带来了许多“旧改”的繁杂事情。所谓“旧改”​,往往是把现有的系统架构重构,拆分成多个细粒度的服务,然后找合适的时间进行割接升级。这其中,保证数据的平滑迁移往往会成为一个非常重要且复杂的工作。

那么,服务化改造中的数据迁移的问题有哪些呢?

  • 首先是难度大,做一个迁移方案需要了解项目的“前世今生”​,以评估迁移方案、技术工具等。
  • 其次是成本高。由于新旧系统数据结构是不一样的,因此需要定制开发迁移转化功能。很难有一个通用工具能一键迁移。
  • 再次,对于一些容量大、可靠性要求高的系统,要做到不影响业务,出了问题能追溯,因此在设计方案时要全面、细致。

按照数据迁移的方案及流程,一般可以采取停机迁移、业务双写、追日志(增量)迁移等几种方式。其中增量迁移对业务侵入性最低,能实现较为完美的平滑迁移,这也是本次重点介绍的方案。

增量迁移的基本思路是先进行全量的迁移转换,待完成后持续进行增量数据的处理,直到数据追平后切换系统,如图所示。

2.1、关键点

(1)系统需要支持增量数据的记录保存,而且在全量迁移过程开始之前就应该开始监听。

尽管Change Stream支持断点恢复(resumeAfter)的功能,但其本质是基于oplog实现的。假设全量迁移需要12个小时,而oplog窗口却只有10个小时,则意味全量迁移完成时会有两个小时的增量数据被丢弃。为了保证数据完整,有必要事先保存这些增量数据。

(2)增量数据的回放是持续进行的。在所有的增量数据回放转换过程中,系统仍然会产生新的增量数据,这要求迁移工具能做到将增量数据持续回放并将之追平,之后才能进行系统切换。当然,对于一些业务吞吐量较大的场景,还应该保证迁移程序的写入速度足够块。

2.2、实战:使用Change Stream实现增量迁移

本次设计了一个简单的论坛帖子迁移样例,用于演示如何利用Change Stream实现完美的增量迁移方案。

背景:现有的系统中有一批帖子,每个帖子都属于一个频道(channel)​,见表。

新系统中频道字段将切换为英文简称,相关的转换如图所示。

原理说明:topic是帖子原表,在迁移开始前将开启watch任务持续获得增量数据,并记录到topic_incr表中;接着执行全量的迁移转换,之后持续对增量表数据进行迁移,直到无新的增量为止。

下面我们使用Java程序来完成相关代码,mongodb-java–driver在MongoDB 3.6版本后才支持watch功能,需要确保升级到对应版本,代码如下:

2.2.1、准备工作

(1)定义Channel枚举,用于转换频道信息,代码如下:


(2)为topic表预置一部分数据,用于模拟存量数据,代码如下:

可见,在这段代码的实现中,每个帖子都分配了随机的频道(channel)​。

2.2.2、记录增量日志

(1)对topic表开启监听任务,将所有变更写入增量表topic_incr,代码如下:

上述代码中,通过watch命令获得一个MongoCursor对象,用于遍历所有的变更。FullDocument.UPDATE_LOOKUP选项启用后,在update变更事件中将携带完整的文档数据(FullDocument)​。

尽管如此,FullDocument可能仍然会产生空值,原因在于Change Stream只保证事件产生的顺序一致性,在产生update事件后会尝试对源文档进行查询,如果此时文档已经被删除就查询不到。

watch命令提交后,mongos会与分片上的mongod(主节点)建立订阅通道,这可能需要花费一点时间。

为了模拟线上业务的真实情况,启用几个线程对topic表进行持续写操作,代码如下:


ChangeTask的实现逻辑如下:

每一个变更任务会不断对topic表产生写操作,触发一系列ChangeEvent产生。

  • doInsert:生成随机频道的topic表后,执行insert操作。
  • doUpdate:随机取得一个topic表,将其channel字段改为随机值,执行update操作。
  • doReplace:随机取得一个topic表,将其channel字段改为随机值,执行replace操作。
  • doDelete:随机取得一个topic表,执行delete操作。

以doUpdate为例,实现代码如下:

2.2.3、全量迁移

在开启监听之后,就可以执行全量的迁移任务,将topic表中的数据迁移到topic_new新表,代码如下:


在全量迁移开始前,先获得当前时刻的最大_id值(可以将此值记录下来)作为终点。随后逐步完成数据转换和写入。

2.2.4、增量迁移

在全量迁移完成后,便可以开始执行增量迁移任务。需要注意的是,在增量迁移过程中,变更操作仍然在进行。相关的代码如下:



增量迁移的实现是一个不断拉取的过程,利用_id字段的有序特性进行分段迁移,即记录下当前处理的_id值,循环拉取在该_id值之后的记录进行处理。

一般情况下,增量表(topic_incr)中除了delete事件变更,其余的类型都保留了整个文档,因此可直接利用replace+upsert操作追加到新表。更新事件中的fullDocument可能为空,需规避处理。

2.2.5、验证程序

最后,让我们来梳理一下整个案例的过程:

  1. 预置存量数据。
  2. 启动监听,将增量写入日志表。
  3. 模拟并发的变更任务。
  4. 启动全量迁移。
  5. 全量迁移结束,启动增量迁移。
  6. 停止变更任务,停止向日志表追加,等待增量迁移完成。

好了,现在基本上是完整的了,启动任务后输出如下:

此时若查看topic表和topic_new表,可以发现两者数量是相同的。而为了进一步确认一致性,我们对两个表分别做一次聚合统计。

topic表的代码如下:

输出结果如图所示。

topic_new表的代码如下:

输出结果如图所示。

可见,前后对比的结果是一致的!

3、多文档事务

3.1、事务简介

事务(transaction)是传统数据库所具备的一项基本能力,其根本目的是为数据的可靠性与一致性提供保障。而在通常的实现中,事务包含了一个系列的数据库读写操作,这些操作要么全部完成,要么全部撤销。例如,在电子商城场景中,当顾客下单购买某件商品时,除了生成订单,还应该同时扣减商品的库存,这些操作应该被作为一个整体的执行单元进行处理,否则就会产生不一致的情况。

数据库事务需要包含4个基本特性,即常说的ACID,具体如下。

  • 原子性(atomicity)​:事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行。
  • 一致性(consistency)​:事务应确保数据库的状态从一个一致状态转变为另一个一致状态。一致状态的含义是数据库中的数据应满足完整性约束。
  • 隔离性(isolation)​:多个事务并发执行时,一个事务的执行不应影响其他事务的执行。
  • 持久性(durability)​:已被提交的事务对数据库的修改应该是永久性的。

隔离级别:

在隔离性方面,事务机制需要确保在多个事务并发执行时,其数据的中间状态是彼此不可见的。如果不考虑事务的隔离性,则可能会发生如下几个问题。

  • 脏读,即事务中读取了“脏”的数据,这些数据可能是未提交的,或者是在将来发生了回滚。
  • 不可重复读,在一个事务中,同一条数据的状态是不稳定的,例如,第一次查询和第二次查询获得的结果不同,可能是读到了其他事务提交的结果。
  • 幻读,与不可重复读类似,但幻读所对应的现象是数据的“有无”发生变化。例如,第一个事务执行了范围修改操作之后,而第二个事务插入了新增的数据(在同一范围内)​,此后第一个事务将会发现还存在没有修改的数据行,就好像出现了幻觉一样。

针对这些问题,标准的SQL规范为事务隔离性定义了4种级别。

  • Read Uncommitted(读未提交)​:事务在执行过程中,可能访问到其他事务未经提交的修改,这种级别是最弱的,无法避免“脏读”​。
  • Read Committed(读已提交)​:事务在执行时,可以读取另一个事务已经提交到数据库的结果。该级别可以避免“脏读”​,但事务中多次读取可能产生不一样的结果,因此会存在无法重复读的问题。
  • Repeatable Read(可重复读)​:在同一个事务内,数据所呈现的状态将能持续保持一致(从事务的起始时间点开始)​,当前事务只能读取到本事务所做出的修改。但是该级别所定义的隔离范围并不包括插入操作,即事务还是会读取到其他事务提交的新增数据。
  • Serializable(串行化)​:在该级别下,规定了事务只能串行化执行,而不能并发执行。该隔离级别可以有效防止“脏读”​、不可重复读和幻读的问题,但实际应用中很少使用,因为会带来性能问题。

下表整理了各个级别所应对的问题。

通常,事务的隔离级别越高,越能保证数据库的完整性和一致性。另外,隔离级别越高,对并发性能的影响也更加明显,应用上通常的选择是Read Committed(读已提交)​、Repeatable Read(可重复读)这两种级别。而解决幻读问题的手段,一般是采用MVCC或者锁机制来实现。

3.2、MongoDB中的事务

如果此前对WiredTiger引擎有所了解,就不难理解为什么MongoDB特意将4.0版本的事务称之为多文档事务(multi document transaction)了。WiredTiger引擎本身是支持事务的,而MongoDB在内部实现中则使用了该引擎所提供的事务性API,从MongoDB 3.0版本开始便对单文档的操作提供了事务原子性的保证。在经过多个版本的迭代之后,MongoDB 4.0版本开始支持真正意义的多文档事务(基于副本集)​,如此命名只是便于区分。而从MongoDB 4.2版本开始,提供了跨分片的分布式事务,事务能力得到了进一步完善。

MongoDB的事务是基于逻辑会话(session)的,MongoDB 3.6版本便开始支持会话特性,会话提供了因果一致性的保证。对于事务来说,必须先创建会话才能使用事务,系统允许在任何时刻运行多个会话,但对于每个会话来说,同一时刻只能执行一个事务。这点可以类比多线程任务的场景,把会话看作一个线程,而事务则是绑定到线程上的一个任务单元。

3.2.1、在mongo shell中使用事务

在使用事务之前,需要先创建相关的集合,代码如下:

>use data>db.createCollection("goods")

多文档事务内部不允许执行createCollection这样的DDL操作,包括由insert事件触发的DDL行为都将导致报错。创建一个会话,用于执行事务,代码如下:

>session=db.getMongo().startSession()session{"id":UUID("7a586278-2b42-4572-92ae-f8f9c94418d4")}

接下来,我们启动事务,并向goods集合插入一些文档,代码如下:

>session.startTransaction()>collection=session.getDatabase("data").goods data.goods>collection.insert({_id:0,name:"football",price:79})>collection.insert({_id:1,name:"basketball",price:128})

在事务提交之前,会发现只有在当前会话中(事务内)才能查到写入的数据,而在会话外查询则会得到空的结果:如果我们在会话的外部执行查询,会发现仍然无法找到写入的数据。具体代码如下:

//会话内>collection.find(){_id:0,name:"football",price:79}{_id:1,name:"basketball",price:128}//会话外>db.goods.find()//结果为空

执行事务提交之后,在会话外部成功查到了写入的数据,代码如下:

>session.commitTransaction()>db.goods.find(){_id:0,name:"football",price:79}{_id:1,name:"basketball",price:128}

如果希望回滚事务,则可以使用session.abortTransaction方法,这样一来,事务中的所有修改都会被永久撤销。

3.2.2、验证隔离性

MongoDB的事务采用了快照(snapshot)一致性的隔离级别,即事务之间基于自身的快照上下文实施读写操作。分别开启两个事务,在第一个事务中执行修改,代码如下:

//事务一>session=db.getMongo().startSession()>session.startTransaction()>collection=session.getDatabase("data").goods//修改文档>collection.update({_id:1},{$set:{price:99}})//插入文档>collection.insert({_id:2,name:"pingpong",price:31})//删除文档collection.remove({_id:0})

此时,第一个事务还未提交,我们在第二个事务窗口中进行查询,代码如下:

//事务二>session=db.getMongo().startSession()>session.startTransaction()>collection=session.getDatabase("data").goods>collection.find(){_id:0,name:"football",price:79}{_id:1,name:"basketball",price:128}

此时事务二看到的仍然是原来的状态(事务一启动之前)​。将第一个事务进行提交,并确认已经生效,代码如下:

//事务一>session.commitTransaction()>db.goods.find(){_id:0,name:"football",price:79}{_id:1,name:"basketball",price:128}

再次在事务二中进行查询,代码如下:

//事务二>db.goods.find(){_id:0,name:"football",price:79}{_id:1,name:"basketball",price:128}

结果是尽管事务一已经提交了相关修改,但这些修改对事务二仍然是不可见的。MongoDB的快照隔离级别是比可重复读更严谨的一种级别,除了解决不可重复读的问题,还避免了幻读,如上述过程中,事务一的提交中尽管插入了新的记录,但在事务二中仍然无法读取出来。

3.2.3、事务超时

在执行事务的过程中,如果操作太多,或者存在一些长时间的等待,则可能会产生如下异常:


原因在于,默认情况下MongoDB会为每个事务设置1分钟的超时时间,如果在该时间内没有提交,就会强制将其终止。该超时时间可以通过transactionLifetimeLimitSecond变量设定。

4、基于Spring开发事务

4.1、在驱动中实现事务

MongoDB的客户端驱动已经全面支持事务功能,对于使用MongoDB Java Driver的应用来说,必须升级到3.11.0版本以上,代码如下:

在编程模型上,MongoDB事务的开发与关系型事务比较相似,实现事务的代码片段如下:

事务需要绑定在会话中运行,因此第一步总是需要启动一个ClientSession对象。ClientSession实现了Java中的AutoClose接口,这是JDK9提供的try-with-resources特性,即只需要在try语句中初始化资源对象后,编译器会在资源使用完毕后自动调用close方法进行释放。

例子中的事务包含了插入文档、更新文档的操作,且在事务过程发生异常时调用session.abortTransaction命令进行回滚。

MongoDB为事务异常定义了两种类型。

  • TransientTransactionError:指事务中的操作所产生的临时错误,如果发生了该异常,则应用程序应该尝试进行重试处理。
  • UnknownTransactionCommitResult:指事务在提交时产生的未知错误,在发生该异常时,应用同样应该尝试重新提交。

上述例子中使用的是Core API调用方式,这需要开发者自行实现事务中发生异常时的重试逻辑。如果希望获得简化,则可以使用Callback API调用方式,代码如下:

这里的session.withTransaction方法的入参是一个TransactionBody对象,我们只需要提供其execute方法的实现即可,此时事务的启动、停止、异常处理则直接交给驱动处理。在Callback API这种风格的实现上,驱动会自动捕获TransientTransactionError、UnknownTransactionCommitResult这两种错误,并在有限的时间内进行重试,该时间一般是120s,这大约是事务超时时间的两倍。

为了理解这种区别,你可以在另外一个事务中对文档(_id=0)进行更新,这样可以产生一个WriteConfilict错误。此时,如果使用Callback API,驱动会自动重试并最终返回成功。

4.2、使用Spring Data实现事务

随着MongoDB多文档事务特性的推出,Spring Data MongoDB也在2.1.0版本之后开始支持事务功能。

通过前面的介绍,我们已了解如何使用Spring DataMongoDB实现数据库的读写,而一般应用的编程会基于两种风格实现:

  • 基于MongoRepository接口实现标准的CRUD。
  • 基于MongoTemplate实现自定义的操作。
4.2.1、事务管理器

为了尽可能保证编程风格的统一,Spring DataMongoDB可以使用Spring传统的事务管理器。而且,事务的加入并不需要改变之前操作数据库的方式。下面,来看一个例子:

在电子商城中,平台方通常会根据用户的消费情况给予 一些福利,例如用户可以使用积分来换取一定的优惠券。对于兑换优惠券这一操作来说,会涉及用户积分表、优惠券表的操作,实现代码如下:


doExchangeCoupon方法用于完成积分的扣减,以及优惠券的生成。如我们所看到的,BonusPointsRepository、CouponRepository都是标准的CRUD接口,分别提供了BonusPoints(用户积分)​、Coupon(优惠券)实体的操作功能。接下来,我们将为这段业务逻辑添加事务的支持。

(1)声明一个MongoTransactionManager事务管理器Bean对象,代码如下:


(2)添加事务注解@Transactional,代码如下:

这里我们新增了一个exchangeCoupon方法,@Transactional注解表示将该方法作为一个事务执行。

@Transactional是Spring Data的声明式事务注解,通过这种注解的方式,Spring Data会以AOP的方式织入事务处理的逻辑(依赖于事务管理器)​,从而避免业务代码的侵入式修改。

此时,可以在SpringBoot程序中对事务功能进行测试,代码如下:


一定要记住,事务不支持隐式的createCollection操作(某些写入操作导致建表)​,在操作事务之前务必确保所读写的集合已经创建。如上述代码中使用mongoTemplate.createCollection确保了这点。

在启动程序后,通过日志可以观察到事务的行为:

4.2.2、使用TransactionTemplate

除了注解的方式,我们还可以使用TransactionTemplate来完成事务的操作,这在使用风格上很像MongoTemplate。具体实现代码如下:

需要注意的是,Spring Data MongoDB是基于Java驱动的,在事务处理上采用的仍然是Core API方式。这意味着应用需要自行处理事务产生的错误(如TransientTransactionError)​。对此官方建议使用Spring Retry组件来解决此类问题。

5、事务实现原理

5.1、MVCC与快照的一致性

快照(snapshot)指的是系统在瞬时间的一致性状态,这保证了事务之间的状态彼此隔离。我们曾经提及过,WiredTiger基于MVCC实现了数据的并发读写控制,而这正是隐藏在事务隔离级别背后的原理。

在MVCC机制中,数据会在内存中同时保存多个版本,这为事务的快照读提供了基础。如果事务对数据产生了修改,则将会在MVCC链表头上追加一个元素,记录的内容为:

  • 写事务的编号transaction_id。
  • 时间戳。
  • 修改的数据。

而事务的读取会从MVCC链表的头部开始查找,根据当前读事务的快照和在元素中修改事务的编号transaction_id来判断是否可读,如果不可读则向链表尾部方向移动,直到找到当前事务可读的版本,如图所示。

事务T0最早发生,而事务T4发生的时刻最晚。由于T1/T2/T3对数据做了修改,那么在MVCC链表中会相应增加3个版本。在快照隔离级别下,读事务T0只能看到T0之前提交的值10,而对于读事务T4来说,由于事务T3并未提交,且事务T2因为回滚而失效,因此它只能读取到12这个版本的值(事务T1的提交)​。

既然事务需要根据自己的快照来确定什么是可见的,那么快照具体都包含了什么呢?在事务开启或首次进行操作时,数据库需要对内部正在执行或将要执行的事务做一次快照,用于保存当时所有事务的状态,并以此来区分哪些事务对当前事务可见,而哪些事务又是不可见的。一个快照对象包含的信息如下:

具体的例子如图所示。

假设在T5时刻,数据库对正在进行的事务创建一个快照,那么产生的结果如下:

此时,对于事务T5能访问的范围包括3个区间:

  • 所有小于T1事务的修改[0,T1)​。
  • 在T1和T4区间内已经提交的事务的修改,这里只有事务T2。

也就是说,在事务T5建立快照的那一刻起,凡是大于snap_max(T4)或者在snap_array(T1,T4)中出现的事务的修改都是不可见的。这个约束将贯穿整个事务过程,譬如事务T1在后面产生了提交,其对于事务T5仍然是不可见的。当然了,事务在读取数据时除了快照,还应该包含自身事务内的修改。

实际上,WiredTiger对事务的支持同时包含了未提交读、提交读、快照一致性读。而MongoDB事务采用的是快照一致性读。

5.2、事务持久性

ACID的一个重要特性就是保证事务的持久性,即事务一旦提交成功,其修改就是永久性的。然而,WiredTiger的写模型是缓冲式的,其为了避免频繁的I/O操作会将数据的修改先存储在内存中,后面再一并刷到磁盘。因此事务中的修改只有在执行CheckPoint操作之后才算是真正落盘。那么,是不是意味着事务的持久性就无法保证了呢?并非如此,MongoDB保证事务持久性的手段是重新操作日志(redo log)​,也就是之前所说的journal日志。

事务在开启时会向日志缓冲区(redo log buffer)预写入一条日志记录,而随后的一些操作也会写入该记录中,当事务提交时也一并将该日志变更为已提交状态。随后,多个并发提交的事务日志会被合并写入磁盘的文件(每100ms刷新一次)中。重新操作日志保证了已提交事务在系统宕机时仍然可以恢复,MongoDB在重启后会检查日志,并执行日志回放。

关于事务的持久性流程如图所示。

5.3、读写隔离设定

在读写级别方面,多文档事务会产生一些不同的约束。

(1)readPreference

在多文档事务中,readPreference被强制约束为Primary,即客户端对事务的读操作只能通过主节点完成。

(2)readConcern(rc)

包括本地(local)读、大多数(majority)读、集群快照(snapshot)读。

  • readConcern=local:默认级别,该级别无法保证脏读。
  • readConcern=majority:只有在事务使用writeConcern=majority时才能保证读大多数提交。
  • readConcern=snapshot:保证从大多数提交的一致性快照上读取,该级别可实现多个分片上的一致性快照。该级别同样只有在writeConcern=majority时才能保证效果。

需要注意的是,readConcern级别是针对事务读取的快照,对于事务内部则始终保持快照的隔离级别。

(3)writeConcern(wc)

事务可选择writeConcern:1或者writeConcern:majority,默认的选项是writeConcern:1。在writeConcern:majority级别下,事务的提交可以实现大多数写。

也只有在writeConcern:majority这样的设定下,事务读操作可以保证从大多数提交的一致性快照中读取,不会存在脏读或幻读等问题。而writeConcern:1的设定则意味着读取操作仅来自本地快照,这可能会导致脏读,但带来的好处是性能的提升。

对于MongoDB集群来说,writeConcern级别对事务的隔离级别存在一定的影响,例如是否存在脏读问题。可参考下面的两种场景。

场景1:writeConcert:1、readConcern:snapshot(见图)


在wc:1级别下,事务2在事务1提交到主节点之后启动,可能读取到“暂态”数据,产生脏读。

场景2:writeConcern:majority、readConcern:snapshot(见图)


在wc:majority级别下,对于rc:snapshot的快照读级别,事务2只会读取事务1启动之前的状态。在这种模式下,可以获得集群内的一致性保证。

6、写冲突模式

对于MongoDB事务,一个令人感兴趣的问题是:当多个事务尝试更新同一个文档时会发生什么?

一种可能的结果是相互覆盖,即以最终执行的事务为准。但这并不是大多数人希望看到的,因为这会带来一些不确定的风险。下面,让我们来完成一个实验。

打开两个mongo shell窗口,分别启动事务,代码如下:

在第一个窗口事务中执行修改,代码如下:

在第二个窗口事务中对同一文档执行修改,代码如下:


结果表明,尽管事务一并未提交,但事务二在执行过程中已经提前检测到了冲突,并产生了异常。

实际上,在事务中对一个文档进行更新时,MongoDB需要获取该文档的一个排它锁,如果在5ms内无法获取则会产生写冲突(WriteConflict)​,并导致事务中止。导致事务内写冲突的原因通常是文档已经被其他事务锁定,或者在产生快照之后被其他非事务性写操作所篡改,如图所示。


同样,对于非事务场景,文档写操作也会尝试获取锁,如果该文档被锁定(可能来自未提交事务的修改)​,那么同样会产生冲突。但不同之处在于,非事务性的写操作会自动重试,直到成功或者产生了超时(OvermaxTimeMs)​,如图所示。

实现资源锁定

通过事务中的冲突检测,我们可以知道当前正在修改的文档是否正在被其他人修改。借由这样的机制,我们就能在事务中实现某种资源的锁定。下面介绍一个例子。

在最开始时,创建锁对应的集合及初始文档,如下:
在事务启动后,执行文档更新以锁定资源,代码如下:

注意,我们在每次更新锁记录时都会使用一个新的ObjectId对象,这是为了保证每次更新都会产生新的值。MongoDB只有当存在真实变更的update操作时才会产生写锁,而ObjectId天生避免了重复问题(由时间戳和计数器所组成)​,因此非常适合这样的场景。

7、使用事务的限制

MongoDB的多文档事务特性存在诸多限制,在使用时仍然需要注意,主要有如下几点。

  • 不允许在事务中对不存在的集合进行操作,执行集合的创建、删除都是禁止的。这同时也包括一些导致集合级联创建的insert、upsert命令。
  • 不允许在事务中对索引进行创建、删除。
  • 事务中不支持对固定集合进行写入。
  • 不允许存在对config、admin、local数据库的读写操作,包括不允许向system.*命名的集合写入数据。
  • 不允许执行一些非常规读写的命令,如listCollection、listIndexes、explain等操作。
  • 事务中不支持collection.count命令,需要使用聚合框架的$count操作来替代。
  • 事务中无法调用事务外部所创建的游标对象进行getMore遍历,而反过来亦是如此。除此之外,事务在性能方面的一些制约因素主要如下。
  • 对于MongoDB 4.0版本,一个事务最多只能包含16MB的修改,原因在于4.0版本将同一个事务写入了一条oplog中,而BSON文档存在不能超过16MB的限制。在MongoDB 4.2版本中该限制已经被解除,但建议应尽量减少超大的事务,通常一个事务内不要超过1000条。
  • 一个事务的最大执行时间不超过60s,超过该时间后事务会被自动淘汰。MongoDB的事务严重依赖于WiredTiger的快照能力,长时间运行的事务会导致WiredTiger的缓存中积压大量未被持久化的数据,进而加大内存使用的压力。业务上应当避免长时间运行的事务。
  • 应小心出现一些长时间运行的DDL操作(如创建索引)等,可能会对事务产生阻塞。
  • 分布式事务是基于二阶段提交的,相比之前的单文档事务模式来说,性能有一定的降级。在业务表设计上,建议尽可能利用单文档模型来保证数据的一致性和完整性。
http://www.jsqmd.com/news/1265691/

相关文章:

  • 无人公司自动化运营:AI代理与编排技术解析
  • Windows 11缺失atl100.dll的完整解决方案与技术解析
  • 《数学三》考研模拟题及解析(六)
  • Linux防火墙与文件共享技术解析
  • 威海海风盐雾环境房屋漏水维修要点与2026本地服务商横向对比 - 雨婺虹房屋维修
  • C++单例设计模式详细讲解
  • Linux内存管理:malloc实现原理与性能优化
  • 民办高校实力如何分辨?权威维度看清优质院校底色,民办本科/民办大学,民办大学有哪些 - 品牌推荐师
  • 鸟枪换炮后的威力
  • 深入解析CC27xx LRFDPBE寄存器与HAL API:从原理到实践
  • 涂胶显影机(Track)技术岗【首席专家】面试打分卡
  • 高德地图与监控画面叠加的仿射变换实践
  • 02-PyTorch框架简介
  • 锂电池剩余寿命预测:DTW对齐与BiLSTM融合方案
  • OpenClaw:基于WSL的跨平台开发工具链配置指南
  • 2026 年现阶段,上海有实力的设备吊装实力厂家推荐几家,吊装效率翻倍的秘密,藏在这套操作流程里-淞琅起重设备 - 行业推荐【认证官】
  • C# 编程语言
  • LeRobot SO-101 机械臂从底层控制到 VLA 视觉语言动作模型完整实操复盘
  • 多模态大模型技术解析与工业应用实践
  • 【Linux系统编程】进程状态的理解
  • 2026 年现阶段大同专业的冷补彩色沥青优质厂家哪家专业,破了的彩色路面不用铲重铺?这玩意儿竟能速修又好看还省钱!-恒达新材料 - 行业推荐官【官方】
  • 5个实用技巧,让每个人都能轻松保存抖音直播回放和批量下载内容
  • 外贸成交38 | 诊断式销售:像医生一样和客户对话 - 外贸圈集团
  • AI降重后论文人工复核的8个关键要点
  • 总结STM32单片机的C语言基础
  • 2024大厂C++笔试备战:从算法模板到工程实践的系统指南
  • C#编程的最佳工具
  • Linux PCI设备探测机制与驱动绑定详解
  • AM62L DDR防火墙寄存器配置实战:从原理到调试
  • 空间语义描述子增强技术与多模态特征融合实践