关于mongoDB插入去重以及高并发问题

最近在项目中碰到过向mongoDB插入数据去重问题。一开始我的想法直接用upsert,我的项目部分代码如下:

        //使用Upsert进行插入,如果存在就更新,不存在则插入
        //根据报告时间和code进行筛选去重
        Query query = new Query();
        query.addCriteria(Criteria.where("reporttimeStamp").is(meteorologicalData.getReporttimeStamp()));
        query.addCriteria(Criteria.where("adcode").is(meteorologicalData.getAdcode()));
        //建立更新update类
        Update update=new Update();
        //下面步骤可以替换成想要的对象。这里我用的反射让每个属性值赋值在update集合中。
        Field[] declaredFields = MeteorologicalData.class.getDeclaredFields();
        for (Field declaredField : declaredFields) {
            declaredField.setAccessible(true);
            update.set(declaredField.getName(),declaredField.get(meteorologicalData));
            declaredField.setAccessible(false);
        }
        //插入对应集合
        mongoTemplate.upsert(query,update,MeteorologicalData.class);

虽然解决了插入去重问题,但是发现在高并发情况下,依然会出现重复插入问题,研究一番之后,把问题锁定在了多进程同时更新数据库时产生的upsert问题。这里讨论一个简化模型来阐明此问题并且论述解决方法。

简化模型

为了简化讨论,这里讨论一个投票程序。某城市人民选举该市市长。每个市民选择一个人为他/她投票。由于该选举为广泛选举,在选举之前并没有候选人集合,而是每个市民可以把票投给任何一个人。为简化问题,假设城市里没有人重名,因此每个人的名字可以作为其唯一代号使用。实际情况中,可以使用其他唯一表示,比如身份证号等等。

这里的数据集非常简单:

Collection: db.election

{'name': 'Alice', 'votes': 10}
{'name': 'Bob', 'votes': 21}

作为一个计票的进程,主要任务就是拿过一张选票,查看其name属性,在数据库中给名字为name的文档的票数加1。注意,这里name不一定已经存在于数据库中。如果此名字不存在,则应新建一条文档。

在只有一个进程运行的情况下,这段代码虽然速度并不快,但会给出正确的计票结果。如果我们使用多进程,创建几个worker,分别收集选票,给指定的被选人计票,会怎么样呢?

多进程下的写入矛盾

当简单地把上面的upsert交给几个进程来处理的时候,我们会发现运行结果出了这样的问题:每个被选人的票数似乎少了很多,而被选人的数量增加了。仔细检查会发现,其实是同一个候选人被创建了多个文档。为什么会导致这样的写入错误呢?不难想象到,这是多个进程同时试图更新一个文档的时候导致的。

需要注意的是,MongoDB本身是有文档级的写入锁的。也就是说,当一个进程开始修改一个文档时,该文档被锁定,其他文档不可以再对其进行写入甚至读取。这个写入锁的存在本身就是为了防止不同程序更新文档时产生的写入冲突。然而,update其实分为两步。首先是搜索文档位置,然后是文档更新。当两个程序同时试图更新一个不存在的文档的时候,假设程序A先发现文档不存在,然后程序B发现文档不存在。此时A还没来得及对文档进行写入,因此文档锁并没有挂起。或者说,由于文档不存在,讨论文档锁也就失去了意义。这个时候,两个进程就会分别创建文档并给其votes加1。于是就出现了不必要的重复。

如何解决?

解决方法其实很简单:unique index。 上文提到,name属性是唯一的。如果我们给它加一个唯一索引,不就可以从根本上避免一个人有多个不同的文档了吗?
这个时候,即使两个进程经过搜索都得到了某个文档不存在的结果,假设A先一步创建了该文档,那么当B创建文档时,由于含有相同name的文档已经被A进程抢先创建,MongoDB就会拒绝B进程创建。pymongo对此类错误应该是有应对机制的,这是B进程会稍等片刻,重新尝试更新文档。这个时候,A进程已经完成计票并且释放了写入锁,文档被成功创建,而进程B再尝试时,也会检索到这个被新创建的文档,直接在上面把票数加1,而不是创建新文档。这样一个小的时间差,就解决了写入矛盾。

同时,我们还得到了额外的奖励:当name上创建了unique index之后,找到特定候选人的速度就会快很多。这个优势在计票初期,候选人数量不多时并没有显示,但当后期候选人数变多时,一方面再有新的候选人被加入的概率会变得很小(该被加的差不多都被加进来了),因此修改索引的几率越来越少;另一方面,在候选人基数变得很大的时候,相比于没有索引的情况,有唯一索引的情况下程序的速度优势会越发明显。这两个方面综合在一起,结果就是,添加唯一索引之后程序在后期速度优势会越来越明显。在我自己的程序中,运行初期多线程比单线程只快了三四倍,但在数据量较大时,多线程(加上唯一索引)会比单线程快10到20倍。这多处来的速度,就是唯一索引导致的。

后续

虽然这样能解决高并发产生的重复插入问题,但是我们发现db进行upsert的速度越来越慢,以前两个小时就能消费完队列里的数据,现在需要四五个小时,并且消耗时间是呈现不断上升的趋势。所以我觉得应该是和upsert这个操作有关。

  • 问题定位:

由于是写多读少的场景,所以我们并没有对集合加入索引。并且经查阅资料发现,mongodb索引的存储机制和mysql不同,mysql的索引是存储在硬盘中,需要时会调用部分到内存中。而mongodb的索引则是直接存储在内存和临时文件中,并且和内存大小限制有很直接的关系,如果超过内存限制,则从硬盘加载索引。所以mongodb索引的使用,在大数据集合面前,会面临内存耗尽的风险。

下面这个链接是官方对索引使用限制的说明:
https://docs.mongodb.com/manual/reference/limits/#index-limitations
官方介绍

另外upsert操作会先在集合中进行数据查找,如果数据已经存在,则更新,否则才插入。数据的查找那就势必会使用索引,mongo索引用的是B树,时间复杂度为Olog(n),而没有索引的情况下则时间复杂度是O(n),差别见下图:时间复杂度曲线
问题显而易见了,随着数据日益增多,upsert性能是线性下滑的,所以后来的想法就是,如果有高并发的情况下,还是先进行对数据库查询然后在进行insert,这个时候对这些操作进行加锁操作,当然会对速度很有影响,但是至少规避了后期内存耗尽风险。如果是大公司有钱那就用upsert+唯一索引来解决吧,内存不够就扩充内存,钞能力可以解决任何问题。

参考文档:https://blog.csdn.net/jeffrey11223/article/details/80366368

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐