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、验证程序
最后,让我们来梳理一下整个案例的过程:
- 预置存量数据。
- 启动监听,将增量写入日志表。
- 模拟并发的变更任务。
- 启动全量迁移。
- 全量迁移结束,启动增量迁移。
- 停止变更任务,停止向日志表追加,等待增量迁移完成。
好了,现在基本上是完整的了,启动任务后输出如下:
此时若查看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操作(如创建索引)等,可能会对事务产生阻塞。
- 分布式事务是基于二阶段提交的,相比之前的单文档事务模式来说,性能有一定的降级。在业务表设计上,建议尽可能利用单文档模型来保证数据的一致性和完整性。