Производительность выгрузки большого количества данных из Mongo в ASP.NET Core Web Api

Возникла необходимость выгрузки большого количества данных на клиент из базы MongoDB. Данные представляют собой json, с информацией о машине, полученный от GPS трекера. Эти данные поступают с интервалом в 0.5 секунды. За сутки для одной машины получается примерно 172 000 записей.


Серверный код написан на ASP.NET CORE 2.0 с использованием стандартного драйвера MongoDB.Driver 2.4.4. В процессе тестирования сервиса выяснилось значительное потребление памяти процессом Web Api приложения — порядка 700 Мб, при выполнении одного запроса. При выполнении нескольких запросов параллельно объем памяти процесса может быть больше 1 Гб. Поскольку предполагается использование сервиса в контейнере на самом дешевом дроплете с оперативной памятью в 0.7 Гб, то большое потребление оперативной памяти привело к необходимости оптимизировать процесс выгрузки данных.


Таким образом, базовая реализация метода предполагает выгрузку всех данных и отправку их клиенту. Эта реализация представлена в листинге ниже.


Вариант 1 (все данные отправляются одновременно)


// Получить состояния по диапазону дат
// GET state/startDate/endDate
[HttpGet("{vin}/{startTimestamp}/{endTimestamp}")]
public async Task<StatesViewModel> Get(string vin, DateTime startTimestamp, 
                                        DateTime endTimestamp)
{
    // Фильтр
    var builder = Builders<Machine>.Filter;

    // Набор фильтров
    var filters = new List<FilterDefinition<Machine>>
    {
        builder.Where(x => x.Vin == vin),
        builder.Where(x => x.Timestamp >= startTimestamp 
        && x.Timestamp <= endTimestamp)
    };

    // Объединение фильтров
    var filterConcat = builder.And(filters);

    using (var cursor = await database
                  .GetCollection<Machine>(_mongoConfig.CollectionName)
                  .FindAsync(filterConcat).ConfigureAwait(false))
    {
        var a = await cursor.ToListAsync().ConfigureAwait(false);
        return _mapper.Map<IEnumerable<Machine>, StatesViewModel>(a);
    }

}

В качестве альтернативы применялся метод использования запросов с заданием номера начальной строки и количества выгружаемых строк, который показан ниже. В этом случае выгрузка осуществляется в поток Response для сокращения потребления оперативной памяти.


Вариант 2 (используются подзапросы и запись в поток Response)


      // Получить состояния по диапазону дат
// GET state/startDate/endDate
[HttpGet("GetListQuaries/{vin}/{startTimestamp}/{endTimestamp}")]
public async Task<ActionResult> GetListQuaries(string vin, DateTime startTimestamp,
DateTime endTimestamp)
{

    Response.ContentType = "application/json";
    await Response.WriteAsync("[").ConfigureAwait(false); ;
    // Фильтр
    var builder = Builders<Machine>.Filter;
    // Набор фильтров
    var filters = new List<FilterDefinition<Machine>>
    {
        builder.Where(x => x.Vin == vin),
        builder.Where(x => x.Timestamp >= startTimestamp 
                    && x.Timestamp <= endTimestamp)
    };

    // Объединение фильтров
    var filterConcat = builder.And(filters);
    int batchSize = 15000;
    int total = 0;
    long count =await database.GetCollection<Machine>
    (_mongoConfig.CollectionName)
    .CountAsync((filterConcat));
    while (total < count)
    {

         using (var cursor = await database
                .GetCollection<Machine>(_mongoConfig.CollectionName)
        .FindAsync(filterConcat, new FindOptions<Machine, Machine>() 
                {Skip = total, Limit = batchSize})
                .ConfigureAwait(false))
        {

            // Move to the next batch of docs
            while (cursor.MoveNext())
            {
                var batch = cursor.Current;
                foreach (var doc in batch)
                {
                await Response.WriteAsync(JsonConvert.SerializeObject(doc))
                                  .ConfigureAwait(false);                        
                }
            }
        }
        total += batchSize;
    }
    await Response.WriteAsync("]").ConfigureAwait(false); ;
    return new EmptyResult();
}

Также применялся вариант установки параметра BatchSize в курсоре, данные также записывались в поток Response.


Вариант 3 (используются параметр BatchSize и запись в поток Response)


  // Получить состояния по диапазону дат
// GET state/startDate/endDate
[HttpGet("GetList/{vin}/{startTimestamp}/{endTimestamp}")]
public  async Task<ActionResult> GetList(string vin, DateTime startTimestamp,                                                       DateTime endTimestamp)
{

    Response.ContentType = "application/json";
    // Фильтр
    var builder = Builders<Machine>.Filter;
    // Набор фильтров
    var filters = new List<FilterDefinition<Machine>>
    {
        builder.Where(x => x.Vin == vin),
        builder.Where(x => x.Timestamp >= startTimestamp 
            && x.Timestamp <= endTimestamp)
    };

    // Объединение фильтров
    var filterConcat = builder.And(filters);

    await Response.WriteAsync("[").ConfigureAwait(false); ;

    using (var  cursor = await database
        .GetCollection<Machine> (_mongoConfig.CollectionName)
        .FindAsync(filterConcat, new FindOptions<Machine, Machine>
        { BatchSize = 15000 })
       .ConfigureAwait(false))
    {
        // Move to the next batch of docs
        while (await cursor.MoveNextAsync().ConfigureAwait(false))
        {
            var batch = cursor.Current;
            foreach (var doc in batch)
            {
                await Response.WriteAsync(JsonConvert.SerializeObject(doc))
                              .ConfigureAwait(false); 
            }
        }
    }

    await Response.WriteAsync("]").ConfigureAwait(false); 
    return new EmptyResult();

}

Одна запись в базе данных имеет следующую структуру:


{"Id":"5a108e0cf389230001fe52f1",
"Vin":"357973047728404",
"Timestamp":"2017-11-18T19:46:16Z",
"Name":null,
"FuelRemaining":null,
"EngineSpeed":null,
"Speed":0,
"Direction":340.0,
"FuelConsumption":null,
"Location":{"Longitude":37.27543,"Latitude":50.11379}}

Тестирование производительности осуществлялось при запросе с использованием HttpClient.
Интересными считаю не абсолютные значения, а их порядок.


Результаты тестирования производительности для трех вариантов реализации сведены в таблице ниже.




Данные из таблицы также представлены в виде диаграмм:






Выводы


Подведя итоги, можно сказать, что использование такого рода мер снижения потребления оперативной памяти приводит к существенному ухудшению производительности — более чем в 2 раза. Рекомендую не выгружать поля, которые не используются клиентом в текущий момент.
Делитесь своими методами решения подобной задачи к комментариях.


Дополнения


Протестирован вариант реализации с yeild return


Вариант 4 (используются параметр BatchSize и yeild Return)


[HttpGet("GetListSync/{vin}/{startTimestamp}/{endTimestamp}")]
public  IEnumerable<Machine> GetListSync(string vin, DateTime startTimestamp, DateTime endTimestamp)
{

    var filter = Builders<Machine>.Filter
  .Where(x => x.Vin == vin &&
              x.Timestamp >= startTimestamp && 
              x.Timestamp <= endTimestamp);

    using (var cursor = _mongoConfig.Database
    .GetCollection<Machine>(_mongoConfig.CollectionName)
        .FindSync(filter, new FindOptions<Machine, Machine> { BatchSize = 10000 }))
    {

        while (cursor.MoveNext())
        {
            var batch = cursor.Current;
            foreach (var doc in batch)
            {
                yield return doc;
            }
        }
    }
}

Дополненные результаты сведены в таблицу:



Так же было замерено время на перемещение курсора await cursor.MoveNextAsync() в варианте 3 и сериализацию batch объектов


foreach (var doc in batch)
{
await Response.WriteAsync(JsonConvert.SerializeObject(doc));
}

с записью в поток вывода. Перемещение курсора занимает 1/3 времени, сериализация и вывод 2/3. Поэтому выгодно использовать StringBuilder для Batch около 2000, прирост памяти при этом незначительный, а время получения данных снижается более чем на треть до 6 — 7 секунд, уменьшается количество вызовов await Response.WriteAsync(JsonConvert.SerializeObject(doc)). Также можно сериализовать объект вручную.

Поделиться публикацией
Комментарии 22
    +2
    Монго никогда не использовал. Но по коду, разве тут есть смысл указывать .ConfigureAwait(false)? Я это к тому что в ASP.NET Core это поведение по умолчанию. Источник
      +2

      Людей в гайдах по WPF/WinForms застращали этим ConfigureAwait, так теперь суют его везде.

        0

        Всё ещё имеет смысл делать, если пишешь библиотеки (например netstandard).

        0
        По-моему адекватнее фильтры выглядят так (если уж вы любите разделять условия):

        var filter = Builders<Machine>.Filter
                       .Where(x => x.Vin == vin)
                       .Where(x => x.Timestamp >= startTs && x.Timestamp <= timestamp);
          0
          Да, спасибо.
          0
          А нельзя yield’ить результаты из базы? Да, минус в том, что Content-Length не будет, так как неизвестна длина ответа, но потребление памяти при таком раскладе должно быть ещё меньше чем при батчинге.
            0
            Тогда не получается использовать асинхронность.
              +1
              А какая разница? Доткор всё равно обработку ответа выполняет на потоке из тредпула. Зато памяти не ест практически.
                –2
                При выполнении параллельных запросов к серверу асинхронность доступа к СУБД позволяет обработать запросы быстрее.
                  +1
                  У вас в коде все запросы асинхронны и последовательны — на скорость выполнения никак не влияет.
                    0

                    В общем случае, асинхронность замедляет исполнение, создаёт дополнительную нагрузку на GC, и увеличивает количество переключений контекста.
                    При этом она может обеспечивать параллельное исполнение и увеличивать масштабируемость. Но именно может, потому что никогда нельзя забывать про размер «укоренённого» асинхронным контекстом графа объектов.
                    В общем случае, по закону Литтла, если сервис не успевает обслуживать запросы, то никакая асинхронность ему не поможет.
                    Экономия же на размере стека потоков не всегда оправдана (см. выше про графы объектов).

                    0
                    А если на сервер придет 100500 запросов? Тогда пул будет неограниченно раздуваться и под каждый новый поток в пуле будут выделяться ресурсы.
                      0
                      Горизонтальное масштабирование.
                        0

                        Пул не будет неограниченно раздуваться, а память закончится по другим причинам :)

                          0
                          Ну всё же хотелось бы увидеть тесты при работе с yield’ами. Если можно.
                            0
                            Yeild return добавил в обновлении. Быстрее немного. Но при множестве запросов асинхронность должна выигрывать.
                              0
                              Попробуйте сменить сериализатор. Например. Хорошо так уменьшить потребеление памяти и быстродействие.
                                0
                                Уже интереснее)
                                И если не затруднит — то yield без батчинга.
                    0
                    Видимо всё переделать надо.
                      +1
                      Что-то мне кажется, тут проблема не в монге, а в скорости сериализации через jsonconvert. Профилировать код пробовали?
                        0
                        Вообще не понятно зачем при такой задаче как пробросить данные из источника используется сериализация-десериализация. Пустой бесполезный оверхед, который и сьедает всю память. Подход неоптимальный впринципе.
                        +1
                        А зачем вам такая детализация на клиенте, с точностью до половины секунды? Мне кажется можно упростить отдавать например каждые 10 секунд

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое