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