MongoDB事务特性实现分析

2,188 阅读16分钟

本文正在参加「技术专题19期 漫谈数据库技术」活动

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第2天,点击查看活动详情

MongoDB事务特性实现分析

这篇文章主要针对MongoDB4.0版本来进行讲述,其他版本可能会内容有些差异。文章主要从锁,读写关注,因果一致性等层面来讲述MongoDB事务如何来实现一致性,隔离性,持久性等特点。

在mongodb中通过给全局(global),数据库(database),集合(collection)加锁来保障一致性,同时不同的存储引擎也实现了各自的更细粒度的锁控制(WiredTiger存储引擎实现了在文档(document)级别的锁)。除了加锁的级别之外,同时对于锁的类型也有不同的划分,基本上锁的类型分为以下几种:

  • 共享锁(S):通常对应读操作,对应读操作多个读操互不影响,可以同时读;
  • 排他锁(X):通常对应写操作,对应写操作但是排他锁不会和任何锁共同存在,也就是说当一个集合添加了排他锁之后,那么其他任何形式的锁都无法在应用到这个集合中;
  • 意向锁(IS,IX):当锁在某一个级别的时候,那么所有更高级别都被添加意向锁;

举例来说,当一个集合因为写操作添加了排他锁X,那么这个集合对应的数据库(database),全局(global)都会被添加意向排他锁IX。

另外意向共享锁IS和意向排他锁IX可以同时存在,但是排他锁X不能和任何锁共存,共享锁S可以和意向共享锁IS共存。

整体来看就是

  • 要对一个节点添加IS/S时,需要获取到父节点的IS锁;
  • 要对一个节点添加IX/S时,需要获取到父节点的IX锁;

锁兼容

那么对于这几种锁来讲,可以结合锁兼容矩阵来了解机中锁的兼容情况(MongoDB中只涉及S,X,IS,IX四种) image.png

意向锁可以兼容意向锁,S锁可以兼容S锁,X锁不兼容任何锁;

锁优化

在MongoDB中为了优化吞吐量,对于多个读写操作的锁进行了优化。当一个请求获取到锁的时候,同时此时所有和他兼容的请求都可以被授予锁

举个例子,当多个锁按照以下顺序进行时
IS → IS → X → X → S → IS
如果是一般队列那么就是先进先出的进行顺序处理,但是MongoDB会优化这些操作的锁授权,在这时候MongoDB会授权给所有的IS和S锁,直到他们完成在授权给X锁。但是切记这是对锁授权的这一刻的锁来进行的,如果IS和S已经获取锁授权,但是后续还有其他操作产生,那么新的操作不属于这一时刻范畴需要等到这一时刻的X操作完成后,再结合锁进行处理。

在了解以上信息后,结合下图是常见的MongoDB操作所产生的对应锁关系 mongodb并发-操作对应的锁.png 从这图也可以看到为什么MongoDB的最佳实践中,创建索引要求采用后台创建的方式了。 以下是锁和对应锁模型的关系表

锁模型
RS
WX
rIS
wIX

不同Mongo部署模式下的并发

分片集群

  • 分片集群中分片server(mongos)可以在多个并发操作在多个mongod实例中;
  • 锁在每一个分片上而不是整个集群(每一个mongod实例在集群中都是独立并且拥有它自己的锁,在A mongod实例上的操作不会影响其他mongod实例的操作);

总的来说在分片集群模式下,mongodb的并发处理会有很大的提升,首先是多个mongod实例的事务是独立的,不会出现A实例阻塞B实例,并且mongodb server(mongos)可以通过多个mongod实例来并发执行操作。

副本集

  • primary节点

在副本集中,当写入到一个集中数据时,同时也会在 ‘local’的数据库中记录oplog,也就是说会同时锁两个数据库。因此当写操作发生时,副本集的primary都会被锁。

  • secondaries节点

在副本节点中不会进行写操作,因此主要针对读来说,在4.0版本之前,副本节点的读操作还可能会被未完成的副本复制任务阻塞,但是4.0版本之后,读取和复制可以同时处理,因为如果副本节点正在进行复制,则可以通过从WiredTiger快照读取数据。

事务

在4.0版本之后,MongoDB不仅对于支持单文档操作事务,同时也支持多文档事务。MongoDB的单文档操作永远是原子性的,但是多文档操作则不一定,需要借助事务来控制原子性。

注意: 在4.0版本MongoDB的多文档事务只支持副本集模式,分片集群还不支持多文档事务,MongoDB在4.2版本支持分片集群的多文档事务。

另外多文档事务只有WiredTiger存储引擎支持,其他存储引擎不支持,但是WT作为默认存储引擎也不需要我们特殊配置。

以下是一个多文档事务的示例,在一次事务操作中我们更新/影响多个记录。

session = db.getMongo().startSession( { readPreference: { mode: "primary" } } );

employeesCollection = session.getDatabase("hr").employees;
eventsCollection = session.getDatabase("reporting").events;

// Start a transaction
session.startTransaction( { readConcern: { level: "snapshot" }, writeConcern: { w: "majority" } } );

// Operations inside the transaction
try {
   employeesCollection.updateOne( { employee: 3 }, { $set: { status: "Inactive" } } );
   eventsCollection.insertOne( { employee: 3, status: { new: "Inactive", old: "Active" } } );
} catch (error) {
   // Abort transaction on error
   session.abortTransaction();
   throw error;
}

// Commit the transaction using write concern set at transaction start
session.commitTransaction();

session.endSession();

就如上面提到的多文档事务也是满足事务的基本特性的,因此当多文档事务中

  • 当事务提交后,事务中的所有改动才保存并且可以被其他事务看到,在事务提交之前,那么改动是不会被其他外部事务看到的;
  • 当一个事务终止,事务中的所有数据改动都会被撤回,并且事务中的改动不会被外部看到。也就是说如果事务中任何的操作失败了,那么事务会终止并且所有的数据改动都会被撤回,并且不会被外部事务看到。

重试事务和重新提交

  • 如果一个写事务失败了,那么不论retryWrites有没有被设置成为true,事务都是不会重试的;
  • 如果提交操作失败,那么不论retryWrites有没有被设置成为true,都会进行一次重新提交;

这是因为事务失败可能是条件不满足了,那么表示当前数据已经不再支撑事务结果;而重新提交表示当前事务结果是满足的,可能因为超时等其他原因没有提交成功,因此会进行重试。

事务超时

  • 事务执行超时时间

执行一次事务,事务有效的存活时间默认60s,通过transactionLifetimeLimitSeconds参数设置,当事务执行超过设定时间,事务过期终止。

  • 事务等待锁超时时间

通常情况下事务默认等待5ms来获取事务需要的锁,如果超过5ms还没获取到锁事务终止。可以通过参数maxTransactionLockRequestTimeoutMillis来控制。

进行中的事务和写冲突处理

如果在一个多文档事务t1处理中,这时候有一个外部事务t2要处理一个document A,document A也在多文档事务处理的范围内,那么多文档事务是否可以成功执行根据以下结果来判断

  • 在t2事务处理A的时候,t1没有获取到A的锁,那么t2执行成功,t1事务处理A的时候终止,因为写入冲突;
  • 在t2事务处理A的时候,t1已经获取到A的锁,那么t1执行成功,t2需要等到t1释放A的锁之后在处理A;

进行中的事务和过时的读处理

对于事务中读取的数据是无法完全保证数据是其他事务已经提交的数据。

例如:一个事务处理中,外部事务t2删除了文档A,但是事务中的一个读操作在外部事务t2提交前以readConcern{level:snapshot}级别读取这个删除的文档A。

假如要避免这个情况可以使用findOneAndUpdate()

session.startTransaction( { readConcern: { level: "snapshot" }, writeConcern: { w: "majority" } } );
employeesCollection = session.getDatabase("hr").employees;
employeeDoc = employeesCollection.findOneAndUpdate(
   { _id: 1, employee: 1, status: "Active" },
   { $set: { employee: 1 } },
   { returnNewDocument: true }
);
  • 如果在处理employee文档的时候已经被外部事务修改,那么事务终止;
  • 如果处理employee的时候,文档没有被修改过,那么给文档加锁事务正常处理;

默认事务隔离级别

MongoDB采用读未提交作为默认的事务隔离级别。当读关注级别是‘local’和‘avariable’的时候可以看到其他写操作未提交的数据。

Read uncommitted is the default isolation level and applies to mongod standalone instances as well as to replica sets and sharded clusters.

MongoDB事务提交

在单点的部署模式下事务提交就比较简单,事务获取锁然后执行,执行完成后进行commit。由于副本集也是只有通过主节点进行写入,然后secondary同步主节点数据,因此副本集模式下和单点事务处理是一样的。

对于分片集群来说,事务在每个shard都是独立的,事务的提交包含两阶段:

  • 准备阶段: 协调者向其他节点发送准备命令,每个节点返回一个prepare timestamp,协调者选择一个最大值作为commit timestamp;
  • 提交阶段: 协调者将commit timestamp作为提交时间戳,广播到所有节点,如果所有节点返回成功那么事务处理成功,如果有没成功的在进行新的准备阶段;

如果事务失败了,那么还是一样的协调者发送rollback请求到其他节点,其他节点处理完成后返回结果给协调值。

读关注/写关注

在真正了解事务一致性和隔离性特点之前,先了解一下MongoDB中的读关注和写关注概念,MongoDB通过读关注(readConcern)和写关注(writeConcern)来控制一致性和隔离性。通过写关注来保证数据被写入到mongod实例中,通过读关注来保障一致性和隔离性。

在MongoDB副本集中,primary和secondary节点即是投票节点也是数据承载节点,arbiter节点支持投票节点不作为数据承载节点。了解这些很重要这涉及到'majority'情况下读关注和写关注的"大多数节点判断"。

大多数的判断逻辑

对于"大多数的判断"是以全部投票节点(包含arbiter)和全部数据承载节点(不包含arbiter)中的更小的值来作为"大多数判断的值",也就是说按照全部数据承载节点来计算。

如何在mongo shell中设置写关注和读关注级别

session.startTransaction( { readConcern: { level: "snapshot" }, writeConcern: { w: "majority" } } );

写关注(writeConcern)

写关注主要是为了确保写操作已经被传播到指定数量的mongod实例,简单来说就是确保这次写操作的事务已经应用到对应的节点上。写关注确保写入操作已经被成功的保存到集群中。

{ w: <value>, j: <boolean>, wtimeout: <number> }
级别描述
{number}表示可以填写一个数字number,表示这次写请求需要确认已经传播到指定的number个mongod实例。例如:number=1的时候{w:1},表示在写操作要在单实例上完成或者在副本集的primary上面完成,如果{w:2}则表示写操作必须传播到副本集中primary节点和1个secondary节点
majority表示当写操作请求需要确认传播到所有数据承载节点
{custom write concern name}通过自定义的方式来表示写操作需要确认的范围, 简单来说就是给节点打上不同的标签,然后自己定义一个write concern指定哪些标签属于writer concern范围,具体方式可以参考官方链接

在写关注中还有一个重要参数j,w参数决定写操作成功执行需要确认的节点个数,j参数决定在每个节点上写到那里就算成功也就是写入到成员的内存,还是磁盘才算成功。对于j参数的值来说很简单

  • {j: true}: 表示写入到磁盘才算成功;
  • {j: false}: 表示写入到内存就算成功(数据有丢失的可能性,例如机器宕机);

读关注(readConcern)

读关注包含以下级别,设置不同的读关注可能会读取到不同的数据结果。读关注主要控制一致性和隔离性。

readConcern: { level: <level> }
or
db.collection.find().readConcern(<level>)
级别描述是否支持因果一致性是否支持多文档事务
local查询返回的结果不一定被写入到大多数副本集节点,可能会获取到其他写操作没有提交的数据,也就说使用local级别查询的数据可能会被回滚出现,读到的是读未提交的数据yesyes
avariable查询返回的结果不一定被写入到大多数副本集节点,和local类似也是会读取到其他事务未提交的数据nono
majority查询会返回已经被大多数副本集成员确认的结果,也就是说majority的时候读取返回的结果已经是持久化的yesyes
linearizable线性化理解起来就是只会获取在当前读事务开始之前,已经在大多数节点确认完成写入操作的结果nono
snapshot查询从大多数已经提交数据的快照中返回结果,在4.0版本中只有在多文档事务是可用,但是后续版本可能调整适用范围yesyes

对于读关注是majority的时候
1.非多文档事务中majority只会读取到其他事务已经提交并且保存到大多数节点的数据;
2.在多文档事务下只有当写关注也是majority级别,才能保证读取的是已经提交并且保存到大多数节点的数据,否则也无法保证读取到的数据是已经提交的数据(有回滚出现不一致的可能)

默认读写关注级别

在MongoDB的5.0版本之前使用{w:1}作为默认的写关注级别,在5.0版本之后使用{w:majority}作为默认写关注级别。

MongoDB使用readConcern{level:local}作为默认的读关注级别在primary和secondary节点上(读未提交)。在MongoDB4.4之前版本如果在secondary中的读操作不是因果一致性操作,那么使用readConcern{level:avariable}。

在了解了读写关注的特点之后,我们就可以发现对于一个写入操作是否可以被其他读操作发现,需要将读写关注结合起来看。

可以结合以下两个例子来看
1.如果写操作写关注是{w:1},读操作读关注是readConcert{level:majority}, 那么在写操作刚完成的那一刻和副本集复制这次写操作到大多数secondary节点的这段时间,读操作readConcert{level:majority}是永远不会读取到这次写入的数据的,直到数据被复制到大多数secondary节点。
2.如果写操作写关注是{w:majority},读操作读关注是readConcert{level:majority}, 那么在写操作完成的时刻,读操作就可以看到该请求。

对于更多读写关注和因果一致性的描述可以参考官方文档

读偏好(read preference)

读偏好描述MongoDB怎么路由读操作到副本集的节点上。默认情况下副本集中read preference = primary,也就是说默认情况下从primary读取数据。 image.png

读偏好模式描述是否支持分片集群对冲读在4.4版本后
primary默认模式,所有读操作从副本集primary读取,多文档事务中必须使用primary模式no
primaryPreferred大多数情况下也是从primary读取,但是当primary不可用的时候从secondary节点读取yes
secondary所有操作从secondary节点读取数据yes
secondaryPreferred大多数情况下从secondary读取,但是当没有secondary节点可用的时候从primary读取yes
nearest根据一定的条件从primary或者secondary中选择读取,影响因素有localThresholdMS,maxStalenessSeconds,标签yes

在MongoDB4.4版本之后,对于分片集群可以使用对冲读,对冲读mongos实例可以路由读请求到每个查询分片的两个副本集上,并且从每个分片的第一个响应返回结果。

因果一致性

因果一致性:简单来说就是操作之间存在某些逻辑上的依赖关系,那么这些操作就成为了因果操作,那么对应的结果也要满足这些操作的因果顺序。

例如:要执行一个删除操作,然后再进行一次查询操作验证删除结果,那么这两个操作就要按照因果顺序来执行。

因果一致性不像强一致性那么严格,但是又比最终一致性要严格一些,因果一致性要求操作按照依赖的顺序执行达到最终的一致性。

因果一致性主要由一下几点来保证:

  • read your write(读写一致性): 确保自己要可以读取到自己的写入操作;
  • monotonic-reads(单调读一致性): 强调多次读取的数据应该是一样的;
  • monotonic-writes(单调写一致性): 如果我们按照一定顺序执行写入操作,那么所有mongod实例都应该按照该顺序进行写入操作;
  • writes-follows-reads(写后读一致性): 如果我们以w1写入操作的结果读取数据进行操作,然后进行w2写入操作,那么w2的结果应该在w1操作之后被看到;

集群时间

在读写关注和因果一致性的基础上,mongodb利用集群时间确保我们的目标查询是不回退的。对于客户端的每个操作都会加上一个时间戳,然后从副本中读取数据的时候,副本要确保只能返回时间戳之后的数据。

因果一致性示例

设置causalConsistency=true来声明因果一致性session。

function main() {
  await client.connect();

  const db = client.db(dbName);

  const collection = db.collection('testCollection');
  const session = client.startSession({ causalConsistency: true });

  const newDocument = await collection.insertOne({}, { session, writeConcern: { w: "majority" } }); 
  const foundDocument = await collection.findOne(
    {_id: newDocument.insertedId},
    { session, readConcern: "majority" }
  );

  console.log(foundDocument);
}

不同MongoDB部署模式下的读写请求处理

  • 单点模式: 只有1个mongod示例,读写都在一个节点处理;
  • 主从模式: 主从模式从本质上讲不是高可用架构,只是提供多了1个副本而已,我们可以根据自己的应用来配置读写是否分离,但是主从模式存在一定的数据延时(延时问题在副本集模式下同样存在);
  • 副本集模式: 根据读偏好来确定读操作被路由到哪个节点,默认是从主节点读,主节点写;
  • 分片集群模式: 分片集群主要包含shard,mongos,configservers,分片集群中mongos将请求路由到分片上之后,每一个分片又被部署为副本集的方式
    • shard: 每1个shard包含分片数据的子集,1个分片被部署为1个副本集(从3.6版本开始shard必须被部署为副本集);
    • mongos: 在客户端程序和分片集群中接口处理请求路由;
    • config servers: 存储集群配置和元数据;

在分片集群mongos结合读偏好(read preference)和replication.localPingThresholdMs配置,来决定请求分配到哪个shard副本。具体选择shard的那个副本按照以下规则来处理

  • read preference=primary,那么请求从到副本集的主节点读取数据
  • read preference!=primary,那么会选择对冲读来处理。在选择secondary副本集成员的时候结合localPingThresholdMs参数来判断,replication.localPingThresholdMs参数默认是15毫秒,mongos通过这个参数决定使用哪些secondary副本集成员。
    • 选择成员中ping时间最小的成员;
    • 构造一个15ms内ping时间最接近的成员列表;
    • 在这个列表中随机选择成员;
    • 最大10s间隔重新计算一次ping时间;

简单来说分片集群先根据read preference值来判断,当read preference != primary的时候,在根据localPingThresholdMs参数选择使用哪些secondary副本集成员。

分片集群下的写请求也是通过mongos来处理

总结

在结合官方的文档了解完锁,事务,读/写关注,读偏好,因果一致性,集群时间等诸多因素后发现,mongodb通过诸多方式来确保事务的原子性,一致性,隔离性和持久性,关于持久性还涉及journal相关的事情。

还是推荐使用默认配置除非有特殊需要,否则不要修改mongodb的默认相关配置