Возникла необходимость выгрузки большого количества данных на клиент из базы 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)
nomoreload
05.12.2017 20:07А нельзя yield’ить результаты из базы? Да, минус в том, что Content-Length не будет, так как неизвестна длина ответа, но потребление памяти при таком раскладе должно быть ещё меньше чем при батчинге.
atour Автор
05.12.2017 20:13Тогда не получается использовать асинхронность.
nomoreload
05.12.2017 20:27+1А какая разница? Доткор всё равно обработку ответа выполняет на потоке из тредпула. Зато памяти не ест практически.
atour Автор
05.12.2017 23:51При выполнении параллельных запросов к серверу асинхронность доступа к СУБД позволяет обработать запросы быстрее.
dotnetdonik
06.12.2017 09:58У вас в коде все запросы асинхронны и последовательны — на скорость выполнения никак не влияет.
onyxmaster
06.12.2017 20:58В общем случае, асинхронность замедляет исполнение, создаёт дополнительную нагрузку на GC, и увеличивает количество переключений контекста.
При этом она может обеспечивать параллельное исполнение и увеличивать масштабируемость. Но именно может, потому что никогда нельзя забывать про размер «укоренённого» асинхронным контекстом графа объектов.
В общем случае, по закону Литтла, если сервис не успевает обслуживать запросы, то никакая асинхронность ему не поможет.
Экономия же на размере стека потоков не всегда оправдана (см. выше про графы объектов).
JackOfShadows
06.12.2017 09:32А если на сервер придет 100500 запросов? Тогда пул будет неограниченно раздуваться и под каждый новый поток в пуле будут выделяться ресурсы.
onyxmaster
06.12.2017 21:00Пул не будет неограниченно раздуваться, а память закончится по другим причинам :)
nomoreload
06.12.2017 22:19Ну всё же хотелось бы увидеть тесты при работе с yield’ами. Если можно.
atour Автор
08.12.2017 16:21Yeild return добавил в обновлении. Быстрее немного. Но при множестве запросов асинхронность должна выигрывать.
subn0wa
06.12.2017 01:38Что-то мне кажется, тут проблема не в монге, а в скорости сериализации через jsonconvert. Профилировать код пробовали?
dotnetdonik
06.12.2017 10:00Вообще не понятно зачем при такой задаче как пробросить данные из источника используется сериализация-десериализация. Пустой бесполезный оверхед, который и сьедает всю память. Подход неоптимальный впринципе.
KLUBS
06.12.2017 12:49А зачем вам такая детализация на клиенте, с точностью до половины секунды? Мне кажется можно упростить отдавать например каждые 10 секунд
unsafePtr
Монго никогда не использовал. Но по коду, разве тут есть смысл указывать .ConfigureAwait(false)? Я это к тому что в ASP.NET Core это поведение по умолчанию. Источник
Szer
Людей в гайдах по WPF/WinForms застращали этим ConfigureAwait, так теперь суют его везде.
onyxmaster
Всё ещё имеет смысл делать, если пишешь библиотеки (например netstandard).