欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  数据库

Mailbox:日支撑过亿信息数据库的性能调优及集群迁移

程序员文章站 2022-04-12 15:59:23
...

在之前的文章中,我们分享了 Mailbox如何在六星期实现从零到百万用户及日处理亿条消息。其中我们提过Mailbox以14个人的小团队,在6个星期内实现0到百万用户的壮举,而服务日承载信息破亿条。随后在App发布不到3周,他们将自己以1亿美元的价格卖给了Dropbox。

在之前的文章中,我们分享了 Mailbox如何在六星期实现从零到百万用户及日处理亿条消息。其中我们提过Mailbox以14个人的小团队,在6个星期内实现0到百万用户的壮举,而服务日承载信息破亿条。随后在App发布不到3周,他们将自己以1亿美元的价格卖给了Dropbox。这次我们带来的是,Mailbox在快速扩展过程中,MongoDB所遭遇的性能瓶颈及解决途径。

以下为译文:

Mailbox:日支撑过亿信息数据库的性能调优及集群迁移

在Mailbox快速扩展过程中,其中一个性能问题就是MongoDB的数据库级别写锁,在锁等待过程中耗费的时间,直接反应到用户使用服务过程中的延时。为了解决这个长期存在的问题,我们决定将一个常用的MongoDB集合(储存了邮件相关数据)迁移到独立的集群上。根据我们推断,这将减少50%的锁等待时间;同时,我们还可以添加更多的分片,我们还期望可以独立的优化及管理不同类型数据。

我们首先从MongoDB文档开始,很快的就发现了 cloneCollection命令。然而随后悲剧的发现,它不可以在分片集合中使用;同样, renameCollection也不能在分片集合中使用。在否定了其它可能性之后(基于性能问题),我们编写了一个Python脚本用以复制数据,和另一个用于比较原始和目标数据的脚本。在这个过程中,我们还发现了许多有意思的事情,比如 gevent及 pymongo复制大数据集的时间是 mongodump(C++编写)的一半,即使MongoDB客户端和服务器在同台主机上。通过最终努力,我们开发了 Hydra,用于MongoDB迁移的工具集,现已开源首先,我们建立了MongoDB集合的原始快照。

问题1:悲剧的性能

早期我做了一个实验以测试MongoDB API运作所能达到的极限速度——启用一个简单的使用MongoDB C++ 软件开发工具包的速度。一方面对C++ 感觉厌烦,一方面希望我大多数熟练使用Python的同事可以在其他用途上使用或适应这种代码,我没有更进一步的探索C++的使用,而是发现,如果是针对少量数据,在处理相同任务上,简单的C++应用速度是简单Python应用的5-10倍。

所以,我的研究方向回到了Python,这个Dropbox默认语言。此外,进行了诸如对mongod查询等的一系列远程网络请求时,客户端往往需要耗费大量时间等待服务器响应;似乎也没有很多copy_collection.py (我的MongoDB集合复制工具)需要的CPU密集型操作(部分)。initialcopy_collection.py占很少的CPU使用率也证实了这一点。

然后,MongoDB请求到copy_collection.py.。最初的工作线程实验结果并不理想。但接下来,我们通过Python Queue对象来实现工作线程通信。这样的性能依旧不是很好,因为IPC上的开销让并发带来的提升黯然失色。使用Pipes和其他IPC机制也并没有多大帮助。

接下来,我们尝试了使用单线程Python进行MongoDB异步查询,看看可以有多少性能结余。其中Gevent是实现这个途径常用库之一,我们对它进行了尝试。Gevent 修改了标准Python模块以实现异步操作,比如socket。比较好的一点是,你可以简单的编写异步读取代码,就像同步代码一样。

通常情况下,两个集合之间复制文档的异步代码会是:

import asynclib

def copy_documents(source_collection, destination_collection, _ids, callback):
    """
    Given a list of _id's (MongoDB's unique identifier field for each document),
    copies the corresponding documents from the source collection to the destination
    collection
    """

    def _copy_documents_callback(...):
        if error_detected():
            callback(error)

    # copy documents, passing a callback function that will handle errors and
    # other notifications
    for _id in _ids:
        copy_document(source_collection, destination_collection, _id,
                      _copy_documents_callback)

    # more error handling omitted for brevity
    callback(None)

def copy_document(source_collection, destination_collection, _id, callback):
    """
    Copies document corresponding to the given _id from the source to the
    destination.
    """
    def _insert_doc(doc):
        """
        callback that takes the document read from the source collection
        and inserts it into destination collection
        """
        if error_detected():
            callback(error)
        destination_collection.insert(doc, callback) # another MongoDB operation

    # find the specified document asynchronously, passing a callback to receive
    # the retrieved data
    source_collection.find_one({'$id': _id}, callback=_insert_doc)

有了gevent,这些代码不再需要使用callback:

import gevent
gevent.monkey.patch_all()

def copy_documents(source_collection, destination_collection, _ids):
    """
    Given a list of _id's (MongoDB's unique identifier field for each document),
    copies the corresponding documents from the source collection to the destination
    collection
    """

    # copies each document using a separate greenlet; optimizations are certainly
    # possible but omitted in this example
    for _id in _ids:
        gevent.spawn(copy_document, source_collection, destination_collection, _id)

def copy_document(source_collection, destination_collection, _id):
    """
    Copies document corresponding to the given _id from the source to the
    destination.
    """
    # both of the following function calls block without gevent; with gevent they
    # simply cede control to another greenlet while waiting for Mongo to respond
    source_doc = source_collection.find_one({'$id': _id})
    destination_collection.insert(source_doc) # another MongoDB operation

这种简单的代码可以根据它们的_idfields,从MongoDB源集合拷取代码到目标位置,它们的_idfields是每个MongoDB文档的唯一标识符。opy_documents 会产委派greenlets运行runcopy_document()做文档复制。当greenlets执行一项阻塞操作,比如对MongoDB的任何需求,它会将控制放给其它准备执行的greenlet。因为所有greenlets都在相同的线程和进程中执行,你一般不需要任何形式的内部锁定。

有了gevent,就能够找到比工作者线程池或工作者进程池更快的方法。下面总结了每种方法的性能:

Approach Performance (higher is better)
single process, no gevent 520 documents/sec
thread worker pool 652 documents/sec
process worker pool 670 documents/sec
single process, with gevent 2,381 documents/sec

综合gevent和工作者进程(每个分片一个)可以在性能上得到一个线性提升。有效使用工作进程的关键是尽可能使用更少的IPC。

问题2:快照后的复制修改

因为MongoDB不支持事务,如果你对正在执行修改的大数据集进行读取,你得到的结果可能会因时而异。举个例子,你使用MongoDB find()进行整个数据集上的读取,你的结果集可能是:

  • ncluded: document saved before your find()
  • included: document saved before your find()
  • included: document saved before your find()
  • included: document inserted after your find() began

此外,为了在Mailbox后端指向新副本集时能最小化故障时间,尽可能减少从源集群应用到新集群过程中所耗费的时间则至关重要。

类似多数的异步复制存储,MongoDB使用了操作日志oplog记录下了mongod实例上发生的增、改、删操作,用以分配给这个mongod实例的所有副本。鉴于快照,oplog记录下快照发生后的所有改变。

所以这里的工作就变成了在目标集群上应用源集群的oplog记录,从 Kristina Chodorow的教学博客上,我们清楚了oplog的格式。鉴于序列化的格式,增和删都非常容易执行,而改则成为了其中的难点。

改操作的oplog日志记录结构并不是非常友好:在MongoDB 2.2中使用了duplicate key,然而这些duplicate key并 不能通过Mongo shell呈现,更不必说大部分的MongoDB驱动。深思熟虑之后,选择了一个简单的变通方案:将_id嵌入修改源文档,以触发其它的文档副本。因为只是针对修改,虽然不能做到副本集和源实例的完全同步,但是却可以尽可能的减少副本集实时状态与快照之间的差距。下面这个图表显示为何中间版本(v2)并不一定完全相同,但是源副本与目的副本仍能保持最终一致:

Mailbox:日支撑过亿信息数据库的性能调优及集群迁移

在这里同样出现了目标集群的性能问题:虽然为每个分片的ops使用了独立的进程,但是连续的ops性能仍然匹配不了Mailbox的需求。

这样ops的并行就成了必选之路,然而其中的正确性保证却并不容易。特别的是,同_id操作必须被顺序执行。这里采用了一个Python集去维持正在执行修改ops的_id集:当copy_collection.py上发生一个请求正在执行修改操作的文档时,系统会阻塞后申请的所有ops(不管是修改或者是其它),直到旧的操作结束。如图所示:

Mailbox:日支撑过亿信息数据库的性能调优及集群迁移 >

验证复制数据

比较副本集与源实例数据通常是个简单的操作,但是在多进程与多命名空间中进行却是个非常大的挑战。同时基于数据正在不断的被修改,需要考虑的事情就更多了:

首先使用compare_collections.py(为对比数据开发的工具)对最近修改的文档进行数据校验,如果出现不一致则进行提醒,随后再进行复查。然而这对文档的删除并不有效,因为没有最后修改的时间戳。

其次想到的是“ 最终一致性”,因为这在异步场景中非常流行,比如MongoDB的副本集和MySQL的主/从复制。经过非常多的尝试之后(除下大故障情景下),源数据和副本都会保持最终一致。因此又进行了一些反复对比,在连续的重试中不断的增加backoff。发现仍然有一些问题存在,比如数据在两个值之间摇摆不定;然而在修改模式下,迁移的数据并不会出现任何问题。

在执行新旧MongoDB集群的最终转换之前,必须确保最近ops已经被应用,因此我们在compare_collections.py增加了命令行选项,用以对比文档被修改的最近N个操作,这样可以有效的避免不一致性。这个操作并不用耗费太多的时间,单分片执行数十万的ops对比只需短短的几分钟,还能缓和对比和重试途径的压力。

意外情况处理

尽管使用了多种途径去处理错误(重试、发现可能的异常、日志),在产品迁移之前的最终测试中仍然出现了许多未预计的错误。出现了一些不定期的网络问题,一个特定的文档集会一直导致mongos断开与copy_collection.py连接,以及与mongod的偶然连接重置。

而在尝试之后,我们发现针对这些问题制定出专门的解决方案,所以快速的转到了故障恢复方面。我们记录了这些compare_collections.py 检测出的文档_id,然后专门建立了针对这些_id的文档重复制工具。

最终迁移时刻

在产品迁移过程中,copy_collection.py建立了一个上千万电子邮件的原始快照,并且重现了过亿的MongoDB ops。执行原始快照、建立索引,整个复制过程持续了大约9个小时,而我们设定的时限是24个小时。期间我们又使用copy_collection.py重复3次,对需要复制的数据核查了3次。

全部转换直到今日才完成,与MongoDB相关的工作其实很少(只有几分钟)。在一个简洁的维护窗口中,我们使用compare_collections.py对比每个分片的最近的50万个ops。在确保最后操作中没有不一致后,我们又做了一些相关测试,然后将Mailbox后端指向了新集群,并将服务重新为用户开放。而在转换之后,我们未收到任何用户反馈的问题。让用户感觉不到迁移,就是最大的成功。迁移后的提升如下图所示:

Mailbox:日支撑过亿信息数据库的性能调优及集群迁移

写锁上的时间减少远高于50%(原预计)

开源Hydra

Hydra是上文操作所用到的所有工具合集,现已在 GitHub上开源。

Scaling MongoDB at Mailbox(编译/仲浩 审校/周小璐)

更多内容请关注CSDN云计算频道 及@CSDN云计算微博