notesArea/Database/MongoDB/记一次简单的 MongoDB 管道查询优化.md

记一次简单的 MongoDB 管道查询优化

业务背景

现有一音频标注系统,该系统可以通过任务的方式安排标注人员对音频进行标注。任务的创建需要选定某个数据集(一个数据集通常包含几十万条音频数据),从该数据集中选择指定数量的音频数据进行后续标注。

数据集和任务的关系是一对多,即一个数据集可以被多个任务使用。由于某些任务已经安排标注人员进行标注,这些任务对应的数据集中的部分数据的状态就变成了“已标注”。当使用这些数据集新建任务时,需要过滤这些已标注的数据(不需要安排标注人员再次进行标注了),只统计未标注的数据。

现有问题

新建任务获取数据集列表的接口耗时太久——7s 左右。

问题分析

获取数据集列表的时候需要统计每个数据集中未分配数据的数量,主要的时间应该就花费在“统计”操作上。

旧业务代码

// 1. 数据集列表  
List<DataSetDTO> dataSets = getDataSets();  
  
// 2. 数据集中所有音频数据  
AggregateIterable<Document> documents = mongoTemplate.getCollection("data_log").aggregate(aggregateCond).allowDiskUse(true);  
  
// 3. 统计每个数据集可分配音频数据数  
try (MongoCursor<Document> iterator = documents.iterator()) {  
  ArrayList<DataLog> dataLogs = new ArrayList<>();  
  
  while (iterator.hasNext()) {  
    Document document = iterator.next();  
    DataLog dataLog = documentToDataLogDTO(document);  
    dataLogs.add(dataLog);  
  }  
  
  // 使用过的音频数据  
  List<DataLog> usedDataLogs =  
      dataLogs.stream().filter(e -> e.getIsUse().equals(1)).collect(Collectors.toList());  
  
  dataSets.forEach(  
      dataSet -> {  
        // 音频总数  
        long count =  
            dataLogs.stream().filter(e -> e.getDataSetId().equals(dataSet.getId())).count();  
        dataSet.setAllCount((int) count);  
        // 已分配数  
        long useCount =  
            usedDataLogs.stream().filter(e -> e.getDataSetId().equals(dataSet.getId())).count();  
        dataSet.setMarkedDataCount((int) useCount);  
        // 可分配数  
        dataSet.setAvailableCount((int) count - (int) useCount);  
      });  
}

上述代码的逻辑是先将所有的音频数据存入一个集合中,再针对每个数据集过滤出该数据集对应的音频数据,最后再统计可分配的音频数据数。不难看出该逻辑存在很多的冗余操作,比如使用游标遍历文档的时候其实可以同时进行数据统计操作了,没有必要等遍历完成之后,再针对每个数据集重复过滤遍历。

去除冗余遍历操作进行改进

// 1. 数据集列表  
List<DataSetDTO> dataSets = getDataSets();  
  
// 2. 数据集中所有音频数据  
AggregateIterable<Document> documents =  
    mongoTemplate.getCollection("data_log").aggregate(aggregateCond).allowDiskUse(true);
    
// 3. 数据集id和对象的映射,方便后续快速获取数据集进行数据统计  
Map<Integer, DataSetDTO> dataSetIdObjMap = new LinkedHashMap<>();  
for (DataSetDTO dataSet : dataSets) {  
  dataSetIdObjMap.putIfAbsent(dataSet.getId(), dataSet);  
}  
  
// 4. 统计每个数据集可分配音频数据数  
try (MongoCursor<Document> iterator = documents.iterator()) {  
  while (iterator.hasNext()) {  
    Document document = iterator.next();  
    DataLog dataLog = documentToDataLogDTO(document);  
  
    Integer dataSetId = dataLog.getDataSetId();  
    if (dataSetIdObjMap.containsKey(dataSetId)) {  
      DataSetDTO dataSet = dataSetIdObjMap.get(dataSetId);  
      // 4.1 初始化数据  
      if (Objects.isNull(dataSet.getAllCount())) {  
        dataSet.setAllCount(0);  
      }  
  
      if (Objects.isNull(dataSet.getMarkedDataCount())) {  
        dataSet.setMarkedDataCount(0);  
      }  
  
      // 4.2 统计样本数  
      dataSet.setAllCount(dataSet.getAllCount() + 1);  
  
      // 4.3 统计已分配数  
      if (Objects.equals(1, dataLog.getIsUse())) {  
        dataSet.setMarkedDataCount(dataSet.getMarkedDataCount() + 1);  
      }  
  
      // 4.4 统计可分配数  
      dataSet.setAvailableCount(dataSet.getAllCount() - dataSet.getMarkedDataCount());  
    }  
  }  
}

优化后的代码去除了冗余的遍历操作,在通过游标遍历音频数据的时候同时进行可分配数的统计操作。优化后的代码耗时约为 3s 左右。

使用 MongoDB 聚合管道进行改进

在旧业务代码和改进后的业务代码中,统计操作都是通过 Java 代码另外执行的。既然查询音频文档的时候已经使用了聚合管道,统计时又没有额外业务逻辑,何不讲统计操作也加入管道中一并执行呢?

旧管道聚合表达式

db.getCollection("data_log").aggregate([{
    $match: {
        dataSetId: {
            $in: [
                473
            ]
        }
    }
}, {
    $project: {
        dataSetId: 1,
        isUse: 1
    }
}])

上述表达式包含两个管道操作,$match 操作只查询给定数据集的音频数据,$project 操作过滤无用字段,减少数据传输,加快查询速度。

统计表达式

$group: {
        _id: "$dataSetId",
        allCount: {
            $sum: 1
        },
        usedCount: {
            $sum: {
                $cond: [{
                    $eq: ["$isUse", 1]
                }, 1, 0]
            }
        }
    }

该表达式通过 $group 操作对音频数据按照数据集 id 进行分组,分组之后使用 $sum 累加器可以轻松获取所有音频数据数和已分配音频数据数。

改进后的管道聚合表达式

db.getCollection("data_log").aggregate([{
    $match: {
        dataSetId: {
            $in: [
                473
            ]
        }
    }
}, {
    $project: {
        dataSetId: 1,
        isUse: 1
    }
}, {
    $group: {
        _id: "$dataSetId",
        allCount: {
            $sum: 1
        },
        markedDataCount: {
            $sum: {
                $cond: [{
                    $eq: ["$isUse", 1]
                }, 1, 0]
            }
        }
    }
}])

完整的业务代码

// 1. 数据集列表  
List<DataSetDTO> dataSets = getDataSets();  
  
// 2. 数据集中所有音频数据  
AggregateIterable<Document> documents =  
    mongoTemplate.getCollection("data_log").aggregate(aggregateCond).allowDiskUse(true);  
  
// 3. 数据集id和对象的映射,方便后续快速获取数据集进行数据统计  
Map<Integer, DataSetDTO> dataSetIdObjMap = new LinkedHashMap<>();  
for (DataSetDTO dataSet : dataSets) {  
  dataSetIdObjMap.putIfAbsent(dataSet.getId(), dataSet);  
}  
  
// 4. 统计每个数据集可分配音频数据数  
try (MongoCursor<Document> iterator = documents.iterator()) {  
  while (iterator.hasNext()) {  
    Document document = iterator.next();  
    Integer dataSetId = document.getInteger("_id");  
  
    if (dataSetIdObjMap.containsKey(dataSetId)) {  
      DataSetDTO dataSet = dataSetIdObjMap.get(dataSetId);  
  
      // 样本数  
      dataSet.setAllCount(document.getInteger("allCount"));  
  
      // 已分配数  
      dataSet.setMarkedDataCount(document.getInteger("usedCount"));  
  
      // 可分配数  
      dataSet.setAvailableCount(dataSet.getAllCount() - dataSet.getMarkedDataCount());  
    }  
  }  
}

改进之后耗时统计

改进之后的耗时相比于代码层面的改进有了进一步的降低,达 2s 左右。

大纲
文件