
暂无个人介绍
之前读《LSM-based Storage Techniques: A Survey》做的分享,感觉应该还是有点用的,发出来
本文源自阅读了 MongoDB 于 VLDB 19 上发表的 Tunable Consistency in MongoDB 论文之后,在内部所做的分享(分享 PPT 见文末)。现在把分享的内容整理成此文,并且补充了部分在之前的分享中略过的细节,以及在分享中没有提及的 MongoDB Causal Consistency(也出现在另外一篇 SIGMOD'19 Paper),希望能够帮助大家对 MongoDB 的一致性模型设计有一个清晰的认识。需要额外说明的是,文章后续牵扯到具体实现的分析,都是基于 MongoDB 4.2 (WiredTiger 引擎),但是大部分关于原理的描述也仍然适用 4.2 之前的版本。MongoDB 可调一致性(Tunable Consistency)概念及理论支撑我们都知道,早期的数据库系统往往是部署在单机上的,随着业务的发展,对可用性和性能的要求也越来越高,数据库系统也进而演进为一种分布式的架构。这种架构通常表现为由多个单机数据库节点通过某种复制协议组成一个整体,称之为「Shared-nothing」,典型的如 MySQL,PG,MongoDB。另外一种值得一提是,伴随着「云」的普及,为了发挥云环境下资源池化的优势而出现的「云原生」的架构,典型的如 Aurora,PolarDB,因这种架构通常采用存储计算分离和存储资源共享,所以称之为「Shared-storage」。不管是哪种架构,在分布式环境下,根据大家耳熟能详的 CAP 理论,都要解决所谓的一致性(Consistency)问题,即在读写发生在不同节点的情况下,怎么保证每次读取都能获取到最新写入的数据。这个一致性即是我们今天要讨论的MongoDB 可调一致性模型中的一致性,区别于单机数据库系统中经常提到的 ACID 理论中的一致性。 CAP 理论中的一致性直观来看是强调读取数据的新近度(Recency),但个人认为也隐含了对持久性(Durability)的要求,即,当前如果已经读取了最新的数据,不能因为节点故障或网络分区,导致已经读到的更新丢失。关于这一点,我们后面讨论具体设计的时候也能看到 MongoDB 的一致性模型对持久性的关注。既然标题提到了是可调(Tunable)一致性,那这个可调性具体又指的是什么呢?这里就不得不提分布式系统中的另外一个理论,PACELC。PACELC 在 CAP 提出 10 年之后,即 2012 年,在一篇 Paper 中被正式提出,其核心观点是,根据 CAP,在一个存在网络分区(P)的分布式系统中,我们面临在可用性(A)和一致性(C)之间的选择,但除此之外(E),即使暂时没有网络分区的存在,在实际系统中,我们也要面临在访问延迟(L)和一致性(C)之间的抉择。所以,PACELC 理论是结合现实情况,对 CAP 理论的一种扩展。 而我们今天要讨论的 MongoDB 一致性模型的可调之处,指的就是调节 MongoDB 读写操作对 L 和 C 的选择,或者更具体的来说,是调节对性能(Performance——Latency、Throughput)和正确性(Correctness——Recency、Durability)的选择(Tradeoff)。MongoDB 一致性模型设计在讨论具体的实现之前,我们先来尝试从功能设计的角度,理解 MongoDB 的可调一致性模型,这样的好处是可以对其有一个比较全局的认知,后续也可以帮助我们更好的理解它的实现机制。在学术中,对一致性模型有一些标准的划分和定义,比如我们听到过的线性一致性(Linearizable Consistency),因果一致性(Causal Consistency)等都在这个标准当中,MongoDB 的一致性模型设计自然也不能脱离这个标准。但是,和很多其他的数据库系统一样,设计上需要综合考虑和其他子系统的关联,比如复制、存储引擎,具体的实现往往和标准又不是完全一致的。下面的第一个小节,我们就详细探讨标准的一致性模型和 MongoDB 一致性模型的关系,以对其有一个基本的认识。在这个基础上,我们再来看在具体的功能设计上,MongoDB 的一致性模型是怎么做的,以及在实际的业务场景中是如何被使用的。标准一致性模型和 MongoDB 一致性模型的关系以复制为基础构建的分布式系统中,一致性模型通常可按照「以数据为中心(Data-centric)」和「以客户端为中心(Client-centric)」来划分,下图中的「Linearizable」,「Sequential」,「Causal」,「Eventual」即属于 Data-centric 的范畴,对一致性的保证也是由强到弱。 Data-centric 的一致性模型要求我们站在整个系统的角度看,所有访问进程(客户端)的读写顺序满足同一个特定的约束,比如,对于线性一致性(Linearizable)来说,它要求这个读写顺序和操作真实发生的时间(Real Time)完全一致,是最强的一致性模型,实际系统中很难做到,而对于因果一致性来说,只约束了存在因果关系的操作之间的顺序。Data-centric 一致性模型虽然对访问进程提供了全局一致的视图,但是在真实的系统中,不同的读写进程(客户端)访问的往往是不同的数据,维护这样的全局视图会产生不必要的代价。举个例子,在因果一致性模型下,P1 执行了 Write1(X=1),P2 执行了 Read1(X=1),Write2(X=3),那么 P1 和 P2 之间就产生了因果关系,进而导致P1:Write1(X=1) 和 P2:Write2(X=3) 的可见顺序存在一个约束,即,需要其他访问进程看到的这两个写操作顺序是一样的,且 Write1 在前,但如果其他进程读的不是 X,显然再提供这种全局一致视图就没有必要了。由此,为了简化这种全局的一致性约束,就有了 Client-centric 一致性模型,相比于 Data-centric 一致性模型,它只要求提供单客户端维度的一致性视图,对单客户端的读写操作提供这几个一致性承诺:「RYW(Read Your Write)」,「MR(Monotonic Read)」,「MW(Monotonic Write)」,「WFR(Write Follow Read)」。关于这些一致性模型的概念和划分,本文不做太详细介绍,感兴趣的可以看 CMU 的这两篇 Lecture(Lec1,Lec2),讲的很清晰。MongoDB 的 Causal Consistency Session 即提供了上述几个承诺:RYW,MR,MW,WFR。但是,这里是 MongoDB 和标准不太一样的地方,MongoDB 的因果一致性提供的是 Client-centric 一致性模型下的承诺,而非 Data-centric。这么做主要还是从系统开销角度考虑,实现 Data-centric 下的因果一致性所需要的全局一致性视图代价过高,在真实的场景中,Client-centric 一致性模型往往足够了,关于这一点的详细论述可参考 MongoDB 官方在 SIGMOD'19 上 Paper 的 2.3 节。Causal Consistency 在 MongoDB 中是相对比较独立一块实现,只有当客户端读写的时候开启 Causal Consistency Session 才提供相应承诺。没有开启 Causal Consistency Session 时,MongoDB 通过 writeConcern 和 readConcern 接口提供了可调一致性,具体来说,包括线性一致性和最终一致性。最终一致性在标准中的定义是非常宽松的,是最弱的一致性模型,但是在这个一致性级别下 MongoDB 也通过 writeConcern 和 readConcern 接口的配合使用,提供了丰富的对性能和正确性的选择,从而贴近真实的业务场景。MongoDB 可调一致性模型功能接口 —— writeConcern 和 readConcern在 MongoDB 中,writeConcern 是针对写操作的配置,readConcern 是针对读操作的配置,而且都支持在单操作粒度(Operation Level) 上调整这些配置,使用起来非常的灵活。writeConcern 和 readConcern 互相配合,共同构成了 MongoDB 可调一致性模型的对外功能接口。writeConcern —— 唯一关心的就是写入数据的持久性(Durability)我们首先来看针对写操作的 writeConcern,写操作改变了数据库的状态,才有了读操作的一致性问题。同时,我们在后面章节也会看到,MongoDB 一些 readConcern 级别的实现也强依赖 writeConcern 的实现。MongoDB writeConcern 包含如下选项,{ w: <value>, j: <boolean>, wtimeout: <number> }w,指定了这次的写操作需要复制并应用到多少个副本集成员才能返回成功,可以为数字或 “majority”(为了避免引入过多的复杂性,这里忽略基于 tag 的自定义 writeConcern)。w:0 时比较特殊,即客户端不需要收到任何有关写操作是否执行成功的确认,具有最高性能。w: majority 需要收到多数派节点(含 Primary)关于操作执行成功的确认,具体个数由 MongoDB 根据副本集配置自动得出。j,额外要求节点回复确认时,写操作对应的修改已经被持久化到存储引擎日志中。wtimeout,Primary 节点在等待足够数量的确认时的超时时间,超时返回错误,但并不代表写操作已经执行失败。从上面的定义我们可以看出,writeConcern 唯一关心的就是写操作的持久性,这个持久性不仅仅包含由 j 决定、传统的单机数据库层面的持久性,更重要的是包含了由 w 决定、整个副本集(Cluster)层面的持久性。w 决定了当副本集发生重新选主时,已经返回写成功的修改是否会“丢失”,在 MongoDB 中,我们称之为被回滚。w 值越大,对客户端来说,数据的持久性保证越强,写操作的延迟越大。 这里还要提及两个概念,「local committed」 和 「majority committed」,对应到 writeConcern 分别为 w:1 和 w: majority,它们在后续实现分析中会多次涉及。每个 MongoDB 的写操作会开启底层 WiredTiger 引擎上的一个事务,如下图,w:1 要求事务只要在本地成功提交(local committed)即可,而 w: majority 要求事务在副本集的多数派节点提交成功(majority committed)。 readConcern —— 关心读取数据的新近度(Recency)和持久性(Durability)在 MongoDB 4.2 中包含 5 种 readConcern 级别,我们先来看前 4 种:「local」, 「available」, 「majority」, 「linearizable」,它们对一致性的承诺依次由弱到强。其中,「linearizable」即对应我们前面提到的标准一致性模型中的线性一致性,另外 3 种 readConcern 级别代表了 MongoDB 在最终一致性模型下,对 Latency 和 Consistency(Recency & Durability) 的取舍。下面我们结合一个三节点副本集复制架构图,来简要说明这几个 readConcern 级别的含义。在这个图中,oplog 代表了MongoDB 的复制日志,类似于 MySQL 中的 binlog,复制日志上最新的x=<value>,表示了节点的复制进度。 local/available:local 和 available 的语义基本一致,都是读操作直接读取本地最新的数据。但是,available 使用在 MongoDB 分片集群场景下,含特殊语义(为了保证性能,可以返回孤儿文档),这个特殊语义和本文的主题关联不大,所以后面我们只讨论 local readConcern。在这个级别下,发生重新选主时,已经读到的数据可能会被回滚掉。majority:读取「majority committed」的数据,可以保证读取的数据不会被回滚,但是并不能保证读到本地最新的数据。比如,对于上图中的 Primary 节点读,虽然 x=5 已经是最新的已提交值,但是由于不是「majority committed」,所以当读操作使用 majority readConcern 时,只返回x=4。linearizable:承诺线性一致性,即,既保证能读取到最新的数据(Recency Guarantee),也保证读到数据不会被回滚(Durability Guarantee)。前面我们说了,线性一致性在真实系统中很难实现,MongoDB 在这里采用了一个相当简化的设计,当读操作指定 linearizable readConcern level 时,读操作只能读取 Primary 节点,而考虑到写操作也只能发生在 Primary,相当于 MongoDB 的线性一致性承诺被限定在了单机环境下,而非分布式环境,实现上自然就简单很多。考虑到会有重新选主的情况,MongoDB 在这个 readConcern level 下唯一需要解决的问题就是,确保每次读发生在真正的 Primary 节点上。后面分析具体实现我们可以看到,解决这个问题是以增加读延迟为代价的。以上各 readConcern level 在 Latency、Durability、Recency 上的 Tradeoff 如下, 我们还有最后一种 readConcern level 没有提及,即「snapshot readConcern」,放在这里单独讨论的原因是,「snapshot readConcern」是伴随着 4.0 中新出现的多文档事务( multi-document transaction,其他系统也常称之为多行事务)而设计的,只能用在显式开启的多文档事务中。而在 4.0 之前的版本中,对于一条读写操作,MongoDB 默认只支持单文档上的事务性语义(单行事务),前面提到的 4 种 readConcern level 正是为这些普通的读写操作(未显式开启多文档事务)而设计的。「snapshot readConcern」从定义上来看,和 majority readConcern 比较相似,即,读取「majority committed」的数据,也可能读不到最新的已提交数据,但是其特殊性在于,当用在多文档事务中时,它承诺真正的一致性快照语义,而其他的 readConcern level 并不提供,关于这一点,我们在后面的实现部分再详细探讨。writeConcern 和 readConcern 的关系在分布式系统中,当我们讨论一致性的时候,通常指的是读操作对数据的关注,即「what read concerns」,那为什么在 MongoDB 中我们还要单独讨论 writeConcern 呢?从一致性承诺的角度来看,writeConcern 从如下两方面会对 readConcern 产生影响,「linearizable readConcern」读取的数据需要是以「majority writeConcern」写入且持久化到日志中,才能提供真正的「线性一致性」语义。考虑如下情况,数据写入到 majority 节点后,没有在日志中持久化,当 majority 节点发生重启恢复,那么之前使用 「linearizable readConcern」读取到的数据就可能丢失,显然和「线性一致性」的语义不相符。在 MongoDB 中,writeConcernMajorityJournalDefault 参数控制了,当写操作指定 「majority writeConcern」的时候,是否保证写操作在日志中持久化,该参数默认为 true。另外一种情况是,写操作持久化到了日志中,但是没有复制到 majority 节点,在重新选主后,同样可能会发生数据丢失,违背一致性承诺。「majority readConcern」要求读取 majority committed 的数据,所以受限于不同节点的复制进度,可能会读取到更旧的值。但是如果数据是以更高的 writeConcern w 值写入的,即写操作在扩散到更多的副本集节点上之后才返回写成功,显然之后再去读取,「majority readConcern」能有更大的概率读到最新写入的值(More Recency Guarantee)。所以,writeConcern 虽然只关注了写入数据的持久化程度,但是作为读操作的数据来源,也间接的也影响了 MongoDB 对读操作的一致性承诺。writeConcern 和 readConcern 在实际业务中的应用前面是对 writeConcern 和 readConcern 在功能定义上的介绍,可以看到,读写采用不同的配置,每个配置下面又包含不同的级别,这个接口设计对于使用者来说还是稍显复杂的(社区中也有不少类似的反馈),下面我们就来了解一下 writeConcern 和 readConcern 在真实业务中的统计数据以及几个典型应用场景,以加深对它们的理解。 上面的统计数据来自于 MongoDB 自己的 Atlas 云服务中用户 Driver 上报的数据,统计样本在百亿量级,所以准确性是可以保证的,从数据中我们可以分析出如下结论,大部分的用户实际上只是单纯的使用默认值在读取数据时,99% 以上的用户都只关心能否尽可能快的读取数据,即使用 local readConcern在写入数据时,虽然大部分用户也只要求写操作在本地写成功即可,但仍然有不小的比例使用了 majority writeConcern(16%,远高于使用 majority readConcern 的比例),因为写操作被回滚对用户来说通常都是更影响体验的。此外,MongoDB 的默认配置({w:1} writeConcern, local readConcern)都是更倾向于保护 Latency 的,主要是基于这样的一个事实:主备切换事件发生的概率比较低,即使发生了丢数据的概率也不大。 统计数据给了我们一个 MongoDB readConcern/writeConcern 在真实业务场景下使用情况的直观认识,即,大部分用户更关注 Latency,而不是 Consistency。但是,统计数据同时也说明 readConcern/writeConcern 的使用组合是非常丰富的,用户通过使用不同的配置值来满足需求各异的业务场景对一致性和性能的要求,比如如下几个实际业务场景中的应用案例(均来自于 Atlas 云服务中的用户使用场景),Majority reads and writes:在这个组合下,意味着对数据安全性的关注是第一优先级的。考虑一个助学贷款的网站,网站的访问流量并不高,大约每分钟两次写入(提交申请),对于一个申请贷款的学生来说,显然不能接受自己成功提交的申请在后台 MongoDB 数据库发生重新选主时数据“丢失”,同样也不能接受获取到申请通过结果的情况下,再次查询,可能因为读取的数据被回滚,结果发生变化的情形,所以业务选择使用 majority readConcern & writeConcern 的组合,通过牺牲读写延迟来换取数据的安全性。Local reads and Majority writes:考虑一个餐饮评价的 App,比如大众点评,用户可能要花很大的精力来编辑一条精彩的评价,如果因为后端 MongoDB 实例发生主备切换导致评论丢失,对用户来说显然是不可接受的,所以用户评价的提交(写)需要使用 majority writeConcern,但是读到一条可能后续会因为回滚而“消失”的评价,对用户来说往往是可以接受,考虑到要兼顾性能,使用 local readConcern 显然是一个更优的选择。Multiple Write Concern Values:在同一个业务场景中,也不用只局限于一种 writeConcern/readConcern value,可以在不同的条件下使用不同的值来兼顾性能和一致性。比如,考虑一个文档系统,通常这样的系统在用户编辑文档时,会提供自动保存功能,对于非用户主动触发的发布或保存,自动保存的结果如果产生丢失,用户往往是感知不到的,而自动保存功能相对又是会比较频繁的触发(写压力更大),所以这种写动作使用 local writeConcern 显然更合理,写延迟更低,而低频的主动保存或发布,应该使用 majority writeConcern,因为这种情况用户对要保存的数据有明确的感知,很难接受数据的丢失。MongoDB 因果一致性模型功能接口 —— Causal Consistency Session前面已经提及了,相比于 writeConcern/readConcern 构建的可调一致性模型,MongoDB 的因果一致性模型是另外一块相对比较独立的实现,有自己专门的功能接口。MongoDB 的因果一致性是借助于客户端的 causally consistent session 来实现的,causally consistent session 可以理解为,维护一系列存在因果关系的读写操作间的因果一致性的执行载体。causally consistent session 通过维护 Server 端返回的一些操作执行的元信息(主要是关于操作定序的信息),再结合 Server 端的实现来提供 MongoDB Causal Consistency 所定义的一致性承诺(RYW,MR,MW,WFR),具体原理我们在后面的实现部分再详述。针对 causally consistent session,我们可以看一个简单的例子,比如现在有一个订单集合 orders,用于存储用户的订单信息,为了扩展读流量,客户端采用主库写入从库读取的方式,用户希望自己在提交订单之后总是能够读取到最新的订单信息(Read Your Write),为了满足这个条件,客户端就可以通过 causally consistent session 来实现这个目的,""" new order """ with client.start_session(causal_consistency=True) as s1: orders = client.get_database( 'test', read_concern=ReadConcern('majority'), write_concern=WriteConcern('majority', wtimeout=1000)).orders orders.insert_one( {'order_id': "123", 'user': "tony", 'order_info': {}}, session=s1) """ another session get user orders """ with client.start_session(causal_consistency=True) as s2: s2.advance_cluster_time(s1.cluster_time) # hybird logical clock s2.advance_operation_time(s1.operation_time) orders = client.get_database( 'test', read_preference=ReadPreference.SECONDARY, read_concern=ReadConcern('majority'), write_concern=WriteConcern('majority', wtimeout=1000)).orders for order in orders.find({'user': "tony"}, session=s2): print(order)从上面的例子我们可以看到,使用 causally consistent session,仍然需要指定合适的 readConcern/writeConcern value,原因是,只有指定 majority writeConcern & readConcern,MongoDB 才能提供完整的 Causal Consistency 语义,即同时满足前面定义的 4 个承诺(RYW,MR,MW,WFR)。 简单起见,我们只举例其中的一种情况:为什么在 {w: 1} writeConcern 和 majority readConcern 下,不能满足 RYW(Read Your Write)? 上图是一个 5 节点的副本集,当发生网络分区时(P~old~, S~1~ 和 P~new~, S~2~, S~3~ 分区),在 P~old~ 上发生的 W~1~ 写入因为使用了 {w:1} writeConcern ,会向客户端返回成功,但是因为没有复制到多数派节点,最终会在网络恢复后被回滚掉,R~1~ 虽然发生在 W~1~ 之后,但是从 S~2~ 并不能读取到 W~1~ 的结果,不符合 RYW 语义。其他情况下为什么不能满足 Causal Consistency 语义,可以参考官方文档,有非常详细的说明。MongoDB 一致性模型实现机制及优化前面对 MongoDB 的可调一致性和因果一致性模型,在理论以及具体的功能设计层面做了一个总体的阐述,下面我们就深入到内核层面,来看下 MongoDB 的一致性模型的具体实现机制以及在其中做了哪些优化。writeConcern在 MongoDB 中,writeConcern 的实现相对比较简单,因为不同的 writeConcern value 实际上只是决定了写操作返回的快慢。w <= 1 时,写操作的执行及返回的流程只发生在本地,并不会涉及等待副本集其他成员确认的情况,比较简单,所以我们只探讨 w > 1 时 writeConcern 的实现。w>1 时 writeConcern 的实现每一个用户的写操作会开启 WiredTiger 引擎层的一个事务,这个事务在提交时会顺便记录本次写操作对应的 Oplog Entry 的时间戳(Oplog 可理解为 MongoDB 的复制日志,这里不做详细介绍,可参考文档),这个时间戳在代码里面称之为lastOpTime。// mongo::RecoveryUnit::OnCommitChange::commit -> mongo::repl::ReplClientInfo::setLastOp void ReplClientInfo::setLastOp(OperationContext* opCtx, const OpTime& ot) { invariant(ot >= _lastOp); _lastOp = ot; lastOpInfo(opCtx).lastOpSetExplicitly = true; }引擎层事务提交后,相当于本地已经完成了本次写操作,对于 w:1 的 writeConcern,已经可以直接向客户端返回成功,但是当 w > 1 时就需要等待足够多的 Secondary 节点也确认写操作执行成功,这个时候 MongoDB 会通过执行 ReplicationCoordinatorImpl::_awaitReplication_inlock 阻塞在一个条件变量上,等待被唤醒,被阻塞的用户线程会被加入到 _replicationWaiterList 中。Secondary 在拉取到 Primary 上的这个写操作对应的 Oplog 并且 Apply 完成后,会更新自身的位点信息,并通知另外一个后台线程汇报自己的 appliedOpTime 和 durableOpTime 等信息给 upstream(主要的方式,还有其他一些特殊的汇报时机)。void ReplicationCoordinatorImpl::setMyLastAppliedOpTimeAndWallTimeForward( ... if (opTime > myLastAppliedOpTime) { _setMyLastAppliedOpTimeAndWallTime(lock, opTimeAndWallTime, false, consistency); _reportUpstream_inlock(std::move(lock)); // 这里是向 sync source 汇报自己的 oplog apply 进度信息 } ... }appliedOpTime 和 durableOpTime 的含义和区别如下,appliedOpTime:Secondary 上 Apply 完一批 Oplog 后,最新的 Oplog Entry 的时间戳。durableOpTime:Secondary 上 Apply 完成并在 Disk 上持久化的 Oplog Entry 最新的时间戳, Oplog 也是作为 WiredTiger 引擎的一个 Table 来实现的,但 WT 引擎的 WAL sync 策略默认是 100ms 一次,所以这个时间戳通常滞后于appliedOpTime。上述信息的汇报是通过给 upstream 发送 replSetUpdatePosition 命令来完成的,upstream 在收到该命令后,通过比较如果发现某个副本集成员汇报过来的时间戳信息比上次新,就会触发,唤醒等待 writeConcern 的用户线程的逻辑。唤醒逻辑会去比较用户线程等待的 lastOptime 是否小于等于 Secondary 汇报过来的时间戳 TS,如果是,表示有一个 Secondary 节点满足了本次 writeConcern 的要求。那么,TS 要使用 Secondary 汇报过来的那个时间戳呢?如果 writeConcern 中 j 参数指定的是 false,意味着本次写操作并不关注是否在 Disk 上持久化,那么 TS 使用 appliedOpTime, 否则使用 durableOpTime 。当有指定的 w 个节点(含 Primary 自身)汇报的 TS 大于等于 lastOptime,用户线程即可被唤醒,向客户端返回成功。// TopologyCoordinator::haveNumNodesReachedOpTime for (auto&& memberData : _memberData) { const OpTime& memberOpTime = durablyWritten ? memberData.getLastDurableOpTime() : memberData.getLastAppliedOpTime(); if (memberOpTime >= targetOpTime) { --numNodes; } if (numNodes <= 0) { return true; } }到这里,用户线程因 writeConcern 被阻塞到唤醒的基本流程就完成了,但是我们还需要思考一个问题,MongoDB 是支持链式复制的,即, P->S1->S2 这种复制拓扑,如果在 P 上执行了写操作,且使用了 writeConcern w:3,即,要求得到三个节点的确认,而 S2 并不直接向 P 汇报自己的 Oplog Apply 信息,那这种场景下 writeConcern 要如何满足?MongoDB 采用了信息转发的方式来解决这个问题,当 S1 收到 S2 汇报过来的 replSetUpdatePosition 命令,进行处理时(processReplSetUpdatePosition()),如果发现自己不是 Primary 角色,会立刻触发一个 forwardSlaveProgress 任务,即,把自己的 Oplog Apply 信息,连同自己的 Secondary 汇报过来的,构造一个 replSetUpdatePosition 命令,发往上游,从而保证,当任一个 Secondary 节点的 Oplog Apply 进度推进,Primary 都能够及时的收到消息,尽可能降低 w>1 时,因 writeConcern 而带来的写操作延迟。readConcernreadConcern 的实现相比于 writeConcern,要复杂很多,因为它和存储引擎的关联要更为紧密,在某些情况下,还要依赖于 writeConcern 的实现,同时部分 readConcern level 的实现还要依赖 MongoDB 的复制机制和存储引擎共同提供支持。另外,MongoDB 为了在满足指定 readConcern level 要求的前提下,尽量降低读操作的延迟和事务执行效率,也做了一些优化。下面我们就结合不同的 readConcern level 来分别描述它们的实现原理和优化手段。“majority” readConcern“majority” readConcern 的语义前面的章节已经介绍,这里不再赘述。为了保证客户端读到 majority committed 数据,根据存储引擎能力的不同,MongoDB 分别实现了两种机制用于提供该承诺。依赖 WiredTiger 存储引擎快照的实现方式WiredTiger 为了保证并发事务在执行时,不同事务的读写不会互相 block,提升事务执行性能,也采用了 MVCC 的并发控制策略,即不同的写事务在提交时,会生成多个版本的数据,每个版本的数据由一个时间戳(commit_ts)来标识。所谓的存储引擎快照(Snapshot),实际上就是在某个时间点看到的,由历史版本数据所组成的一致性数据视图。所以,在引擎内部,快照也是由一个时间戳来标识的。前面我们已经提到,由于 MongoDB 采用异步复制的机制,不同节点的复制进度会有差异。如果我们在某个副本集节点直接读取最新的已提交数据,如果它还没有复制到大多数节点,显然就不满足 “majority” readConcern 语义。这个时候可以采取一个办法,就是仍然读取最新的数据,但是在返回 Client 前等待其他节点确认本次读取的数据已经 apply 完成了,但是这样显然会大幅的增加读操作的延迟(虽然这种情况下,一致性体验反而更好了,因为能读到更新的数据,但是前面我们已经分析了,绝大部分用户在读取时,希望更快的返回的数据,而不是追求一致性)。所以,MongoDB 采用的做法是在存储引擎层面维护一个 majority committed 数据视图(快照),这个快照对应的时间戳在 MongoDB 里面称之为 majority committed point(后面简称 mcp)。当 Client 指定 majority 读时,通过直接读取这个快照,来快速的返回数据,无需等待。需要注意的一点是,由于复制进度的差异,mcp 并不能反映当前最新的已提交数据,即,这个方法是通过牺牲 Recency 来换取更低的 Latency。// 以 getMore 命令举例 void applyCursorReadConcern(OperationContext* opCtx, repl::ReadConcernArgs rcArgs) { ... switch (rcArgs.getMajorityReadMechanism()) { case repl::ReadConcernArgs::MajorityReadMechanism::kMajoritySnapshot: { // Make sure we read from the majority snapshot. opCtx->recoveryUnit()->setTimestampReadSource( RecoveryUnit::ReadSource::kMajorityCommitted); // 获取 majority committed snapshot uassertStatusOK(opCtx->recoveryUnit()->obtainMajorityCommittedSnapshot()); break; ... }但基于 mcp 快照的实现方式需要解决一个问题,即,如何保证这个快照的有效性? 进一步来说, 如何保证 mcp 视图所依赖的历史版本数据不会被 WiredTiger 引擎清理掉?正常情况下,WiredTiger 会根据事务的提交情况自动的去清理多版本的数据,只要当前的活跃事务对某个历史版本的数据没有依赖,即可以从内存中的 MVCC List 里面删掉(不考虑 LAS 机制,WT 的多版本数据设计上只存放在内存中)。但是,所谓的 majority committed point,实际上是 Server 层的概念,引擎层并不感知,如果只根据事务的依赖来清理历史版本数据,mcp 依赖的历史版本版本数据可能就会被提前清理掉。举个例子,在下图的三节点副本集中,如果 Client 从 Primary 节点读取并且指定了 majority readConcern,由于 mcp = 4,那么 MongoDB 只能向 Client 返回 commit_ts = 4 的历史值。但是,对于 WiredTiger 引擎来说,当前活跃的事务列表中只有 T1,commit_ts = 4 的历史版本是可以被清理的,但清理掉该版本,mcp 所依赖的 snapshot 显然就无法保证。所以,需要 WiredTiger 引擎层提供一个新机制,根据 Server 层告知的复制进度,即, mcp 位点,来清理历史版本数据。 在 WiredTiger 3.0 版本中,开始提供「Application-specified Transaction Timestamps」功能,来解决 Server 层对事务提交顺序(基于 Application Timestamp)的需求和 WiredTiger 引擎层内部的事务提交顺序(基于 Internal Transaction ID)不一致的问题(根源来自于基于 Oplog 的复制机制,这里不作展开)。进一步,在这个功能的基础上,WT 也提供了所谓的「read "as of" a timestamp」功能(也有文章称之为 「Time Travel Query」),即支持从某个指定的 Timestamp 进行快照读,而这个特性正是前面提到的基于 mcp 位点实现 "majority" readConcern 的功能基础。WiredTiger 对外提供了 set_timestamp() 的 API,用于 Server 层来更新相关的 Application Timestamp。WT 目前包含如下语义的 Application Timestamp, 要回答前面提到的关于 mcp snapshot 有效性保证的问题,我们需要重点关注红框中的几个 Timestamp。首先,stable timestamp 在 MongoDB 中含义是,在这个时间戳之前提交的写,不会被回滚,所以它和 majority commit point(mcp) 的语义是一致的。stable timestamp 对应的快照被存储引擎持久化后,称之为「stable checkpoint」,这个 checkpoint 在 MongoDB 中也有重要的意义,在后面的「"local" readConcern」章节我们再详述。MongoDB 在 Crash Recovery 时,总是从 stable checkpoint 初始化,然后重新应用增量的 Oplog 来完成一次恢复。所以为了提升 Crash Recovery 效率及回收日志空间,引擎层需要定期的产生新的 stable checkpoint,也就意味着stable timestamp 也需要不断的被 Server 层推进(更新)。而 MongoDB 在更新 stable timestamp 的同时,也会顺便去基于该时间戳去更新 oldest timestamp,所以,在基于快照的实现机制下,oldest timestamp 和 stable timestamp 的语义也是一致的。... ->ReplicationCoordinatorImpl::_updateLastCommittedOpTimeAndWallTime() ->ReplicationCoordinatorImpl::_setStableTimestampForStorage() ->WiredTigerKVEngine::setStableTimestamp() ->WiredTigerKVEngine::setOldestTimestampFromStable() ->WiredTigerKVEngine::setOldestTimestamp() 当前 WiredTiger 收到新的 oldest timestamp 时,会结合当前的活跃事务(oldest_reader)和 oldest timestamp 来计算新的全局 pinned timestamp,当进行历史版本数据的清理时,pinned timestamp 之后的版本不会被清理,从而保证了 mcp snapshot 的有效性。// 计算新的全局 pinned timestamp __conn_set_timestamp->__wt_txn_global_set_timestamp->__wt_txn_update_pinned_timestamp-> __wt_txn_get_pinned_timestamp { ... tmp_ts = include_oldest ? txn_global->oldest_timestamp : 0; ... if (!include_oldest && tmp_ts == 0) return (WT_NOTFOUND); *tsp = tmp_ts; ... } // 判断历史版本是否可清理 static inline bool __wt_txn_visible_all(WT_SESSION_IMPL *session, uint64_t id, wt_timestamp_t timestamp) { ... __wt_txn_pinned_timestamp(session, &pinned_ts); return (timestamp <= pinned_ts); }在分析了 mcp snapshot 有效性保证的机制之后,我们还需要回答下面两个关键问题,整个细节才算完整。Secondary 的复制进度,以及进一步由复制进度计算出的 mcp 是由 oplog 中的 ts 字段来标识的,而数据的版本号是由 commit_ts 来标识的,他们之间有什么关系,为什么是可比的?前面提到了引擎的 Crash Recovery 需要 stable timestamp(mcp)不断的推进来产生新的 stable checkpoint,那 mcp 具体是如何推进的?要回答第一个问题,我们需要先看下,对于一条 insert 操作,它所对应的 oplog entry 的 ts 字段值是怎么来的,以及这条 oplog 和 insert 操作的关系。首先,当 Server 层收到一条 insert 操作后,会提前调用 LocalOplogInfo::getNextOpTimes() 来给其即将要写的 oplog entry 生成 ts 值,获取这个 ts 是需要加锁的,避免并发的写操作产生同样的 ts。然后, Server 层会调用 WiredTigerRecoveryUnit::setTimestamp 开启 WiredTiger 引擎层的事务,并且把这个事务中后续写操作的 commit_ts 都设置为 oplog entry 的 ts,insert 操作在引擎层执行完成后,会把其对应的 oplog entry 也通过同一事务写到 WiredTiger Table 中,之后事务才提交。 也就是说 MongoDB 是通过把写 oplog 和写操作放到同一个事务中,来保证复制日志和实际数据之间的一致性,同时也确保了,oplog entry ts 和写操作本身所产生修改的版本号是一致的。对于第二个问题,mcp 如何推进,在前面的 writeConcern 实现章节我们提到了,downstream 在 apply 完一批 oplog 之后会向 upstream 汇报自己的 apply 进度信息,upstream 同时也会向自己的 upstream 转发这个信息,基于这个机制,对 Primary 来说,显然最终它能不断的获取到整个副本集所有成员的 oplog apply 进度信息,进而推进自己的 majority commit point(计算的方式比较简单,具体见TopologyCoordinator::updateLastCommittedOpTimeAndWallTime)。但是,上述是一个单向传播的机制,而副本集的 Secondary 节点也是能够提供读的,同样需要获取其他节点的 oplog apply 信息来更新 mcp 视图,所以 MongoDB 也提供了如下两种机制来保证 Secondary 节点的 mcp 是可以不断推进的:基于副本集高可用的心跳机制:i. 默认情况下,每个副本集节点都会每 2 秒向其他成员发送心跳(replSetHeartBeat 命令)ii. 其他成员返回的信息中会包含 $replData 元信息,Secondary 节点会根据其中的 lastOpCommitted 直接推进自己的 mcp$replData: { term: 147, lastOpCommitted: { ts: Timestamp(1598455722, 1), t: 147 } ...基于副本集的增量同步机制:i. 基于心跳机制的 mcp 推进方式,显然实时性是不够的,Primary 计算出新的 mcp 后,最多要等 2 秒,下游才能更新自己的 mcpii. 所以,MongoDB 在 oplog 增量同步的过程中,upstream 同样会在向 downstream 返回的 oplog batch 中夹带 $replData 元信息,下游节点收到这个信息后同样会根据其中的 lastOpCommitted 直接推进自己的 mcpiii. 由于 Secondary 节点的 oplog fetcher 线程是持续不断的从上游拉取 oplog,只要有新的写入,导致 Primary mcp 推进,那么下游就会立刻拉取新的 oplog,可以保证在 ms 级别同步推进自己的 mcp 另外一点需要说明的是,心跳回复中实际上也包含了目标节点的 lastAppliedOpTime 和 lastDurableOpTime 信息,但是 Secondary 节点并不会根据这些信息自行计算新的 mcp,而是总是等待 Primary 把 lastOpCommittedOpTime 传播过来,直接 set 自己的 mcp。Speculative Read —— 不依赖快照的实现方式 类似于 MySQL,MongoDB 也是支持插件式的存储引擎体系的,但是并非每个支持的存储引擎都实现了 MVCC,即具备快照能力,比如在 MongoDb 3.2 之前默认的 MMAPv1 引擎就不具备。此外,即使对于具备 MVCC 的 WiredTiger 引擎,维护 majority commit point 对应的 snapshot 是会带来存储引擎 cache 压力上涨的,所以 MongoDB 提供了 replication.enableMajorityReadConcern 参数用于关闭这个机制。所以,结合以上两方面的原因,MongoDB 需要提供一种不依赖快照的机制来实现 majority readConcern,MongoDB 把这个机制称之为 Speculative Read ,中文上我觉得可以称为“未决读”。Speculative Read 的实现方式非常简单,上一小节实际上也基本描述了,就是直接读当前最新的数据,但是在实际返回 Client 前,会等待读到的数据在多数节点 apply 完成,故可以满足 majority readConcern 语义。本质上,这是一种后验的机制,在其他的数据库系统中,比如 Hekaton,VoltDB ,事务的并发控制中也有类似的做法。在具体的实现上,首先在命令实际执行前会通过 WiredTigerRecoveryUnit::setTimestampReadSource() 设置自己的读时间戳,即 readTs,读事务在执行的过程中只会读到 readTs 或之前的版本。在命令执行完成后,会调用 waitForSpeculativeMajorityReadConcern() 确保 readTs 对应的时间点及之前的 oplog 在 majority 节点应用完成。这里实际上最终也是通过调用 ReplicationCoordinatorImpl::_awaitReplication_inlock 阻塞在一个条件变量上,等待足够多的 Secondary 节点汇报自己的复制进度信息后才被唤醒,完全复用了 majority writeConcern 的实现。所以,writeConcern,readConcern 除了在功能设计上有强关联,在内部实现上也有互相依赖。需要注意的是,Speculative Read 机制 MongoDB 并不打算提供给普通用户使用,如果把 replication.enableMajorityReadConcern 设置为 false 之后,继续使用 majority readConcern,MongoDB 会返回 ReadConcernMajorityNotEnabled 错误。目前在一些内部命令的场景下才会使用该机制,测试目的的话,可以在 find 命令中加一个特殊参数: allowSpeculativeMajorityRead: true,强制开启 Speculative Read 的支持。针对 readConcern 的优化 —— Query Yielding考虑到后文逻辑上的依赖,在分析其他 readConcern level 之前,需要先看一个 MongoDB 针对 readConcern 的优化措施。默认情况下,MongoDB Server 层面所有的读操作在 WiredTiger 上都会开启一个事务,并且采用 snapshot 隔离级别。在 snapshot isolation 下,事务需要读到一个一致性的快照,且读取的数据是事务开始时最新提交的数据。而 WiredTiger 目前的多版本数据只能存放在内存中,所以在这个规则下,执行时间太久的事务会导致 WiredTiger 的内存压力升高,进一步会影响事务的执行性能。 比如,在上图中,事务 T1 开始后,根据 majority commit point 读取自己可见的版本,x=1,其他的事务继续对 x 产生修改并且提交,会产生的新的版本 x=2,x=3……,T1 只要不提交,那么 x=2 及之后的版本都不能从内存中清理,否则就会违反 snapshot isolation 的语义。面对上述情况,MongoDB 采用了一种称之为「Query Yielding」的手段来“优化” 这个问题。 「Query Yielding」的思路其实非常简单,就是在事务执行的过程中,定期的进行 yield,即释放锁,abort 当前的 WiredTiger 事务,释放 hold 的 snapshot,然后重新打开事务,获取新的 snapshot。显然,通过这种方式,对于一个执行时间很长的 MongoDB 读操作,它在引擎层事务的 read_ts 是不断推进的,进而保证 read_ts 之后的版本能够被及时从内存中清理。之所以在优化前面加一个引号的原因是,这种方式虽然解决了长事务场景下,WT 内存压力上涨的问题,但是是以牺牲快照隔离级别的语义为代价的(降级为 read committed 隔离级别),又是一个典型的牺牲一致性来换取更好的访问性能的应用案例。"local" 和 "majority" readConcern 都应用了「Query Yielding」机制,他们的主要区别是,"majority" readConcern 在 reopen 事务时采用新推进的 mcp 对应的 snapshot,而 "local" readConcern 采用最新的时间点对应的 snapshot。Server 层在一个 Query 正常执行的过程中(getNext()),会不断的调用 _yieldPolicy->shouldYieldOrInterrupt() 来判定是否需要 yield,目前主要由如下两个因素共同决定是否 yield:internalQueryExecYieldIterations:shouldYieldOrInterrupt() 调用累积次数超过该配置值会主动 yield,默认为 128,本质上反映的是从索引或者表上获取了多少条数据后主动 yield。yield 之后该累积次数清零。internalQueryExecYieldPeriodMS:从上次 yield 到现在的时间间隔超过该配置值,主动 yield,默认为 10ms,本质上反映的是当前线程获取数据的行为持续了多久需要 yield。最后,除了根据上述配置主动的 yield 行为,存储引擎层面也会因为一些原因,比如需要从 disk load page,事务冲突等,告知计划执行器(PlanExecutor)需要 yield。MongoDB 的慢查询日志中会输出一些有关执行计划的信息,其中一项就是 Query 执行期间 yield 的次数,如果数据集不变的情况下,执行时长差别比较大,那么就可能和要访问的 page 在 WiredTiger Cache 中的命中率相关,可以通过 yield 次数来进行一定的判断。“snapshot” readConcern前面我们已经提到了 "snapshot" readConcern 是专门用于 MongoDB 的多文档事务的,MongoDB 多文档事务提供类似于传统关系型数据库的事务模型(Conversational Transaction),即通过 begin transaction 语句显示开启事务, 根据业务逻辑执行不同的操作序列,然后通过 commit transaction 语句提交事务。"snapshot" readConcern 除了包含 "majority" readConcern 提供的语义,同时它还提供真正的一致性快照语义,因为多文档事务中的多个操作只会对应到一个 WiredTiger 引擎事务,并不会应用「Query Yielding」。 这里这么设计的主要考虑是,和默认情况下为了保证性能而采用单文档事务不同,当应用显示启用多文档事务时,往往意味着它希望 MongoDB 提供类似关系型数据库的,更强的一致性保证,「Query Yielding」导致的 snapshot “漂移”显然是无法接受的。而且在目前的实现中,如果应用使用了多文档事务,即使指定 "majority" 或 "local" readConcern,也会被强制提升为 "snapshot" readConcern。// If "startTransaction" is present, it must be true due to the parsing above. const bool upconvertToSnapshot(sessionOptions.getStartTransaction()); auto newReadConcernArgs = uassertStatusOK( _extractReadConcern(invocation.get(), request.body, upconvertToSnapshot)); // 这里强制提升为 "snapshot" readConcern不采用 「Query Yielding」也就意味着存在上节所说的“WiredTiger Cache 压力过大”的问题,在 “snapshot” readConcern 下,当前版本没有太好的解法(在 4.4 中会通过 durable history,即支持把多版本数据写到磁盘,而不是只保存在内存中来解决这个问题)。MongoDB 目前采用了另外一个比较简单粗暴的方式来缓解这个问题,即限制事务执行的时长,transactionLifetimeLimitSeconds 配置的值决定了多文档事务的最大执行时长,默认为 60 秒。超出最大执行时长的事务由后台线程负责清理,默认每 30 秒进行一次清理动作。每个多文档事务都会和一个 Logical Session 关联,清理线程会遍历内存中的 SessionCatalog 缓存找到所有过期事务,清理和事务关联的 Session,然后 abortTransaction(具体可参考killAllExpiredTransactions())。"snapshot" readConcern 为了同时维持分布式环境下的 "majority" read 语义和事务本地执行的一致性快照语义,还会带来另外一个问题:事务因为写冲突而 abort 的概率提升。在单机环境下,事务的写冲突往往是因为并发事务的执行修改了同一份数据,进而导致后提交的事务需要 abort(first-writer-win)。但是通过后面的解释我们会看到,"snapshot" readConcern 为了同时维持两种语义,即使在单机环境下看起来是非并发的事务,也会因为写冲突而 abort。要说明这个问题,先来简单看下事务在 snapshot isolation 下的读写规则。 对于读:对任意事务 $T_i$ ,如果它读到了数据 $X$ 的版本 $X_j$,而 $X_j$ 是由事务 $T_j$ 修改产生,则 $T_j$ 一定已经提交,且 $T_j$ 的提交时间戳一定小于事务 $T_i$ 的快照读时间戳,即只有这样, $T_j$ 的修改对 $T_i$ 才是可见的。这个规则保证了事务只能读取到自己可见范围内的数据。另外,对任意事务 $T_k$,如果它修改了 $X$ 并且产生了新的版本 $X_k$,且 $T_k$ 已提交,那么 $T_k$ 要么在事务 $T_j$ 之前提交($commit(T_k) < commit(T_j)$),要么在事务 $T_i$ 的快照读时间戳之后提交。这个规则保证了事务在可见范围内读取最新的数据。对于写:对于任意事务 $T_i$ 和 $T_j$,他们都成功提交的前提是没有产生冲突。冲突的定义:如果 $T_j$ 的提交时间戳在事务 $T_i$ 的观测时间段([$snapshot(T_i)$, $commit(T_i)$])内,且二者的修改数据集存在交集,则二者存在冲突。这种情况下 $T_i$ 需要 abort。对这个规则可以有一个通俗的理解,即事务的并发控制存在一个基本原则:「过去不能修改将来」,$snapshot(T_i) < commit(T_j)$ 表明 $T_i$ 相对于 $T_j$ 发生在过去(此时 $T_i$ 看不到 $T_j$ 产生的修改), $T_i$ 如果正常提交,因为 $commit(T_i) > commit(T_j)$,也就意味着发生在过去的 $T_i$ 的写会覆盖将来的 $T_j$。然后再回到前面的问题:为什么在 "snapshot" readConcern 下事务冲突 abort 的概率会提升?这里我们结合一个例子来进行说明, 上图中,C1 发起的事务 T1 在主节点(P)上提交后,需要复制到一个从节点(S) 并且 apply 完成才算是 majority committed。在事务从 local committed 变为 majority committed 这个延迟内(上图中的红圈),如果 C2 也发起了一个事务 T2,虽然 T2 是在 T1 提交之后才开始的,但根据 "majority" read 语义的要求,T2 不能够读取 T1 刚提交的修改,而是基于 mcp 读取 T1 修改前的版本,这个是符合前面的 snapshot read rule 的( D1 规则)。但是,如果 T2 读取了这个更早的版本并且做了修改,因为 T2 的 commit_ts(有递增要求) 大于 T1 的,根据前面的 snapshot commit rule(D2 规则),T2 需要 abort。需要说明的是,应用对数据的访问在时间和空间上往往呈现一定的局部性,所以上述这种 back-to-back transaction workload(T1 本地修改完成后,T2 接着修改同一份数据)在实际场景中是比较常见的,所以很有必要对这个问题作出优化。MongoDB 对这个问题的优化也比较简单,采用了和 "majority" readConcern 一样的实现思路,即「speculative read」。MongoDB 把这种基于「speculative read」机制实现的 snapshot isolation 称之为「speculative snapshot isolation」。 仍然使用上面的例子,在「speculative snapshot isolation」机制下,事务 T2 在开始时不再基于 mcp 读取 T1 提交前的版本,而是直接读取最新的已提交值(T1 提交),这样 $snapshot(T_2) >= commit(T_1)$ ,即使 T2 修改了同一条数据,也不会违反 D2 规则。但是此时 T1 还没有被复制到 majority 节点,T2 如果直接返回客户端成功,显然违反了 "majority" read 的语义。MongoDB 的做法是,在事务 T2 提交时,如果要维持 "majority" read 的语义,其必须也以 "majority" writeConcern 提交。这样,如果 T2 产生了修改,在其等待自身的修改成为 majority committed 时,发生它之前的事务 T1 的修改显然也已经是 majority committed(这个是由 MongoDB 复制协议的顺序性和 batch 并发 apply 的原子性保证的),所以自然可保证 T2 读取到的最新值满足 "majority" 语义。这个方式本质上是一种牺牲 Latency 换取 Consistency 的做法,和基于 snapshot 的 "majority" readConcern 做法正好相反。这里这么设计的原因,并不是有目的的去提供更好的一致性,主要还是为了降低事务冲突 abort 的概率,这个对 MongoDB 自身性能和业务的影响非常大,在这个基础上,也可以说,保证业务读取到最新的数据总是更有用的。关于牺牲 Latency,实际上上述实现机制,对于写事务来说并没有导致额外的延迟,因为事务自身以 "majority" writeConcern 提交进行等待以满足自身写的 majority committed 要求时,也顺便满足了 「speculative read」对等待的需求,缺点就是事务的提交必须要和 "majority" readConcern 强绑定,但是从多文档事务隐含了对一致性有更高的要求来看,这种绑定也是合理的,避免了已提交事务的修改在重新选主后被回滚。真正产生额外延迟的是只读事务,因为事务本身没有做任何修改,仍然需要等待。实际上这个延迟也可以被优化掉,因为事务如果只是只读,不管读取了哪个时间点的快照,都不会和其他写事务形成冲突,但是 MongoDB 目前并没有提供标记多文档事务为只读事务的接口,期待后续的优化。“local” readConcern"local" readConcern 在 MongoDB 里面的语义最为简单,即直接读取本地最新的已提交数据,但是它在 MongoDB 里面的实现却相对复杂。首先我们需要了解的是 MongoDB 的复制协议是一种类似于 Raft 的复制状态机(Replicated State Machine)协议,但它和 Raft 最大区别是,Raft 先把日志复制到多数派节点,然后再 Apply RSM,而 MongoDB 是先 Apply RSM,然后再异步的把日志复制到 Follower(Secondary) 去 Apply。 这种实现方式除了可以降低写操作(在 default writeConcern下)的延迟,也为实现 "local" readConcern 提供了机会,而 Recency,前面的统计数据已经分析了,正是大部分的业务所更加关注的。MongoDB 的这种设计虽然更贴近于用户需求,但也为它的 RSM 协议引入了额外的复杂性,这点主要体现在重新选举时。重新选主时可能会发生,已经在之前的 Primary 上追加的部分 log entry 没有来及复制到新的 Primary 节点,那么在前任 Primary重新加入集群时,需要把这部分多余的 log entry 回滚掉(注:这种情况,除了旧主可能发生,其他节点也可能发生)。对于 Raft 来说这个回滚动作特别简单,只需对 replicated log 执行 truncate,移除尾部多余的 log entry,然后重新从现任 Primary 追日志即可。但是,对于 MongoDB 来说,由于在追加日志前就已经对状态机进行了 apply,所以除了 Log Truncation,还需要一个状态机回滚(Data Rollback)流程。Data Rollback 是一个代价比较大的过程,而 MongoDB 本身的日志复制是通常是很快的,真正在发生重新选举时,未及时同步到新主的 log entry 是比较少的,所以如果能够让新主在接受写操作之前,把旧主上“多余”的日志重新拉取过来并应用,显然可以避免旧主的 Data Rollback。关于 MongoDB 基于 Raft 协议修改的延伸阅读:4 modifications for Raft consensus重选举时的 Catchup PhaseMongoDB 从 3.4 版本开始实现了上述机制(catchup phase),流程如下,候选节点在成功收到多数派节点的投票后,会通过心跳(replSetHeartBeat 命令)向其他节点广播自己当选的消息;其他节点的的 heartbeat response 中会包含自己最新的 applied opTime,当选节点会把其中最大的 opTIme 作为自己 catchup 的 targetOpTime;从 applied opTime 最大的节点或其下游节点同步数据,这个过程和正常的基于 oplog 的增量复制没有太大区别;如果在超时时间(由 settings.catchUpTimeoutMillis 决定,3.4 默认 60 秒)内追上了 targetOpTime,catchup 完成;如果超时,当选节点并不会 stepDown,而是继续作为新的 Primary 节点。void ReplicationCoordinatorImpl::CatchupState::signalHeartbeatUpdate_inlock() { auto targetOpTime = _repl->_topCoord->latestKnownOpTimeSinceHeartbeatRestart(); ... ReplicationMetrics::get(getGlobalServiceContext()).setTargetCatchupOpTime(targetOpTime.get()); log() << "Heartbeats updated catchup target optime to " << *targetOpTime; ... }上述第 5 步意味着,catchup 过程中如果有超时发生,其他节点仍然需要回滚,所以在 3.6 版本中,MongoDB 对这个机制进行了强化。3.6 把 settings.catchUpTimeoutMillis 的默认值调整为 -1,即不超时。但为了避免 catchup phase 无限进行,影响可用性(集群不可写),增加了 catchup takeover 机制,即集群当前正在被当选节点作为同步源 catchup 的节点,在等待一定的时间后,会主动发起选举投票,来使“不合格”的当选节点下台,从而减少 Data Rollback 的几率和保证集群尽快可用。这个等待时间由副本集的 settings.catchUpTakeoverDelayMillis 配置决定,默认为 30 秒。stdx::unique_lock<stdx::mutex> ReplicationCoordinatorImpl::_handleHeartbeatResponseAction_inlock( ... case HeartbeatResponseAction::CatchupTakeover: { // Don't schedule a catchup takeover if any takeover is already scheduled. if (!_catchupTakeoverCbh.isValid() && !_priorityTakeoverCbh.isValid()) { Milliseconds catchupTakeoverDelay = _rsConfig.getCatchUpTakeoverDelay(); _catchupTakeoverWhen = _replExecutor->now() + catchupTakeoverDelay; LOG_FOR_ELECTION(0) << "Scheduling catchup takeover at " << _catchupTakeoverWhen; _catchupTakeoverCbh = _scheduleWorkAt( _catchupTakeoverWhen, [=](const mongo::executor::TaskExecutor::CallbackArgs&) { _startElectSelfIfEligibleV1(StartElectionReasonEnum::kCatchupTakeover); // 主动发起选举 }); } ...Data Rollback 是无法彻底避免的,因为 catchup phase 也只能发生在拥有最新 log entry 的节点在线的情况下,即能够向当选节点恢复心跳包,如果在选举完成后,节点才重新加入集群,仍然需要回滚。MongoDB 目前存在两种 Data Rollback 机制:「Refeched Based Rollback」 和 「Recover To Timestamp Rollback」,其中后一种是在 4.0 及之后的版本,伴随着 WiredTiger 存储引擎能力的提升而演进出来的,下面就简要描述一下它们的实现方式及关联。Refeched Based Rollback「Refeched Based Rollback」 可以称之为逻辑回滚,下面这个图是逻辑回滚的流程图, 首先待回滚的旧主,需要确认重新选主后,自己的 oplog 历史和新主的 oplog 历史发生“分叉”的时间点,在这个时间点之前,新主和旧主的 oplog 是一致的,所以这个点也被称之为「common point」。旧主上从「common point」开始到自己最新的时间点之间的 oplog 就是未来及复制到新主的“多余”部分,需要回滚掉。common point 的查找逻辑在 syncRollBackLocalOperations() 中实现,大致流程为,由新到老(反向)从同步源节点获取每条 oplog,然后和自己本地的 oplog 进行比对。本地 oplog 的扫描同样为反向,由于 oplog 的时间戳可以保证递增,扫描时可以通过保存中间位点的方式来减少重复扫描。如果最终在本地找到一条 oplog 的时间戳和 term 和同步源的完全一样,那么这条 oplog 即为 common point。由于在分布式环境下,不同节点的时钟不能做到完全实时同步,而 term 可以唯一标识一个主节点在任期间的修改(oplog)历史,所以需要把 oplog ts 和 term 结合起来进行 common point 的查找。在找到 common point 之后,待回滚节点需要把当前最新的时间戳到 common point 之间的 oplog 都回滚掉,由于回滚采用逻辑的方式,整个流程还是比较复杂的。首先,MongoDB 的 oplog 本质上是一种 redo log,可以通过重新 apply 来进行数据恢复,而且 oplog 记录时对部分操作进行了重写,比如 {$inc : {quantity : 1}} 重写为 {$set : {quantity : val}} 等,来保证 oplog 的幂等性,按序重复应用 oplog,并不会导致数据不一致。但是 oplog 并不包含 undo 信息,所以对于部分操作来说,无法实现基于本地信息直接回滚,比如对于 delete,dropCollection 等操作,删除掉的文档在 oplog 并无记录,显然无法直接回滚。对于上述情况,MongoDB 采用了所谓「refetch」的方式进行回滚,即重新从同步源获取无法在本地直接回滚的文档,但是这个方式的问题在于 oplog 回滚到 tcommon 时,节点可能处于一个不一致的状态。举个例子,在 tcommon 时旧主上存在两条文档 {x : 10} 和 {y : 20},在重新选主之后,旧主上对 x 的 delete 操作并未同步到新主,在新主新的历史中,客户端先后对 x 和 y 做了更新:{$set : {y : 200}} ; {$set : {x : 100}}。在旧主通过「refetch」的方式完成回滚后,它在 tcommon 的状态为: {x : 100} 和 {y : 20},显然这个状态对于客户端来说是不一致的。这个问题的根本原因在于,「refetch」时只能获取到被删除文档当前最新的状态,而不是被删除前的状态,这个方式破坏了在客户端看来可能存在因果关系的不同文档间的一致性状态。我们具体上面的例子来说,回滚节点在「refetch」时相当于直接获取了 {$set : {x : 100}} 的状态变更操作,而跳过了 {$set : {y : 200}},如果要达到一致性状态,看起来只要重新应用 {$set : {y : 200}} 即可。但是回滚节点基于现有信息是无法分析出来跳过了哪些状态的,对于这个问题,直接但是有效的做法是,把同步源从 tcommon 之后的 oplog 都重新拉取并「reapply」一遍,显然可以把跳过的状态补齐。而这中间也可能存在对部分状态变更操作的重复应用,比如 {$set : {x : 100}},这个时候 oplog 的幂等性就发挥作用了,可以保证数据在最终「reapply」完后的一致性不受影响。剩下的问题就是,拉取到同步源 oplog 的什么位置为止?对于回滚节点来说,导致状态被跳过的原因是进行了「refetch」,所以只需要记录每次「refetch」时同步源最新的 oplog 时间戳,「reapply」时拉取到最后一次「refetch」对应的这个同步源时间戳就可以保证状态的正确补齐,MongoDB 在实现中把这个时间戳称之为 minValid。MongoDB 在逻辑回滚的过程中也进行了一些优化,比如在「refetch」之前,会扫描一遍需要回滚的操作(这个不需要专门来做,在查找 common point 的过程即可实现),对于一些存在“互斥”关系的操作,比如 {insert : {_id:1} 和 {delete : {_id:1}},就没必要先 refetch 再 delete 了,直接忽略回滚处理即可。但是从上面整体流程看,「Refeched Based Rollback」仍然复杂且代价高:「refetch」阶段需要和同步源通信,并进行数据拉取,如果回滚的是删表操作,代价很大「reapply」阶段也需要和同步源通信,如果「refetch」阶段比较慢,需要拉取和重新应用的 oplog 也比较多实现上复杂,每种可能出现在 oplog 中的操作都需要有对应的回滚逻辑,新增类型时同样需要考虑,代码维护代价高所以在 4.0 版本中,随着 WiredTiger 引擎提供了回滚到指定的 Timestamp 的功能后,MongoDB 也用物理回滚的机制取代了上述逻辑回滚的机制,但在某些特殊情况下,逻辑回滚仍然有用武之地,下面就对这些做简要分析。Recover To Timestamp Rollback「Recover To Timestamp Rollback」是借助于存储引擎把物理数据直接回滚到某个指定的时间点,所以这里把它称之为物理回滚,下面是 MongoDB 物理回滚的一个简化的流程图, 前面已经提到了 stable timestamp 的语义,这里不再赘述,MongoDB 有一个后台线程(WTCheckpointThread)会定期(默认情况下每 60 秒,由 storage.syncPeriodSecs 配置决定)根据 stable timestamp 触发新的 checkpoint 创建,这个 checkpoint 在实现中被称为 「stable checkpoint」。class WiredTigerKVEngine::WiredTigerCheckpointThread : public BackgroundJob { public: ... virtual void run() { ... { stdx::unique_lock<stdx::mutex> lock(_mutex); MONGO_IDLE_THREAD_BLOCK; _condvar.wait_for(lock, stdx::chrono::seconds(static_cast<std::int64_t>( wiredTigerGlobalOptions.checkpointDelaySecs))); } ... UniqueWiredTigerSession session = _sessionCache->getSession(); WT_SESSION* s = session->getSession(); invariantWTOK(s->checkpoint(s, "use_timestamp=true")); ... } ... }stable checkpoint 本质上是一个持久化的历史快照,它所包含的数据修改已经复制到多数派节点,所以不会发生重新选主后修改被回滚。其实 WiredTiger 本身也可以配置根据生成的 WAL 大小或时间来自动触发创建新的 checkpoint,但是 Server 层并没有使用,原因就在于 MongoDB 需要保证在回滚到上一个 checkpoint 时,状态机肯定是 “stable” 的,不需要回滚。WiredTiger 在创建 stable checkpoint 时也是开启一个带时间戳的事务来保证 checkpoint 的一致性,checkpoint 线程会把事务可见范围内的脏页刷盘,最后对应到磁盘上就是一个由多个变长数据块(WT 中称之为extent)构成的 BTree。回滚时,同样要先确定 common point,这个流程和逻辑回滚没有区别,之后, Server 层会首先 abort 掉所有活跃事务,接着调用 WT 提供的 rollback_to_stable() 接口把数据库回滚到 stable checkpoint 对应的状态,这个动作主要是重新打开 checkpoint 对应的 BTree,并重新初始化 catalog 信息,rollback_to_stable() 执行完后会向 Server 层返回对应的 stable timestamp。考虑到 stable checkpoint 触发的间隔较大,通常 common point 总是大于 stable checkpoint 对应的时间戳,所以 Server 层在拿到引擎返回的时间戳之后会还需要从其开始重新 apply 本地的 oplog 到 common point 为止,然后把 common point 之后的 oplog truncate 掉,从而达到和新的同步源一致的状态。这个流程主要在 RollbackImpl::_runRollbackCriticalSection() 中实现,Status RollbackImpl::_runRollbackCriticalSection( OperationContext* opCtx, RollBackLocalOperations::RollbackCommonPoint commonPoint) noexcept try { ... killSessionsAbortAllPreparedTransactions(opCtx); // abort 活跃事务 ... auto stableTimestampSW = _recoverToStableTimestamp(opCtx); // 引擎层回滚 ... Timestamp truncatePoint = _findTruncateTimestamp(opCtx, commonPoint); // 查找并设置 truncate 位点 _replicationProcess->getConsistencyMarkers()->setOplogTruncateAfterPoint(opCtx, truncatePoint); ... // Run the recovery process. // 这里会进行 reapply oplog 和 truncate oplog _replicationProcess->getReplicationRecovery()->recoverFromOplog(opCtx, stableTimestampSW.getValue()); ... }此外,为了确保回滚可以正常进行,Server 层在 oplog 的自动回收时还需要考虑 stable checkpoint 对部分 oplog 的依赖。通常来说,stable timestamp 之前的 oplog 可以安全的回收,但是在 4.2 中 MongoDB 增加了对大事务(对应的 oplog 大小超过 16MB)和分布式事务的支持,在 stable timestamp 之前的 oplog 在回滚 reapply oplog 的过程中也可能是需要的,所以在 4.2 中 oplog 的回收需要综合考虑当前最老的活跃事务和 stable timestamp。StatusWith<Timestamp> WiredTigerKVEngine::getOplogNeededForRollback() const { ... if (oldestActiveTransactionTimestamp) { return std::min(oldestActiveTransactionTimestamp.value(), Timestamp(stableTimestamp)); } else { return Timestamp(stableTimestamp); } }整体上来说,基于引擎 stable checkpoint 的物理回滚方式在回滚效率和回滚逻辑复杂性上都要优于逻辑回滚。但是 stable checkpoint 的推进要依赖 Server 层 majority commit point 的推进,而 majority commit point 的推进受限于各个节点的复制进度,所以复制慢时可能会导致 Primary 节点 cache 压力过大,所以 MongoDB 提供了 replication.enableMajorityReadConcern 参数用于控制是否维护 mcp,关闭后存储引擎也不再维护 stable checkpoint,此时回滚就仍然需要进行逻辑回滚,这也是在 4.2 中仍然保留「Refeched Based Rollback」的原因。“linearizable” readConcern在一个分布式系统中,如果总是把可用性摆在第一位,那么因果一致性是其能够实现的最高一致性级别(证明可见此处)。前面我们也通过统计数据分析了在大部分情况下用户总是更关注延迟(可用性)而不是一致性,而 MongoDB 副本集,正是从用户需求角度出发,被设计成了一个在默认情况下总是优先保证可用性的分布式系统,下图是一个简单的例证。 既然如此,那 MongoDB 是如何实现 “linearizable” readConcern,即更高级别的线性一致性呢? MongoDB 的策略很简单,就是把它退化到几乎是单机环境下的问题,即只允许客户端在 Primary 节点上进行 “linearizable” 读。说是“几乎”,因为这个策略仍然需要解决如下两个在副本集这个分布式环境下存在的问题,Primary 角色可能会发生变化,“linearizable” readConcern 需要保证每次读取总是能够从当前的 Primary 读取,而不是被取代的旧主。需要保证读取到读操作开始前最新的写,而且读到的结果不会在重新选主后发生回滚。MongoDB 采用同一个手段解决了上述两个问题,当客户端采用 “linearizable” readConcern 时,在读取完 Primary 上最新的数据后,在返回前会向 Oplog 中显示的写一条 noop 的操作,然后等待这条操作在多数派节点复制成功。显然,如果当前读取的节点并不是真正的主,那么这条 noop 操作就不可能在 majority 节点复制成功,同时,如果 noop 操作在 majority 节点复制成功,也就意味着之前读取的在 noop 之前写入的数据也已经复制到多数派节点,确保了读到的数据不会被回滚。// src/mongo/db/read_concern_mongod.cpp:waitForLinearizableReadConcern() ... writeConflictRetry( opCtx, "waitForLinearizableReadConcern", NamespaceString::kRsOplogNamespace.ns(), [&opCtx] { WriteUnitOfWork uow(opCtx); opCtx->getClient()->getServiceContext()->getOpObserver()->onOpMessage( opCtx, BSON("msg" << "linearizable read")); // 写 noop 操作 uow.commit(); }); ... auto awaitReplResult = replCoord->awaitReplication(opCtx, lastOpApplied, wc); // 等待 noop 操作 majority committed这个方案的缺点比较明显,单纯的读操作既产生了额外的写开销,也增加了延迟,但是这个是选择最高的一致性级别所需要付出的代价。Causal Consistency前面几个章节描述的由 writeConcern 和 readConcern 所构成的 MongoDB 可调一致性模型,仍然是属于最终一致性的范畴(特殊实现的 “linearizable” readConcern 除外)。虽然最终一致性对于大部分业务场景来说已经足够了,但是在某些情况下仍然需要更高的一致性级别,比如在下图这个经典的银行存款业务中,如果只有最终一致性,那么就可能导致客户看到的账户余额异常。 这个问题虽然可以在业务端通过记录一些额外的状态和重试来解决,但是显然会导致业务逻辑过于复杂,所以 MongoDB 实现了「Causal Consistency Session」功能来帮助降低业务复杂度。Causal Consistency 定义了分布式系统上的读写操作需要满足一个偏序(Partial Order)关系,即只部分操作发生的先后顺序可比。这个部分操作,进一步来说,指的是存在因果关系的操作,在 MongoDB 的「Causal Consistency Session」实现中,什么样的操作间算是存在因果关系,可参考前文提到的 Client-centric Consistency Model 下的 4 个一致性承诺分别对应的读写场景,此处不再赘述。所以,要实现因果一致性,MongoDB 首要解决的问题是如何给分布式环境下存在因果关系的操作定序,这里 MongoDB 借鉴了 Hybrid Logical Clock 的设计,实现了自己的 ClusterTime 机制,下面就对其实现进行分析。分布式系统中存在因果关系的操作定序关于分布式系统中的事件如何定序的论述,最有影响力的当属 Leslie Lamport 的这篇 《Time, Clocks, and the Ordering of Events in a Distributed System》,其中提到了一种 Logical Clock 用来确定不同事件的全序,后人也把它称为 Lamport Clock。 Lamport Clock 只用一个单纯的变量(scalar value)来表示,所以它的缺点之一是无法识别存在并发的事件(independent event),而这个会在实际的系统带来一些问题,比如在支持多点写入的系统中,无法基于 Lamport Clock 对存在写冲突的事件进行识别和处理。所以,后面又衍生出了 vector clock 来解决这一问题,但 vector clock 会存储数据的多个版本,数据量和系统中的节点数成正比,所以实际使用会带来一些扩展性的问题。Lamport Clock 存在的另外一个缺点是,它完全是一个逻辑意义上的值,和具体的物理时钟没有关联,而在现实的应用场景中,存在一些需要基于真实的物理时间进行访问的场景,比如数据的备份和恢复。Google 在其 Spanner 分布式数据库中提到了一种称之为 TrueTime 的分布式时钟设计,为事务执行提供时间戳。TrueTime 和真实物理时钟关联,但是需要特殊的硬件(原子钟/GPS)支持,MongoDB 作为一款开源软件,需要做到通用的部署,显然无法采用该方案。考虑到 MongoDB 本身不支持 「Multi-Master」 架构,而上述的分布式时钟方案均存在一些 MongoDB 在设计上需要规避的问题,所以 MongoDB 采用了一种所谓的混合逻辑时钟(Hybrid Logical Clock)的方案。HLC 设计上基于 Lamport Clock,只使用单个时钟变量,在具备给因果操作定序的能力同时,也能够(尽可能)接近真实的物理时钟。Hybrid Logical Clock 基本原理 先来了解一下 HLC 中几个基本的概念,pt:节点本地的物理时钟,通常是基于 ntp 进行时钟同步,HLC 只会读取该值,不会对该值做修改。l:HLC 的高位部分,是 HLC 的物理部分,和pt关联,HLC 保证 l 和 pt间的差值不会无限增长(bounded)。c:HLC 的低位部分,是 HLC 的逻辑部分。从上面的 HLC 时钟推进图中,可以看到,如果不考虑 l 部分(假设 l 总是不变),则 c 等同于 Lamport Clock,如果考虑 l 的变化,因为 l 是高位部分,只需要保证 if e hb f, l.e <= l.f,仍然可以确定存在因果关系的事件的先后顺序,具体的更新规则可以参考上面的算法。但是 l 的更新机制也决定了其他节点的时钟出现跳变或不同步,会导致 HLC 被推进,进而导致和 pt 产生误差,但 HLC 的机制决定了这个误差是有限的。上面的图就是一个很好的案例,假设当前的真实物理时钟是 0,而 0 号节点的时钟出现了跳变,变为 10,则在后续的时钟推进中,l 部分不会再增长,只会增加 c 部分,直到真实的物理时钟推进到 10,l 才会关联新的 pt 。MongoDB 在实现 Causal Consistency 之前就已经在副本集同步的 oplog 时间戳中使用了类似的设计,选择 HLC,也是为了方便和现有设计集成。Causal Consistency 不仅是在单一的副本集层面使用,在基于副本集构建的分片集群中同样有需求,所以这个新的分布式时钟,在 v3.6 中被称为 ClusterTime。MongoDB ClusterTime 实现MongoDB ClusterTime 基本上是严格按照 HLC 的思路来实现的,但它和 HLC 最大的一点不同是,在 HLC 或 Lamport Clock 中,消息的发送和接受都被认为是一个事件,会导致时钟值增加,但在 MongoDB ClusterTime 实现中,只有会改变数据库状态的操作发生才会导致 ClusterTime 增加,比如通常的写操作,这么做的目的还是为了和现有的 oplog 中的混合时间戳机制集成,避免更大的重构开销和由此带来的兼容性问题,同时这么做也并不会影响 ClusterTime 在逻辑上的正确性。 因为有了上述区别,ClusterTime 的实现就可以被分为两部分,一个是 ClusterTime 的增加(Tick),一个是 ClusterTime 的推进(Advance)。ClusterTime 的 Tick 发生在 MongoDB 接收到写操作时,ClusterTime 由 <Time><Counter> 来表示,是一个 64bit 的整数,其中高 32 位对应到 HLC 中的物理部分,低 32 位对应到 HLC 中的逻辑部分。而每一个写操作在执行前都会为即将要写的 oplog 提前申请对应的 OpTime(调用 getNextOpTimes() 来完成),OpTime 由 <Time><Counter><ElectionTerm> 来表示,ElectionTerm 和 MongoDB 的复制协议相关,是一个本地的状态值,不需要被包含到 ClusterTime 中,所以原有的 OpTime 在新版本中实际上是可以由 ClusterTime 直接转化得来,而 ClusterTime 也会随着 Oplog 写到磁盘而被持久化。std::vector<OplogSlot> LocalOplogInfo::getNextOpTimes(OperationContext* opCtx, std::size_t count) { ... // 申请 OpTime 时会 Tick ClusterTime 并获取 Tick 后的值 ts = LogicalClock::get(opCtx)->reserveTicks(count).asTimestamp(); const bool orderedCommit = false; ... std::vector<OplogSlot> oplogSlots(count); for (std::size_t i = 0; i < count; i++) { oplogSlots[i] = {Timestamp(ts.asULL() + i), term}; // 把 ClusterTime 转化为 OpTime ... return oplogSlots; } // src/mongo/db/logical_clock.cpp:LogicalClock::reserveTicks() 包含了 Tick 的逻辑,和 HLC paper 一致,主要逻辑如下 { newCounter = 0; wallClockSecs = now(); // _clusterTime is a current local value of node’s ClusterTime currentSecs = _clusterTime.getSecs(); if (currentSecs > wallClockSecs) { newSecs = currentSecs; newCounter = _clusterTime.getCounter() + 1; } else { newSecs = wallClockSecs; } _clusterTime = ClusterTime(newSecs, newCounter); return _clusterTime; }ClusterTime 的 Advance 逻辑比较简单,MongoDB 会在每个请求的回复中带上当前节点最新的 ClusterTime,如下,"$clusterTime" : { "clusterTime" : Timestamp(1495470881, 5), "signature" : { "hash" : BinData(0, "7olYjQCLtnfORsI9IAhdsftESR4="), "keyId" : "6422998367101517844" } } 接收到该 ClusterTime 的角色(mongos,client)如果发现更新的 ClusterTime,就会更新本地的值,同时在和别的节点通信的时候,带上这个新 ClusterTime,从而推进其他节点上的 ClusterTime,这个流程实际上是一种类似于 Gossip 的消息传播机制。因为 Client 会参与到 ClusterTime 的推进(Advance),如果有恶意的 Client 篡改了自己收到的 ClusterTime,比如把高位和低位部分都改成了 UINT32_MAX,则收到该 ClusterTime 的节点后续就无法再进行 Tick,这个会导致整个服务不可用,所以 MongoDB 的 ClusterTime 实现增加了签名机制(这个安全方面的增强 HLC 没有提及),上面的signature 字段即对应该功能,mongos 或 mongod 在收到 Client 发送过来的 $ClusterTime 时,会根据 config server 上存储的 key 来进行签名校验,如果 ClusterTime 被篡改,则签名不匹配,就不会推进本地时钟。除了恶意的 Client,操作失误也可能导致 mongod 节点的 wall clock 被更新为一个极大的值,同样会导致 ClusterTime 不能 Tick,针对这个问题,MongoDB 做了一个限制,新的 ClusterTime 和当前 ClusterTime 的差值如果超出 maxAcceptableLogicalClockDriftSecs,默认为 1 年,则当前的 ClusterTime 不会被推进。MongoDB Causal Consistency 实现在 ClusterTime 机制的基础上,我们就可以给不同的读写操作定序,但是操作对应的 ClusterTime 是在其被发送到数据节点(mongod)上之后才被赋予的,如果要实现 Causal Consistency 的承诺,比如前面提到的「Read Your Own Write」,显然我们需要 Client 也知道写操作在主节点执行完后对应的 ClusterTime。 ... "operationTime" : Timestamp(1612418230, 1), # Stable ClusterTime "ok" : 1, "$clusterTime" : { ... }所以 MongoDB 在请求的回复中除了带上 $clusterTIme 用于帮助推进混合逻辑时钟,还会带上另外一个字段 operationTime 用来表明这个请求包含的操作对应的 ClusterTime,operationTime 在 MongoDB 中也被称之为 「Stable ClusterTime」,它的准确含义是操作执行完成时,当前最新的 Oplog 时间戳(OpTime)。所以对于写操作来说,operationTime 就是这个写操作本身对应的 Oplog 的 OpTime,而对于读操作,取决于并发的写操作的执行情况。 Client 在收到这个 operationTime 后,如果要实现因果一致,就会在发送给其他节点的请求的 afterClusterTime 字段中带上这个 operationTime,其他节点在处理这个请求时,只会读取 afterClusterTime 之后的数据状态,这个过程是通过显式的等待同步位点推进来实现的,等待的逻辑和前面提到的 speculative “majority” readConcern 实现类似。上图是 MongoDB 副本集实现「Read Your Own Write」的基本流程。如果是在分片集群形态下,由于混合逻辑时钟的推进依赖于各个参与方(client/mongos/mongd)的交互,所以会暂时出现不同分片间的逻辑时钟不一致的情况,所以在这个架构下,我们需要解决某个分片的逻辑时钟滞后于 afterClusterTime 而且一直没有新的写入,导致请求持续被阻塞的问题,MongoDB 的做法是,在这种情况下显式的写一条 noop 操作到 oplog 中,相当于强制把这个分片的数据状态推进到 afterClusterTime 之后,从而确保操作能够尽快返回,同时也符合因果一致性的要求。 总结本文对 MongoDB 一致性模型在设计上的一些考虑和主要的实现机制进行了分析,这其中包括由 writeConcern 和 readConcern 机制构建的可调一致性模型,对应到标准模型中就是最终一致性和线性一致性,但是 MongoDB 借助read/write concern 这两者的配合,为用户提供更丰富的一致性和性能间的选择。此外,我们也分析了 MongoDB 如何基于 ClusterTime 混合逻辑时钟机制来给分布式环境下的读写操作定序,进而实现因果一致性。从功能和设计思路来看,MongoDB 无疑是丰富和先进的,但是在接口层面,读写采用不同的配置和级别,事务和非事务的概念区分,Causal Consistency Session 对 read/writeConcern的依赖等,都为用户的实际使用增加了门槛,当然这些也是 MongoDB 在易用性、功能性和性能多方取舍的结果,相信 MongoDB 后续会持续的做出改进。最后,伴随着 NewSQL 概念的兴起,「分布式+横向扩展+事务能力」逐渐成为新数据库系统的标配,MongoDB 也不例外。当我们在传统单机数据库环境下谈论一致性,更多指的是事务间的隔离性(Isolation),如果把隔离性这个概念映射到分布式架构下,可以容易看出,MongoDB 的 "local" readConcern 即对应 read uncommitted,"majority" readConcern 即对应 read committed,而 "snapshot" readConcern 对应的就是分布式的全局快照隔离,即这些新的概念部分也是来自于经典的 ACID 理论在分布式环境下的延伸,带上这样的视角可以让我们更容易理解 MongoDB 的一致性模型设计。参考文档Tunable Consistency In MongoDB: http://www.vldb.org/pvldb/vol12/p2071-schultz.pdfImplementation of Cluster-wide Logical Clock and Causal Consistency in MongoDB: https://dl.acm.org/doi/pdf/10.1145/3299869.3314049Logical Physical Clocks: https://cse.buffalo.edu/~demirbas/publications/hlc.pdfPACELC: http://www.cs.umd.edu/~abadi/papers/abadi-pacelc.pdfConsistency and Replication 1: https://web2.qatar.cmu.edu/~msakr/15440-f11/lectures/Lecture11_15440_VKO_10Oct_2011.pptxConsistency and Replication 2: https://web2.qatar.cmu.edu/~msakr/15440-f11/lectures/Lecture11_15440_VKO_10Oct_2011.pptxMongoDB writeConcern: https://docs.mongodb.com/manual/reference/write-concern/MongoDB readConcern: https://docs.mongodb.com/manual/reference/read-concern/WiredTiger Application-specified Transaction Timestamps: https://source.wiredtiger.com/develop/transactions.html#transaction_timestampsDatabase Replication Using Generalized Snapshot Isolation: https://infoscience.epfl.ch/record/53561/files/srds2005-gsi.pdfMongoDB Logical Session: https://www.mongodb.com/blog/post/transactions-background-part-2-logical-sessions-in-mongodb4 modifications for Raft consensus: https://www.openlife.cc/blogs/2015/september/4-modifications-raft-consensusTime, Clocks, and the Ordering of Events in a Distributed System: https://lamport.azurewebsites.net/pubs/time-clocks.pdfMongoDB Sharding Internals: https://github.com/mongodb/mongo/blob/master/src/mongo/db/s/README.mdMongoDB Replication Internals: https://github.com/mongodb/mongo/blob/master/src/mongo/db/repl/README.md
本文源自阅读了 MongoDB 于 VLDB 19 上发表的 Tunable Consistency in MongoDB 论文之后,在内部所做的分享(分享 PPT 见文末)。现在把分享的内容整理成此文,并且补充了部分在之前的分享中略过的细节,以及在分享中没有提及的 MongoDB Causal Consistency(也出现在另外一篇 SIGMOD'19 Paper),希望能够帮助大家对 MongoDB 的一致性模型设计有一个清晰的认识。需要额外说明的是,文章后续牵扯到具体实现的分析,都是基于 MongoDB 4.2 (WiredTiger 引擎),但是大部分关于原理的描述也仍然适用 4.2 之前的版本。MongoDB 可调一致性(Tunable Consistency)概念及理论支撑我们都知道,早期的数据库系统往往是部署在单机上的,随着业务的发展,对可用性和性能的要求也越来越高,数据库系统也进而演进为一种分布式的架构。这种架构通常表现为由多个单机数据库节点通过某种复制协议组成一个整体,称之为「Shared-nothing」,典型的如 MySQL,PG,MongoDB。另外一种值得一提是,伴随着「云」的普及,为了发挥云环境下资源池化的优势而出现的「云原生」的架构,典型的如 Aurora,PolarDB,因这种架构通常采用存储计算分离和存储资源共享,所以称之为「Shared-storage」。不管是哪种架构,在分布式环境下,根据大家耳熟能详的 CAP 理论,都要解决所谓的一致性(Consistency)问题,即在读写发生在不同节点的情况下,怎么保证每次读取都能获取到最新写入的数据。这个一致性即是我们今天要讨论的MongoDB 可调一致性模型中的一致性,区别于单机数据库系统中经常提到的 ACID 理论中的一致性。 CAP 理论中的一致性直观来看是强调读取数据的新近度(Recency),但个人认为也隐含了对持久性(Durability)的要求,即,当前如果已经读取了最新的数据,不能因为节点故障或网络分区,导致已经读到的更新丢失。关于这一点,我们后面讨论具体设计的时候也能看到 MongoDB 的一致性模型对持久性的关注。既然标题提到了是可调(Tunable)一致性,那这个可调性具体又指的是什么呢?这里就不得不提分布式系统中的另外一个理论,PACELC。PACELC 在 CAP 提出 10 年之后,即 2012 年,在一篇 Paper 中被正式提出,其核心观点是,根据 CAP,在一个存在网络分区(P)的分布式系统中,我们面临在可用性(A)和一致性(C)之间的选择,但除此之外(E),即使暂时没有网络分区的存在,在实际系统中,我们也要面临在访问延迟(L)和一致性(C)之间的抉择。所以,PACELC 理论是结合现实情况,对 CAP 理论的一种扩展。 而我们今天要讨论的 MongoDB 一致性模型的可调之处,指的就是调节 MongoDB 读写操作对 L 和 C 的选择,或者更具体的来说,是调节对性能(Performance——Latency、Throughput)和正确性(Correctness——Recency、Durability)的选择(Tradeoff)。MongoDB 一致性模型设计在讨论具体的实现之前,我们先来尝试从功能设计的角度,理解 MongoDB 的可调一致性模型,这样的好处是可以对其有一个比较全局的认知,后续也可以帮助我们更好的理解它的实现机制。在学术中,对一致性模型有一些标准的划分和定义,比如我们听到过的线性一致性(Linearizable Consistency),因果一致性(Causal Consistency)等都在这个标准当中,MongoDB 的一致性模型设计自然也不能脱离这个标准。但是,和很多其他的数据库系统一样,设计上需要综合考虑和其他子系统的关联,比如复制、存储引擎,具体的实现往往和标准又不是完全一致的。下面的第一个小节,我们就详细探讨标准的一致性模型和 MongoDB 一致性模型的关系,以对其有一个基本的认识。在这个基础上,我们再来看在具体的功能设计上,MongoDB 的一致性模型是怎么做的,以及在实际的业务场景中是如何被使用的。标准一致性模型和 MongoDB 一致性模型的关系以复制为基础构建的分布式系统中,一致性模型通常可按照「以数据为中心(Data-centric)」和「以客户端为中心(Client-centric)」来划分,下图中的「Linearizable」,「Sequential」,「Causal」,「Eventual」即属于 Data-centric 的范畴,对一致性的保证也是由强到弱。 Data-centric 的一致性模型要求我们站在整个系统的角度看,所有访问进程(客户端)的读写顺序满足同一个特定的约束,比如,对于线性一致性(Linearizable)来说,它要求这个读写顺序和操作真实发生的时间(Real Time)完全一致,是最强的一致性模型,实际系统中很难做到,而对于因果一致性来说,只约束了存在因果关系的操作之间的顺序。Data-centric 一致性模型虽然对访问进程提供了全局一致的视图,但是在真实的系统中,不同的读写进程(客户端)访问的往往是不同的数据,维护这样的全局视图会产生不必要的代价。举个例子,在因果一致性模型下,P1 执行了 Write1(X=1),P2 执行了 Read1(X=1),Write2(X=3),那么 P1 和 P2 之间就产生了因果关系,进而导致P1:Write1(X=1) 和 P2:Write2(X=3) 的可见顺序存在一个约束,即,需要其他访问进程看到的这两个写操作顺序是一样的,且 Write1 在前,但如果其他进程读的不是 X,显然再提供这种全局一致视图就没有必要了。由此,为了简化这种全局的一致性约束,就有了 Client-centric 一致性模型,相比于 Data-centric 一致性模型,它只要求提供单客户端维度的一致性视图,对单客户端的读写操作提供这几个一致性承诺:「RYW(Read Your Write)」,「MR(Monotonic Read)」,「MW(Monotonic Write)」,「WFR(Write Follow Read)」。关于这些一致性模型的概念和划分,本文不做太详细介绍,感兴趣的可以看 CMU 的这两篇 Lecture(Lec1,Lec2),讲的很清晰。MongoDB 的 Causal Consistency Session 即提供了上述几个承诺:RYW,MR,MW,WFR。但是,这里是 MongoDB 和标准不太一样的地方,MongoDB 的因果一致性提供的是 Client-centric 一致性模型下的承诺,而非 Data-centric。这么做主要还是从系统开销角度考虑,实现 Data-centric 下的因果一致性所需要的全局一致性视图代价过高,在真实的场景中,Client-centric 一致性模型往往足够了,关于这一点的详细论述可参考 MongoDB 官方在 SIGMOD'19 上 Paper 的 2.3 节。Causal Consistency 在 MongoDB 中是相对比较独立一块实现,只有当客户端读写的时候开启 Causal Consistency Session 才提供相应承诺。没有开启 Causal Consistency Session 时,MongoDB 通过 writeConcern 和 readConcern 接口提供了可调一致性,具体来说,包括线性一致性和最终一致性。最终一致性在标准中的定义是非常宽松的,是最弱的一致性模型,但是在这个一致性级别下 MongoDB 也通过 writeConcern 和 readConcern 接口的配合使用,提供了丰富的对性能和正确性的选择,从而贴近真实的业务场景。MongoDB 可调一致性模型功能接口 —— writeConcern 和 readConcern在 MongoDB 中,writeConcern 是针对写操作的配置,readConcern 是针对读操作的配置,而且都支持在单操作粒度(Operation Level) 上调整这些配置,使用起来非常的灵活。writeConcern 和 readConcern 互相配合,共同构成了 MongoDB 可调一致性模型的对外功能接口。writeConcern —— 唯一关心的就是写入数据的持久性(Durability)我们首先来看针对写操作的 writeConcern,写操作改变了数据库的状态,才有了读操作的一致性问题。同时,我们在后面章节也会看到,MongoDB 一些 readConcern 级别的实现也强依赖 writeConcern 的实现。MongoDB writeConcern 包含如下选项,{ w: <value>, j: <boolean>, wtimeout: <number> }w,指定了这次的写操作需要复制并应用到多少个副本集成员才能返回成功,可以为数字或 “majority”(为了避免引入过多的复杂性,这里忽略基于 tag 的自定义 writeConcern)。w:0 时比较特殊,即客户端不需要收到任何有关写操作是否执行成功的确认,具有最高性能。w: majority 需要收到多数派节点(含 Primary)关于操作执行成功的确认,具体个数由 MongoDB 根据副本集配置自动得出。j,额外要求节点回复确认时,写操作对应的修改已经被持久化到存储引擎日志中。wtimeout,Primary 节点在等待足够数量的确认时的超时时间,超时返回错误,但并不代表写操作已经执行失败。从上面的定义我们可以看出,writeConcern 唯一关心的就是写操作的持久性,这个持久性不仅仅包含由 j 决定、传统的单机数据库层面的持久性,更重要的是包含了由 w 决定、整个副本集(Cluster)层面的持久性。w 决定了当副本集发生重新选主时,已经返回写成功的修改是否会“丢失”,在 MongoDB 中,我们称之为被回滚。w 值越大,对客户端来说,数据的持久性保证越强,写操作的延迟越大。 这里还要提及两个概念,「local committed」 和 「majority committed」,对应到 writeConcern 分别为 w:1 和 w: majority,它们在后续实现分析中会多次涉及。每个 MongoDB 的写操作会开启底层 WiredTiger 引擎上的一个事务,如下图,w:1 要求事务只要在本地成功提交(local committed)即可,而 w: majority 要求事务在副本集的多数派节点提交成功(majority committed)。 readConcern —— 关心读取数据的新近度(Recency)和持久性(Durability)在 MongoDB 4.2 中包含 5 种 readConcern 级别,我们先来看前 4 种:「local」, 「available」, 「majority」, 「linearizable」,它们对一致性的承诺依次由弱到强。其中,「linearizable」即对应我们前面提到的标准一致性模型中的线性一致性,另外 3 种 readConcern 级别代表了 MongoDB 在最终一致性模型下,对 Latency 和 Consistency(Recency & Durability) 的取舍。下面我们结合一个三节点副本集复制架构图,来简要说明这几个 readConcern 级别的含义。在这个图中,oplog 代表了MongoDB 的复制日志,类似于 MySQL 中的 binlog,复制日志上最新的x=<value>,表示了节点的复制进度。 local/available:local 和 available 的语义基本一致,都是读操作直接读取本地最新的数据。但是,available 使用在 MongoDB 分片集群场景下,含特殊语义(为了保证性能,可以返回孤儿文档),这个特殊语义和本文的主题关联不大,所以后面我们只讨论 local readConcern。在这个级别下,发生重新选主时,已经读到的数据可能会被回滚掉。majority:读取「majority committed」的数据,可以保证读取的数据不会被回滚,但是并不能保证读到本地最新的数据。比如,对于上图中的 Primary 节点读,虽然 x=5 已经是最新的已提交值,但是由于不是「majority committed」,所以当读操作使用 majority readConcern 时,只返回x=4。linearizable:承诺线性一致性,即,既保证能读取到最新的数据(Recency Guarantee),也保证读到数据不会被回滚(Durability Guarantee)。前面我们说了,线性一致性在真实系统中很难实现,MongoDB 在这里采用了一个相当简化的设计,当读操作指定 linearizable readConcern level 时,读操作只能读取 Primary 节点,而考虑到写操作也只能发生在 Primary,相当于 MongoDB 的线性一致性承诺被限定在了单机环境下,而非分布式环境,实现上自然就简单很多。考虑到会有重新选主的情况,MongoDB 在这个 readConcern level 下唯一需要解决的问题就是,确保每次读发生在真正的 Primary 节点上。后面分析具体实现我们可以看到,解决这个问题是以增加读延迟为代价的。以上各 readConcern level 在 Latency、Durability、Recency 上的 Tradeoff 如下, 我们还有最后一种 readConcern level 没有提及,即「snapshot readConcern」,放在这里单独讨论的原因是,「snapshot readConcern」是伴随着 4.0 中新出现的多文档事务( multi-document transaction,其他系统也常称之为多行事务)而设计的,只能用在显式开启的多文档事务中。而在 4.0 之前的版本中,对于一条读写操作,MongoDB 默认只支持单文档上的事务性语义(单行事务),前面提到的 4 种 readConcern level 正是为这些普通的读写操作(未显式开启多文档事务)而设计的。「snapshot readConcern」从定义上来看,和 majority readConcern 比较相似,即,读取「majority committed」的数据,也可能读不到最新的已提交数据,但是其特殊性在于,当用在多文档事务中时,它承诺真正的一致性快照语义,而其他的 readConcern level 并不提供,关于这一点,我们在后面的实现部分再详细探讨。writeConcern 和 readConcern 的关系在分布式系统中,当我们讨论一致性的时候,通常指的是读操作对数据的关注,即「what read concerns」,那为什么在 MongoDB 中我们还要单独讨论 writeConcern 呢?从一致性承诺的角度来看,writeConcern 从如下两方面会对 readConcern 产生影响,「linearizable readConcern」读取的数据需要是以「majority writeConcern」写入且持久化到日志中,才能提供真正的「线性一致性」语义。考虑如下情况,数据写入到 majority 节点后,没有在日志中持久化,当 majority 节点发生重启恢复,那么之前使用 「linearizable readConcern」读取到的数据就可能丢失,显然和「线性一致性」的语义不相符。在 MongoDB 中,writeConcernMajorityJournalDefault 参数控制了,当写操作指定 「majority writeConcern」的时候,是否保证写操作在日志中持久化,该参数默认为 true。另外一种情况是,写操作持久化到了日志中,但是没有复制到 majority 节点,在重新选主后,同样可能会发生数据丢失,违背一致性承诺。「majority readConcern」要求读取 majority committed 的数据,所以受限于不同节点的复制进度,可能会读取到更旧的值。但是如果数据是以更高的 writeConcern w 值写入的,即写操作在扩散到更多的副本集节点上之后才返回写成功,显然之后再去读取,「majority readConcern」能有更大的概率读到最新写入的值(More Recency Guarantee)。所以,writeConcern 虽然只关注了写入数据的持久化程度,但是作为读操作的数据来源,也间接的也影响了 MongoDB 对读操作的一致性承诺。writeConcern 和 readConcern 在实际业务中的应用前面是对 writeConcern 和 readConcern 在功能定义上的介绍,可以看到,读写采用不同的配置,每个配置下面又包含不同的级别,这个接口设计对于使用者来说还是稍显复杂的(社区中也有不少类似的反馈),下面我们就来了解一下 writeConcern 和 readConcern 在真实业务中的统计数据以及几个典型应用场景,以加深对它们的理解。 上面的统计数据来自于 MongoDB 自己的 Atlas 云服务中用户 Driver 上报的数据,统计样本在百亿量级,所以准确性是可以保证的,从数据中我们可以分析出如下结论,大部分的用户实际上只是单纯的使用默认值在读取数据时,99% 以上的用户都只关心能否尽可能快的读取数据,即使用 local readConcern在写入数据时,虽然大部分用户也只要求写操作在本地写成功即可,但仍然有不小的比例使用了 majority writeConcern(16%,远高于使用 majority readConcern 的比例),因为写操作被回滚对用户来说通常都是更影响体验的。此外,MongoDB 的默认配置({w:1} writeConcern, local readConcern)都是更倾向于保护 Latency 的,主要是基于这样的一个事实:主备切换事件发生的概率比较低,即使发生了丢数据的概率也不大。 统计数据给了我们一个 MongoDB readConcern/writeConcern 在真实业务场景下使用情况的直观认识,即,大部分用户更关注 Latency,而不是 Consistency。但是,统计数据同时也说明 readConcern/writeConcern 的使用组合是非常丰富的,用户通过使用不同的配置值来满足需求各异的业务场景对一致性和性能的要求,比如如下几个实际业务场景中的应用案例(均来自于 Atlas 云服务中的用户使用场景),Majority reads and writes:在这个组合下,意味着对数据安全性的关注是第一优先级的。考虑一个助学贷款的网站,网站的访问流量并不高,大约每分钟两次写入(提交申请),对于一个申请贷款的学生来说,显然不能接受自己成功提交的申请在后台 MongoDB 数据库发生重新选主时数据“丢失”,同样也不能接受获取到申请通过结果的情况下,再次查询,可能因为读取的数据被回滚,结果发生变化的情形,所以业务选择使用 majority readConcern & writeConcern 的组合,通过牺牲读写延迟来换取数据的安全性。Local reads and Majority writes:考虑一个餐饮评价的 App,比如大众点评,用户可能要花很大的精力来编辑一条精彩的评价,如果因为后端 MongoDB 实例发生主备切换导致评论丢失,对用户来说显然是不可接受的,所以用户评价的提交(写)需要使用 majority writeConcern,但是读到一条可能后续会因为回滚而“消失”的评价,对用户来说往往是可以接受,考虑到要兼顾性能,使用 local readConcern 显然是一个更优的选择。Multiple Write Concern Values:在同一个业务场景中,也不用只局限于一种 writeConcern/readConcern value,可以在不同的条件下使用不同的值来兼顾性能和一致性。比如,考虑一个文档系统,通常这样的系统在用户编辑文档时,会提供自动保存功能,对于非用户主动触发的发布或保存,自动保存的结果如果产生丢失,用户往往是感知不到的,而自动保存功能相对又是会比较频繁的触发(写压力更大),所以这种写动作使用 local writeConcern 显然更合理,写延迟更低,而低频的主动保存或发布,应该使用 majority writeConcern,因为这种情况用户对要保存的数据有明确的感知,很难接受数据的丢失。MongoDB 因果一致性模型功能接口 —— Causal Consistency Session前面已经提及了,相比于 writeConcern/readConcern 构建的可调一致性模型,MongoDB 的因果一致性模型是另外一块相对比较独立的实现,有自己专门的功能接口。MongoDB 的因果一致性是借助于客户端的 causally consistent session 来实现的,causally consistent session 可以理解为,维护一系列存在因果关系的读写操作间的因果一致性的执行载体。causally consistent session 通过维护 Server 端返回的一些操作执行的元信息(主要是关于操作定序的信息),再结合 Server 端的实现来提供 MongoDB Causal Consistency 所定义的一致性承诺(RYW,MR,MW,WFR),具体原理我们在后面的实现部分再详述。针对 causally consistent session,我们可以看一个简单的例子,比如现在有一个订单集合 orders,用于存储用户的订单信息,为了扩展读流量,客户端采用主库写入从库读取的方式,用户希望自己在提交订单之后总是能够读取到最新的订单信息(Read Your Write),为了满足这个条件,客户端就可以通过 causally consistent session 来实现这个目的,""" new order """ with client.start_session(causal_consistency=True) as s1: orders = client.get_database( 'test', read_concern=ReadConcern('majority'), write_concern=WriteConcern('majority', wtimeout=1000)).orders orders.insert_one( {'order_id': "123", 'user': "tony", 'order_info': {}}, session=s1) """ another session get user orders """ with client.start_session(causal_consistency=True) as s2: s2.advance_cluster_time(s1.cluster_time) # hybird logical clock s2.advance_operation_time(s1.operation_time) orders = client.get_database( 'test', read_preference=ReadPreference.SECONDARY, read_concern=ReadConcern('majority'), write_concern=WriteConcern('majority', wtimeout=1000)).orders for order in orders.find({'user': "tony"}, session=s2): print(order)从上面的例子我们可以看到,使用 causally consistent session,仍然需要指定合适的 readConcern/writeConcern value,原因是,只有指定 majority writeConcern & readConcern,MongoDB 才能提供完整的 Causal Consistency 语义,即同时满足前面定义的 4 个承诺(RYW,MR,MW,WFR)。 简单起见,我们只举例其中的一种情况:为什么在 {w: 1} writeConcern 和 majority readConcern 下,不能满足 RYW(Read Your Write)? 上图是一个 5 节点的副本集,当发生网络分区时(P~old~, S~1~ 和 P~new~, S~2~, S~3~ 分区),在 P~old~ 上发生的 W~1~ 写入因为使用了 {w:1} writeConcern ,会向客户端返回成功,但是因为没有复制到多数派节点,最终会在网络恢复后被回滚掉,R~1~ 虽然发生在 W~1~ 之后,但是从 S~2~ 并不能读取到 W~1~ 的结果,不符合 RYW 语义。其他情况下为什么不能满足 Causal Consistency 语义,可以参考官方文档,有非常详细的说明。MongoDB 一致性模型实现机制及优化前面对 MongoDB 的可调一致性和因果一致性模型,在理论以及具体的功能设计层面做了一个总体的阐述,下面我们就深入到内核层面,来看下 MongoDB 的一致性模型的具体实现机制以及在其中做了哪些优化。writeConcern在 MongoDB 中,writeConcern 的实现相对比较简单,因为不同的 writeConcern value 实际上只是决定了写操作返回的快慢。w <= 1 时,写操作的执行及返回的流程只发生在本地,并不会涉及等待副本集其他成员确认的情况,比较简单,所以我们只探讨 w > 1 时 writeConcern 的实现。w>1 时 writeConcern 的实现每一个用户的写操作会开启 WiredTiger 引擎层的一个事务,这个事务在提交时会顺便记录本次写操作对应的 Oplog Entry 的时间戳(Oplog 可理解为 MongoDB 的复制日志,这里不做详细介绍,可参考文档),这个时间戳在代码里面称之为lastOpTime。// mongo::RecoveryUnit::OnCommitChange::commit -> mongo::repl::ReplClientInfo::setLastOp void ReplClientInfo::setLastOp(OperationContext* opCtx, const OpTime& ot) { invariant(ot >= _lastOp); _lastOp = ot; lastOpInfo(opCtx).lastOpSetExplicitly = true; }引擎层事务提交后,相当于本地已经完成了本次写操作,对于 w:1 的 writeConcern,已经可以直接向客户端返回成功,但是当 w > 1 时就需要等待足够多的 Secondary 节点也确认写操作执行成功,这个时候 MongoDB 会通过执行 ReplicationCoordinatorImpl::_awaitReplication_inlock 阻塞在一个条件变量上,等待被唤醒,被阻塞的用户线程会被加入到 _replicationWaiterList 中。Secondary 在拉取到 Primary 上的这个写操作对应的 Oplog 并且 Apply 完成后,会更新自身的位点信息,并通知另外一个后台线程汇报自己的 appliedOpTime 和 durableOpTime 等信息给 upstream(主要的方式,还有其他一些特殊的汇报时机)。void ReplicationCoordinatorImpl::setMyLastAppliedOpTimeAndWallTimeForward( ... if (opTime > myLastAppliedOpTime) { _setMyLastAppliedOpTimeAndWallTime(lock, opTimeAndWallTime, false, consistency); _reportUpstream_inlock(std::move(lock)); // 这里是向 sync source 汇报自己的 oplog apply 进度信息 } ... }appliedOpTime 和 durableOpTime 的含义和区别如下,appliedOpTime:Secondary 上 Apply 完一批 Oplog 后,最新的 Oplog Entry 的时间戳。durableOpTime:Secondary 上 Apply 完成并在 Disk 上持久化的 Oplog Entry 最新的时间戳, Oplog 也是作为 WiredTiger 引擎的一个 Table 来实现的,但 WT 引擎的 WAL sync 策略默认是 100ms 一次,所以这个时间戳通常滞后于appliedOpTime。上述信息的汇报是通过给 upstream 发送 replSetUpdatePosition 命令来完成的,upstream 在收到该命令后,通过比较如果发现某个副本集成员汇报过来的时间戳信息比上次新,就会触发,唤醒等待 writeConcern 的用户线程的逻辑。唤醒逻辑会去比较用户线程等待的 lastOptime 是否小于等于 Secondary 汇报过来的时间戳 TS,如果是,表示有一个 Secondary 节点满足了本次 writeConcern 的要求。那么,TS 要使用 Secondary 汇报过来的那个时间戳呢?如果 writeConcern 中 j 参数指定的是 false,意味着本次写操作并不关注是否在 Disk 上持久化,那么 TS 使用 appliedOpTime, 否则使用 durableOpTime 。当有指定的 w 个节点(含 Primary 自身)汇报的 TS 大于等于 lastOptime,用户线程即可被唤醒,向客户端返回成功。// TopologyCoordinator::haveNumNodesReachedOpTime for (auto&& memberData : _memberData) { const OpTime& memberOpTime = durablyWritten ? memberData.getLastDurableOpTime() : memberData.getLastAppliedOpTime(); if (memberOpTime >= targetOpTime) { --numNodes; } if (numNodes <= 0) { return true; } }到这里,用户线程因 writeConcern 被阻塞到唤醒的基本流程就完成了,但是我们还需要思考一个问题,MongoDB 是支持链式复制的,即, P->S1->S2 这种复制拓扑,如果在 P 上执行了写操作,且使用了 writeConcern w:3,即,要求得到三个节点的确认,而 S2 并不直接向 P 汇报自己的 Oplog Apply 信息,那这种场景下 writeConcern 要如何满足?MongoDB 采用了信息转发的方式来解决这个问题,当 S1 收到 S2 汇报过来的 replSetUpdatePosition 命令,进行处理时(processReplSetUpdatePosition()),如果发现自己不是 Primary 角色,会立刻触发一个 forwardSlaveProgress 任务,即,把自己的 Oplog Apply 信息,连同自己的 Secondary 汇报过来的,构造一个 replSetUpdatePosition 命令,发往上游,从而保证,当任一个 Secondary 节点的 Oplog Apply 进度推进,Primary 都能够及时的收到消息,尽可能降低 w>1 时,因 writeConcern 而带来的写操作延迟。readConcernreadConcern 的实现相比于 writeConcern,要复杂很多,因为它和存储引擎的关联要更为紧密,在某些情况下,还要依赖于 writeConcern 的实现,同时部分 readConcern level 的实现还要依赖 MongoDB 的复制机制和存储引擎共同提供支持。另外,MongoDB 为了在满足指定 readConcern level 要求的前提下,尽量降低读操作的延迟和事务执行效率,也做了一些优化。下面我们就结合不同的 readConcern level 来分别描述它们的实现原理和优化手段。“majority” readConcern“majority” readConcern 的语义前面的章节已经介绍,这里不再赘述。为了保证客户端读到 majority committed 数据,根据存储引擎能力的不同,MongoDB 分别实现了两种机制用于提供该承诺。依赖 WiredTiger 存储引擎快照的实现方式WiredTiger 为了保证并发事务在执行时,不同事务的读写不会互相 block,提升事务执行性能,也采用了 MVCC 的并发控制策略,即不同的写事务在提交时,会生成多个版本的数据,每个版本的数据由一个时间戳(commit_ts)来标识。所谓的存储引擎快照(Snapshot),实际上就是在某个时间点看到的,由历史版本数据所组成的一致性数据视图。所以,在引擎内部,快照也是由一个时间戳来标识的。前面我们已经提到,由于 MongoDB 采用异步复制的机制,不同节点的复制进度会有差异。如果我们在某个副本集节点直接读取最新的已提交数据,如果它还没有复制到大多数节点,显然就不满足 “majority” readConcern 语义。这个时候可以采取一个办法,就是仍然读取最新的数据,但是在返回 Client 前等待其他节点确认本次读取的数据已经 apply 完成了,但是这样显然会大幅的增加读操作的延迟(虽然这种情况下,一致性体验反而更好了,因为能读到更新的数据,但是前面我们已经分析了,绝大部分用户在读取时,希望更快的返回的数据,而不是追求一致性)。所以,MongoDB 采用的做法是在存储引擎层面维护一个 majority committed 数据视图(快照),这个快照对应的时间戳在 MongoDB 里面称之为 majority committed point(后面简称 mcp)。当 Client 指定 majority 读时,通过直接读取这个快照,来快速的返回数据,无需等待。需要注意的一点是,由于复制进度的差异,mcp 并不能反映当前最新的已提交数据,即,这个方法是通过牺牲 Recency 来换取更低的 Latency。// 以 getMore 命令举例 void applyCursorReadConcern(OperationContext* opCtx, repl::ReadConcernArgs rcArgs) { ... switch (rcArgs.getMajorityReadMechanism()) { case repl::ReadConcernArgs::MajorityReadMechanism::kMajoritySnapshot: { // Make sure we read from the majority snapshot. opCtx->recoveryUnit()->setTimestampReadSource( RecoveryUnit::ReadSource::kMajorityCommitted); // 获取 majority committed snapshot uassertStatusOK(opCtx->recoveryUnit()->obtainMajorityCommittedSnapshot()); break; ... }但基于 mcp 快照的实现方式需要解决一个问题,即,如何保证这个快照的有效性? 进一步来说, 如何保证 mcp 视图所依赖的历史版本数据不会被 WiredTiger 引擎清理掉?正常情况下,WiredTiger 会根据事务的提交情况自动的去清理多版本的数据,只要当前的活跃事务对某个历史版本的数据没有依赖,即可以从内存中的 MVCC List 里面删掉(不考虑 LAS 机制,WT 的多版本数据设计上只存放在内存中)。但是,所谓的 majority committed point,实际上是 Server 层的概念,引擎层并不感知,如果只根据事务的依赖来清理历史版本数据,mcp 依赖的历史版本版本数据可能就会被提前清理掉。举个例子,在下图的三节点副本集中,如果 Client 从 Primary 节点读取并且指定了 majority readConcern,由于 mcp = 4,那么 MongoDB 只能向 Client 返回 commit_ts = 4 的历史值。但是,对于 WiredTiger 引擎来说,当前活跃的事务列表中只有 T1,commit_ts = 4 的历史版本是可以被清理的,但清理掉该版本,mcp 所依赖的 snapshot 显然就无法保证。所以,需要 WiredTiger 引擎层提供一个新机制,根据 Server 层告知的复制进度,即, mcp 位点,来清理历史版本数据。 在 WiredTiger 3.0 版本中,开始提供「Application-specified Transaction Timestamps」功能,来解决 Server 层对事务提交顺序(基于 Application Timestamp)的需求和 WiredTiger 引擎层内部的事务提交顺序(基于 Internal Transaction ID)不一致的问题(根源来自于基于 Oplog 的复制机制,这里不作展开)。进一步,在这个功能的基础上,WT 也提供了所谓的「read "as of" a timestamp」功能(也有文章称之为 「Time Travel Query」),即支持从某个指定的 Timestamp 进行快照读,而这个特性正是前面提到的基于 mcp 位点实现 "majority" readConcern 的功能基础。WiredTiger 对外提供了 set_timestamp() 的 API,用于 Server 层来更新相关的 Application Timestamp。WT 目前包含如下语义的 Application Timestamp, 要回答前面提到的关于 mcp snapshot 有效性保证的问题,我们需要重点关注红框中的几个 Timestamp。首先,stable timestamp 在 MongoDB 中含义是,在这个时间戳之前提交的写,不会被回滚,所以它和 majority commit point(mcp) 的语义是一致的。stable timestamp 对应的快照被存储引擎持久化后,称之为「stable checkpoint」,这个 checkpoint 在 MongoDB 中也有重要的意义,在后面的「"local" readConcern」章节我们再详述。MongoDB 在 Crash Recovery 时,总是从 stable checkpoint 初始化,然后重新应用增量的 Oplog 来完成一次恢复。所以为了提升 Crash Recovery 效率及回收日志空间,引擎层需要定期的产生新的 stable checkpoint,也就意味着stable timestamp 也需要不断的被 Server 层推进(更新)。而 MongoDB 在更新 stable timestamp 的同时,也会顺便去基于该时间戳去更新 oldest timestamp,所以,在基于快照的实现机制下,oldest timestamp 和 stable timestamp 的语义也是一致的。... ->ReplicationCoordinatorImpl::_updateLastCommittedOpTimeAndWallTime() ->ReplicationCoordinatorImpl::_setStableTimestampForStorage() ->WiredTigerKVEngine::setStableTimestamp() ->WiredTigerKVEngine::setOldestTimestampFromStable() ->WiredTigerKVEngine::setOldestTimestamp() 当前 WiredTiger 收到新的 oldest timestamp 时,会结合当前的活跃事务(oldest_reader)和 oldest timestamp 来计算新的全局 pinned timestamp,当进行历史版本数据的清理时,pinned timestamp 之后的版本不会被清理,从而保证了 mcp snapshot 的有效性。// 计算新的全局 pinned timestamp __conn_set_timestamp->__wt_txn_global_set_timestamp->__wt_txn_update_pinned_timestamp-> __wt_txn_get_pinned_timestamp { ... tmp_ts = include_oldest ? txn_global->oldest_timestamp : 0; ... if (!include_oldest && tmp_ts == 0) return (WT_NOTFOUND); *tsp = tmp_ts; ... } // 判断历史版本是否可清理 static inline bool __wt_txn_visible_all(WT_SESSION_IMPL *session, uint64_t id, wt_timestamp_t timestamp) { ... __wt_txn_pinned_timestamp(session, &pinned_ts); return (timestamp <= pinned_ts); }在分析了 mcp snapshot 有效性保证的机制之后,我们还需要回答下面两个关键问题,整个细节才算完整。Secondary 的复制进度,以及进一步由复制进度计算出的 mcp 是由 oplog 中的 ts 字段来标识的,而数据的版本号是由 commit_ts 来标识的,他们之间有什么关系,为什么是可比的?前面提到了引擎的 Crash Recovery 需要 stable timestamp(mcp)不断的推进来产生新的 stable checkpoint,那 mcp 具体是如何推进的?要回答第一个问题,我们需要先看下,对于一条 insert 操作,它所对应的 oplog entry 的 ts 字段值是怎么来的,以及这条 oplog 和 insert 操作的关系。首先,当 Server 层收到一条 insert 操作后,会提前调用 LocalOplogInfo::getNextOpTimes() 来给其即将要写的 oplog entry 生成 ts 值,获取这个 ts 是需要加锁的,避免并发的写操作产生同样的 ts。然后, Server 层会调用 WiredTigerRecoveryUnit::setTimestamp 开启 WiredTiger 引擎层的事务,并且把这个事务中后续写操作的 commit_ts 都设置为 oplog entry 的 ts,insert 操作在引擎层执行完成后,会把其对应的 oplog entry 也通过同一事务写到 WiredTiger Table 中,之后事务才提交。 也就是说 MongoDB 是通过把写 oplog 和写操作放到同一个事务中,来保证复制日志和实际数据之间的一致性,同时也确保了,oplog entry ts 和写操作本身所产生修改的版本号是一致的。对于第二个问题,mcp 如何推进,在前面的 writeConcern 实现章节我们提到了,downstream 在 apply 完一批 oplog 之后会向 upstream 汇报自己的 apply 进度信息,upstream 同时也会向自己的 upstream 转发这个信息,基于这个机制,对 Primary 来说,显然最终它能不断的获取到整个副本集所有成员的 oplog apply 进度信息,进而推进自己的 majority commit point(计算的方式比较简单,具体见TopologyCoordinator::updateLastCommittedOpTimeAndWallTime)。但是,上述是一个单向传播的机制,而副本集的 Secondary 节点也是能够提供读的,同样需要获取其他节点的 oplog apply 信息来更新 mcp 视图,所以 MongoDB 也提供了如下两种机制来保证 Secondary 节点的 mcp 是可以不断推进的:基于副本集高可用的心跳机制:i. 默认情况下,每个副本集节点都会每 2 秒向其他成员发送心跳(replSetHeartBeat 命令)ii. 其他成员返回的信息中会包含 $replData 元信息,Secondary 节点会根据其中的 lastOpCommitted 直接推进自己的 mcp$replData: { term: 147, lastOpCommitted: { ts: Timestamp(1598455722, 1), t: 147 } ...基于副本集的增量同步机制:i. 基于心跳机制的 mcp 推进方式,显然实时性是不够的,Primary 计算出新的 mcp 后,最多要等 2 秒,下游才能更新自己的 mcpii. 所以,MongoDB 在 oplog 增量同步的过程中,upstream 同样会在向 downstream 返回的 oplog batch 中夹带 $replData 元信息,下游节点收到这个信息后同样会根据其中的 lastOpCommitted 直接推进自己的 mcpiii. 由于 Secondary 节点的 oplog fetcher 线程是持续不断的从上游拉取 oplog,只要有新的写入,导致 Primary mcp 推进,那么下游就会立刻拉取新的 oplog,可以保证在 ms 级别同步推进自己的 mcp 另外一点需要说明的是,心跳回复中实际上也包含了目标节点的 lastAppliedOpTime 和 lastDurableOpTime 信息,但是 Secondary 节点并不会根据这些信息自行计算新的 mcp,而是总是等待 Primary 把 lastOpCommittedOpTime 传播过来,直接 set 自己的 mcp。Speculative Read —— 不依赖快照的实现方式 类似于 MySQL,MongoDB 也是支持插件式的存储引擎体系的,但是并非每个支持的存储引擎都实现了 MVCC,即具备快照能力,比如在 MongoDb 3.2 之前默认的 MMAPv1 引擎就不具备。此外,即使对于具备 MVCC 的 WiredTiger 引擎,维护 majority commit point 对应的 snapshot 是会带来存储引擎 cache 压力上涨的,所以 MongoDB 提供了 replication.enableMajorityReadConcern 参数用于关闭这个机制。所以,结合以上两方面的原因,MongoDB 需要提供一种不依赖快照的机制来实现 majority readConcern,MongoDB 把这个机制称之为 Speculative Read ,中文上我觉得可以称为“未决读”。Speculative Read 的实现方式非常简单,上一小节实际上也基本描述了,就是直接读当前最新的数据,但是在实际返回 Client 前,会等待读到的数据在多数节点 apply 完成,故可以满足 majority readConcern 语义。本质上,这是一种后验的机制,在其他的数据库系统中,比如 Hekaton,VoltDB ,事务的并发控制中也有类似的做法。在具体的实现上,首先在命令实际执行前会通过 WiredTigerRecoveryUnit::setTimestampReadSource() 设置自己的读时间戳,即 readTs,读事务在执行的过程中只会读到 readTs 或之前的版本。在命令执行完成后,会调用 waitForSpeculativeMajorityReadConcern() 确保 readTs 对应的时间点及之前的 oplog 在 majority 节点应用完成。这里实际上最终也是通过调用 ReplicationCoordinatorImpl::_awaitReplication_inlock 阻塞在一个条件变量上,等待足够多的 Secondary 节点汇报自己的复制进度信息后才被唤醒,完全复用了 majority writeConcern 的实现。所以,writeConcern,readConcern 除了在功能设计上有强关联,在内部实现上也有互相依赖。需要注意的是,Speculative Read 机制 MongoDB 并不打算提供给普通用户使用,如果把 replication.enableMajorityReadConcern 设置为 false 之后,继续使用 majority readConcern,MongoDB 会返回 ReadConcernMajorityNotEnabled 错误。目前在一些内部命令的场景下才会使用该机制,测试目的的话,可以在 find 命令中加一个特殊参数: allowSpeculativeMajorityRead: true,强制开启 Speculative Read 的支持。针对 readConcern 的优化 —— Query Yielding考虑到后文逻辑上的依赖,在分析其他 readConcern level 之前,需要先看一个 MongoDB 针对 readConcern 的优化措施。默认情况下,MongoDB Server 层面所有的读操作在 WiredTiger 上都会开启一个事务,并且采用 snapshot 隔离级别。在 snapshot isolation 下,事务需要读到一个一致性的快照,且读取的数据是事务开始时最新提交的数据。而 WiredTiger 目前的多版本数据只能存放在内存中,所以在这个规则下,执行时间太久的事务会导致 WiredTiger 的内存压力升高,进一步会影响事务的执行性能。 比如,在上图中,事务 T1 开始后,根据 majority commit point 读取自己可见的版本,x=1,其他的事务继续对 x 产生修改并且提交,会产生的新的版本 x=2,x=3……,T1 只要不提交,那么 x=2 及之后的版本都不能从内存中清理,否则就会违反 snapshot isolation 的语义。面对上述情况,MongoDB 采用了一种称之为「Query Yielding」的手段来“优化” 这个问题。 「Query Yielding」的思路其实非常简单,就是在事务执行的过程中,定期的进行 yield,即释放锁,abort 当前的 WiredTiger 事务,释放 hold 的 snapshot,然后重新打开事务,获取新的 snapshot。显然,通过这种方式,对于一个执行时间很长的 MongoDB 读操作,它在引擎层事务的 read_ts 是不断推进的,进而保证 read_ts 之后的版本能够被及时从内存中清理。之所以在优化前面加一个引号的原因是,这种方式虽然解决了长事务场景下,WT 内存压力上涨的问题,但是是以牺牲快照隔离级别的语义为代价的(降级为 read committed 隔离级别),又是一个典型的牺牲一致性来换取更好的访问性能的应用案例。"local" 和 "majority" readConcern 都应用了「Query Yielding」机制,他们的主要区别是,"majority" readConcern 在 reopen 事务时采用新推进的 mcp 对应的 snapshot,而 "local" readConcern 采用最新的时间点对应的 snapshot。Server 层在一个 Query 正常执行的过程中(getNext()),会不断的调用 _yieldPolicy->shouldYieldOrInterrupt() 来判定是否需要 yield,目前主要由如下两个因素共同决定是否 yield:internalQueryExecYieldIterations:shouldYieldOrInterrupt() 调用累积次数超过该配置值会主动 yield,默认为 128,本质上反映的是从索引或者表上获取了多少条数据后主动 yield。yield 之后该累积次数清零。internalQueryExecYieldPeriodMS:从上次 yield 到现在的时间间隔超过该配置值,主动 yield,默认为 10ms,本质上反映的是当前线程获取数据的行为持续了多久需要 yield。最后,除了根据上述配置主动的 yield 行为,存储引擎层面也会因为一些原因,比如需要从 disk load page,事务冲突等,告知计划执行器(PlanExecutor)需要 yield。MongoDB 的慢查询日志中会输出一些有关执行计划的信息,其中一项就是 Query 执行期间 yield 的次数,如果数据集不变的情况下,执行时长差别比较大,那么就可能和要访问的 page 在 WiredTiger Cache 中的命中率相关,可以通过 yield 次数来进行一定的判断。“snapshot” readConcern前面我们已经提到了 "snapshot" readConcern 是专门用于 MongoDB 的多文档事务的,MongoDB 多文档事务提供类似于传统关系型数据库的事务模型(Conversational Transaction),即通过 begin transaction 语句显示开启事务, 根据业务逻辑执行不同的操作序列,然后通过 commit transaction 语句提交事务。"snapshot" readConcern 除了包含 "majority" readConcern 提供的语义,同时它还提供真正的一致性快照语义,因为多文档事务中的多个操作只会对应到一个 WiredTiger 引擎事务,并不会应用「Query Yielding」。 这里这么设计的主要考虑是,和默认情况下为了保证性能而采用单文档事务不同,当应用显示启用多文档事务时,往往意味着它希望 MongoDB 提供类似关系型数据库的,更强的一致性保证,「Query Yielding」导致的 snapshot “漂移”显然是无法接受的。而且在目前的实现中,如果应用使用了多文档事务,即使指定 "majority" 或 "local" readConcern,也会被强制提升为 "snapshot" readConcern。// If "startTransaction" is present, it must be true due to the parsing above. const bool upconvertToSnapshot(sessionOptions.getStartTransaction()); auto newReadConcernArgs = uassertStatusOK( _extractReadConcern(invocation.get(), request.body, upconvertToSnapshot)); // 这里强制提升为 "snapshot" readConcern不采用 「Query Yielding」也就意味着存在上节所说的“WiredTiger Cache 压力过大”的问题,在 “snapshot” readConcern 下,当前版本没有太好的解法(在 4.4 中会通过 durable history,即支持把多版本数据写到磁盘,而不是只保存在内存中来解决这个问题)。MongoDB 目前采用了另外一个比较简单粗暴的方式来缓解这个问题,即限制事务执行的时长,transactionLifetimeLimitSeconds 配置的值决定了多文档事务的最大执行时长,默认为 60 秒。超出最大执行时长的事务由后台线程负责清理,默认每 30 秒进行一次清理动作。每个多文档事务都会和一个 Logical Session 关联,清理线程会遍历内存中的 SessionCatalog 缓存找到所有过期事务,清理和事务关联的 Session,然后 abortTransaction(具体可参考killAllExpiredTransactions())。"snapshot" readConcern 为了同时维持分布式环境下的 "majority" read 语义和事务本地执行的一致性快照语义,还会带来另外一个问题:事务因为写冲突而 abort 的概率提升。在单机环境下,事务的写冲突往往是因为并发事务的执行修改了同一份数据,进而导致后提交的事务需要 abort(first-writer-win)。但是通过后面的解释我们会看到,"snapshot" readConcern 为了同时维持两种语义,即使在单机环境下看起来是非并发的事务,也会因为写冲突而 abort。要说明这个问题,先来简单看下事务在 snapshot isolation 下的读写规则。 对于读:对任意事务 $T_i$ ,如果它读到了数据 $X$ 的版本 $X_j$,而 $X_j$ 是由事务 $T_j$ 修改产生,则 $T_j$ 一定已经提交,且 $T_j$ 的提交时间戳一定小于事务 $T_i$ 的快照读时间戳,即只有这样, $T_j$ 的修改对 $T_i$ 才是可见的。这个规则保证了事务只能读取到自己可见范围内的数据。另外,对任意事务 $T_k$,如果它修改了 $X$ 并且产生了新的版本 $X_k$,且 $T_k$ 已提交,那么 $T_k$ 要么在事务 $T_j$ 之前提交($commit(T_k) < commit(T_j)$),要么在事务 $T_i$ 的快照读时间戳之后提交。这个规则保证了事务在可见范围内读取最新的数据。对于写:对于任意事务 $T_i$ 和 $T_j$,他们都成功提交的前提是没有产生冲突。冲突的定义:如果 $T_j$ 的提交时间戳在事务 $T_i$ 的观测时间段([$snapshot(T_i)$, $commit(T_i)$])内,且二者的修改数据集存在交集,则二者存在冲突。这种情况下 $T_i$ 需要 abort。对这个规则可以有一个通俗的理解,即事务的并发控制存在一个基本原则:「过去不能修改将来」,$snapshot(T_i) < commit(T_j)$ 表明 $T_i$ 相对于 $T_j$ 发生在过去(此时 $T_i$ 看不到 $T_j$ 产生的修改), $T_i$ 如果正常提交,因为 $commit(T_i) > commit(T_j)$,也就意味着发生在过去的 $T_i$ 的写会覆盖将来的 $T_j$。然后再回到前面的问题:为什么在 "snapshot" readConcern 下事务冲突 abort 的概率会提升?这里我们结合一个例子来进行说明, 上图中,C1 发起的事务 T1 在主节点(P)上提交后,需要复制到一个从节点(S) 并且 apply 完成才算是 majority committed。在事务从 local committed 变为 majority committed 这个延迟内(上图中的红圈),如果 C2 也发起了一个事务 T2,虽然 T2 是在 T1 提交之后才开始的,但根据 "majority" read 语义的要求,T2 不能够读取 T1 刚提交的修改,而是基于 mcp 读取 T1 修改前的版本,这个是符合前面的 snapshot read rule 的( D1 规则)。但是,如果 T2 读取了这个更早的版本并且做了修改,因为 T2 的 commit_ts(有递增要求) 大于 T1 的,根据前面的 snapshot commit rule(D2 规则),T2 需要 abort。需要说明的是,应用对数据的访问在时间和空间上往往呈现一定的局部性,所以上述这种 back-to-back transaction workload(T1 本地修改完成后,T2 接着修改同一份数据)在实际场景中是比较常见的,所以很有必要对这个问题作出优化。MongoDB 对这个问题的优化也比较简单,采用了和 "majority" readConcern 一样的实现思路,即「speculative read」。MongoDB 把这种基于「speculative read」机制实现的 snapshot isolation 称之为「speculative snapshot isolation」。 仍然使用上面的例子,在「speculative snapshot isolation」机制下,事务 T2 在开始时不再基于 mcp 读取 T1 提交前的版本,而是直接读取最新的已提交值(T1 提交),这样 $snapshot(T_2) >= commit(T_1)$ ,即使 T2 修改了同一条数据,也不会违反 D2 规则。但是此时 T1 还没有被复制到 majority 节点,T2 如果直接返回客户端成功,显然违反了 "majority" read 的语义。MongoDB 的做法是,在事务 T2 提交时,如果要维持 "majority" read 的语义,其必须也以 "majority" writeConcern 提交。这样,如果 T2 产生了修改,在其等待自身的修改成为 majority committed 时,发生它之前的事务 T1 的修改显然也已经是 majority committed(这个是由 MongoDB 复制协议的顺序性和 batch 并发 apply 的原子性保证的),所以自然可保证 T2 读取到的最新值满足 "majority" 语义。这个方式本质上是一种牺牲 Latency 换取 Consistency 的做法,和基于 snapshot 的 "majority" readConcern 做法正好相反。这里这么设计的原因,并不是有目的的去提供更好的一致性,主要还是为了降低事务冲突 abort 的概率,这个对 MongoDB 自身性能和业务的影响非常大,在这个基础上,也可以说,保证业务读取到最新的数据总是更有用的。关于牺牲 Latency,实际上上述实现机制,对于写事务来说并没有导致额外的延迟,因为事务自身以 "majority" writeConcern 提交进行等待以满足自身写的 majority committed 要求时,也顺便满足了 「speculative read」对等待的需求,缺点就是事务的提交必须要和 "majority" readConcern 强绑定,但是从多文档事务隐含了对一致性有更高的要求来看,这种绑定也是合理的,避免了已提交事务的修改在重新选主后被回滚。真正产生额外延迟的是只读事务,因为事务本身没有做任何修改,仍然需要等待。实际上这个延迟也可以被优化掉,因为事务如果只是只读,不管读取了哪个时间点的快照,都不会和其他写事务形成冲突,但是 MongoDB 目前并没有提供标记多文档事务为只读事务的接口,期待后续的优化。“local” readConcern"local" readConcern 在 MongoDB 里面的语义最为简单,即直接读取本地最新的已提交数据,但是它在 MongoDB 里面的实现却相对复杂。首先我们需要了解的是 MongoDB 的复制协议是一种类似于 Raft 的复制状态机(Replicated State Machine)协议,但它和 Raft 最大区别是,Raft 先把日志复制到多数派节点,然后再 Apply RSM,而 MongoDB 是先 Apply RSM,然后再异步的把日志复制到 Follower(Secondary) 去 Apply。 这种实现方式除了可以降低写操作(在 default writeConcern下)的延迟,也为实现 "local" readConcern 提供了机会,而 Recency,前面的统计数据已经分析了,正是大部分的业务所更加关注的。MongoDB 的这种设计虽然更贴近于用户需求,但也为它的 RSM 协议引入了额外的复杂性,这点主要体现在重新选举时。重新选主时可能会发生,已经在之前的 Primary 上追加的部分 log entry 没有来及复制到新的 Primary 节点,那么在前任 Primary重新加入集群时,需要把这部分多余的 log entry 回滚掉(注:这种情况,除了旧主可能发生,其他节点也可能发生)。对于 Raft 来说这个回滚动作特别简单,只需对 replicated log 执行 truncate,移除尾部多余的 log entry,然后重新从现任 Primary 追日志即可。但是,对于 MongoDB 来说,由于在追加日志前就已经对状态机进行了 apply,所以除了 Log Truncation,还需要一个状态机回滚(Data Rollback)流程。Data Rollback 是一个代价比较大的过程,而 MongoDB 本身的日志复制是通常是很快的,真正在发生重新选举时,未及时同步到新主的 log entry 是比较少的,所以如果能够让新主在接受写操作之前,把旧主上“多余”的日志重新拉取过来并应用,显然可以避免旧主的 Data Rollback。关于 MongoDB 基于 Raft 协议修改的延伸阅读:4 modifications for Raft consensus重选举时的 Catchup PhaseMongoDB 从 3.4 版本开始实现了上述机制(catchup phase),流程如下,候选节点在成功收到多数派节点的投票后,会通过心跳(replSetHeartBeat 命令)向其他节点广播自己当选的消息;其他节点的的 heartbeat response 中会包含自己最新的 applied opTime,当选节点会把其中最大的 opTIme 作为自己 catchup 的 targetOpTime;从 applied opTime 最大的节点或其下游节点同步数据,这个过程和正常的基于 oplog 的增量复制没有太大区别;如果在超时时间(由 settings.catchUpTimeoutMillis 决定,3.4 默认 60 秒)内追上了 targetOpTime,catchup 完成;如果超时,当选节点并不会 stepDown,而是继续作为新的 Primary 节点。void ReplicationCoordinatorImpl::CatchupState::signalHeartbeatUpdate_inlock() { auto targetOpTime = _repl->_topCoord->latestKnownOpTimeSinceHeartbeatRestart(); ... ReplicationMetrics::get(getGlobalServiceContext()).setTargetCatchupOpTime(targetOpTime.get()); log() << "Heartbeats updated catchup target optime to " << *targetOpTime; ... }上述第 5 步意味着,catchup 过程中如果有超时发生,其他节点仍然需要回滚,所以在 3.6 版本中,MongoDB 对这个机制进行了强化。3.6 把 settings.catchUpTimeoutMillis 的默认值调整为 -1,即不超时。但为了避免 catchup phase 无限进行,影响可用性(集群不可写),增加了 catchup takeover 机制,即集群当前正在被当选节点作为同步源 catchup 的节点,在等待一定的时间后,会主动发起选举投票,来使“不合格”的当选节点下台,从而减少 Data Rollback 的几率和保证集群尽快可用。这个等待时间由副本集的 settings.catchUpTakeoverDelayMillis 配置决定,默认为 30 秒。stdx::unique_lock<stdx::mutex> ReplicationCoordinatorImpl::_handleHeartbeatResponseAction_inlock( ... case HeartbeatResponseAction::CatchupTakeover: { // Don't schedule a catchup takeover if any takeover is already scheduled. if (!_catchupTakeoverCbh.isValid() && !_priorityTakeoverCbh.isValid()) { Milliseconds catchupTakeoverDelay = _rsConfig.getCatchUpTakeoverDelay(); _catchupTakeoverWhen = _replExecutor->now() + catchupTakeoverDelay; LOG_FOR_ELECTION(0) << "Scheduling catchup takeover at " << _catchupTakeoverWhen; _catchupTakeoverCbh = _scheduleWorkAt( _catchupTakeoverWhen, [=](const mongo::executor::TaskExecutor::CallbackArgs&) { _startElectSelfIfEligibleV1(StartElectionReasonEnum::kCatchupTakeover); // 主动发起选举 }); } ...Data Rollback 是无法彻底避免的,因为 catchup phase 也只能发生在拥有最新 log entry 的节点在线的情况下,即能够向当选节点恢复心跳包,如果在选举完成后,节点才重新加入集群,仍然需要回滚。MongoDB 目前存在两种 Data Rollback 机制:「Refeched Based Rollback」 和 「Recover To Timestamp Rollback」,其中后一种是在 4.0 及之后的版本,伴随着 WiredTiger 存储引擎能力的提升而演进出来的,下面就简要描述一下它们的实现方式及关联。Refeched Based Rollback「Refeched Based Rollback」 可以称之为逻辑回滚,下面这个图是逻辑回滚的流程图, 首先待回滚的旧主,需要确认重新选主后,自己的 oplog 历史和新主的 oplog 历史发生“分叉”的时间点,在这个时间点之前,新主和旧主的 oplog 是一致的,所以这个点也被称之为「common point」。旧主上从「common point」开始到自己最新的时间点之间的 oplog 就是未来及复制到新主的“多余”部分,需要回滚掉。common point 的查找逻辑在 syncRollBackLocalOperations() 中实现,大致流程为,由新到老(反向)从同步源节点获取每条 oplog,然后和自己本地的 oplog 进行比对。本地 oplog 的扫描同样为反向,由于 oplog 的时间戳可以保证递增,扫描时可以通过保存中间位点的方式来减少重复扫描。如果最终在本地找到一条 oplog 的时间戳和 term 和同步源的完全一样,那么这条 oplog 即为 common point。由于在分布式环境下,不同节点的时钟不能做到完全实时同步,而 term 可以唯一标识一个主节点在任期间的修改(oplog)历史,所以需要把 oplog ts 和 term 结合起来进行 common point 的查找。在找到 common point 之后,待回滚节点需要把当前最新的时间戳到 common point 之间的 oplog 都回滚掉,由于回滚采用逻辑的方式,整个流程还是比较复杂的。首先,MongoDB 的 oplog 本质上是一种 redo log,可以通过重新 apply 来进行数据恢复,而且 oplog 记录时对部分操作进行了重写,比如 {$inc : {quantity : 1}} 重写为 {$set : {quantity : val}} 等,来保证 oplog 的幂等性,按序重复应用 oplog,并不会导致数据不一致。但是 oplog 并不包含 undo 信息,所以对于部分操作来说,无法实现基于本地信息直接回滚,比如对于 delete,dropCollection 等操作,删除掉的文档在 oplog 并无记录,显然无法直接回滚。对于上述情况,MongoDB 采用了所谓「refetch」的方式进行回滚,即重新从同步源获取无法在本地直接回滚的文档,但是这个方式的问题在于 oplog 回滚到 tcommon 时,节点可能处于一个不一致的状态。举个例子,在 tcommon 时旧主上存在两条文档 {x : 10} 和 {y : 20},在重新选主之后,旧主上对 x 的 delete 操作并未同步到新主,在新主新的历史中,客户端先后对 x 和 y 做了更新:{$set : {y : 200}} ; {$set : {x : 100}}。在旧主通过「refetch」的方式完成回滚后,它在 tcommon 的状态为: {x : 100} 和 {y : 20},显然这个状态对于客户端来说是不一致的。这个问题的根本原因在于,「refetch」时只能获取到被删除文档当前最新的状态,而不是被删除前的状态,这个方式破坏了在客户端看来可能存在因果关系的不同文档间的一致性状态。我们具体上面的例子来说,回滚节点在「refetch」时相当于直接获取了 {$set : {x : 100}} 的状态变更操作,而跳过了 {$set : {y : 200}},如果要达到一致性状态,看起来只要重新应用 {$set : {y : 200}} 即可。但是回滚节点基于现有信息是无法分析出来跳过了哪些状态的,对于这个问题,直接但是有效的做法是,把同步源从 tcommon 之后的 oplog 都重新拉取并「reapply」一遍,显然可以把跳过的状态补齐。而这中间也可能存在对部分状态变更操作的重复应用,比如 {$set : {x : 100}},这个时候 oplog 的幂等性就发挥作用了,可以保证数据在最终「reapply」完后的一致性不受影响。剩下的问题就是,拉取到同步源 oplog 的什么位置为止?对于回滚节点来说,导致状态被跳过的原因是进行了「refetch」,所以只需要记录每次「refetch」时同步源最新的 oplog 时间戳,「reapply」时拉取到最后一次「refetch」对应的这个同步源时间戳就可以保证状态的正确补齐,MongoDB 在实现中把这个时间戳称之为 minValid。MongoDB 在逻辑回滚的过程中也进行了一些优化,比如在「refetch」之前,会扫描一遍需要回滚的操作(这个不需要专门来做,在查找 common point 的过程即可实现),对于一些存在“互斥”关系的操作,比如 {insert : {_id:1} 和 {delete : {_id:1}},就没必要先 refetch 再 delete 了,直接忽略回滚处理即可。但是从上面整体流程看,「Refeched Based Rollback」仍然复杂且代价高:「refetch」阶段需要和同步源通信,并进行数据拉取,如果回滚的是删表操作,代价很大「reapply」阶段也需要和同步源通信,如果「refetch」阶段比较慢,需要拉取和重新应用的 oplog 也比较多实现上复杂,每种可能出现在 oplog 中的操作都需要有对应的回滚逻辑,新增类型时同样需要考虑,代码维护代价高所以在 4.0 版本中,随着 WiredTiger 引擎提供了回滚到指定的 Timestamp 的功能后,MongoDB 也用物理回滚的机制取代了上述逻辑回滚的机制,但在某些特殊情况下,逻辑回滚仍然有用武之地,下面就对这些做简要分析。Recover To Timestamp Rollback「Recover To Timestamp Rollback」是借助于存储引擎把物理数据直接回滚到某个指定的时间点,所以这里把它称之为物理回滚,下面是 MongoDB 物理回滚的一个简化的流程图, 前面已经提到了 stable timestamp 的语义,这里不再赘述,MongoDB 有一个后台线程(WTCheckpointThread)会定期(默认情况下每 60 秒,由 storage.syncPeriodSecs 配置决定)根据 stable timestamp 触发新的 checkpoint 创建,这个 checkpoint 在实现中被称为 「stable checkpoint」。class WiredTigerKVEngine::WiredTigerCheckpointThread : public BackgroundJob { public: ... virtual void run() { ... { stdx::unique_lock<stdx::mutex> lock(_mutex); MONGO_IDLE_THREAD_BLOCK; _condvar.wait_for(lock, stdx::chrono::seconds(static_cast<std::int64_t>( wiredTigerGlobalOptions.checkpointDelaySecs))); } ... UniqueWiredTigerSession session = _sessionCache->getSession(); WT_SESSION* s = session->getSession(); invariantWTOK(s->checkpoint(s, "use_timestamp=true")); ... } ... }stable checkpoint 本质上是一个持久化的历史快照,它所包含的数据修改已经复制到多数派节点,所以不会发生重新选主后修改被回滚。其实 WiredTiger 本身也可以配置根据生成的 WAL 大小或时间来自动触发创建新的 checkpoint,但是 Server 层并没有使用,原因就在于 MongoDB 需要保证在回滚到上一个 checkpoint 时,状态机肯定是 “stable” 的,不需要回滚。WiredTiger 在创建 stable checkpoint 时也是开启一个带时间戳的事务来保证 checkpoint 的一致性,checkpoint 线程会把事务可见范围内的脏页刷盘,最后对应到磁盘上就是一个由多个变长数据块(WT 中称之为extent)构成的 BTree。回滚时,同样要先确定 common point,这个流程和逻辑回滚没有区别,之后, Server 层会首先 abort 掉所有活跃事务,接着调用 WT 提供的 rollback_to_stable() 接口把数据库回滚到 stable checkpoint 对应的状态,这个动作主要是重新打开 checkpoint 对应的 BTree,并重新初始化 catalog 信息,rollback_to_stable() 执行完后会向 Server 层返回对应的 stable timestamp。考虑到 stable checkpoint 触发的间隔较大,通常 common point 总是大于 stable checkpoint 对应的时间戳,所以 Server 层在拿到引擎返回的时间戳之后会还需要从其开始重新 apply 本地的 oplog 到 common point 为止,然后把 common point 之后的 oplog truncate 掉,从而达到和新的同步源一致的状态。这个流程主要在 RollbackImpl::_runRollbackCriticalSection() 中实现,Status RollbackImpl::_runRollbackCriticalSection( OperationContext* opCtx, RollBackLocalOperations::RollbackCommonPoint commonPoint) noexcept try { ... killSessionsAbortAllPreparedTransactions(opCtx); // abort 活跃事务 ... auto stableTimestampSW = _recoverToStableTimestamp(opCtx); // 引擎层回滚 ... Timestamp truncatePoint = _findTruncateTimestamp(opCtx, commonPoint); // 查找并设置 truncate 位点 _replicationProcess->getConsistencyMarkers()->setOplogTruncateAfterPoint(opCtx, truncatePoint); ... // Run the recovery process. // 这里会进行 reapply oplog 和 truncate oplog _replicationProcess->getReplicationRecovery()->recoverFromOplog(opCtx, stableTimestampSW.getValue()); ... }此外,为了确保回滚可以正常进行,Server 层在 oplog 的自动回收时还需要考虑 stable checkpoint 对部分 oplog 的依赖。通常来说,stable timestamp 之前的 oplog 可以安全的回收,但是在 4.2 中 MongoDB 增加了对大事务(对应的 oplog 大小超过 16MB)和分布式事务的支持,在 stable timestamp 之前的 oplog 在回滚 reapply oplog 的过程中也可能是需要的,所以在 4.2 中 oplog 的回收需要综合考虑当前最老的活跃事务和 stable timestamp。StatusWith<Timestamp> WiredTigerKVEngine::getOplogNeededForRollback() const { ... if (oldestActiveTransactionTimestamp) { return std::min(oldestActiveTransactionTimestamp.value(), Timestamp(stableTimestamp)); } else { return Timestamp(stableTimestamp); } }整体上来说,基于引擎 stable checkpoint 的物理回滚方式在回滚效率和回滚逻辑复杂性上都要优于逻辑回滚。但是 stable checkpoint 的推进要依赖 Server 层 majority commit point 的推进,而 majority commit point 的推进受限于各个节点的复制进度,所以复制慢时可能会导致 Primary 节点 cache 压力过大,所以 MongoDB 提供了 replication.enableMajorityReadConcern 参数用于控制是否维护 mcp,关闭后存储引擎也不再维护 stable checkpoint,此时回滚就仍然需要进行逻辑回滚,这也是在 4.2 中仍然保留「Refeched Based Rollback」的原因。“linearizable” readConcern在一个分布式系统中,如果总是把可用性摆在第一位,那么因果一致性是其能够实现的最高一致性级别(证明可见此处)。前面我们也通过统计数据分析了在大部分情况下用户总是更关注延迟(可用性)而不是一致性,而 MongoDB 副本集,正是从用户需求角度出发,被设计成了一个在默认情况下总是优先保证可用性的分布式系统,下图是一个简单的例证。 既然如此,那 MongoDB 是如何实现 “linearizable” readConcern,即更高级别的线性一致性呢? MongoDB 的策略很简单,就是把它退化到几乎是单机环境下的问题,即只允许客户端在 Primary 节点上进行 “linearizable” 读。说是“几乎”,因为这个策略仍然需要解决如下两个在副本集这个分布式环境下存在的问题,Primary 角色可能会发生变化,“linearizable” readConcern 需要保证每次读取总是能够从当前的 Primary 读取,而不是被取代的旧主。需要保证读取到读操作开始前最新的写,而且读到的结果不会在重新选主后发生回滚。MongoDB 采用同一个手段解决了上述两个问题,当客户端采用 “linearizable” readConcern 时,在读取完 Primary 上最新的数据后,在返回前会向 Oplog 中显示的写一条 noop 的操作,然后等待这条操作在多数派节点复制成功。显然,如果当前读取的节点并不是真正的主,那么这条 noop 操作就不可能在 majority 节点复制成功,同时,如果 noop 操作在 majority 节点复制成功,也就意味着之前读取的在 noop 之前写入的数据也已经复制到多数派节点,确保了读到的数据不会被回滚。// src/mongo/db/read_concern_mongod.cpp:waitForLinearizableReadConcern() ... writeConflictRetry( opCtx, "waitForLinearizableReadConcern", NamespaceString::kRsOplogNamespace.ns(), [&opCtx] { WriteUnitOfWork uow(opCtx); opCtx->getClient()->getServiceContext()->getOpObserver()->onOpMessage( opCtx, BSON("msg" << "linearizable read")); // 写 noop 操作 uow.commit(); }); ... auto awaitReplResult = replCoord->awaitReplication(opCtx, lastOpApplied, wc); // 等待 noop 操作 majority committed这个方案的缺点比较明显,单纯的读操作既产生了额外的写开销,也增加了延迟,但是这个是选择最高的一致性级别所需要付出的代价。Causal Consistency前面几个章节描述的由 writeConcern 和 readConcern 所构成的 MongoDB 可调一致性模型,仍然是属于最终一致性的范畴(特殊实现的 “linearizable” readConcern 除外)。虽然最终一致性对于大部分业务场景来说已经足够了,但是在某些情况下仍然需要更高的一致性级别,比如在下图这个经典的银行存款业务中,如果只有最终一致性,那么就可能导致客户看到的账户余额异常。 这个问题虽然可以在业务端通过记录一些额外的状态和重试来解决,但是显然会导致业务逻辑过于复杂,所以 MongoDB 实现了「Causal Consistency Session」功能来帮助降低业务复杂度。Causal Consistency 定义了分布式系统上的读写操作需要满足一个偏序(Partial Order)关系,即只部分操作发生的先后顺序可比。这个部分操作,进一步来说,指的是存在因果关系的操作,在 MongoDB 的「Causal Consistency Session」实现中,什么样的操作间算是存在因果关系,可参考前文提到的 Client-centric Consistency Model 下的 4 个一致性承诺分别对应的读写场景,此处不再赘述。所以,要实现因果一致性,MongoDB 首要解决的问题是如何给分布式环境下存在因果关系的操作定序,这里 MongoDB 借鉴了 Hybrid Logical Clock 的设计,实现了自己的 ClusterTime 机制,下面就对其实现进行分析。分布式系统中存在因果关系的操作定序关于分布式系统中的事件如何定序的论述,最有影响力的当属 Leslie Lamport 的这篇 《Time, Clocks, and the Ordering of Events in a Distributed System》,其中提到了一种 Logical Clock 用来确定不同事件的全序,后人也把它称为 Lamport Clock。 Lamport Clock 只用一个单纯的变量(scalar value)来表示,所以它的缺点之一是无法识别存在并发的事件(independent event),而这个会在实际的系统带来一些问题,比如在支持多点写入的系统中,无法基于 Lamport Clock 对存在写冲突的事件进行识别和处理。所以,后面又衍生出了 vector clock 来解决这一问题,但 vector clock 会存储数据的多个版本,数据量和系统中的节点数成正比,所以实际使用会带来一些扩展性的问题。Lamport Clock 存在的另外一个缺点是,它完全是一个逻辑意义上的值,和具体的物理时钟没有关联,而在现实的应用场景中,存在一些需要基于真实的物理时间进行访问的场景,比如数据的备份和恢复。Google 在其 Spanner 分布式数据库中提到了一种称之为 TrueTime 的分布式时钟设计,为事务执行提供时间戳。TrueTime 和真实物理时钟关联,但是需要特殊的硬件(原子钟/GPS)支持,MongoDB 作为一款开源软件,需要做到通用的部署,显然无法采用该方案。考虑到 MongoDB 本身不支持 「Multi-Master」 架构,而上述的分布式时钟方案均存在一些 MongoDB 在设计上需要规避的问题,所以 MongoDB 采用了一种所谓的混合逻辑时钟(Hybrid Logical Clock)的方案。HLC 设计上基于 Lamport Clock,只使用单个时钟变量,在具备给因果操作定序的能力同时,也能够(尽可能)接近真实的物理时钟。Hybrid Logical Clock 基本原理 先来了解一下 HLC 中几个基本的概念,pt:节点本地的物理时钟,通常是基于 ntp 进行时钟同步,HLC 只会读取该值,不会对该值做修改。l:HLC 的高位部分,是 HLC 的物理部分,和pt关联,HLC 保证 l 和 pt间的差值不会无限增长(bounded)。c:HLC 的低位部分,是 HLC 的逻辑部分。从上面的 HLC 时钟推进图中,可以看到,如果不考虑 l 部分(假设 l 总是不变),则 c 等同于 Lamport Clock,如果考虑 l 的变化,因为 l 是高位部分,只需要保证 if e hb f, l.e <= l.f,仍然可以确定存在因果关系的事件的先后顺序,具体的更新规则可以参考上面的算法。但是 l 的更新机制也决定了其他节点的时钟出现跳变或不同步,会导致 HLC 被推进,进而导致和 pt 产生误差,但 HLC 的机制决定了这个误差是有限的。上面的图就是一个很好的案例,假设当前的真实物理时钟是 0,而 0 号节点的时钟出现了跳变,变为 10,则在后续的时钟推进中,l 部分不会再增长,只会增加 c 部分,直到真实的物理时钟推进到 10,l 才会关联新的 pt 。MongoDB 在实现 Causal Consistency 之前就已经在副本集同步的 oplog 时间戳中使用了类似的设计,选择 HLC,也是为了方便和现有设计集成。Causal Consistency 不仅是在单一的副本集层面使用,在基于副本集构建的分片集群中同样有需求,所以这个新的分布式时钟,在 v3.6 中被称为 ClusterTime。MongoDB ClusterTime 实现MongoDB ClusterTime 基本上是严格按照 HLC 的思路来实现的,但它和 HLC 最大的一点不同是,在 HLC 或 Lamport Clock 中,消息的发送和接受都被认为是一个事件,会导致时钟值增加,但在 MongoDB ClusterTime 实现中,只有会改变数据库状态的操作发生才会导致 ClusterTime 增加,比如通常的写操作,这么做的目的还是为了和现有的 oplog 中的混合时间戳机制集成,避免更大的重构开销和由此带来的兼容性问题,同时这么做也并不会影响 ClusterTime 在逻辑上的正确性。 因为有了上述区别,ClusterTime 的实现就可以被分为两部分,一个是 ClusterTime 的增加(Tick),一个是 ClusterTime 的推进(Advance)。ClusterTime 的 Tick 发生在 MongoDB 接收到写操作时,ClusterTime 由 <Time><Counter> 来表示,是一个 64bit 的整数,其中高 32 位对应到 HLC 中的物理部分,低 32 位对应到 HLC 中的逻辑部分。而每一个写操作在执行前都会为即将要写的 oplog 提前申请对应的 OpTime(调用 getNextOpTimes() 来完成),OpTime 由 <Time><Counter><ElectionTerm> 来表示,ElectionTerm 和 MongoDB 的复制协议相关,是一个本地的状态值,不需要被包含到 ClusterTime 中,所以原有的 OpTime 在新版本中实际上是可以由 ClusterTime 直接转化得来,而 ClusterTime 也会随着 Oplog 写到磁盘而被持久化。std::vector<OplogSlot> LocalOplogInfo::getNextOpTimes(OperationContext* opCtx, std::size_t count) { ... // 申请 OpTime 时会 Tick ClusterTime 并获取 Tick 后的值 ts = LogicalClock::get(opCtx)->reserveTicks(count).asTimestamp(); const bool orderedCommit = false; ... std::vector<OplogSlot> oplogSlots(count); for (std::size_t i = 0; i < count; i++) { oplogSlots[i] = {Timestamp(ts.asULL() + i), term}; // 把 ClusterTime 转化为 OpTime ... return oplogSlots; } // src/mongo/db/logical_clock.cpp:LogicalClock::reserveTicks() 包含了 Tick 的逻辑,和 HLC paper 一致,主要逻辑如下 { newCounter = 0; wallClockSecs = now(); // _clusterTime is a current local value of node’s ClusterTime currentSecs = _clusterTime.getSecs(); if (currentSecs > wallClockSecs) { newSecs = currentSecs; newCounter = _clusterTime.getCounter() + 1; } else { newSecs = wallClockSecs; } _clusterTime = ClusterTime(newSecs, newCounter); return _clusterTime; }ClusterTime 的 Advance 逻辑比较简单,MongoDB 会在每个请求的回复中带上当前节点最新的 ClusterTime,如下,"$clusterTime" : { "clusterTime" : Timestamp(1495470881, 5), "signature" : { "hash" : BinData(0, "7olYjQCLtnfORsI9IAhdsftESR4="), "keyId" : "6422998367101517844" } } 接收到该 ClusterTime 的角色(mongos,client)如果发现更新的 ClusterTime,就会更新本地的值,同时在和别的节点通信的时候,带上这个新 ClusterTime,从而推进其他节点上的 ClusterTime,这个流程实际上是一种类似于 Gossip 的消息传播机制。因为 Client 会参与到 ClusterTime 的推进(Advance),如果有恶意的 Client 篡改了自己收到的 ClusterTime,比如把高位和低位部分都改成了 UINT32_MAX,则收到该 ClusterTime 的节点后续就无法再进行 Tick,这个会导致整个服务不可用,所以 MongoDB 的 ClusterTime 实现增加了签名机制(这个安全方面的增强 HLC 没有提及),上面的signature 字段即对应该功能,mongos 或 mongod 在收到 Client 发送过来的 $ClusterTime 时,会根据 config server 上存储的 key 来进行签名校验,如果 ClusterTime 被篡改,则签名不匹配,就不会推进本地时钟。除了恶意的 Client,操作失误也可能导致 mongod 节点的 wall clock 被更新为一个极大的值,同样会导致 ClusterTime 不能 Tick,针对这个问题,MongoDB 做了一个限制,新的 ClusterTime 和当前 ClusterTime 的差值如果超出 maxAcceptableLogicalClockDriftSecs,默认为 1 年,则当前的 ClusterTime 不会被推进。MongoDB Causal Consistency 实现在 ClusterTime 机制的基础上,我们就可以给不同的读写操作定序,但是操作对应的 ClusterTime 是在其被发送到数据节点(mongod)上之后才被赋予的,如果要实现 Causal Consistency 的承诺,比如前面提到的「Read Your Own Write」,显然我们需要 Client 也知道写操作在主节点执行完后对应的 ClusterTime。 ... "operationTime" : Timestamp(1612418230, 1), # Stable ClusterTime "ok" : 1, "$clusterTime" : { ... }所以 MongoDB 在请求的回复中除了带上 $clusterTIme 用于帮助推进混合逻辑时钟,还会带上另外一个字段 operationTime 用来表明这个请求包含的操作对应的 ClusterTime,operationTime 在 MongoDB 中也被称之为 「Stable ClusterTime」,它的准确含义是操作执行完成时,当前最新的 Oplog 时间戳(OpTime)。所以对于写操作来说,operationTime 就是这个写操作本身对应的 Oplog 的 OpTime,而对于读操作,取决于并发的写操作的执行情况。 Client 在收到这个 operationTime 后,如果要实现因果一致,就会在发送给其他节点的请求的 afterClusterTime 字段中带上这个 operationTime,其他节点在处理这个请求时,只会读取 afterClusterTime 之后的数据状态,这个过程是通过显式的等待同步位点推进来实现的,等待的逻辑和前面提到的 speculative “majority” readConcern 实现类似。上图是 MongoDB 副本集实现「Read Your Own Write」的基本流程。如果是在分片集群形态下,由于混合逻辑时钟的推进依赖于各个参与方(client/mongos/mongd)的交互,所以会暂时出现不同分片间的逻辑时钟不一致的情况,所以在这个架构下,我们需要解决某个分片的逻辑时钟滞后于 afterClusterTime 而且一直没有新的写入,导致请求持续被阻塞的问题,MongoDB 的做法是,在这种情况下显式的写一条 noop 操作到 oplog 中,相当于强制把这个分片的数据状态推进到 afterClusterTime 之后,从而确保操作能够尽快返回,同时也符合因果一致性的要求。 总结本文对 MongoDB 一致性模型在设计上的一些考虑和主要的实现机制进行了分析,这其中包括由 writeConcern 和 readConcern 机制构建的可调一致性模型,对应到标准模型中就是最终一致性和线性一致性,但是 MongoDB 借助read/write concern 这两者的配合,为用户提供更丰富的一致性和性能间的选择。此外,我们也分析了 MongoDB 如何基于 ClusterTime 混合逻辑时钟机制来给分布式环境下的读写操作定序,进而实现因果一致性。从功能和设计思路来看,MongoDB 无疑是丰富和先进的,但是在接口层面,读写采用不同的配置和级别,事务和非事务的概念区分,Causal Consistency Session 对 read/writeConcern的依赖等,都为用户的实际使用增加了门槛,当然这些也是 MongoDB 在易用性、功能性和性能多方取舍的结果,相信 MongoDB 后续会持续的做出改进。最后,伴随着 NewSQL 概念的兴起,「分布式+横向扩展+事务能力」逐渐成为新数据库系统的标配,MongoDB 也不例外。当我们在传统单机数据库环境下谈论一致性,更多指的是事务间的隔离性(Isolation),如果把隔离性这个概念映射到分布式架构下,可以容易看出,MongoDB 的 "local" readConcern 即对应 read uncommitted,"majority" readConcern 即对应 read committed,而 "snapshot" readConcern 对应的就是分布式的全局快照隔离,即这些新的概念部分也是来自于经典的 ACID 理论在分布式环境下的延伸,带上这样的视角可以让我们更容易理解 MongoDB 的一致性模型设计。参考文档Tunable Consistency In MongoDB: http://www.vldb.org/pvldb/vol12/p2071-schultz.pdfImplementation of Cluster-wide Logical Clock and Causal Consistency in MongoDB: https://dl.acm.org/doi/pdf/10.1145/3299869.3314049Logical Physical Clocks: https://cse.buffalo.edu/~demirbas/publications/hlc.pdfPACELC: http://www.cs.umd.edu/~abadi/papers/abadi-pacelc.pdfConsistency and Replication 1: https://web2.qatar.cmu.edu/~msakr/15440-f11/lectures/Lecture11_15440_VKO_10Oct_2011.pptxConsistency and Replication 2: https://web2.qatar.cmu.edu/~msakr/15440-f11/lectures/Lecture11_15440_VKO_10Oct_2011.pptxMongoDB writeConcern: https://docs.mongodb.com/manual/reference/write-concern/MongoDB readConcern: https://docs.mongodb.com/manual/reference/read-concern/WiredTiger Application-specified Transaction Timestamps: https://source.wiredtiger.com/develop/transactions.html#transaction_timestampsDatabase Replication Using Generalized Snapshot Isolation: https://infoscience.epfl.ch/record/53561/files/srds2005-gsi.pdfMongoDB Logical Session: https://www.mongodb.com/blog/post/transactions-background-part-2-logical-sessions-in-mongodb4 modifications for Raft consensus: https://www.openlife.cc/blogs/2015/september/4-modifications-raft-consensusTime, Clocks, and the Ordering of Events in a Distributed System: https://lamport.azurewebsites.net/pubs/time-clocks.pdfMongoDB Sharding Internals: https://github.com/mongodb/mongo/blob/master/src/mongo/db/s/README.mdMongoDB Replication Internals: https://github.com/mongodb/mongo/blob/master/src/mongo/db/repl/README.md
背景 在内部针对 MongoDB 4.4 的测试中,我们发现在带写压力的情况下对主库进行 unclean shutdown (kill -9),主库在重启之后少了很多数据,通过分析日志可以看到在加载完 stable checkpoint,进行 replication recovery,即 apply oplog 之前,有很多在 oplogTruncateAfterPoint 之后的 oplog 被 truncate 掉了,导致只有很少的 oplog 被加载。 {"t":{"$date":"2021-01-27T16:40:22.601+08:00"},"s":"I", "c":"REPL", "id":21557, "ctx":"SwitchWarmStandbyThread","msg":"Removing unapplied oplog entries after oplogTruncateAfterPoint","attr":{"oplogTruncateAfterPoint":{"":{"$timestamp":{"t":1611736805,"i":1799}}}}} {"t":{"$date":"2021-01-27T16:40:22.651+08:00"},"s":"I", "c":"REPL", "id":21553, "ctx":"SwitchWarmStandbyThread","msg":"Truncating oplog from truncateAfterOplogEntryTimestamp (non-inclusive)","attr":{"truncateAfterOplogEntryTimestamp":{"$timestamp":{"t":1611736805,"i":1799}},"oplogTruncateAfterPoint":{"$timestamp":{"t":1611736805,"i":1799}}}} {"t":{"$date":"2021-01-27T16:40:22.656+08:00"},"s":"I", "c":"REPL", "id":21554, "ctx":"SwitchWarmStandbyThread","msg":"Replication recovery oplog truncation finished","attr":{"durationMillis":55}} {"t":{"$date":"2021-01-27T16:40:22.657+08:00"},"s":"I", "c":"REPL", "id":21544, "ctx":"SwitchWarmStandbyThread","msg":"Recovering from stable timestamp","attr":{"stableTimestamp":{"$timestamp":{"t":1611736795,"i":1259}},"topOfOplog":{"ts":{"$timestamp":{"t":1611736805,"i":1799}},"t":1},"appliedThrough":{"ts":{"$timestamp":{"t":0,"i":0}},"t":-1},"oplogTruncateAfterPoint":{"$timestamp":{"t":0,"i":0}}}} ... {"t":{"$date":"2021-01-27T16:40:25.860+08:00"},"s":"I", "c":"REPL", "id":21536, "ctx":"SwitchWarmStandbyThread","msg":"Completed oplog application for recovery","attr":{"numOpsApplied":14901,"numBatches":19,"applyThroughOpTime":{"ts":{"$timestamp":{"t":1611736805,"i":1799}},"t":1}}} 在 4.2 及之前的版本中,oplogTruncateAfterPoint 只用于备库,用来保证 oplog batch 应用的原子性,最终目的是确保主备数据的一致性,但是在 4.4 中主库也会维护 oplogTruncateAfterPoint ,而这个问题根本原因就是由 oplogTruncateAfterPoint 在 4.4 中的作用变化导致,下面简要分析一下。 分析 在 4.2 中 oplogTruncateAfterPoint 只有备库会维护,用来保证 oplog batch write 的原子性,主库是不需要维护的(有 replset.oplogTruncateAfterPoint集合,但是对应的 value 总是TImestamp(0,0)空值),所以主库在 replication recovery 时不会进行 oplog 的 truncate。 但是在 4.4 中,主库也会维护 oplogTruncateAfterPoint,这个做的原因是和 oplog hole 相关。 oplog hole 是因为事务在 WiredTiger 层面的提交顺序和 oplog 顺序不一致导致,复制时如果要保证主备一致性需要避开这个 hole,这个大家应该都比较熟悉了,不做展开。 但是我们考虑一个场景,op1,op2,op3 三条 oplog,对应三个事务,在 WT 层面的提交顺序为 op1,op3,op2,那么当 op2 也成功提交后,1~3 这三条 oplog 对应备库来说显然是 no hole,可以放心复制。但是即使 no hole,我们也需要需要区分是 in-memory no hole ,还是 on-disk no hole。 因为 op2 对应的 WAL 在 unclean shutdown 的情况下,因为在尾部是可能丢失的。如果备库在 in-memory no hole 的条件下就对 op 1~3 做了复制,那么在主库在重启的时候,不管是继续做主还是降级为从,都会和副本集其他成员数据不一致(缺失了 op2)。 在分析 4.2 和 4.4 是怎么避免上面这个问题之前,我们需要先了解下 in-memory no hole 是怎么维护的,这个在 4.2 和 4.4 中的方式是一样的。 在更早的版本,比如 3.2,3.4 ,是通过在 server 层自己维护一个队列来跟踪 oplog 对应的事务的提交情况来确定到哪个点是 no hole 的。由于在 4.0 之后,server 层都使用了带时间戳的事务,而这个时间戳实际上是 oplog 中的 ts 字段( PS:这个时间戳在事务开始前就申请好了),所以可以依靠引擎层(WT)来告知我们截止到哪个时间点之前的事务都提交了,这个事情对于引擎来说很容易做到,也更应该由它们来做。这个时间点在 WT 中被称之为 all_durable,但是千万不要被这个坑爹的 “durable” 给欺骗了,它并不是真正的说事务被持久化了,见这段注释, /** * Manages oplog visibility. * * On demand, queries WiredTiger's all_durable timestamp value and updates the oplog read timestamp. * This is done asynchronously on a thread that startVisibilityThread() will set up. * * The WT all_durable timestamp is the in-memory timestamp behind which there are no oplog holes * in-memory. Note, all_durable is the timestamp that has no holes in-memory, which may NOT be * the case on disk, despite 'durable' in the name. * * The oplog read timestamp is used to read from the oplog with forward cursors, in order to ensure * readers never see 'holes' in the oplog and thereby miss data that was not yet committed when * scanning passed. Out-of-order primary writes allow writes with later timestamps to be committed * before writes assigned earlier timestamps, creating oplog 'holes'. */ 4.2 和 4.4 正是依靠从 WT 查询 all_durable timestamp 来获得 in-memory no hole point,并设置 oplogReadTimestamp,所有需要需要读 opog 的场景(复制,changeStream 等)都需要基于 oplogReadTimestamp 来读,oplogReadTimestamp 之后的 oplog 对读者不可见(可能有 hole(坑) :-))。 既然从 WT 拿到的是 in-memory no hole 的时间戳,4.2 是如何避免上面那个 unclean shutdown 导致的问题的?4.2 在从 WT 获取到 all_durable ts 之后,还会等待这个 ts 之前的事务全部都持久化(WAL 刷盘)才会设置新的 oplogReadTimestamp, // 4.2: src/mongo/db/storage/wiredtiger/wiredtiger_oplog_manager.cpp const uint64_t newTimestamp = fetchAllDurableValue(sessionCache->conn()); // The newTimestamp may actually go backward during secondary batch application, // where we commit data file changes separately from oplog changes, so ignore // a non-incrementing timestamp. if (newTimestamp <= _oplogReadTimestamp.load()) { LOG(2) << "No new oplog entries were made visible: " << Timestamp(newTimestamp); continue; } // In order to avoid oplog holes after an unclean shutdown, we must ensure this proposed // oplog read timestamp's documents are durable before publishing that timestamp. sessionCache->waitUntilDurable(/*forceCheckpoint=*/false, false); // 这里显式等待 这个方式实际上是杜绝了备库可能复制到比主库更多数据的可能,但是这么做的缺点是会导致复制延迟的增加(毕竟需要等到事务提交并且落盘才能复制到备库),而 unclean shutdown 显然是一种很不常见的 case,不应该因为这个影响通用场景。所以在 4.4 中对这里做了优化,主库更新 oplogReadTimestamp 不再调用 waitUntilDurable 等待刷盘,而是 WAL 刷盘的过程中,异步的去更新 oplogTruncateAfterPoint,主库上的 oplogTruncateAfterPoint 表示这之前的 oplog 是 on-disk no hole 的。 // 4.4: src/mongo/db/repl/replication_coordinator_external_state_impl.cpp JournalListener::Token ReplicationCoordinatorExternalStateImpl::getToken(OperationContext* opCtx) { // If in state PRIMARY, the oplogTruncateAfterPoint must be used for the Durable timestamp in // order to avoid majority confirming any writes that could later be truncated. // // TODO (SERVER-45847): temporary hack for the ephemeral storage engine that passes in a nullptr // for the opCtx. The ephemeral engine does not do parallel writes to cause oplog holes, // therefore it is safe to skip updating the oplogTruncateAfterPoint that tracks oplog holes. if (MONGO_likely(opCtx)) { auto truncatePoint = repl::ReplicationProcess::get(opCtx) ->getConsistencyMarkers() ->refreshOplogTruncateAfterPointIfPrimary(opCtx); // 更新主库 oplogTruncateAfterPoint if (truncatePoint) { return truncatePoint.get(); } } // All other repl states use the last applied. return repl::ReplicationCoordinator::get(_service)->getMyLastAppliedOpTimeAndWallTime(); } 在真的出现 unclean shutdown 时,主库重启时会把 oplogTruncateAfterPoint 之后的 oplog 清理掉,避免出现和副本集其他成员不一致,而自己还不感知的情况。 至此就分析清楚了,最开始问题的原因,因为测试过程中总是 kill -9 的(unclean shutdown),在带写压力的情况下切换,oplogTruncateAfterPoint 肯定是没有更新到最新的 oplog ts,导致新主库部分 oplog 没有加载。 验证:通过 kill(clean shutdown),而不是 kill -9 来触发切换,可以看到数据不会变少。 影响 初看之下,主库允许备库复制自己还没有完全持久化的 oplog,似乎副本集的持久化能力降低了,但仔细思考一下,持久化能力实际是增强了。 考虑一下上面那个 op 1-3 的例子,op2 最后提交,如果说在 op2 持久化之前,主库 crash 了,在 4.2 里面,新的主库实际上是没有 op1-3 的数据的(虽然对用户返回写成功了),但是在 4.4 里面因为不等 op2 持久化就复制,那么新主库有这个数据。在分布式环境下,复制也是持久化能力的一部分,更低的复制延迟就意味着在整个分布式系统层面更好的持久性。
本文基于 4.2 代码分析 背景 Oplog Collection 首先是作为一个 Capped Collection 来实现的,但是单纯的 Capped Collection 会在每一次的写操作之后,如果发现集合大小超出配置的最大值,就会同步的去进行删除文档的操作。 删除文档的步骤大约是, 计算:设置一个 end cursor 指向 capped collection 头部,然后不断的调用 cursor->next(),通过累积 value 大小来确定需要删除的文档数 删除: 需要删除的文档数量小于等于 3 个,直接循环调用 cursor->remove() 删除文档 否则,设置两个 cursor,一个 start,一个 end,start 直接设置在 capped collection 的头部,然后调用 session->truncate(session, start, end) 来批量删除文档,session->truncate() 实际最终也是调用 cursor->remove() 具体代码可参考:WiredTigerRecordStore::_cappedDeleteAsNeeded_inlock 可以看到确定需要删除的文档数是这里面比较耗时的部分,cursor->next() 来统计 value 大小,实际就是在不断的做读取。对于 Oplog Collection 来说,所有的用户写操作都会记录 Oplog,当 Oplog 写满后,每一次的用户写操作都会触发同步的删除操作,显然效率很低。 所以 MongoDB 采用了一种标记删除位点,然后批量删除的策略来解决这个问题。 Oplog Stones 初始化 所谓的 Oplog Stone,实际上就是用 truncate point(删除位点) 在 oplog 上分隔的逻辑区域,而 truncate point 本质上就是 oplog entry 的 ts 字段,同时作为 RecordID,实际也是对应的 WiredTiger Table 中一条记录的 key。 Oplog Stone 的信息 MongoDB 并没有做持久化,而是选择每次重启的时候重新初始化。 Oplog Stones 整体初始化的逻辑还是比较简单的,首先是根据 cappedMaxSize 计算需要多少个 stone, // The minimum oplog stone size should be BSONObjMaxInternalSize. const unsigned int oplogStoneSize = std::max(gOplogStoneSizeMB * 1024 * 1024, BSONObjMaxInternalSize); // IDL does not support unsigned long long types. const unsigned long long kMinStonesToKeep = static_cast<unsigned long long>(gMinOplogStones); const unsigned long long kMaxStonesToKeep = static_cast<unsigned long long>(gMaxOplogStonesDuringStartup); unsigned long long numStones = maxSize / oplogStoneSize; size_t numStonesToKeep = std::min(kMaxStonesToKeep, std::max(kMinStonesToKeep, numStones)); 这里有很多默认值参与计算,我们需要知道的是,oplog stone 最多 100 个,最少 10 个,如果 oplogSizeMB 配置值超过 2GB,在默认情况下,基本上就需要 100 个 stone(这个是根据 Capped Collection配置的最大值算出来的基准,如果 Capped Collection 实际还没有写满,会根据实际大小来换算,stone 会更少,但是无论如何,上限不会超过 100)。 确定了 Oplog Stone 的个数,下面要做的就是确定每个 Oplog Stone 的边界,分两种情况, Oplog 集合当前的 record 数量太少,小于 20 倍的需要 sample 的数量(对于 100 个 stone 来说,每个 stone 默认 sample 10 条记录,所以 Collection record 数量低于 2w 条,就走全表的逻辑),直接通过全表扫描的方式来确定 Oplog Stone 边界,这个逻辑很简单,就是对扫描到的 oplog entry 累加大小,超过单个 oplog stone 大小上限就生成一个 stone,保存下来,直到扫描结束。(代码:WiredTigerRecordStore::OplogStones::_calculateStonesByScanning) 否则,就不能通过全表扫描了,效率太低。MongoDB 借助于 WiredTiger 提供的 random cursor 来进行采样,从而快速确定每个 oplog stone 的边界。(代码:WiredTigerRecordStore::OplogStones::_calculateStonesBySampling) 正常来说,100 个 oplog stone,采样 100 次,似乎就可以确定所有 stone 的边界了,但是为了保证边界尽可能准确,MongoDB 采用了 oversampling 的方式,即,对于每一个 stone 采样 10 次(默认),100 个 stone 就采样 1000 次。然后,把这些采集到的 sample 按 key(opTime)排序,每个 oplog stone 使用每 10 个 sample 中最后一个的 key 作为其边界,即,从 0 开始数,那么 9th, 19th, 29th, ……号 sample 是顺序对应的 oplog stone 的边界。 另外,MongoDB 在后面的版本迭代中还使用了优化的 random cursor,可以保证更精确的采样,通过 next_random_sample_size 告知 WT 需要 random 采样的个数,WT 会把底层的 table 划分为 next_random_sample_size 份,分别从其中获取一个采样,显然这样划分后,每第 10 个 sample 可以更贴近预期的 oplog stone 边界。 这个是进程启动时 oplog stone 的初始化的方式,随着有新的写入,还会创建新的 oplog stone,这个时候 oplog stone 的大小是可以保证精确的,因为在写入的时候,可以很方便的统计到当前已经在最新的 stone 里面写了多少数据,这个数值是精确的。所以,如果初始启动的时候因为 oplog stone 边界不精确导致 oplog 删除的过多或过少,并不是个大问题,这个会在后续的更新中把误差抹掉。 Oplog 回收 有了 Oplog Stone 后,oplog 的回收就不需要再每次写入时去计算要删除的文档数,再同步去删除,只需要在当前 oplog stone 写满后,创建新的 oplog stone 时,把最老的 oplog stone 删除掉即可。 这个按 stone 删除通常会一次性删除比较多的文档,所以oplog 的删除动作是放在后台的 OplogTruncaterThread来做的,删除时会直接调用 session->truncate 方法,使用 oldest oplog stone 的边界作为 range truncate 的上界。 //WiredTigerRecordStore::reclaimOplog setKey(cursor, stone->lastRecord); invariantWTOK(session->truncate(session, nullptr, nullptr, cursor, nullptr)); 删除的时候还需要考虑 stable checkpoint 对 oplog 的依赖,具体逻辑后面再发文分析。 Oplog Stones 初始化的时间开销分析 单纯从代码逻辑看,可以看到 Oplog Stones 的初始化的绝大部分时间都会花在 random cursor 的采样上,因为其他的步骤都是简单的 in-memory compute,几个 ms 足以完成。总的初始化时长会和采样的个数成正比,采样个数最多是 1000 个,所以这个初始化时间是有上限的,并不是随 oplog 集合大小无限增长。 为了验证上述结论,我们构造一个测试场景来证明。 先构造数据集,创建副本集 1,使用 mongo shell 自带的 benchmark 工具生成数据, benchRun( { "host": "127.0.0.1:9111", "ops": [ { "ns": "test.foo", "op": "insert", "doc": { "x": { "#RAND_STRING": [ 64 ] }, "y": { "#RAND_STRING": [ 128 ] } } } ], "parallel": 16, "seconds": $seconds } ) 生成大约 1.5 亿条 oplog,33GB,(有压缩,实际是在 50G左右) xdjmgset-dbfs1:PRIMARY> show dbs admin 0.000GB config 0.000GB local 33.363GB test 35.284GB xdjmgset-dbfs1:PRIMARY> use local switched to db local xdjmgset-dbfs1:PRIMARY> db.oplog.rs.count() 150531637 在代码上加了一些日志用于查看 cursor random 的时间开销,以及每一次 random 采样的开销, diff --git a/src/mongo/db/storage/wiredtiger/wiredtiger_record_store.cpp b/src/mongo/db/storage/wiredtiger/wiredtiger_record_store.cpp index f2c3d1c220..7f029b788d 100644 --- a/src/mongo/db/storage/wiredtiger/wiredtiger_record_store.cpp +++ b/src/mongo/db/storage/wiredtiger/wiredtiger_record_store.cpp @@ -460,8 +460,14 @@ void WiredTigerRecordStore::OplogStones::_calculateStonesBySampling(OperationCon // each logical section. auto cursor = _rs->getRandomCursorWithOptions(opCtx, extraConfig); std::vector<RecordId> oplogEstimates; + const std::uint64_t startWaitTime = curTimeMicros64(); for (int i = 0; i < numSamples; ++i) { + const std::uint64_t startWaitTime = curTimeMicros64(); auto record = cursor->next(); + auto waitTime = curTimeMicros64() - startWaitTime; + LOG(1) << "WT cursor random sample " << i << ", " + << Timestamp(record->id.repr()).toStringPretty() << ", took " + << waitTime / 1000.0 << "ms"; if (!record) { // This shouldn't really happen unless the size storer values are far off from reality. // The collection is probably empty, but fall back to scanning the oplog just in case. @@ -471,6 +477,8 @@ void WiredTigerRecordStore::OplogStones::_calculateStonesBySampling(OperationCon } oplogEstimates.push_back(record->id); } + auto waitTime = curTimeMicros64() - startWaitTime; + LOG(1) << "WT cursor random sampling total took " << waitTime/1000.0 << "ms"; std::sort(oplogEstimates.begin(), oplogEstimates.end()); for (int i = 1; i <= wholeStones; ++i) { 把副本集 1 重启,观察日志, 020-10-27T15:34:09.058+0800 I STORAGE [initandlisten] Taking 755 samples and assuming that each section of oplog contains approximately 1991955 records totaling to 687194924 bytes 2020-10-27T15:34:09.058+0800 D1 STORAGE [initandlisten] WT cursor random sample 0, Oct 26 19:54:57:7347, took 0.415ms 2020-10-27T15:34:09.063+0800 D1 STORAGE [initandlisten] WT cursor random sample 1, Oct 26 19:57:47:6215, took 4.488ms 2020-10-27T15:34:09.067+0800 D1 STORAGE [initandlisten] WT cursor random sample 2, Oct 26 15:37:47:1030, took 4.608ms 2020-10-27T15:34:09.072+0800 D1 STORAGE [initandlisten] WT cursor random sample 3, Oct 26 15:44:15:4619, took 4.471ms 2020-10-27T15:34:09.076+0800 D1 STORAGE [initandlisten] WT cursor random sample 4, Oct 26 15:46:51:2640, took 4.597ms 2020-10-27T15:34:09.081+0800 D1 STORAGE [initandlisten] WT cursor random sample 5, Oct 26 15:49:22:10335, took 4.556ms 2020-10-27T15:34:09.086+0800 D1 STORAGE [initandlisten] WT cursor random sample 6, Oct 26 15:52:03:10684, took 4.746ms 2020-10-27T15:34:09.090+0800 D1 STORAGE [initandlisten] WT cursor random sample 7, Oct 26 15:54:14:4494, took 4.586ms 2020-10-27T15:34:09.095+0800 D1 STORAGE [initandlisten] WT cursor random sample 8, Oct 26 15:56:46:1960, took 4.889ms 2020-10-27T15:34:09.100+0800 D1 STORAGE [initandlisten] WT cursor random sample 9, Oct 26 15:59:18:7246, took 4.695ms 2020-10-27T15:34:09.105+0800 D1 STORAGE [initandlisten] WT cursor random sample 10, Oct 26 16:02:05:4727, took 4.895ms 2020-10-27T15:34:09.110+0800 D1 STORAGE [initandlisten] WT cursor random sample 11, Oct 26 16:04:30:5742, took 4.673ms 2020-10-27T15:34:09.115+0800 D1 STORAGE [initandlisten] WT cursor random sample 12, Oct 26 16:06:45:1917, took 4.881ms 2020-10-27T15:34:09.119+0800 D1 STORAGE [initandlisten] WT cursor random sample 13, Oct 26 16:08:50:5188, took 4.786ms 2020-10-27T15:34:09.124+0800 D1 STORAGE [initandlisten] WT cursor random sample 14, Oct 26 16:11:13:7634, took 4.449ms 2020-10-27T15:34:09.129+0800 D1 STORAGE [initandlisten] WT cursor random sample 15, Oct 26 16:13:25:6775, took 5.204ms ... 2020-10-27T15:34:12.463+0800 D1 STORAGE [initandlisten] WT cursor random sample 752, Oct 26 15:56:15:232, took 4.923ms 2020-10-27T15:34:12.467+0800 D1 STORAGE [initandlisten] WT cursor random sample 753, Oct 26 15:58:47:1953, took 4.399ms 2020-10-27T15:34:12.472+0800 D1 STORAGE [initandlisten] WT cursor random sample 754, Oct 26 16:01:28:5317, took 4.598ms 2020-10-27T15:34:12.472+0800 D1 STORAGE [initandlisten] WT cursor random sampling total took 3414.51ms 可以看到这个实例采样了 755 次,总共耗时 3414ms,每次采样的时间都比较固定,在 4ms - 5ms 之间。 然后 MongoDB 的 serverStatus 命令本身也提供了一个 section 的输出用于查看启动时,初始化 oplog stones 的总时长,和初始化方法(是 scan 还是 sampling), xdjmgset-dbfs1:PRIMARY> db.serverStatus().oplogTruncation { "totalTimeProcessingMicros" : NumberLong(3418164), "processingMethod" : "sampling", "totalTimeTruncatingMicros" : NumberLong(0), "truncateCount" : NumberLong(0) } 可以看到,其他部分的时间开销在 4ms 左右,不到总时长的 1%。 为了验证初始化时间和 sample 的个数成正比,同样根据上述方法构造另外一个数据集,25GB,1.13 亿条 oplog, xdjmgset-dbfs1:PRIMARY> show dbs admin 0.000GB config 0.000GB local 25.145GB test 26.517GB xdjmgset-dbfs1:PRIMARY> use local switched to db local xdjmgset-dbfs1:PRIMARY> db.oplog.rs.count() 113211477 重启之后查看日志输出, 2020-10-27T15:43:02.121+0800 I STORAGE [initandlisten] Taking 568 samples and assuming that each section of oplog contains approximately 1991875 records totaling to 687195044 bytes 2020-10-27T15:43:02.121+0800 D1 STORAGE [initandlisten] WT cursor random sample 0, Oct 27 12:33:29:5201, took 0.216ms 2020-10-27T15:43:02.125+0800 D1 STORAGE [initandlisten] WT cursor random sample 1, Oct 27 12:36:06:5577, took 4.489ms 2020-10-27T15:43:02.130+0800 D1 STORAGE [initandlisten] WT cursor random sample 2, Oct 27 12:38:30:1191, took 4.417ms 2020-10-27T15:43:02.134+0800 D1 STORAGE [initandlisten] WT cursor random sample 3, Oct 27 12:40:51:1654, took 4.526ms 2020-10-27T15:43:02.139+0800 D1 STORAGE [initandlisten] WT cursor random sample 4, Oct 27 12:43:12:9085, took 4.51ms 2020-10-27T15:43:02.144+0800 D1 STORAGE [initandlisten] WT cursor random sample 5, Oct 27 12:45:36:3523, took 4.465ms 2020-10-27T15:43:02.148+0800 D1 STORAGE [initandlisten] WT cursor random sample 6, Oct 27 12:48:09:6883, took 4.63ms 2020-10-27T15:43:02.153+0800 D1 STORAGE [initandlisten] WT cursor random sample 7, Oct 27 12:50:09:6716, took 4.484ms 2020-10-27T15:43:02.157+0800 D1 STORAGE [initandlisten] WT cursor random sample 8, Oct 27 12:52:24:1495, took 4.531ms 2020-10-27T15:43:02.162+0800 D1 STORAGE [initandlisten] WT cursor random sample 9, Oct 27 12:54:39:3871, took 4.705ms 2020-10-27T15:43:02.167+0800 D1 STORAGE [initandlisten] WT cursor random sample 10, Oct 27 12:57:15:3946, took 4.661ms 2020-10-27T15:43:02.171+0800 D1 STORAGE [initandlisten] WT cursor random sample 11, Oct 27 12:59:36:5033, took 4.74ms 2020-10-27T15:43:02.176+0800 D1 STORAGE [initandlisten] WT cursor random sample 12, Oct 27 13:01:52:6908, took 4.424ms 2020-10-27T15:43:02.181+0800 D1 STORAGE [initandlisten] WT cursor random sample 13, Oct 27 13:04:22:2838, took 4.637ms 2020-10-27T15:43:02.186+0800 D1 STORAGE [initandlisten] WT cursor random sample 14, Oct 27 13:06:42:6574, took 5.21ms ... 2020-10-27T15:43:04.771+0800 D1 STORAGE [initandlisten] WT cursor random sample 567, Oct 27 12:17:32:2820, took 4.397ms 2020-10-27T15:43:04.771+0800 D1 STORAGE [initandlisten] WT cursor random sampling total took 2650.65ms 进行了 568 次 sample,总时间开销 2650ms,而 2650ms 基本上等于 ( 568.0/755) * 3414ms = 2568ms ,和 sample 个数成正比的结论可成立。 综上,考虑到单次 random cursor sample 的开销大约是 4-5ms,总 sample 上限是 1000,那么 oplog stones 初始化时间的上限在 5s 左右(NVMe SSD)。 update:上面的分析忽略了一个事情,即每次 random cursor next,是需要把获取 record id 的,因为这个就是 oplog 的 truncate point,但是同时也会把 value 读上来,而且 WiredTiger 的读取都是以 page(extent)为粒度的,如果平均每条 oplog 的 value 都很大,必然造成单个 disk page 也很大,重启的时候,显然所有 page 的读取都要自己读 disk,那么就会造成单次 random cursor next 耗时提升。 所以,经过进一步测试,最终的结论是,当单条 oplog 平均 size 不大时(几百个字节 - 几 KB), oplog stones 初始化时间的上限在 5s 左右。当单条 oplog 平均 size 太大时(几十 KB - 几 MB),那么因为 page size 太大,冷启动读取 disk page 开销变大, oplog stones 初始化时间可能达到几十秒甚至更高,具体时间和单条 oplog 平均 size 强相关。 Oplog Stones reload 优化 阿里云数据库平台有一套完善的日志采集系统,通过对线上运行日志分析,仍然发现有不少实例写入时的 Value 比较大,导致的结果是 oplog entry 的大小超过几百 KB,设置达到几 MB,这种情况下,如果重启,初始化 oplog stones 的时间开销就要达到几十秒甚至更多,所以仍然有必要对这个问题进行优化。 优化前后对比 构造 1MB 大小的 oplog entry,重启 100 个 stone,994 个 sample,加载时间在 27.7s 左右。 xdjmgset-dbfs1:PRIMARY> db.serverStatus().oplogTruncation { "totalTimeProcessingMicros" : NumberLong(27791069), "processingMethod" : "sampling", "totalTimeTruncatingMicros" : NumberLong(0), "truncateCount" : NumberLong(0) } 2020-11-13T14:47:52.391+0800 I STORAGE [initandlisten] The size storer reports that the oplog contains 20378 records totaling to 21355524826 bytes 2020-11-13T14:47:52.391+0800 D1 COMMAND [WT-OplogStonesSaverThread-local.oplogstones.rs] BackgroundJob starting: WT-OplogStonesSaverThread-local.oplogstones.rs 2020-11-13T14:47:52.391+0800 D2 STORAGE [WT-OplogTruncaterThread-local.oplog.rs] no global storage engine yet 2020-11-13T14:47:52.391+0800 I STORAGE [initandlisten] Sampling the oplog to determine where to place markers for truncation 2020-11-13T14:47:52.391+0800 D2 STORAGE [WT-OplogStonesSaverThread-local.oplogstones.rs] WT-OplogStonesSaverThread-local.oplogstones.rs: no global storage engine yet 2020-11-13T14:47:52.393+0800 I STORAGE [initandlisten] Sampling from the oplog between Nov 13 14:43:08:618 and Nov 13 14:47:21:3 to determine where to place markers for truncation 2020-11-13T14:47:52.393+0800 I STORAGE [initandlisten] Taking 994 samples and assuming that each section of oplog contains approximately 205 records totaling to 214833771 bytes 2020-11-13T14:47:52.572+0800 D1 STORAGE [initandlisten] WT cursor random sample 0, Nov 13 14:43:28:232, took 178.246ms 2020-11-13T14:47:52.620+0800 D1 STORAGE [initandlisten] WT cursor random sample 1, Nov 13 14:45:13:143, took 48.437ms 2020-11-13T14:47:52.661+0800 D1 STORAGE [initandlisten] WT cursor random sample 2, Nov 13 14:45:35:242, took 40.899ms 2020-11-13T14:47:52.868+0800 D1 STORAGE [initandlisten] WT cursor random sample 3, Nov 13 14:43:19:114, took 206.935ms ... 2020-11-13T14:48:20.182+0800 I STORAGE [initandlisten] WiredTiger record store oplog processing took 27791ms 优化后,加载时间在 7ms 左右,减少了 3-4 个数量级 xdjmgset-dbfs1:PRIMARY> db.serverStatus().oplogTruncation { "totalTimeProcessingMicros" : NumberLong(7261), "processingMethod" : "reloadingLocal", "totalTimeTruncatingMicros" : NumberLong(0), "truncateCount" : NumberLong(0) } 优化思路简述 把内存中的 oplog stones 保存在本地 后台线程负责在内存 oplog stones 变化时更新本地文件。 重启加载从本地文件加载保存的 oplog stones 信息,需要处理一些边界条件。 最后,打一个小广告,阿里云 MongoDB 目前上线了 Serverless 版本,让您方便的以超低成本试用 MongoDB,活动期间首购 1 元包月,续费也有 5 折优惠,欢迎试用:https://www.aliyun.com/product/mongodb 。
MongoDB 4.4 作为每年一度的大版本更新,已经在 7.30 号正式宣布 GA,不像之前的大版本,总是有一些重磅 Feature 的发布,比如 3.6 的 Change Stream & Causal Consistency,4.0 的多文档事务,4.2 的分布式事务,这次的 4.4 版本更像是一个维护性的版本,而且是一个用户期待已久的维护性版本,MongoDB 官方也把这次发布称之为「User-Driven Engineering」,说明新版本主要是针对用户呼声最高的一些痛点,重点进行了改进。 MongoDB 在 3.0 支持新的 WiredTiger 引擎后经过几年的快速奔跑,终于在 4.4 稍作歇息,开始在细节上进行打磨,4.4 发布的新特性很多,下面笔者就针对一些用户关注度比较高的 Feature 进行重点介绍。 扩展性和性能增强 Hidden Indexes Hidden Index 是阿里云 MongoDB 和 MongoDB 官方达成战略合作后共建的一个 Feature。我们都知道数据库维护太多的索引会导致写性能的下降,但是往往业务上的复杂性决定了运维 MongoDB 的同学不敢轻易的删除一个潜在的低效率索引,担心错误的删除会带来业务性能的抖动,而重建索引往往代价也非常大。 Hidden Index 正是为了解决 DBA 同学面临的上述困境,它支持通过 collMod 命令对现有的索引进行隐藏,保证后续的 Query 都不会利用到该索引,在观察一段时间后,确定业务没有异常,可以放心的删除该索引。 db.runCommand( { collMod: 'testcoll', index: { keyPattern: 'key_1', hidden: false } } ) 需要注意的是,索引被隐藏之后只是对 MongoDB 的执行计划器不可见,并不会改变索引本身的一些特殊行为,比如唯一键约束,TTL 淘汰等。 索引在隐藏期间,如果新的写入,也是会被更新的,所以也可以通过取消隐藏,很方便的让索引立刻变的可用。 Refinable Shard Keys 当使用 MongoDB 分片集群时,相信大家都知道选择一个好的 Shard key 是多么的重要,因为它决定了分片集群在指定的 Workload 下是否有良好的扩展性。但是在实际使用 MongoDB 的过程中,即使我们事先仔细斟酌了要选择的 Shard Key,也会因为 Workload 的变化而导致出现 Jumbo Chunk,或者业务流量都打向单一 Shard 的情况。 在 4.0 及之前的版本中,集合选定的 Shard Key 及其对应的 Value 都是不能更改的,在 4.2 版本,虽然可以修改 Shard Key 的 Value,但是数据的跨 Shard 迁移以及基于分布式事务的实现机制导致性能开销很大,而且并不能完全解决 Jumbo Chunk 或访问热点的问题。比如,现在有一个订单表,Shard Key 为 {customer_id:1},在业务初期每个客户不会有很多的订单,这样的 Shard Key 完全可以满足需求,但是随着业务的发展,某个大客户累积的订单越来越多,进而对这个客户订单的访问成为某个单一 Shard 的热点,由于订单和customer_id天然的关联关系,修改customer_id并不能改善访问不均的情况。 针对上述类似场景,在 4.4 中,你可以通过 refineCollectionShardKey 命令给现有的 Shard Key 增加一个或多个 Suffix Field 来改善现有的文档在 Chunk 上的分布问题。比如,在上面描述的订单业务场景中,通过refineCollectionShardKey命令把 Shard key 更改为{customer_id:1, order_id:1},即可避免单一 Shard 上的访问热点问题。 需要了解的是,refineCollectionShardKey 命令性能开销非常低,只是更改 Config Server 上的元数据,不需要任何形式的数据迁移(因为单纯的添加 Suffix 并不会改变数据在现有chunk 上的分布),数据的打散仍然是在后续正常的 Chunk 自动分裂和迁移的流程中逐步进行的。此外,Shard Key 需要有对应的 Index 来支撑,所以refineCollectionShardKey 要求提前创建新 Shard Key 对应的 Index。 因为并不是所有的文档都存在新增的 Suffix Field(s),所以在 4.4 中实际上隐含支持了「Missing Shard Key」的功能,即新插入的文档可以不包含指定的 Shard Key Field。但是,笔者不建议这么做,很容易产生 Jumbo Chunk。 Compound Hashed Shard Keys 在 4.4 之前的版本中,只能指定单字段的哈希片键,原因是此时 MongoDB 不支持复合哈希索引,这样导致的结果是,很容易出现集合数据在分片上分布不均。 而在 4.4 中支持了复合哈希索引,即,可以在复合索引中指定单个哈希字段,位置不限,可以作为前缀,也可以作为后缀,进而也就提供了对复合哈希片键的支持, sh.shardCollection( "examples.compoundHashedCollection", { "region_id" : 1, "city_id": 1, field1" : "hashed" } ) sh.shardCollection( "examples.compoundHashedCollection", { "_id" : "hashed", "fieldA" : 1} ) 有这个新功能之后,会带来很多好处,比如在如下两个场景下, 因为法律法规的要求,需要使用 MongoDB 的 zone sharding 功能,把数据尽量均匀打散在某个地域的多个分片上。 集合指定的片键的值是递增的,比如在上文中举的例子,{customer_id:1, order_id:1} 这个片键,如果customer_id 是递增的,而业务也总是访问最新的顾客的数据,导致的结果是大部分的流量总是访问单一分片。 在没有「复合哈希片键」支持的情况下,只能由业务对需要的字段提前计算哈希值,存储到文档中的某个特殊字段中,然后再通过「范围分片」的方式指定这个预先计算出哈希值的特殊字段及其他字段作为片键来解决上述问题。 而在 4.4 中直接把需要的字段指定为为哈希的方式即可轻松解决上述问题,比如,对于上文描述的第二个问题场景,片键设置为 {customer_id:'hashed', order_id:1} 即可,大大简化了业务逻辑的复杂性。 Hedged Reads 访问延迟的升高可能会带来直接的经济损失,Google 有一个研究报告表明,如果网页的加载时间超过 3 秒,用户的跳出率会增加 50%。所以,在 4.4 中 MongoDB 提供了 Hedged Reads 的功能,即在分片集群场景下,mongos 会把一个读请求同时发送到某个分片的两个副本集成员,然后选择最快的返回结果回复客户端,来减少业务上的 P95 和 P99 延迟。 Hedged Reads 功能是作为 Read Preference 的一部分来提供的, 所以可以是在 Operation 粒度上做配置,当 Read Preference 指定 nearest 时,默认启用 Hedged Reads 功能,当指定为 primary 时,不支持 Hedged Reads 功能,当指定为其他时,需要显示的指定 hedgeOptions,如下, db.collection.find({ }).readPref( "secondary", // mode [ { "datacenter": "B" }, { } ], // tag set { enabled: true } // hedge options ) 此外,Hedged Reads 也需要 mongos 开启支持,配置 readHedgingMode 参数为 on,默认 mongos 开启该功能支持。 db.adminCommand( { setParameter: 1, readHedgingMode: "on" } ) 降低复制延迟 主备复制的延迟对 MongoDB 的读写有非常大的影响,一方面,在一些特定的场景下,读写需要等待,备库需要及时的复制并应用主库的增量更新,读写才能继续,另一方面,更低的复制延迟,也会带来备库读时更好的一致性体验。 Streaming Replication 在 4.4 之前的版本中,备库通过不断的轮询主库来获取增量更新操作。每次轮询时,备库主动给主库发送一个 getMore 命令读取其上的 Oplog 集合,如果有数据,返回一个最大 16MB 的 Batch,如果没有数据,备库也会通过 awaitData 选项来控制备库无谓的 getMore 开销,同时能够在有新的增量更新时,第一时间获取到对应的 Oplog。 拉取是由单个 OplogFetcher 线程来完成,每个 Batch 的获取都需要经历一个完整的 RTT,在副本集网络状况不好的情况下,复制的性能就严重受限于网络延迟。所以,在 4.4 中,增量的 Oplog 是不断的“流向”备库的,而不是依靠备库主动轮询,相比于之前的方式,至少在 Oplog 获取上节省了一半的 RTT。 当用户的写操作指定了 “majority” writeConcern 的时候,写操作需要等待足够多的备库返回复制成功的确认,MongoDB 内部的一个测试表明,在新的复制机制下,在高延迟的网络环境中,可以平均提升 50% 的 majority 写性能。 另外一个场景是用户使用了Causal Consistency,为了保证可以在备库读到自己的写操作(Read Your Write),同样强依赖备库对主库 Oplog 的及时复制。 Simultaneous Indexing 在 4.4 之前的版本中,索引创建需要在主库完成之后,才会复制到备库上执行。备库上的创建动作,在不同的版本中,因为创建机制和创建方式(前台、后台)的不同,对备库 Oplog 的应用影响也大为不同。 但是,即使在 4.2 中,统一了前后台索引创建机制,使用了相当细粒度的加锁机制——只在索引创建的开始和结束阶段对集合加独占锁,也会因为索引创建本身的性能开销(CPU、IO),导致复制延迟,或者因为一些特殊操作,比如 collMod 命令修改集合元信息,而导致 Oplog 的应用阻塞,甚至会因为主库历史 Oplog 被覆盖掉而进入 Recovering 状态。 在 4.4 中,主库和备库上的索引创建操作是同时进行的,可以大幅减少因为上述情况所带来的主备延迟,尽量保证即使在索引创建过程中,备库读也可以访问到最新的数据。 此外,新的索引创建机制是在 majority 的具备投票权限的数据承载节点返回成功后,索引才会真正生效。所以,也可以减轻在读写分离场景下,因为索引不同而导致的性能差异。 可用性和容错性增强 Mirrored Reads 在服务阿里云 MongoDB 客户的过程中,笔者观察到有很多的客户虽然购买的是三节点的副本集,但是实际在使用过程中读写都是在 Primary 节点,其中一个可见的 Secondary 并未承载任何的读流量。 那么在偶尔的宕机切换之后,客户能明显的感受到业务的访问延迟会有抖动,经过一段时间后才会恢复到之前的水平,抖动原因就在于,新选举出的主库之前从未提供过读服务,并不了解业务的访问特征,没有针对性的对数据做缓存,所以在突然提供服务后,读操作会出现大量的「Cache Miss」,需要从磁盘重新加载数据,造成访问延迟上升。在大内存实例的情况下,这个问题更为明显。 在 4.4 中,MongoDB 针对上述问题实现了「Mirrored Reads」功能,即,主库会按一定的比例把读流量复制到备库上执行,来帮助备库预热缓存。这个执行是一个「Fire and Forgot」的行为,不会对主库的性能产生任何实质性的影响,但是备库负载会有一定程度的上升。 流量复制的比例是可动态配置的,通过 mirrorReads 参数设置,默认复制 1% 的流量。 db.adminCommand( { setParameter: 1, mirrorReads: { samplingRate: 0.10 } } ) 此外,可以通过db.serverStatus( { mirroredReads: 1 } )来查看 Mirrored Reads 相关的统计信息, SECONDARY> db.serverStatus( { mirroredReads: 1 } ).mirroredReads { "seen" : NumberLong(2), "sent" : NumberLong(0) } Resumable Initial Sync 在 4.4 之前的版本中,如果备库在做全量同步,出现网络抖动而导致连接闪断,那么备库是需要重头开始全量同步的,导致之前的工作全部白费,这个情况在数据量比较大时,比如 TB 级别,更加让人崩溃。 而在 4.4 中,MongoDB 提供了,因网络异常导致全量同步中断情况下,从中断位置恢复全量同步的能力。在尝试恢复一段时间后,如果仍然不成功,那么会重新选择一个同步源进行新的全量同步。这个尝试的超时时间默认是 24 小时,可以通过 replication.initialSyncTransientErrorRetryPeriodSeconds 在进程启动时更改。 需要注意的是,对于全量同步过程中遇到的非网络异常导致的中断,仍然需要重新发起全量同步。 Time-Based Oplog Retention 我们知道,MongoDB 中的 Oplog 集合记录了所有的数据变更操作,除了用于复制,还可用于增量备份,数据迁移,数据订阅等场景,是 MongoDB 数据生态的重要基础设施。 Oplog 是作为 Capped Collection 来实现的,虽然从 3.6 开始,MongoDB 支持通过 replSetResizeOplog 命令动态修改 Oplog 集合的大小,但是大小往往并不能准确反映下游对 Oplog 增量数据的需求,考虑如下场景, 计划在凌晨的 2 - 4 点对某个 Secondary 节点进行停机维护,应避免上游 Oplog 被清理而触发全量同步。 下游的数据订阅组件可能会因为一些异常情况而停止服务,但是最慢会在 3 个小时之内恢复服务并继续进行增量拉取,也应当避免上游的增量缺失。 所以,在真实的应用场景下,很多时候是需要保留最近一个时间段内的 Oplog,这个时间段内产生多少的 Oplog 往往是很难确定的。 在 4.4 中,MongoDB 支持 storage.oplogMinRetentionHours 参数定义最少保留的 Oplog 时长,也可以通过 replSetResizeOplog 命令在线修改这个值,如下, // First, show current configured value db.getSiblingDB("admin").serverStatus().oplogTruncation.oplogMinRetentionHours // Modify db.adminCommand({ "replSetResizeOplog" : 1, "minRetentionHours" : 2 }) 查询能力和易用性增强 传统的关系型数据库(RDBMS)普遍以 SQL 语言为接口,客户端可以在本地编写融入部分业务逻辑的复杂 SQL 语句,来实现强大的查询能力。MongoDB 作为一个新型的文档数据库系统,也有自定义的 MQL 语言,复杂查询能力主要借助于 Aggregation Pipeline 来实现,虽弱于 RDBMS,但在最近的几个大版本中也在持续不断的打磨,最终的目的是使用户在享受到 MongoDB 灵活性和扩展性的同时,也能享受到丰富的功能性。 Union 在多表联合查询能力上,4.4 之前只提供了一个 &dollar;lookup stage 用于实现类似于 SQL 中的「left outer join」功能,在 4.4 中新增的 &dollar;unionWith stage 又提供了类似 SQL 中的「union all」功能,用户把两个集合中的数据聚合到一个结果集中,然后做指定的查询和过滤。区别于 &dollar;lookup stage 的是,&dollar;unionWith stage 支持分片集合。当在 Aggregate Pipeline 中使用了多个 &dollar;unionWith stage 的时候,可以对多个集合数据做聚合,使用方式如下, { $unionWith: { coll: "<collection>", pipeline: [ <stage1>, ... ] } } 可以在 pipeline 参数中指定不同的 stage,用于在对集合数据聚合前,先进行一定的过滤,使用起来非常灵活,下面举一个简单的例子,比如业务上对订单数据按表拆分存储到不同的集合,第二季度有如下数据(演示目的), db.orders_april.insertMany([ { _id:1, item: "A", quantity: 100 }, { _id:2, item: "B", quantity: 30 }, ]); db.orders_may.insertMany([ { _id:1, item: "C", quantity: 20 }, { _id:2, item: "A", quantity: 50 }, ]); db.orders_june.insertMany([ { _id:1, item: "C", quantity: 100 }, { _id:2, item: "D", quantity: 10 }, ]); 现在假设业务上需要知道,二季度不同产品的销量,在 4.4 之前,可能需要业务自己把数据都读出来,然后在应用层面做聚合才能解决这个问题,或者依赖某种数据仓库产品来做分析,但是需要有某种数据的同步机制。 而在 4.4 中只需要如下一条 Aggregate 语句即可解决问题, db.orders_april.aggregate( [ { $unionWith: "orders_may" }, { $unionWith: "orders_june" }, { $group: { _id: "$item", total: { $sum: "$quantity" } } }, { $sort: { total: -1 }} ] ) Custom Aggregation Expressions 4.4 之前的版本中可以通过 find 命令中的 &dollar;where operator 或者 MapReduce 功能来实现在 Server 端执行自定义的 JavaScript 脚本,进而提供更为复杂的查询能力,但是这两个功能并没有做到和 Aggregation Pipeline 在使用上的统一。 所以,在 4.4 中,MongoDB 提供了两个新的 Aggregation Pipeline Operator,&dollar;accumulator 和 $function 用来取代 $where operator 和 MapReduce,借助于「Server Side JavaScript」来实现自定义的 Aggregation Expression,这样做到复杂查询的功能接口都集中到 Aggregation Pipeline 中,完善接口统一性和用户体验的同时,也可以把Aggregation Pipeline 本身的执行模型利用上,实现所谓 「1+1 > 2」 的效果。 $accumulator 和 MapReduce 功能有些相似,会先通过init 函数定义一个初始的状态,然后对于每一个输入的文档,根据指定的 accumate 函数更新状态,然后会根据需要决定是否执行 merge 函数,比如,如果在分片集合上使用了 $accumulator operator,那么最后需要把不同分片上执行完成的结果做 merge,最后,如果指定了 finalize 函数,在所有输入文档处理完成后,会根据该函数把状态转换为一个最终的输出。 &dollar;function 和 &dollar;where operator 在功能上基本一致,但是强大之处是可以和其他的 Aggregation Pipeline Operator 配合使用,此外也可以在 find 命令中借助于 $expr operator 来使用 $function operator,等价于之前的 &dollar;where operator,MongoDB 官方在文档中也建议优先使用 &dollar;function operator。 其他易用性增强 Some Other New Aggregation Operators and Expressions 除了上述的 $accumulator 和 $function operator,4.4 中还新增了其他多个 Aggregation Pipeline Operator,比如做字符串处理的,获取数组收尾元素的,还有用来获取文档或二进制串大小的操作符,具体见如下列表, Operator Description $accumulator Returns the result of a user-defined accumulator operator. $binarySize Returns the size of a given string or binary data value’s content in bytes. $bsonSize Returns the size in bytes of a given document (i.e. bsontype Object) when encoded as BSON. $first Returns the first element in an array. $function Defines a custom aggregation expression. $last Returns the last element in an array. $isNumber Returns boolean true if the specified expression resolves to an integer, decimal, double, or long.Returns boolean false if the expression resolves to any other BSON type, null, or a missing field $replaceOne Replaces the first instance of a matched string in a given input. $replaceAll Replaces all instances of a matched string in a given input. Connection Monitoring and Pooling 4.4 的 Driver 中增加了对客户端连接池的行为监控和自定义配置,通过标准的 API 来订阅和连接池相关的事件,包括连接的关闭和打开,连接池的清理。也可以通过 API 来配置连接池的一些行为,比如,拥有的最大/最小连接数,每个连接的最大空闲时间,线程等待可用连接时的超时时间,具体可以参考 MongoDB 官方的设计文档。 Global Read and Write Concerns 在 4.4 之前的版本中,如果操作的执行没有显式指定 readConcern 或者 writeConcern,也会有默认行为,比如readConcern 默认是 local,而 writeConcern 默认是 {w: 1}。但是,这个默认行为并不可以变更,如果用户想让所有的 insert 操作的 writeConcern 默认都是是 {w: majority},那么只能所有访问 MongoDB 的代码都显式去指定该值。 在 4.4 中可以通过 setDefaultRWConcern 命令来配置全局默认的 readConcern 和 writeConcern,如下, db.adminCommand({ "setDefaultRWConcern" : 1, "defaultWriteConcern" : { "w" : "majority" }, "defaultReadConcern" : { "level" : "majority" } }) 也可以通过 getDefaultRWConcern 命令获取当前默认的readConcern 和 writeConcern。 此外,这次 MongoDB 做的更加贴心,在记录慢日志或诊断日志的时候,会记录当前操作的 readConcern 或者 writeConcern 设置的来源,二者相同的来源定义有如下三种, Provenance Description clientSupplied 由应用自己指定 customDefault 由用户通过 setDefaultRWConcern 命令指定 implicitDefault 完全没做任何配置,Server 默认行为 对于 writeConcern 来说,还有如下一种来源, Provenance Description getLastErrorDefaults 继承自副本集的 settings.getLastErrorDefaults 配置 New MongoDB Shell (beta) 对于运维 MongoDB 的同学来说,使用最多的工具可能就是 mongo shell,4.4 提供了新版本的 mongo shell,增加了像代码高亮,命令自动补全,更加可读的错误信息等非常人性化的功能,不过,目前还是 beta 版本,很多命令还不支持,仅供尝鲜。 MongoDB 云平台 MongoDB 云平台在这次的 4.4 新版本发布中也有非常多的更新,比如,类似于 DynamoDB 的 Auto-Scale 功能,可以根据业务负载的变化自动变更资源配额,为用户降低成本,索引和 Schema 推荐功能,数据归档和归档查询功能,增强的的数据可视化服务,和收购的移动端数据库 Realm 的整合等。 在分析能力上,MongoDB 在 2019 年 6 月份推出了自己的 Atlas 数据湖产品,在这次的 4.4 版本发布中,数据湖同样也有多项重要更新,比如联邦查询功能,一条 MQL 语句可以同时查询 Atlas 云服务实例和 AWS S3 中的数据,Aggregation Pipeline 执行结果直接导出到 S3,SQL 查询能力支持,可以更好的配合使用现有的 BI 工具。 总的来说,MongoDB 逐渐的从一个专注于数据库服务的厂商,转变为提供数据平台服务的厂商。 其他 这次的 4.4 发布,前面讲了主要是一个维护性的版本,所以除了上述解读,还有很多其他小的优化,像 $indexStats 优化,TCP Fast Open 支持优化建连,索引删除优化等等,还有一些相对大的增强,像新的结构化日志LogV2,新的安全机制支持等,这些可能不是用户最优先去关注的,在这里就不一一描述了,感兴趣的读者可以自行参考官方的 Release Notes。 最后,阿里云 MongoDB 作为和官方的独家战略合作伙伴,预计会在十月中旬全网独家发布 4.4 新版本,敬请期待。 最后,打一个小广告,阿里云 MongoDB 目前上线了 Serverless 版本,让您方便的以超低成本试用 MongoDB,活动期间首购 1 元包月,续费也有 5 折优惠,欢迎试用:https://www.aliyun.com/product/mongodb 。
Introduction MongoDB 官方认证包括两个类型, • Certified DBA• Certified Developer DBA 强调系统的管理,Developer 强调对 MongoDB 的使用,比如要对 Aggregation 的各种 Operator 的使用很熟悉,但是也有很多共性的知识点,比如 CRUD,Index 等,我们选择 Certified DBA 即可。 完成任何一个认证,需要进行线上的考试,考试时长 90 分钟,需要准备一个单独房间和干净的桌面,不能用任何参考资料(网页,手抄等),监考老师会通过视频会议软件和你沟通,全程需要录屏,并要求你的正面头像要一直处于摄像头范围内,还是比较严格的。 每个月会有一个考试时间段,称为一个 Session,每个 Session 大约是 10 天,报名交 150 美金,可以约接下来 Session 的任何一天来参加考试,详细的注册步骤根据邮件流程走即可,需要注意的是预约的时间要选好时区,在中国的话选择 Taipei 即可。另外,考试如果没通过是不退费的。 注册地址:https://university.mongodb.com/certification/dba/about Learning Path Certified DBA 主要有两个学习资料,一个是 MongoDB University 的 DBA 课程,如下, • M001: MongoDB Basics• M103: Basic Cluster Administration• M201: MongoDB Performance• M310: MongoDB Security• M312: Diagnostics & Debugging 另外一个是,官方给的 MongoDB Certification Exam Study Guide ,这个 study guide 的优点是全面,涵盖考试涉及的所有知识点,但是内容非常多,而且全部看文档也比较无聊,对知识理解也不深刻,所以我的建议是先学习上面的视频课程,跟着课程走一遍,抓住重点,每门课程中间会穿插一些小考试,结束的时候会有一个最终考试,6 个选择题,考试通过会给你发一个电子证书,也建议大家都通过一下。 课程学习时间参考:前两个比较基础,每门差不多2-3 个小时应该就可以全过一遍,后面三门课程细节比较多一些,预计每门课程需要 5-6 个小时。 课程学完之后, MongoDB Certification Exam Study Guide 就可以发挥作用了,可以根据学习的课程,把这个 study guide 快速整体过一遍,看看还有没有不熟悉的知识点,适当做些笔记,后面再回顾几次,知识部分的学习我觉得就足以通过这个考试了。 这里共享一下,我自己记录的笔记,这个笔记只记录了我在看 study guide 对应的 documentation 的时候不熟悉的知识点,从最后的实际考试来看,有些知识点可能超出了考试的范围,仅供参考, MongoDB DBA 认证知识 Review.pdf Exam 考试全部是选择题,总共 60 个,大部分为多选,得分要求是 490 分以上才能通过,但是考试的总分以及每个题的评分标准并没有公布,所以目前是不清楚的,题目按 study guide 分类,最后如果通过的话,给出的考试结果,也是各个分类题目正确的百分比,没有具体的分数,下面是我的一个参考结果, 在知识部分学习完成之后,大家可以先去试一下 Practice Exam (https://university.mongodb.com/certification/exam-prep ),熟悉一下考试操作和题目分类,另外真正的考试也遇到几个题和 Practice Exam 高度相似,这个 Practice Exam 还是值得去多试几次的。 说下正式考试的流程,按预约的时间提前 15 分钟找个会议室,然后点 Start Exam,会有 Proctor 跟你视频,确认你的 ID 和设置的安全提问,所以身份证要记得带着,然后监考会确认考试环境没有干扰,就可以开始考试了,上述流程都是在一个第三方的网站,但是当真正考试的时候,就还是在 MongoDB 自己的网站,和你 Practice Exam 的环境一致。 最后,Good Luck。 最后,打一个小广告,阿里云 MongoDB 目前上线了 Serverless 版本,让您方便的以超低成本试用 MongoDB,活动期间首购 1 元包月,续费也有 5 折优惠,欢迎试用:https://www.aliyun.com/product/mongodb 。
dbStats dataSize,未压缩数据的大小的,数据的逻辑大小,删除文档会变化,为各 collection stats.size 的累加 storageSize,block manger实际分配的空间大小(文件大小),删除文档不会发生变化,compact 会,使用压缩后,可能会比 dataSize 小,为各 collection stats.storageSize 的累加 indexSize,类似于 storageSize,但是反馈的是所有索引表通过 BM 分配的的大小 fsUsedSize,文件系统当前实际使用空间 fsTotalSize,文件系统总容量,上面两个值都是通过boost::filesystem::space拿到值,如果共有同一个文件系统,和具体的 Database 没有关联 // src/mongo/db/catalog/database_impl.cpp:284 if (!opCtx->getServiceContext()->getStorageEngine()->isEphemeral()) { boost::filesystem::path dbpath( opCtx->getServiceContext()->getStorageEngine()->getFilesystemPathForDb(_name)); boost::system::error_code ec; boost::filesystem::space_info spaceInfo = boost::filesystem::space(dbpath, ec); if (!ec) { output->appendNumber("fsUsedSize", (spaceInfo.capacity - spaceInfo.available) / scale); output->appendNumber("fsTotalSize", spaceInfo.capacity / scale); } else { output->appendNumber("fsUsedSize", -1); output->appendNumber("fsTotalSize", -1); log() << "Failed to query filesystem disk stats (code: " << ec.value() << "): " << ec.message(); } } collStats size, collection 中所有document 的未压缩大小 storageSize,collection 文件大小 wiredTiger."block-manager"."file size in bytes",等于storageSize wiredTiger."block-manager"."file bytes available for reuse",collection 文件可重用的空间大小 totalIndexSize,collection 上所有索引对应的wt表文件的大小 indexSizes,Object,分别列出了各个索引的文件大小 indexDetails.index_name."block-manager"."file size in bytes",类似于 collection,反映的是索引表的文件大小 indexDetails.index_name."block-manager"."file bytes available for reuse",类似于 collection,反映的是索引表可重用空间大小 所以要计算单个 collection(含索引)的磁盘空间碎片率,计算脚本, function getCollectionDiskSpaceFragRatio(dbname, coll) { var res = db.getSiblingDB(dbname).runCommand({ collStats: coll }); var totalStorageUnusedSize = 0; var totalStorageSize = res['storageSize'] + res['totalIndexSize']; Object.keys(res.indexDetails).forEach(function(key) { var size = res['indexDetails'][key]['block-manager']['file bytes available for reuse']; print("index table " + key + " unused size: " + size); totalStorageUnusedSize += size; }); var size = res['wiredTiger']['block-manager']['file bytes available for reuse']; print("collection table " + coll + " unused size: " + size); totalStorageUnusedSize += size; print("collection and index table total unused size: " + totalStorageUnusedSize); print("collection and index table total file size: " + totalStorageSize); print("Fragmentation ratio: " + ((totalStorageUnusedSize * 100.0) / totalStorageSize).toFixed(2) + "%"); } 一次查看 db 下的所有 collection 碎片率, use xxxdb db.getCollectionNames().forEach((c) => {print("\n\n" + c); getCollectionDiskSpaceFragRatio(db.getName(), c);}); compact 如果碎片率过高,我们需要做 compact,MongoDB 本身提供了 compact 命令,关于 compact 命令的使用和原理,可以参考团队另外一位同学文章:https://developer.aliyun.com/article/709526 如果是 Sharding 实例,需要执行 compact 命令,请按如下方式执行命令:https://developer.aliyun.com/article/328761 最后,打一个小广告,阿里云 MongoDB 目前上线了 Serverless 版本,让您方便的以超低成本试用 MongoDB,活动期间首购 1 元包月,续费也有 5 折优惠,欢迎试用:https://www.aliyun.com/product/mongodb 。
背景 从监控看 Secondary 使用的物理内存比 Primary 多 11GB 左右, 基本的内存分析可以先看团队另一位同学写的这个排查文档, 用户没有设置在备库读,Secondary 基本没有流量,只有复制的流量,连接数也不多,基本排除是业务行为导致 Secondary 内存高,所以怀疑和 tcmalloc 分配器的缓存行为有关。 排查 查看Primary 和 Secondary 的 serverStatus.tcmalloc 输出, Primary, mgset-25489817:PRIMARY> db.serverStatus().tcmalloc { "generic" : { "current_allocated_bytes" : NumberLong("16296822448"), "heap_size" : NumberLong("34201272320") }, "tcmalloc" : { "pageheap_free_bytes" : 933314560, "pageheap_unmapped_bytes" : NumberLong("15870619648"), "max_total_thread_cache_bytes" : NumberLong(1073741824), "current_total_thread_cache_bytes" : 543050048, "total_free_bytes" : NumberLong(1100498976), "central_cache_free_bytes" : 557461008, "transfer_cache_free_bytes" : 4096, "thread_cache_free_bytes" : 543031184, "aggressive_memory_decommit" : 0, "pageheap_committed_bytes" : NumberLong("18330652672"), "pageheap_scavenge_count" : 22937964, "pageheap_commit_count" : 31247638, "pageheap_total_commit_bytes" : NumberLong("218141866151936"), "pageheap_decommit_count" : 23394903, "pageheap_total_decommit_bytes" : NumberLong("218123535499264"), "pageheap_reserve_count" : 9872, "pageheap_total_reserve_bytes" : NumberLong("34201272320"), "spinlock_total_delay_ns" : NumberLong("113428202936"), Secondary, mgset-25489817:SECONDARY> db.serverStatus().tcmalloc { "generic" : { "current_allocated_bytes" : NumberLong("16552694552"), "heap_size" : NumberLong("33373687808") }, "tcmalloc" : { "pageheap_free_bytes" : NumberLong("11787452416"), "pageheap_unmapped_bytes" : NumberLong("4039823360"), "max_total_thread_cache_bytes" : NumberLong(1073741824), "current_total_thread_cache_bytes" : 113279256, "total_free_bytes" : 993717480, "central_cache_free_bytes" : 879823248, "transfer_cache_free_bytes" : 614976, "thread_cache_free_bytes" : 113279256, "aggressive_memory_decommit" : 0, "pageheap_committed_bytes" : NumberLong("29333864448"), "pageheap_scavenge_count" : 2605518, "pageheap_commit_count" : 4694997, "pageheap_total_commit_bytes" : NumberLong("672231747584"), "pageheap_decommit_count" : 3544502, "pageheap_total_decommit_bytes" : NumberLong("642897883136"), "pageheap_reserve_count" : 25284, "pageheap_total_reserve_bytes" : NumberLong("33373687808"), "spinlock_total_delay_ns" : NumberLong("3132393632"), 我们重点关注 *_free_bytes 的输出项,其中, pageheap_free_bytes:Number of bytes in free, mapped pages in page heap. These bytes can be used to fulfill allocation requests. They always count towards virtual memory usage, and unless the underlying memory is swapped out by the OS(线上目前没有开启 swap), they also count towards physical memory usage. total_free_bytes = central_cache_free_bytes + transfer_cache_free_bytes + thread_cache_free_bytes,注意这个total_free_bytes 是不包含pageheap_free_bytes的,见 tcmalloc 代码 所以如果查看 tcmalloc cache 了多少内存,需要看 pageheap_free_bytes + total_free_bytes 最后,对比一下 Secondary 和 Primary 的 serverStatus 输出,可以看到total_free_bytes二者是差不多的,都在 1GB 左右,但是pageheap_free_bytes ,Secondary 比 Primary 多了 11GB 左右,和前面 OS 层面观察到的 RSS 差值一致。 关于 central_cache_free_bytes 、thread_cache_free_bytes 、 thread_cache_free_bytes的含义也列一下,这个代码里面没有解释,在其他地方找到了, central_cache_free_bytes, Number of free bytes in the central cache that have been assigned to size classes. They always count towards virtual memory usage, and unless the underlying memory is swapped out by the OS, they also count towards physical memory usage. This property is not writable. transfer_cache_free_bytes, Number of free bytes that are waiting to be transfered between the central cache and a thread cache. They always count towards virtual memory usage, and unless the underlying memory is swapped out by the OS, they also count towards physical memory usage. This property is not writable. thread_cache_free_bytes, Number of free bytes in thread caches. They always count towards virtual memory usage, and unless the underlying memory is swapped out by the OS, they also count towards physical memory usage. This property is not writable. 优化 阿里云 MongoDB 实现了一个 tcmallocRelease 命令(后端可执行,不对外部用户提供),背后是调用 tcmalloc 的ReleaseFreeMemory()进行 PageHeap 的回收,不过这个命令在执行过程中会锁住整个 PageHeap,可能导致其他需要分配内存的请求 hang 住,线上执行要小心。另外,如果对这部分 cache 住的内存不是特别敏感,不建议执行,毕竟不是真的浪费了,也减少了后续需要调用系统调用的次数。 此外,这个方法不影响 Central Cache 和 Thread Cache。关于tcmalloc cache 内存归还操作系统的策略和时机,比较复杂,详细的资料可以参考这个文章。 我们在上述实例的Hidden 节点执行db.adminCommand({tcmallocRelease: 1})命令,可以观察到pageheap_free_bytes下降了 90%以上, before, mgset-25489817:SECONDARY> db.serverStatus().tcmalloc { "generic" : { "current_allocated_bytes" : NumberLong("16549856240"), "heap_size" : NumberLong("34105942016") }, "tcmalloc" : { "pageheap_free_bytes" : NumberLong("7499571200"), "pageheap_unmapped_bytes" : NumberLong("9387900928"), "max_total_thread_cache_bytes" : NumberLong(1073741824), "current_total_thread_cache_bytes" : 133710112, "total_free_bytes" : 668613648, "central_cache_free_bytes" : 534325360, "transfer_cache_free_bytes" : 578176, "thread_cache_free_bytes" : 133710112, after, mgset-25489817:SECONDARY> db.serverStatus().tcmalloc { "generic" : { "current_allocated_bytes" : NumberLong("16546167280"), "heap_size" : NumberLong("34105942016") }, "tcmalloc" : { "pageheap_free_bytes" : 38395904, "pageheap_unmapped_bytes" : NumberLong("16852795392"), "max_total_thread_cache_bytes" : NumberLong(1073741824), "current_total_thread_cache_bytes" : 134981800, "total_free_bytes" : 668583440, "central_cache_free_bytes" : 533437608, "transfer_cache_free_bytes" : 164032, "thread_cache_free_bytes" : 134981800, 官方 JIRA Issue 查了一下有几个,但是我们重点关注这个,https://jira.mongodb.org/browse/SERVER-37541 , 这个 issue 实际上是对今天这里讨论的问题的一个汇总,主要包括两方面的原因, Fragmentation,即碎片导致,这个问题大神 Bruce Lucas 开了一个 jira,但是 mongodb 团队反馈说是不在高优先级 list 上,所以 backlog 了(PS:优化内存碎片率是世界性难题,tcmalloc/jemalloc 都不能做到完美,可能要优化确实很困难)。 另外一个就是内存分配器的缓存行为,tcmalloc 在向操作系统归还内存时,是比较 "reluctant" 的,而且有时候还会达到一个临界点突然归还内存,导致性能抖动,可以配置server parameter tcmallocAggressiveMemoryDecommit 来进行更激进的内存回收,但是 MongoDB 团队测试发现有性能问题,所以默认没有开启。
1. 支持版本: 从 3.2 版本开始支持该选项(Read Concern特性也是从该版本开始支持) (https://docs.mongodb.com/v3.2/reference/configuration-options/#replication.enableMajorityReadConcern )。 3.2 和 3.4 中 默认值为 false,即默认不支持 majority 级别的 read concern(或称之为committed reads,通过serverStatus输出中的 storageEngine.supportsCommittedReads 可判断该能力是否开启)。 3.6 及以后版本默认值为true 修改该参数需要更改配置文件并重启mongod 2. 关闭后的影响 2.1 Change Streams 能力 4.0 及之前版本会失去该能力,4.2版本不受影响 2.2 事务能力 2.2.1 副本集: 事务能力不受影响(无论是哪个版本) 2.2.2 分片集群 这个个人理解只有4.2受影响,因为4.0及之前版本并不支持shared cluster上的事务,见:https://docs.mongodb.com/manual/core/transactions/#transactions-and-atomicity 如果事务涉及的shard关闭了majority read concern 能力支持, 该事务不可以使用snapshot级别(及更高)的read concern。 事务如果需要写多个shard,会写失败 3. 开启后的影响 read concern 实现原理见@林青(linqing.zyd) 这篇文章:https://yq.aliyun.com/articles/60553 . 通过引擎层的snapshot来实现,snapshot在内存中,增加了cache维护的压力,对性能有影响,目前看关闭之后,点查场景,性能有 30%左右的提升,如下 , 参考文档 https://docs.mongodb.com/manual/reference/read-concern-majority/
Redis Stream Redis最新的大版本5.0已经RC1了,其中最重要的Feature莫过于Redis Stream了,关于Redis Stream的基本使用介绍和设计理念可以看我之前的一篇文章(Redis Stream简介)。Redis Stream本质上是在Redis内核上(非Redis Module)实现的一个消息发布订阅功能组件。相比于现有的PUB/SUB、BLOCKED LIST,其虽然也可以在简单的场景下作为消息队列来使用,但是Redis Stream无疑要完善很多。Redis Stream提供了消息的持久化和主备复制功能、新的RadixTree数据结构来支持更高效的内存使用和消息读取、甚至是类似于Kafka的Consumer Group功能。今天我们重点关注怎么在实际业务场景下去使用Redis Stream。 Redis Stream实战——IRC系统 相信大家对IRC都比较了解了(还记得被和谐掉的xx聊天室吗:-)),很多知名的开源项目(包括Redis)都有自己的IRC频道,方便开发者和使用者实时的进行思想火花的碰撞,我们今天介绍的主角——Redis Stream,本身就是起源于IRC中一个用户的idea。IRC的模型如下, 在某个IRC频道中的用户,既可以向所有的其他用户自由的发送消息,也可以接收其他所有用户发送的消息。如果要基于Redis来构建一个IRC系统,那我们不由自主的会想到使用Redis的PUB/SUB功能, 可以看到,基于PUB/SUB,只需要所有的用户(client)都订阅(subscribe)同一个IRC频道(channel1),就可以接收所有用户发出的消息了。发出消息时,只需使用发布命令(publish)命令即可。整个业务逻辑非常的清晰简单,这也是Redis强大和流行的重要原因——提供的功能和数据结构能尽可能提升开发者的开发效率。 但是基于PUB/SUB构建的IRC,有一个问题是PUB/SUB的消息模型是Fire and Forgot。也就是说Redis本身并不保存任何历史消息,如果IRC中某个用户的网络连接出现异常,重新加入IRC后,他是看不到断链期间的聊天记录的,新加入的用户同样也看不到最近一段时间的历史记录,这个对用户迅速的理解当前讨论的问题非常不便。此外,如果Redis发生了重启,所有的用户也需要重新订阅频道。 那如果基于Redis Stream来构建IRC呢? 创建频道 # 目前Redis还不支持创建空的stream,所以我们可以添加一个特殊消息, # 来创建一个新的stream(频道) ip:7000> xadd channel1 * create-channel null 1528702126345-0 发送消息 # 发送一条消息,只需要使用xadd命令即可,我们可以给每条消息命名,顺便带上消息来源,方便业务逻辑处理。 # 我们也可以一次发送多条消息,可以作为优化网络开销的一种手段。 ip:7000> xadd channel1 * msg1-tony "Hello everyone." 1528702503377-0 ip:7000> xadd channel1 * msg2-tony "I am a big Redis fan." msg3-tony "Hope we can learn from each other.:-)" 1528702573546-0 接收消息 # 新用户初次加入频道时,指定'$'作为一个特殊起始ID读取消息,表示只接收最新的频道消息 # 之后如果新消息,只需从上一次的返回结果ID继续读取即可 # 当没有新消息时,xread命令返回空集 ip:7000> xread BLOCK 100 STREAMS channel1 $ 1) 1) "channel1" 2) 1) 1) 1528703048021-0 2) 1) "msg1-tony" 2) "Hello everyone." ip:7000> xread BLOCK 100 STREAMS channel1 1528703048021-0 1) 1) "channel1" 2) 1) 1) 1528703061087-0 2) 1) "msg2-tony" 2) "I am a big Redis fan." 3) "msg3-tony" 4) "Hope we can learn from each other.:-)" ip:7000> xread BLOCK 100 STREAMS channel1 1528703061087-0 (nil) 获取历史消息前面我们提到了,Redis Stream和PUB/SUB相比,一个重要的区别是,Redis Stream可以获取历史发送的消息,所以当一个用户断开连接重新加入IRC时,可以通过如下方式获取历史消息: # 1528703061087-0 为用户记录的最后接收的消息的ID ip:7000> xrange channel1 1528703061087-0 + 1) 1) 1528706457462-0 2) 1) "msg1-andy" 2) "Nice to meet you guys." 2) 1) 1528706497200-0 2) 1) "msg4-tony" 2) "When will Redis 5.0 GA comes out?" 3) 1) 1528706601973-0 2) 1) "msg1-antirez" 2) "I think it will arrive in the second half of 2018." Redis Stream实战——IoT数据采集 Redis除了强大而且丰富的数据结构支持,还有一个很重要的能力是跨平台,甚至是作为一个嵌入式的存储系统跑在基于ARM的平台上,比如作者之前就宣称,Redis成功的跑在了“树莓派”上。 试想一下,在IoT时代,会有无数随时随地可以接入互联网的智能设备,你家里的冰箱会实时的汇报,冰箱里面有哪些食物,数量多少,新鲜程度如何,空调会汇报现在温度多少,空气质量如何,你的车会不断的汇报发动机的各项数据,变速箱的各项数据,车内空气的各项数据。这么多的IoT设备会形成巨大的数据洪流,采集完成后在云端进行分析,产生巨大的用户价值。 这些数据虽然内容各个不同,但是都有一个共同的特点,都是一种时序数据。看到这里,你可能会突然发现,Redis Stream从设计初就是为了支持时间序列数据而生(见第一部分Redis Stream介绍),Redis又成功的跑在了ARM平台,而未来物联网会有万亿级的设备基于ARM平台。所以,我们不由自主的可以猜想,除了现在在各种互联网服务中作为Cache和KV存储广泛应用,Redis下一个大放异彩的领域也许就在物联网。 上面这个图,就是一个典型的物联网设备信息采集,分析,展示的架构。Redis作为一个嵌入式的存储系统跑在各个IoT设备上,各个设备使用Redis Stream暂存产生的时序数据,然后再异步的推送到云端。云上部署的各个业务程序,会读取推送的原始数据,基于一定的规则进行分析,然后将结果写入可靠的数据存储系统。用户读取结果,在APP或者web页面上进行展示,从而整个系统形成一个闭环。 作者简介 夏德军,花名夏周,阿里云Redis技术专家,负责阿里云Redis内核开发和维护。活跃于开源社区,Redis Contributor,设计并实现了阿里云Redis开源项目ApsaraCache的部分核心feature,如时间点恢复,binlog同步等。 招聘:阿里云-技术专家-KVstore
会议议程 Day 0是Training Day,之后的两天就都是正式的会议session了,下面图是Day 1 session day的所有议程。早上会有一个breakfast,之后就是keynotes,主要是redislabs的新特性发布,毕竟是会议举办方嘛,然后是邀请的大客户出来站台,后面会议记录会详细介绍下。接着是各个session的演讲时间了,每个session差不多45分钟时长,会有5-6个session同时进行,所以我们是按需求和兴趣挑选着来听的。所有session结束后,会有一个meetup,大家可以互相做个交流。 会议记录 早餐比较简单,汉堡和咖啡,苹果不错~ 上午的keynotes开场是Redislabs的CEO宣传自己公司取得的成就,目前已经进入Forrester Wave象限的leader角色,结束后他邀请作者上来讲Redis 5.0规划的feature。 作者开场先说明了5.0最重要的feature是Redis Stream,在之后的session时间他也会详细介绍Redis Stream的设计。然后介绍了他最近做的一个“秘密项目”,把之前开发的disque以Redis Module的形式迁移到Redis,这个据作者介绍开发的工作量还是比较大的,但是为了在RedisConf18上给大家惊喜,只好push自己加班了……。然后作者讲了5.0中和cluster相关的改进,最重磅的莫过于作者要给cluster实现一个proxy,私底下交流作者觉得现在smart client在实现时都没有统一的标准,问题很多,加个proxy以统一,非smart client也可以直接使用了。然后是异步迁移的实现,这个从github上来看,会合入codis的PR实现。最后介绍的是client side caching的新feature,实际上是提供一些key的统计信息给client,client side决定是否缓存或丢弃key,这个是为了解决热点key问题的,不过一致性就要由client自己来做取舍了。关于multi io thread,作者说也是在计划中。 最后关于5.0 的发布时间,目前是还没有RC1的,按作者计划是在几周以后RC1,不过GA时间可能就比较久了,目前作者也无法确定,因为还要写很多的文档、unit test和压测,他说自己在4.0因为心急犯了错误,5.0不会再犯了。 接着Redis labs的人上台重点介绍了Redis module,使用Redis Module,能大大降低部署在AWS上应用的架构复杂性。然后RedisGraph(module)的开发者上台,介绍了他们重新实现了RedisGraph,基于新的GraphBLAS算法,插入性能和查找相邻节点性能和Neo4j相比有数量级的提升。 然后是RedisSearch(module)的开发者上台,介绍新的feature,RedisSearch是如何在cluster版本中也实现支持的,主要是使用了一个coordinator节点用来聚合结果,最后还在现场对RedisSearch的性能和AWS的对比做了一个演示,RedisSearch的作者是个柔软的胖子,昨天我们也有过私下交流,非常的风趣幽默。最后是Redis labs的人上台宣布发布Redis University,面向开发者进行Redis教学,当然也可以顺便宣传自己的产品,目前是已经上线可以注册了。 keynotes的最后邀请了stackoverflow的CEO Joel Spolsky上台来跟大家分享他的创业经历,实际是一个脱口秀的形式,Joel Spolsky非常的幽默,感觉全场一直在笑,贴个图吧,毕竟码农经常用这个网站,膜拜一下, keynotes环节完成之后是session time,我们先听了Redis作者对Redis stream的介绍,这里主要介绍了实现Redis Stream的初衷和Redis Stream的主要接口,推荐大家看之前分享的一个ppt,对Redis Stream做了一个详细说明,和作者今天讲的思路一致,移步:Redis Stream 之后是google cloud的分享。谷歌redis云服务计划今年5-6月份上线,目前是一个beta版本,整体功能目前只有主从和单节点两种形式,内存容量从1-384GB内存,没有对源码进行深度定制化开发,直接使用VM来构建redis,同时目前不支持集群,另外有一套类似阿里云DMS的监控管理工具,不过据反馈需要收费,整体费用比例看很高。但是从现场了解到有很多客户在gcc(google cloud computing)使用redisLabs提供服务。google同学表示也是看到redis的需求之后希望自己能够在私有云提供Redis服务。同时当前也都不支持Redis Module 不支持CRDT,详细roadmap没有透露。另外会后和一个中国的google同学沟通反馈他们大部分开发同学是从aws过来的。 因为上午keynotes时间比较长,所以只有一个session时间,接着就是lunch time了,lunch是餐车的形式,人很多要排挺长队,餐食还是汉堡……这点体验不太好:) 下午先听了AWS的分享。AWS ElasticCache的负责人Audi Gutmans分享了AWS在开源项目的贡献及ElastiCahe对开源Redis的共享,Audi Gutmans首先介绍了他是PHP group的创始人,是PHP steering的委员和license持有人,另外还参与了Apache开源基金会,开源了很多项目,比如Zend Framework,Eclipse PDT,同时还是mysql的贡献者,在mysql客户端目前都还有他的代码。会议开始之前Redis作者坐到了会议的第一排,在第一排认真的进行了听讲。会议开始之后Gutmans首先介绍了aws使用和贡献的开源项目,接下去讲到了aws同学在redis的贡献。 其中最大的一个feature是SSL的支持,这个feature是由一个180几的妹子完成的,使用了aws开源的s2n的SSL库,整个库的代码比openssl简洁,同时性能提升性能比openssl好20%。会和和SSL的feature的作者交流,整体性能在加密上比没有开启降低30%,这个也和阿里云实现的版本是一致的。 会议结束之后开源看到redis作者对Gutmans还是非常尊敬,主动上去和他攀谈并握手致意。 在会议结束之后我们在展台找到aws的同学,找到了QUCHEN和他进行了短暂交流,主要确定了下aws模式下用户如果从主从变成集群是否需要更改用户客户端,这里他确定用户需要修改,我们反馈这个对用户不够友好,会影响用户业务。另外问了下aws对读写分离支持的时候有没有考虑单写入放大的或者,比如进行cpu限制或者cpu限制,他们反馈只有VM级别的流量限制,同时对于这种问题需要用户解决。在数据迁移上主要询问了如果扩容的时候大key如何迁移的问题,不过他们如果包的大小大于一定数字(256MBU左右),客户端会断开连接。咨询询问了是否使用migrate来进行迁移,QUCHEN直接说不能说了。后面来了aws的产品经理,一个女同学,我们介绍了下阿里云在Redis上进行的工作,相互交流了下就完了。 因为aws分享session时间比较短,所以在同一个房间接着Redis Labs的工程师分享了RoF(Redis on Flash)with 3D XPoint,3D XPoint是一种新型存储介质。不过这里感觉Redis Labs对技术做了很高的保密,询问性能如何,他们只回答了very fast,并没有给出详细的性能数据,然后目前Redis Labs也还是在内部测试中,没有相关产品公测。最后一页PPT是精华,和原来的on 普通SSD相比,on 3D XPoint之后,他们的架构也有很大的改变,基于3D XPoint的存储设备是直接map到虚拟地址空间中的,然后Redis Labs重新实现了引擎部分,原来是基于RocksDB的,现在是自己写的一个LMDB,用于数据的换入换出,也取消了多io线程的设计。 接下来的session和业务相关多一点,首先是“auto-scaling redis caches”,这个主要讲的是如何根据业务运行的latency和命中率,动态的调整cache的大小(基于Redis)来获取目标的latency,同时尽量降低cache的成本,主要是提到了一个cache model的概念,业务latency的下降并不是随着cache size的提高而线性下降的,可能会在某段cache size范围内,latency是完全不变的,但是过了某个点,latency会出现陡变。 我们听的最后一个session是lyft分享的关于Redis大规模使用的经验(自运维)。Lyft是一个打车软件,目前大约有2000+ instance。不过Lyft使用Redis是一个纯粹做cache的场景,遇到的问题相对简单。他们不开AOF和RDB,也没有使用Replication。最早他们使用的是twemproxy,这个自建过Redis的同学可能都知道,twemproxy是Twitter很早开源的一个Redis集群代理,现在转为闭源,开源部分也不维护了。所以Lyft就转而使用envoy,envoy目前google,apple,Netflix,腾讯等都有在用,更新也很频繁,github上看star 4000+,长期来看比较靠谱。lyft使用的路由算法是一致性hash,这个倒也符合cache场景的用法。lyft自己写了一些比较好的运维工具,redis-look,已经开源。最后因为Lyft的服务都是跑在aws上的,他们分享了一下spectre漏洞对他们的影响,大约瞬时25%+的性能,他们的做法就是把ec2规格从C4升级到C5解决问题,有钱任性~ session听完之后,会有一个meetup的时间段,供大家会后交流,有冰好的beer,这点还是不错的。我们主要和aws elasticache的工程师和产品经理做了交流。同时还遇到了redis-rdb-tools的作者,他看到我们一直在aws展台交流,就主动把我们叫过去了:),redis-rdb-tools确实是个很不错的工具,我们在平时工单处理过程中也会推荐客户使用。开发redis-rdb-tools的哥们是一个印度人,他又做了一个专门用来优化Redis内存使用的工具,看宣传页是根据你的数据结构使用情况,来给你提供优化内存使用的建议,比如使用hash时,拆分为小hash,不要用大value等等这样的策略。我们给他推荐了阿里云的云市场,一个专门的第三方工具市场,redis-rdb-tools在github上有2000+ star,redis也在国内比较流行,从这两点来看,应该能给这位印度哥们带来不错的商机~ 最后放一张图镇楼,o( ̄︶ ̄)o
1. 背景 阿里云Redis线上在某些任务流中使用redis-port来进行实例之间的数据同步。redis-port是一个MIT协议的开源软件,主要原理是从源实例读取RDB快照文件、解析、然后在目标实例上应用灌数据的写命令。为了限制每个进程的最大内存使用,我们使用cgroup来做隔离,最近线上出现redis-port在同步数据时OOM的情况,最高内存使用达到了10G以上,而实际RDB的大小只有4.5GB左右。 2. 分析 2.1 GCTRACE Golang,自带gc,在不改动代码的情况下,我们可以设置GODEBUG='gctrace=1'环境变量启动程序,来向标准错误输出打印gc log, gc 21 @8.389s 0%: 0.069+4.0+0.16 ms clock, 1.6+2.8/14/10+4.0 ms cpu, 87->88->45 MB, 89 MB goal, 24 P gc 22 @8.919s 0%: 0.053+3.7+0.63 ms clock, 1.0+1.9/16/12+12 ms cpu, 87->88->44 MB, 90 MB goal, 24 P gc 23 @9.431s 0%: 0.055+3.9+0.18 ms clock, 1.3+2.6/15/13+4.5 ms cpu, 87->88->45 MB, 89 MB goal, 24 P gc 24 @9.948s 0%: 0.093+6.0+1.1 ms clock, 2.2+2.3/18/5.6+27 ms cpu, 87->88->44 MB, 90 MB goal, 24 P gc 25 @10.108s 0%: 0.026+10+0.55 ms clock, 0.63+0/12/15+13 ms cpu, 88->89->66 MB, 89 MB goal, 24 P gc 26 @10.407s 0%: 0.023+23+0.57 ms clock, 0.56+0/41/3.3+13 ms cpu, 178->178->133 MB, 179 MB goal, 24 P gc 27 @10.792s 0%: 0.024+45+0.58 ms clock, 0.58+0/63/2.5+14 ms cpu, 282->282->222 MB, 283 MB goal, 24 P gc 28 @11.560s 0%: 0.023+92+0.13 ms clock, 0.55+0/17/100+3.1 ms cpu, 520->520->399 MB, 521 MB goal, 24 P gc 29 @13.113s 0%: 0.035+186+0.53 ms clock, 0.85+0/21/187+12 ms cpu, 997->997->755 MB, 998 MB goal, 24 P gc 30 @14.490s 0%: 0.035+9.2+0.55 ms clock, 0.84+0.20/18/3.0+13 ms cpu, 858->858->518 MB, 1510 MB goal, 24 P gc 31 @16.208s 0%: 0.032+381+0.15 ms clock, 0.77+0/400/3.5+3.7 ms cpu, 1610->1610->1466 MB, 1611 MB goal, 24 P gc 32 @16.841s 0%: 0.024+9.6+0.12 ms clock, 0.59+0.12/15/6.7+3.0 ms cpu, 1488->1489->993 MB, 2932 MB goal, 24 P gc 33 @22.381s 0%: 0.026+752+0.14 ms clock, 0.64+0/18/760+3.3 ms cpu, 3358->3359->2888 MB, 3359 MB goal, 24 P gc 34 @23.237s 0%: 0.020+7.7+0.12 ms clock, 0.49+0.14/17/2.0+2.9 ms cpu, 2889->2889->1940 MB, 5776 MB goal, 24 P gc 35 @34.475s 0%: 0.032+9.9+0.11 ms clock, 0.77+0.65/17/9.3+2.7 ms cpu, 2910->2910->1940 MB, 3881 MB goal, 24 P gc 36 @34.732s 0%: 0.025+1412+0.13 ms clock, 0.60+0/1422/10+3.2 ms cpu, 5746->5746->5732 MB, 5747 MB goal, 24 P gc 37 @54.129s 0%: 0.028+9.9+0.12 ms clock, 0.67+0.95/18/11+3.0 ms cpu, 7274->7274->3836 MB, 11464 MB goal, 24 P gc 38 @59.297s 0%: 0.032+2910+0.13 ms clock, 0.78+0/2920/13+3.2 ms cpu, 11847->11847->11420 MB, 11848 MB goal, 24 P gc 39 @64.199s 02010.046+29024+0.32 ms clock, 1.1+0/18/3195+7.7 ms cpu, 15532->15532->11577 MB, 22840 MB goal, 24 P 上面是部分gc log,gc后面的数字表示是第几次gc,@后面的数字表示程序启动经历的时间,后面几项和gc消耗的cpu时间有关,在分析gc导致的程序hang时很有用,但是不是我们这次关注的重点,主要看下倒数第2和第3项 #->#-># MB heap size at GC start, at GC end, and live heap # MB goal goal heap size (这个解释一下,每次gc circle完,gc会根据当前分配的堆内存大小和GOGC环境变量,计算下一次gc的目标内存大小,如果后面内存使用没有超过这个goal,gc不会用力太猛) 从这个gc log我们可以看到从某个时间点开始,内存使用开始猛增,gc基本没有回收多少内存,看了下源实例的key情况,主要是string类型和hash类型,而且hash类型存在大key(一个hash有2800w的member,不过不建议大家这么使用,尽量打散到单个hash 10w以内),所以这里怀疑是先从RDB读了一部分string,然后读到大key的时候内存突增。有了方向,要确认详细原因,就要祭出大杀器Golang pprof了。 2.2 Golang pprof 分析内存使用要是光撸代码还是比较困难的,总要借助一些工具。Golang pprof是Golang官方的profiling工具,非常强大,使用也比较方便。 我们在程序中嵌入如下几行代码, import _ "net/http/pprof" go func() { http.ListenAndServe("0.0.0.0:8899", nil) }() 在浏览器中输入http://ip:8899/debug/pprof/可以看到一个汇总页面, /debug/pprof/ profiles: 0 block 32 goroutine 552 heap 0 mutex 51 threadcreate full goroutine stack dump 其中heap项是我们需要关注的信息, heap profile: 96: 1582948832 [21847: 15682528480] @ heap/1048576 91: 1527472128 [246: 4129210368] @ 0x471d87 0x471611 0x4718cd 0x4689bf 0x50deb9 0x50d7ac 0x75893b 0x45d801 # 0x471d86 bytes.makeSlice+0x76 /usr/local/go/src/bytes/buffer.go:231 # 0x471610 bytes.(*Buffer).grow+0x140 /usr/local/go/src/bytes/buffer.go:133 # 0x4718cc bytes.(*Buffer).Write+0xdc /usr/local/go/src/bytes/buffer.go:163 # 0x4689be io.(*multiWriter).Write+0x8e /usr/local/go/src/io/multi.go:60 # 0x50deb8 github.com/CodisLabs/redis-port/pkg/rdb.createValueDump+0x198 go_workspace/src/github.com/CodisLabs/redis-port/pkg/rdb/loader.go:194 # 0x50d7ab github.com/CodisLabs/redis-port/pkg/rdb.(*Loader).NextBinEntry+0x28b go_workspace/src/github.com/CodisLabs/redis-port/pkg/rdb/loader.go:176 # 0x75893a main.newRDBLoader.func1+0x23a go_workspace/src/github.com/CodisLabs/redis-port/cmd/utils.go:733 ...... 包括一些汇总信息,和各个go routine的内存开销,不过这里除了第一行信息比较直观,其他的信息太离散。可以看到当前使用的堆内存是1.58GB,总共分配过15.6GB。 heap profile: 96(inused_objects): 1582948832(inused_bytes) [21847(allocated_objects): 15682528480(allocted_bytes)] @ heap/1048576 更有用的信息我们需要借助go tool pprof来进行分析, go tool pprof -alloc_space/-inuse_space http://ip:8899/debug/pprof/heap 这里有两个选项,-alloc_space和-inuse_space,从名字应该能看出二者的区别,不过条件允许的话,我们优先使用-inuse_space来分析,因为直接分析导致问题的现场比分析历史数据肯定要直观的多,一个函数alloc_space多不一定就代表它会导致进程的RSS高,因为我们比较幸运可以在线下复现这个OOM的场景,所以直接用-inuse_space。 这个命令进入后,是一个类似gdb的交互式界面,输入top命令可以前10大的内存分配,flat是堆栈中当前层的inuse内存值,cum是堆栈中本层级的累计inuse内存值(包括调用的函数的inuse内存值,上面的层级), (pprof) top Showing nodes accounting for 3.73GB, 99.78% of 3.74GB total Dropped 5 nodes (cum <= 0.02GB) Showing top 10 nodes out of 16 flat flat% sum% cum cum% 3.70GB 98.94% 98.94% 3.70GB 98.94% bytes.makeSlice /usr/local/go/src/bytes/buffer.go 0.03GB 0.83% 99.78% 0.03GB 0.83% main.(*cmdRestore).Main /usr/local/go/src/bufio/bufio.go 0 0% 99.78% 3.70GB 98.94% bytes.(*Buffer).Write /usr/local/go/src/bytes/buffer.go 0 0% 99.78% 3.70GB 98.94% bytes.(*Buffer).grow /usr/local/go/src/bytes/buffer.go 0 0% 99.78% 3.70GB 98.94% github.com/CodisLabs/redis-port/pkg/rdb.(*Loader).NextBinEntry go_workspace/src/github.com/CodisLabs/redis-port/pkg/rdb/loader.go 0 0% 99.78% 3.70GB 98.94% github.com/CodisLabs/redis-port/pkg/rdb.(*rdbReader).Read go_workspace/src/github.com/CodisLabs/redis-port/pkg/rdb/reader.go 0 0% 99.78% 3.70GB 98.94% github.com/CodisLabs/redis-port/pkg/rdb.(*rdbReader).ReadBytes go_workspace/src/github.com/CodisLabs/redis-port/pkg/rdb/reader.go 0 0% 99.78% 3.70GB 98.94% github.com/CodisLabs/redis-port/pkg/rdb.(*rdbReader).ReadString go_workspace/src/github.com/CodisLabs/redis-port/pkg/rdb/reader.go 0 0% 99.78% 3.70GB 98.94% github.com/CodisLabs/redis-port/pkg/rdb.(*rdbReader).readFull go_workspace/src/github.com/CodisLabs/redis-port/pkg/rdb/reader.go 0 0% 99.78% 3.70GB 98.94% github.com/CodisLabs/redis-port/pkg/rdb.(*rdbReader).readObjectValue go_workspace/src/github.com/CodisLabs/redis-port/pkg/rdb/reader.go 可以看到大部分内存都是 bytes.makeSlice产生的(flat 98.94%),不过这是一个标准库函数,再撸撸代码,往下看可以看到redis-port实现的函数(*Loader).NextBinEntry,这里推荐使用list命令, (pprof) list NextBinEntry Total: 3.74GB ROUTINE ======================== github.com/CodisLabs/redis-port/pkg/rdb.(*Loader).NextBinEntry in go_workspace/src/github.com/CodisLabs/redis-port/pkg/rdb/loader.go 0 3.70GB (flat, cum) 98.94% of Total . . 137: default: . . 138: key, err := l.ReadString() . . 139: if err != nil { . . 140: return nil, err . . 141: } . 3.70GB 142: val, err := l.readObjectValue(t) . . 143: if err != nil { . . 144: return nil, err . . 145: } . . 146: entry.DB = l.db . . 147: entry.Key = key 可以直接看到这个函数在哪一行代码产生了多少的内存!不过如果是在可以方便导出文件的测试环境,推荐使用命令, go tool pprof -inuse_space -cum -svg http://ip:8899/debug/pprof/heap > heap_inuse.svg 这个可以得到前后调用关系的调用栈图,同时还包括每一层的inuse 内存大小,文件名,函数,到下一层的内存大小,分析起来简直不能再顺手。 最后定位原因就比较简单了,redis-port在解析RDB时,是按key为粒度来处理的,遇到大key时,value可能有好几个GB,然后redis-port直接使用了标准库bytes.Buffer来存储解析出来的value(对于redis hash来说是field,value对),Buffer在空间不够的时候会自己grow,策略是当前capacity 2倍的增长速度,避免频繁内存分配,看看标准库的代码(go 1.9) // grow grows the buffer to guarantee space for n more bytes. // It returns the index where bytes should be written. // If the buffer can't grow it will panic with ErrTooLarge. func (b *Buffer) grow(n int) int { ...... } else { // Not enough space anywhere, we need to allocate. buf := makeSlice(2*cap(b.buf) + n) copy(buf, b.buf[b.off:]) b.buf = buf } ...... } Buffer在空间不够时,申请一个当前空间2倍的byte数组,然后把老的copy到这里,这个峰值内存就是3倍的开销,如果value大小5GB,读到4GB空间不够,那么创建一个8GB的新buffer,那么峰值就是12GB了,此外Buffer的初始大小是64字节,在增长到4GB的过程中也会创建很多的临时byte数组,gc不及时也是额外的内存开销,所以4.5GB的RDB,在有大key的情况下,峰值内存用到15GB也就可以理解了。 Fix 这个问题的根本原因还是按key处理一次读的value太大,在碰到hash这种复杂数据类型时,其实我们可以分而治之,读了一部分value后,比如16MB就生成一个子hash,避免Buffer grow产生太大的临时对象。 此外,解析RDB时,受限于RDB的格式,只能单个go routine处理,但是回放时,是可以由多个go routine来并发处理多个子hash,写到目标实例的。每个子hash处理完,又可以被gc及时的清理掉。同时并发度上去了,同步的速度也有所提升(主要受限于目标Redis,因为Redis是单线程处理请求)。 最后,做个简单的对比,可以看到优化后redis-port的RSS峰值为2.6GB,和之前相比降低了80%。 参考 GCTRACE:https://godoc.org/runtime Golang profiling:https://blog.golang.org/profiling-go-programs
背景 Redis Stream是一个作者已经谋划多年的feature,本质是一个消息队列,但是和kafka、RocketMq等消息中间件相比也有其独特之处。Redis Stream本来是计划放在4.0这个大版本中发布(原计划4.2),但是由于确实是个比较重磅的feature,对内核的改动也比较大,目前已经提升到Redis 5.0发布,根据作者Twitter的消息,不出意外,18年上半年我们可以看到5.0的稳定版。本文就根据作者发布的设计文档、博客及相关资料,对Redis Stream做一个简单的介绍,目前代码在unstable分支。 Redis Stream 参考资料 博客文章:http://antirez.com/news/114 Redis RCP(Redis Change Proposal) of Redis stream: https://github.com/redis/redis-rcp/blob/master/RCP11.md antriez gist of Cgroup:https://gist.github.com/antirez/68e67f3251d10f026861be2d0fe0d2f4 redis stream consumer group final api: https://gist.github.com/antirez/4e7049ce4fce4aa61bf0cfbc3672e64d antriez blog updated(2018-01-27):http://antirez.com/news/116 Why streams have elements that are actually like hashes? https://gist.github.com/antirez/2f1ac5032498177a57f538b22cf1b8e7
背景 Redis Stream是一个作者已经谋划多年的feature,本质是一个消息队列,但是和kafka、RocketMq等消息中间件相比也有其独特之处。Redis Stream本来是计划放在4.0这个大版本中发布(原计划4.2),但是由于确实是个比较重磅的feature,对内核的改动也比较大,目前已经提升到Redis 5.0发布,根据作者Twitter的消息,不出意外,18年上半年我们可以看到5.0的稳定版。本文就根据作者发布的设计文档、博客及相关资料,对Redis Stream做一个简单的介绍,目前代码在unstable分支。 Redis Stream 参考资料 博客文章:http://antirez.com/news/114 Redis RCP(Redis Change Proposal) of Redis stream: https://github.com/redis/redis-rcp/blob/master/RCP11.md antriez gist of Cgroup:https://gist.github.com/antirez/68e67f3251d10f026861be2d0fe0d2f4 redis stream consumer group final api: https://gist.github.com/antirez/4e7049ce4fce4aa61bf0cfbc3672e64d antriez blog updated(2018-01-27):http://antirez.com/news/116 Why streams have elements that are actually like hashes? https://gist.github.com/antirez/2f1ac5032498177a57f538b22cf1b8e7
1. 摘要 Redis作为目前最流行的键值对存储数据库,有着丰富的数据结构支持,在民生、金融、游戏、直播等诸多领域都有广泛的应用,大大提升了开发者的开发效率。今天我们主要介绍Redis在游戏开发中的几个典型应用场景:用户数据缓存、持久化功能的消息队列、乐观锁功能。 2. 典型应用场景 下面我们一一来介绍上述几个场景下是如何应用Redis的。 2.1 Redis作为DB的缓存 通常来说,每个注册的用户在后台数据库中都会保留一些和这个用户关联的信息,譬如说用户名、加密后的密码、昵称等等,然后用户在玩游戏的过程中也会产生一些关键的数据,需要在数据库中永久保存,譬如对于MMORPG类型的游戏来说,可能需要保存用户当前的装备、金钱、充值的点数等等信息,这些数据如果丢失会带来非常严重的问题。 随着业务的发展,注册的用户可能会越来越多,达到了亿级别,这个时候如果每个用户登录的请求,查询/修改个人游戏数据的请求都落到后端的数据库上,数据库的性能肯定是跟不上的,必然会导致比较糟糕的用户体验,这个时候我们自然就想到了缓存。 缓存是一种基于时间或空间局部性原理,以较小的成本带来较大数据访问速度提升的通用手段。在整个计算机体系中处处可见,大到大型网站的构建,小到cpu访存。 注册用户1亿的游戏,也许日活是1千万,如果我们能把这1千万的用户信息放到Redis中缓存起来,90%以上的请求都直接是纯内存访问,势必会带来很大的性能提升。 上面是一个典型的Redis作为数据库缓存的架构,应用在读Cache(Redis)失效时,会访问数据库,然后把读到的数据同时写一份在Redis中,如果Redis内存满了,会基于一定的淘汰策略部分数据,下次访问同一份数据就可以直接读Redis了,这样Redis中就可以一直保存最近一段时间的热点数据。 上面只画了一个Redis节点,如果业务是构建在云上的,数据规模不大,缓存中的数据通常也可以容忍丢失,那么阿里云目前推出的单节点版Redis是一个性价比很高的选择。如果数据规模比较大,可以考虑集群形态的Redis,基于阿里云单节点集群版Redis,我们无需自己处理数据分片,可以像使用主从版一样使用集群版Redis,提升开发效率。 2.2 基于Redis实现带持久化功能的消息队列 生产者消费者模型是非常常用的编程模型,在游戏中也有大量的应用。譬如说用户在游戏中购买了一件虚拟物品,相当于产生了一个订单,订单相关的信息保存在了Redis的list中,另外一个负责处理订单的模块会从list中取出订单信息,然后做响应处理,简化的模型如下, 模块A lpush user_oder_list "user1_order1 info" (integer) 1 lpush user_oder_list "user1_order2 info" (integer) 2 lpush user_oder_list "user2_order3 info" (integer) 3 模块B rpop user_oder_list "user1_order1 info" 处理订单 rpop user_oder_list "user1_order2 info" 处理订单 rpop user_oder_list "user2_order3 info" 处理订单 如果在处理的过程中出现了Redis实例挂掉或所在机器宕机的情况,我们也无需担心,Redis提供了持久化的功能,当Redis重启之后会重新加载本地的数据恢复用户订单队列。阿里云Redis提供了双副本的主从和集群形态,除了会开启AOF持久化,我们还会在主Redis故障时自动进行切换,最大程度保证服务的可用性以及用户业务的稳定性。 2.3 基于Redis的乐观锁 在游戏开发中,我们有时需要实现乐观锁功能,乐观锁在数据竞争概率比较小的情况下会带来比较大的性能提升。memcached中有cas命令,用来实现check-and-set这样功能。Redis中我们可以基于Multi/EXEC/WATCH操作来实现类似的功能。 被WATCH的键会被监视,并且在事务执行前会去感知键的值是否发生了变化。如果有至少一个被监视的键在EXEC执行之前被修改了,那么整个事务都会被取消。 假如有一个用户使用购买的点数,兑换了一部分游戏内的金币,通常的操作如下, curr_point = GET user1_point curr_gold = GET user1_gold curr_point -= x curr_gold += x*100 SET user1_point curr_point SET user1_gold curr_gold 在单个Redis客户端执行上述命令的时候没有问题,但是如果用户做了多次兑换,然后有多个Redis客户端同时在执行上述逻辑,那么用户的数据就错乱了。那如果基于Redis的WATCH和MULTI/EXEC机制,我们可以这么做, WATCH curr_point = GET user1_point curr_gold = GET user1_gold curr_point -= x curr_gold += x*100 MULTI SET user1_point curr_point SET user1_gold curr_gold EXEC 这样如果在执行EXEC之前,user1_point和user1_gold这两个key的值发生了变化,那么事务就会失败,否则事务执行成功。对于业务来说,就是不断的去重试上述逻辑,直到成功,这也是为什么数据冲突概率小的时候,性能很好,因为基本相当于无锁,所以称之为乐观锁。 3. 总结 Redis在游戏开发中的应用,远不止上述三个场景,我们还可以基于Redis来实现分布式锁、用户排行榜、共同关注、共同喜好、二度好友、发布订阅等等功能,后面我们会分享更多和Redis行业应用相关的文章,敬请期待。 4. 参考资料 Redis事务:http://redisbook.readthedocs.io/en/latest/feature/transaction.html 局部性原理:https://www.zhihu.com/question/25142664 乐观锁:http://www.hollischuang.com/archives/934
背景 Redis的代码质量一直被业内人士称赞,在极高的业务压力下也能有很好的稳定性。但是极端情况下,Redis也是有可能会Crash的。有时候因为种种原因,系统配置问题,磁盘空间写满了,进程权限不够等等,我们可能不会运气那么好,有一个core文件可以拿去调试。这个时候,Redis提供了几种异常崩溃情况下的Crash Report,很多时候我们基于Crash Report,再加上一定的分析就可以直接定位问题了。 Crash Report 在异常崩溃时,Redis会通过设置的signal handler来生成Crash Report,目前Redis为生成Crash Report捕获的异常信号主要有以下几种: SIGSEGV SIGFPE SIGILL SIGBUS 这4种信号应该能包含大部分程序异常崩溃情况了,最常见的就是SIGSEGV段错误了,除0异常SIGFPE有时候也会遇到。 当Redis收到上面4种信号之一时,会在设置的sigsegvHandler()函数中生成Crash Report,如下, === REDIS BUG REPORT START: Cut & paste starting from here === [19179] 12 Apr 18:47:42.599 # Redis 2.8.19 crashed by signal: 11 [19179] 12 Apr 18:47:42.599 # Failed assertion: <no assertion failed> (<no file>:0) [19179] 12 Apr 18:47:42.599 # --- STACK TRACE /home/dejun.xdj/kvs-kernel/src/libredis-server.so(logStackTrace+0x4a)[0x7f5be2d6895a] /home/dejun.xdj/kvs-kernel/src/libredis-server.so(debugCommand+0x1b0)[0x7f5be2d69ad0] /lib64/libpthread.so.0(+0xf500)[0x7f5be3c1a500] /home/dejun.xdj/kvs-kernel/src/libredis-server.so(debugCommand+0x1b0)[0x7f5be2d69ad0] /home/dejun.xdj/kvs-kernel/src/libredis-server.so(call+0x8a)[0x7f5be2d2f12a] /home/dejun.xdj/kvs-kernel/src/libredis-server.so(processCommand+0x5dd)[0x7f5be2d3017d] /home/dejun.xdj/kvs-kernel/src/libredis-server.so(processInputBuffer+0x4d)[0x7f5be2d3b86d] /home/dejun.xdj/kvs-kernel/src/libredis-server.so(readQueryFromClient+0xf0)[0x7f5be2d3cb70] /home/dejun.xdj/kvs-kernel/src/libredis-server.so(aeProcessEvents+0x13d)[0x7f5be2d2804d] /home/dejun.xdj/kvs-kernel/src/libredis-server.so(aeMain+0x2b)[0x7f5be2d2833b] /home/dejun.xdj/kvs-kernel/src/libredis-server.so(runRedis+0x4f)[0x7f5be2d31eaf] /home/dejun.xdj/kvs-kernel/src/redis-server /home/dejun.xdj/local/redis/conf/redis_7071.conf *:1071(main+0x180)[0x405db0] /lib64/libc.so.6(__libc_start_main+0xfd)[0x3d4ac1ecdd] /home/dejun.xdj/kvs-kernel/src/redis-server /home/dejun.xdj/local/redis/conf/redis_7071.conf *:1071[0x4055e9] [19179] 12 Apr 18:47:42.599 # --- INFO OUTPUT ... ... [19179] 12 Apr 18:47:42.599 # --- CLIENT LIST OUTPUT [19179] 12 Apr 18:47:42.599 # id=2 addr=127.0.0.1:30494 fd=5 name= age=0 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=debug read=0 write=0 type=admin next_opid=-1 [19179] 12 Apr 18:47:42.599 # --- CURRENT CLIENT INFO [19179] 12 Apr 18:47:42.599 # client: id=2 addr=127.0.0.1:30494 fd=5 name= age=0 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=0 events=r cmd=debug read=0 write=0 type=admin next_opid=-1 [19179] 12 Apr 18:47:42.600 # argv[0]: 'debug' [19179] 12 Apr 18:47:42.600 # argv[1]: 'segfault' [19179] 12 Apr 18:47:42.600 # --- REGISTERS ... ... === REDIS BUG REPORT END. Make sure to include from START to END. === Please report the crash by opening an issue on github: http://github.com/antirez/redis/issues Suspect RAM error? Use redis-server --test-memory to verify it. Crash Report主要包括4部分, STACK TRACE,崩溃时的调用栈信息 INFO OUTPUT,崩溃时的Redis info命令输出 CLIENT OUTPUT,包括崩溃时的CURRENT CLIENT,这个可以看到崩溃时客户端执行的命令 REGISTERS,寄存器信息 其中对于调试最有帮助的就是STACK TRACE信息了,我们直接以上面的Crash Report来说明一下如何调试。 调试 Redis提供了debug segfault命令用于调试,我们直接给Redis发送这个命令,就可以在日志中生成类似于上面的崩溃报告(请不要在生产环境使用这个命令!!!)。 实际调试之前先说明一下,如果编译时没有加上-g选项,可执行文件中没有调试符号信息,是无法进行后面的调试的,考虑到性能的影响很小,Redis默认编译是带有-g选项的。 上面的STACK TRACE信息直接告诉我们了,出core的点在libredis-server.so中,简单分析可以知道是在执行debugCommand时出现段错误,函数后面的+号带的地址是函数内的代码偏移,我们只要知道函数的起始地址就可以获取出core的函数内代码地址了,进而可以通过addr2line获取地址对应的具体的源文件名和行号。 通过nm工具获取函数起始地址, $nm -l /home/dejun.xdj/kvs-kernel/src/libredis-server.so | grep debugCommand 000000000006d920 T debugCommand /home/dejun.xdj/kvs-kernel/src/debug.c:255 000000000007f3f0 T pfdebugCommand /home/dejun.xdj/kvs-kernel/src/hyperloglog.c:1455 我们可以看到debugCommand的起始地址是0x6d920(十六进制),加上偏移0x1b0,可以知道出core的具体地址是0x6dad0,那么我们就可以很方便的获取到具体的行号了, $addr2line -e /home/dejun.xdj/kvs-kernel/src/libredis-server.so 0x6dad0 /home/dejun.xdj/kvs-kernel/src/debug.c:304 参考源文件我们可以发现debug.c的304行存在非法地址访问, *((char*)-1) = 'x'; 总结 以上只是为了说明调试流程,举的一个简单例子,有时候定位了出core的点,可能还需要更为细致的分析,结合info输出和client输出。在没有core文件的场景下,Crash Report确实能够提供很大的帮助,上面的流程,有兴趣的同学可以直接做成一个脚本,直接分析日志,自动化的获取出core点的信息。 参考:http://antirez.com/news/43 诚聘英才:阿里云-技术专家-KVstore
公有云Redis服务:https://www.aliyun.com/product/kvstore?spm=5176.8142029.388261.37.59zzzj 背景 在前一篇文章《Redis作为LRU Cache的实现》中,我们看到了在Redis 2.8.19中LRU算法的具体实现,Redis使用了24 bit的lru时间戳来模拟一个近似的LRU算法,节省了实现一个严格LRU算法所需要的大量内存空间。 但是,上篇文章我们也挖了一个坑,说过现有的近似算法模拟效果还有待提高,今天这篇文章就是来填上这个坑,讲一下在Redis 3.0中对近似LRU算法的优化,既提升了算法的性能也提升了模拟效果。 Redis 3.0 LRU算法优化实现 Redis 3.0中主要做了如下优化: LRU时钟的粒度从秒级提升为毫秒级 使用新的API来获取LRU替换时的采样样本 默认的LRU采样样本数从3提升为5 使用eviction pool来选取需要淘汰的key 提升LRU时钟的粒度,主要是为了在测试LRU算法性能时,能够在更短的时间内获取结果,更新LRU时钟的方法也有所变化,如果LRU时钟的时间粒度高于serverCron刷新的时间粒度,那么就主动获取最新的时间,否则使用server缓存的时间, /* Macro used to obtain the current LRU clock. * If the current resolution is lower than the frequency we refresh the * LRU clock (as it should be in production servers) we return the * precomputed value, otherwise we need to resort to a system call. */ #define LRU_CLOCK() ((1000/server.hz <= LRU_CLOCK_RESOLUTION) ? server.lruclock : getLRUClock()) unsigned int getLRUClock(void) { return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX; } 在源码的utils/lru目录下有测试脚本,测试前需要把src/redis.h中的REDIS_LRU_CLOCK_RESOLUTION宏设置为1,即LRU时钟的分辨率为1ms,然后重新编译源码,执行方式如下, ruby test-lru.rb > /tmp/lru.html 测试完成后会生成一个html页面,包含测试结果,以及一个图形化的插入淘汰流程 在Redis 2.8中每次选取淘汰样本时,都是调用dictGetRandomKey来随机获取一个key,会根据maxmemory-samples配置的大小,多次调用。这个流程在Redis 3.0中被优化为一次调用获取指定数量的key,且不需要每次都调用随机函数,如下, unsigned int dictGetSomeKeys(dict *d, dictEntry **des, unsigned int count) { unsigned long j; /* internal hash table id, 0 or 1. */ unsigned long tables; /* 1 or 2 tables? */ unsigned long stored = 0, maxsizemask; unsigned long maxsteps; if (dictSize(d) < count) count = dictSize(d); maxsteps = count*10; /* Try to do a rehashing work proportional to 'count'. */ for (j = 0; j < count; j++) { if (dictIsRehashing(d)) _dictRehashStep(d); else break; } tables = dictIsRehashing(d) ? 2 : 1; maxsizemask = d->ht[0].sizemask; if (tables > 1 && maxsizemask < d->ht[1].sizemask) maxsizemask = d->ht[1].sizemask; /* Pick a random point inside the larger table. */ unsigned long i = random() & maxsizemask; unsigned long emptylen = 0; /* Continuous empty entries so far. */ while(stored < count && maxsteps--) { for (j = 0; j < tables; j++) { if (tables == 2 && j == 0 && i < (unsigned long) d->rehashidx) { if (i >= d->ht[1].size) i = d->rehashidx; continue; } if (i >= d->ht[j].size) continue; /* Out of range for this table. */ dictEntry *he = d->ht[j].table[i]; /* Count contiguous empty buckets, and jump to other * locations if they reach 'count' (with a minimum of 5). */ if (he == NULL) { emptylen++; if (emptylen >= 5 && emptylen > count) { i = random() & maxsizemask; emptylen = 0; } } else { emptylen = 0; while (he) { /* Collect all the elements of the buckets found non * empty while iterating. */ *des = he; des++; he = he->next; stored++; if (stored == count) return stored; } } } i = (i+1) & maxsizemask; } return stored; } dictGetSomeKeys会随机从db的某个起始位置开始,连续获取指定数量的key,需要注意的是,如果db对应的字典正在做rehash,可能需要从两个hashtable来获取key。如果需要根据某种分布来随机获取字典里面的key,这种采样方式可能是不合适的,但是如果只是为了随机获取一系列key来作为LRU算法的淘汰样本,这种方式是可行的。 采样性能的提升带来的好处就是,我们可以在不牺牲淘汰算法性能的情况下,提高采样的样本数,让Redis的近似LRU算法更接近于严格LRU算法,所以目前Redis把超过maxmemory后默认的采样样本数从3个提升到5个。 最后一个也是最重要的改进是,选取要淘汰key的流程。之前是每次随机选取maxmemory-samples个key,然后比较它们的idle时间,idle时间最久的key会被淘汰掉。在Redis 3.0中增加了一个eviction pool的结构,eviction pool是一个数组,保存了之前随机选取的key及它们的idle时间,数组里面的key按idle时间升序排序,当内存满了需要淘汰数据时,会调用dictGetSomeKeys选取指定的数目的key,然后更新到eviction pool里面,如果新选取的key的idle时间比eviction pool里面idle时间最小的key还要小,那么就不会把它插入到eviction pool里面,这个思路和LIRS替换算法利用的每个块的历史信息思想有些类似, eviction pool更新逻辑代码如下, #define EVICTION_SAMPLES_ARRAY_SIZE 16 void evictionPoolPopulate(dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) { int j, k, count; dictEntry *_samples[EVICTION_SAMPLES_ARRAY_SIZE]; dictEntry **samples; /* Try to use a static buffer: this function is a big hit... * Note: it was actually measured that this helps. */ if (server.maxmemory_samples <= EVICTION_SAMPLES_ARRAY_SIZE) { samples = _samples; } else { samples = zmalloc(sizeof(samples[0])*server.maxmemory_samples); } count = dictGetSomeKeys(sampledict,samples,server.maxmemory_samples); for (j = 0; j < count; j++) { unsigned long long idle; sds key; robj *o; dictEntry *de; de = samples[j]; key = dictGetKey(de); /* If the dictionary we are sampling from is not the main * dictionary (but the expires one) we need to lookup the key * again in the key dictionary to obtain the value object. */ if (sampledict != keydict) de = dictFind(keydict, key); o = dictGetVal(de); idle = estimateObjectIdleTime(o); /* Insert the element inside the pool. * First, find the first empty bucket or the first populated * bucket that has an idle time smaller than our idle time. */ k = 0; while (k < MAXMEMORY_EVICTION_POOL_SIZE && pool[k].key && pool[k].idle < idle) k++; if (k == 0 && pool[MAXMEMORY_EVICTION_POOL_SIZE-1].key != NULL) { /* Can't insert if the element is < the worst element we have * and there are no empty buckets. */ continue; } else if (k < MAXMEMORY_EVICTION_POOL_SIZE && pool[k].key == NULL) { /* Inserting into empty position. No setup needed before insert. */ } else { /* Inserting in the middle. Now k points to the first element * greater than the element to insert. */ if (pool[MAXMEMORY_EVICTION_POOL_SIZE-1].key == NULL) { /* Free space on the right? Insert at k shifting * all the elements from k to end to the right. */ memmove(pool+k+1,pool+k, sizeof(pool[0])*(MAXMEMORY_EVICTION_POOL_SIZE-k-1)); } else { /* No free space on right? Insert at k-1 */ k--; /* Shift all elements on the left of k (included) to the * left, so we discard the element with smaller idle time. */ sdsfree(pool[0].key); memmove(pool,pool+1,sizeof(pool[0])*k); } } pool[k].key = sdsdup(key); pool[k].idle = idle; } if (samples != _samples) zfree(samples); } 当选取的淘汰策略和LRU相关时(allkeys-lru或volatile-lru),freeMemoryIfNeeded会调用evictionPoolPopulate来更新eviction pool,然后淘汰掉eviction pool里面的最后一个元素所对应的key,这样的选取淘汰key的方式的好处是:假设说新随机选取的key的访问时间可能比历史随机选取的key的访问时间还要新,但是在Redis 2.8中,新选取的key会被淘汰掉,这和LRU算法利用的访问局部性原理是相违背的,在Redis 3.0中,这种情况被避免了。 此外,如果某个历史选取的key的idle时间相对来说比较久,但是本次淘汰并没有被选中,因为出现了idle时间更久的key,那么在使用eviction pool的情况下,这种idle时间比较久的key淘汰概率增大了,因为它在eviction pool里面被保存下来,参与下轮淘汰,这个思路和访问局部性原理是契合的。 和Redis 2.8相比,改进的效果我们可以引用一下上篇文章《Redis作为LRU Cache的实现》中第一张图的下半部分, 我们可以看到在前面1/2需要淘汰的key里面(浅灰色的点),Redis 3.0残留下来的key明显比Redis 2.8少了很多,而且后面新插入的1/2的key里面(绿色的点),Redis 3.0没有一个淘汰的key。 总结 Redis 3.0中对于LRU替换算法的优化,在只维护一个eviction pool带来的少量开销情况下,对算法效率的提升是比较明显的,效率的提升带来的是访问命中率的提升。同时,在目前3.4的unstable版本中我们也可以看见Redis计划实现LFU算法以支持更丰富的业务场景,阿里云Redis服务团队也会持续跟进。此外,对于LIRS这种基于LRU的改进算法,在不影响性能的前提下,我们也会研究在内核上做支持。
公有云Redis服务:https://www.aliyun.com/product/kvstore?spm=5176.8142029.388261.37.59zzzj 背景 Redis作为目前最流行的KV内存数据库,也实现了自己的LRU(Latest Recently Used)算法,在内存写满的时候,依据其进行数据的淘汰。LRU算法本身的含义,这里不做赘述,严格的LRU算法,会优先选择淘汰最久没有访问的数据,这种实现也比较简单,通常是用一个双向链表+一个哈希表来实现O(1)的淘汰和更新操作。但是,Redis为了节省内存使用,和通常的LRU算法实现不太一样,Redis使用了采样的方法来模拟一个近似LRU算法。 下面先给一个图来直观的感受一下Redis的近似LRU算法和严格LRU算法的差异, 图中深灰色和浅灰色的点表示的key数量正好可以写满内存,绿色的点表示刚写入的key,浅灰色的点表示被淘汰的key,深灰色的点表示剩余的没有被淘汰的key。 在严格LRU算法下,图的左上部分,最先写入的一半的key,被顺序淘汰掉了,但是在Redis的近似LRU算法下,图的左下部分,可能出现很早之前写入的key,并没有被淘汰掉,写入时间更晚的key反而被淘汰了,但是也没有出现比较极端的刚刚写入不久的key就被淘汰的情况。 根据Redis作者的说法,如果访问Redis的模式呈现幂律分布,即通常说的二八分布,Redis 2.8的近似LRU算法和严格LRU算法差异不大,下面我们就来看看这个近似LRU算法是怎么实现的。 图的右半部分是Redis 3.0对于近似LRU算法的优化,后面我们会写文章再介绍,同时我们的Redis云服务内核后续也会merge该优化。 Redis LRU算法实现 Redis 2.8.19中使用了一个全局的LRU时钟,server.lruclock,定义如下, #define REDIS_LRU_BITS 24 unsigned lruclock:REDIS_LRU_BITS; /* Clock for LRU eviction */ 默认的LRU时钟的分辨率是1秒,可以通过改变REDIS_LRU_CLOCK_RESOLUTION宏的值来改变,Redis会在serverCron()中调用updateLRUClock定期的更新LRU时钟,更新的频率和hz参数有关,默认为100ms一次,如下, #define REDIS_LRU_CLOCK_MAX ((1<<REDIS_LRU_BITS)-1) /* Max value of obj->lru */ #define REDIS_LRU_CLOCK_RESOLUTION 1 /* LRU clock resolution in seconds */ void updateLRUClock(void) { server.lruclock = (server.unixtime/REDIS_LRU_CLOCK_RESOLUTION) & REDIS_LRU_CLOCK_MAX; } server.unixtime是系统当前的unix时间戳,当lruclock的值超出REDIS_LRU_CLOCK_MAX时,会从头开始计算,所以在计算一个key的最长没有访问时间时,可能key本身保存的lru访问时间会比当前的lrulock还要大,这个时候需要计算额外时间,如下, /* Given an object returns the min number of seconds the object was never * requested, using an approximated LRU algorithm. */ unsigned long estimateObjectIdleTime(robj *o) { if (server.lruclock >= o->lru) { return (server.lruclock - o->lru) * REDIS_LRU_CLOCK_RESOLUTION; } else { return ((REDIS_LRU_CLOCK_MAX - o->lru) + server.lruclock) * REDIS_LRU_CLOCK_RESOLUTION; } } 这样计算会不会有问题呢?还是有的,即某个key就是很久很久没有访问,lruclock从头开始后,又超过了该key保存的lru访问时间,这个时间是多久呢,在现有的lru时钟1秒分辨率下,24bit可以表示的最长时间大约是194天,所以一个key如果连续194天没有访问了,Redis计算该key的idle时间是有误的,但是这种情况应该非常罕见。 Redis支持的淘汰策略比较多,这里只涉及和LRU相关的, volatile-lru 设置了过期时间的key参与近似的lru淘汰策略 allkeys-lru 所有的key均参与近似的lru淘汰策略 当进行LRU淘汰时,Redis按如下方式进行的, ...... /* volatile-lru and allkeys-lru policy */ else if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU || server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU) { for (k = 0; k < server.maxmemory_samples; k++) { sds thiskey; long thisval; robj *o; de = dictGetRandomKey(dict); thiskey = dictGetKey(de); /* When policy is volatile-lru we need an additional lookup * to locate the real key, as dict is set to db->expires. */ if (server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU) de = dictFind(db->dict, thiskey); o = dictGetVal(de); thisval = estimateObjectIdleTime(o); /* Higher idle time is better candidate for deletion */ if (bestkey == NULL || thisval > bestval) { bestkey = thiskey; bestval = thisval; } } } ...... Redis会基于server.maxmemory_samples配置选取固定数目的key,然后比较它们的lru访问时间,然后淘汰最近最久没有访问的key,maxmemory_samples的值越大,Redis的近似LRU算法就越接近于严格LRU算法,但是相应消耗也变高,对性能有一定影响,样本值默认为5。 每个key的lru访问时间更新比较简单,但是有一点值得注意,为了避免fork子进程后额外的内存消耗,当进行bgsave或aof rewrite时,lru访问时间是不更新的。 robj *lookupKey(redisDb *db, robj *key) { dictEntry *de = dictFind(db->dict,key->ptr); if (de) { robj *val = dictGetVal(de); /* Update the access time for the ageing algorithm. * Don't do it if we have a saving child, as this will trigger * a copy on write madness. */ if (server.rdb_child_pid == -1 && server.aof_child_pid == -1) val->lru = server.lruclock; return val; } else { return NULL; } } 总结 如果采用双向链表+hash表的方式来实现严格的LRU算法,初步估计每个key要增加额外32个字节左右的内存消耗,当key数量比较多时,还是会带来相当可观的内存消耗,Redis使用近似的LRU算法,每个key只需额外24bit的内存空间,节省还是相当的大的。后面我们会介绍redis 3.x中对近似LRU算法的优化,使用尽量少的内存,使Redis的LRU算法更接近于严格LRU,敬请期待。
背景 最近有用户报使用PHP客户端predis访问阿里云Redis(原KVStore)时,会出现connection refused错误,用户怀疑是因为后端连接数超了,导致连接被拒绝,但是监控看连接数并没有超过阈值,进而怀疑是后端redis的连接数限制逻辑有问题,经过排查发现连接数限制逻辑并没有问题,下面说下具体的排查过程。 排查过程 通常通过socket进行网络通信,出现connection refused错误,主要是由于以下原因引起的, 目标端口没有打开,可能是监听端口的进程的已经挂了 有防火墙阻塞连接,客户端发过来的包直接被丢弃掉 监听端口对应的tcp backlog已经满了 网络质量问题 用户怀疑报connection refused是因为后端连接数限制逻辑有问题,首先来排除这个原因。 实际上根据后端实现,当连接数超限时,会向客户端返回明确的错误信息,实际测试也可以发现,predis抛出的异常信息不是connection refused,而是比较直观的错误信息,如下, max number of clients reached 后端Redis挂了,这个可能性很小,直接看redis日志即可排除。防火墙的问题,在目前云Redis服务上也不存在相关设置,可以排除。 监听端口对应的tcp backlog满了,这个是一个可能性比较大的原因,之前也确实出现过因为backlog设置的过小导致连接拒绝的情况,执行netstat -s | grep -i listen,如果有如下信息且数字还在增长,就基本可以确定是backlog的问题了,更具体的信息可以通过tcpdump抓包获取, 10809 times the listen queue of a socket overflowed 10809 SYNs to LISTEN sockets ignored 但是之前我们就在异常实例的机器上调整过系统的somaxconn参数和redis本身的backlog参数到一个比较大的数值,所以发生connection refused并非是因为tcp backlog满了。 那基于排除法,应该是网络本身的异常导致的问题了,这个时候就要借助于我们强大的后台监控系统天象了,依托天象,我们可以看到,客户机到后端DB机器,在指定时间段内的网络质量情况,从天象上可以看到如下信息, 我们可以看到在连接拒绝期间,没有listen drop和listen overflow的情况,但是TCP重传率很高,有此可以肯定是在TCP建立连接时,三次握手超时导致出现connection refused错误。 总结 当问题出现时,细心排查很重要,但是如果有一个强大的后台支撑系统,能够提供各种实时和历史信息,辅助我们进行排查,往往可以起到事半功倍的效果。况且很多时候,发现问题时,问题已经是过去式了,这个时候借助于这种平台可能就是排查问题的必须条件了,这里必须要赞下我们强大的天象系统了!
2021年02月
2020年10月
2020年09月
2020年08月
这个情况有多种可能,有可能是因为需要重建索引,所以花的时间比较久,在 4.0 以后也有可能是因为要加载大量的 oplog 导致重启时间比较久,这种情况建议还是通过工单反馈阿里云服务同学,会有后端服务同学尽快处理。
mongodb 本身是支持多线程并发执行请求的,而且使用 WiredTiger 引擎支持文档级粒度的锁,你说的那个报错感觉更像是业务层的报错,具体你可以发下日志看下。
有具体的例子可以看下吗,这个通常不会发生,看下是不是 sort key 写的有问题,或者没有配置正确的 collation(本地方字符集)。
你说的这个 heros 下的 lock 值得是数据库或者 Collection 的加锁情况吗,这个你可以用 mongo shell 执行一下 db.currentOp()命令来看下当前正在执行的命令,以及加锁的情况。
导数据的场景下通常会对数据库产生比较大的压力,不是一个正常的业务压力,你可以看下是不是 WiredTiger 的 Cache 配置的太大,导致机器 OOM 了,也可以在导数据时稍微控制一下导入数据的速度。
给你个例子,
sharding:
autoSplit: true
chunkSize: 64
configDB: xxmgset-197026888/111.81.164.123:9666
建议还是直接使用 MongoDB 官方发布的二进制包,具体看这个链接,讲的清晰明了:https://docs.mongodb.com/manual/installation/
欠费后实例会被锁定,隔离期不会扣费,但是欠费太久,比如一个月,实例会被自动销毁。
MongoDB 有一系列的多语言的官方 Driver支持,其中就包括 python,看这里:https://docs.mongodb.com/ecosystem/drivers/ 。
mongodb 不仅支持事务,而且有很强大的事务能力支持,比如 4.0 及之前的多文档事务,4.2 的分布式事务支持(阿里云 MongoDB 独家),具体给个文档你看下:https://docs.mongodb.com/manual/core/transactions/ 。对应的 java api 也会有支持。
Redis 的有开源的多线程版本,而且数量不少,这里分享一个 KeyDB:https://github.com/JohnSully/KeyDB 。
4.x 版本里面的混合持久化,也只是说在 aof rewrite 的时候生成 rdb format 的 aof,然后在 rdb preamble 后面继续追加 aof 格式 命令,所以归根结底,rdb 还是 rdb,aof 还是 aof,二者没有关联,用混合格式的主要原因是 rdb 支持压缩,空间更小,且生成速度比 aof 格式快。
这个需要逐步分析排查,首先你可以看下 Redis 进程的 CPU 是不是跑满的,如果是那问题出在 Redis 上面,可以通过一些命令,比如 monitor 看下是什么在消耗 cpu,还可以看下 slow log,如果不是,需要看下主机的情况,比如主机是不是网卡跑满了,redis 是不是开启了 aof,主机 io load 太高,等等原因,逐步去缩小问题范围。
Redis 的 GEO 功能,相比于 MongoDB 还是比较弱的,如果是简单的使用场景,比如就计算一下附近的人这种类似需求,而且对 qps 要求高,且数据量不大,可以考虑先使用 Redis。
redis 中的 hash 底层是无序存储的,中间会有 rehash 或者新的数据插入,所以 hkeys 和 hvals 如果是各自单独执行,返回结果大概率不是一一对应,如果要一一对应,请用 hgetall。
这个需要改变访问入口,完全无损的迁移基本上是做不到的,比较好的办法是使用 redis-shake 进行持续的数据同步,在业务低峰期对单机实例进行停写,然后业务切换访问入口到新的集群实例。
这个使用阿里云团队开源的 Redis-shake 可以做到,从云下迁移到云上,或者从自建迁移到阿里云集群,redis-shake 的使用可以看下 github:https://github.com/alibaba/RedisShake
首先使用 redis-cli --bigkeys 分析一下是不是有大 key,然后也可以用 info memory 命令看下是不是碎片率太高了,如果是这样可以考虑在业务低峰期,做个主备切换,滚动重启一下 Redis。
原生的复制协议只能按实例(进程)维度去复制,这个直接 slaveof 是不行的,但是现在有一些开源的工具,比如 redis-port 可以按 db 去 sync,具体可以去 github 上看下。
这个功能Redis 一直是没有的,本质原因是这个和 keys 命令一样,需要遍历整个数据空间,开销非常大,如果真想按 pattern 删除 key,可以考虑写个脚本按 pattern scan,然后删除 key。