Возникла необходимость выгрузки большого количества данных на клиент из базы 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)


  1. unsafePtr
    05.12.2017 16:25
    +1

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


    1. Szer
      05.12.2017 18:33
      +1

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


    1. onyxmaster
      06.12.2017 20:53

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


  1. ZOXEXIVO
    05.12.2017 19:58

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

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


    1. atour Автор
      05.12.2017 20:14

      Да, спасибо.


  1. nomoreload
    05.12.2017 20:07

    А нельзя yield’ить результаты из базы? Да, минус в том, что Content-Length не будет, так как неизвестна длина ответа, но потребление памяти при таком раскладе должно быть ещё меньше чем при батчинге.


    1. atour Автор
      05.12.2017 20:13

      Тогда не получается использовать асинхронность.


      1. nomoreload
        05.12.2017 20:27
        +1

        А какая разница? Доткор всё равно обработку ответа выполняет на потоке из тредпула. Зато памяти не ест практически.


        1. atour Автор
          05.12.2017 23:51

          При выполнении параллельных запросов к серверу асинхронность доступа к СУБД позволяет обработать запросы быстрее.


          1. dotnetdonik
            06.12.2017 09:58

            У вас в коде все запросы асинхронны и последовательны — на скорость выполнения никак не влияет.


          1. onyxmaster
            06.12.2017 20:58

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


        1. JackOfShadows
          06.12.2017 09:32

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


          1. nomoreload
            06.12.2017 13:48

            Горизонтальное масштабирование.


          1. onyxmaster
            06.12.2017 21:00

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


            1. nomoreload
              06.12.2017 22:19

              Ну всё же хотелось бы увидеть тесты при работе с yield’ами. Если можно.


              1. atour Автор
                08.12.2017 16:21

                Yeild return добавил в обновлении. Быстрее немного. Но при множестве запросов асинхронность должна выигрывать.


                1. unsafePtr
                  08.12.2017 17:28

                  Попробуйте сменить сериализатор. Например. Хорошо так уменьшить потребеление памяти и быстродействие.


                1. nomoreload
                  08.12.2017 19:49

                  Уже интереснее)
                  И если не затруднит — то yield без батчинга.


  1. x893
    05.12.2017 23:28

    Видимо всё переделать надо.


  1. subn0wa
    06.12.2017 01:38

    Что-то мне кажется, тут проблема не в монге, а в скорости сериализации через jsonconvert. Профилировать код пробовали?


    1. dotnetdonik
      06.12.2017 10:00

      Вообще не понятно зачем при такой задаче как пробросить данные из источника используется сериализация-десериализация. Пустой бесполезный оверхед, который и сьедает всю память. Подход неоптимальный впринципе.


  1. KLUBS
    06.12.2017 12:49

    А зачем вам такая детализация на клиенте, с точностью до половины секунды? Мне кажется можно упростить отдавать например каждые 10 секунд