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