Кардинально сжимаем память в NodeJS

Возможно некоторые знают аналогию с краном и плотиной — заткнуть протечку воды в кране на кухне это один инструмент, а вот в плотине — совершенно другой. Иногда нам надо переложить JSON из одного микросервиса в другой. Иногда JSON размером в пару гигабайт и ты ловишь ошибку с максимальным размером строки в V8.

Кейс из жизни — синхронизируем данные из стрима. Стрим очень деревянный, на HTTP в виде эдакого SSE — канал не закрывается, запрос не завершается сам по себе, данные летят по чуть-чуть, привычный нам JSON, по объекту, можно парсить библиотеками для стримов JSON или реализовать что-то своё. Так или иначе получаем набор данных. И всё хорошо. Но при первом подключении мы получаем слепок данных, текущий стейт, а потом летят обновления. И стейт прилетает пачкой в пару гигабайт JSON.

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

Шаг 1 — в лоб

18ce5bf1724a4f25d47c90a2af5477ef.png

Нам прилетели данные, мы их парсим, создаем модели под сущности, пишем в базу по одному. Неплохо. Неплохо если там сотня объектов. Но вот если их тысяча — долго лить будем, да и какой-то ддос базы получаем. Неприемлемо.

Шаг 2 — балк

ddaa019979773173ffb694600004aa01.png

Отлично, чего нам писать в базу по одному, обычно мы имеем функциональность балк-записей в базу, запишем пачку за один раз. И это решает проблему, данные летят компактно. Только вот если объектов реально тысяча и они не в пару полей — тяжелый запрос в базу, надо бы как-то порезать. Да и пока пишем — надо ведь свежее прилетающее где-то хранить, нужен какой-то буффер.

Шаг 3 — массив

7e5f08589277da8912ed888fafbdfba0.png

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

Бонус для любителей иммутабельности — если начать пересоздавать массив на каждое изменение, то мы умрем примерно вот на этом шаге. Увы, такие подходы подходят только для малых наборов данных, что свойственно, к примеру, для фронтенда, и это нормально и правильно. Но вот на тысячу толстых элементов у нас быстро закончится память и очистка мусора зашьется удалять старое на лету. Иммутабельность для массивов мы забываем на этом шаге.

Шаг 4 — пул

5f09f0a7a40f406a174b808c6bd37e4a.png

Хорошо, сделаем пачку циклов, либо рекурсий — будем доставать из буффера данные, балком писать в базу, ждать завершения и доставать ещё данные. Главное при выборе варианта с рекурсией не забыть что объявленая ещё в ES2015 поддержка хвостовых рекурсий так и не была завезена в V8 ноды и надо сделать разрыв для обхода ограничения на глубину рекурсий и потребление памяти из-за стека вызова, самый лучший вариант это setImmediate, потому что с промисами разрыва не будет, а на таймаутах мы получим толстый оверхед очереди таймера, слишком толстый чтобы не взять иммедиэйт. Или просто сделать цикл.

Всё хорошо у нас…, но на миллионе объектов мы получаем лютейшую просадку, что пошло не так?

Нам нужно знать порядок поступления данных — к нам приходит слепок, затем новые данные, это апдейты старых. Нам очень важно знать порядок. А это значит нам надо класть в массив с одной стороны, а вот забирать с другой. И это значит у нас будет pop и shift. И с pop у нас всё хорошо, мы забираем последний элемент, а вот с шифтом всё интереснее. Дело в том чтобы добавить что-то в начало массива — нужно передвинуть весь масив, буквально. Под капотом полетит перемещение каждого элемента вперед и потом добавление нового в начало. Могут быть внутренние оптимизации, но результат всегда один — адские тормоза на миллионе элементов.

Но что же делать? Ведь нам важен порядок, а что мы можем использовать кроме массива? С объектом не выйдет, мапы с итераторами… как ни странно, но работают, однако в определенный момент счетчик внутри ломается и что-то теряется, не так пишется. Только массивы могут нам обеспечить порядок данных, чтобы друг за другом. Можно решить через сдвигание не сразу, завести счетчик, очищать раз в минуту и прочие оптимизации, когда мы откладываем перемещение массива на потом. Они помогают, реально помогают, но не очень, да и память заполняется. У нас нет решения на основе типов данных JS, но, возможно, есть решения другого толка?

Шаг 5 — цепочка

c4a0bdbfd133496a77189bb96a9252d3.png

И вот тут к нам приходят алгоритмы базовых собеседований. Чаще всего в жизни оно нам не нужно, но вот в такой задаче — как раз этот самый кейс. Ответ банален и смешон — односвязный список. Просто объект, узел, которых хранит значение и ссылку на следующий узел.

В итоге мы можем хранить ссылку на самый первый узел и самый последний. При приходе новых данных — мы создаем новый узел, берем ссылку на конец, берем этот концевой узел, прописываем ему ссылку на следующий узел как ссылку на наши новые данные, на наш новый узел, ну, а после обновляем нашу ссылку на конец, теперь она будет ссылаться на наши новые данные, свежесозданый узел. И так повторяем для каждого нового пришедшего слепка данных. Ну, а данные для записи в базу достаем из начала, по ссылке на первый узел, после меняем ссылку на начало — теперь это ссылка на следующий узел, которая хранилась в текущем узле. В конце обнуляем сслыку на следующий узел, открепляя взятый в работу узел окончательно. Дальше с ним разберется уже очистка памяти движка ноды, как только произойдет запись в базу и он выпадет из видимости функций записи. Элегантно, просто, эффективно.

В итоге мы никогда не имеем проблем с тем что нужно передвигать большие объемы данных, у нас нет проблем со всякими счетчиками, вообще нет даже такого понятия. Мы просто примитивно манипулируем ссылками на данные, а дальше V8 делает всё сам. Результат — у нас больше нет тормозов.

Но, о боже, к нам пришло пару гигабайт JSONа, сервера обзаводятся новыми планками оперативки, что же нам делать?

Шаг 6 — ключи

128f2e5965b1b3a41a97f5dc60a78a7a.png

Не все знают, но понятие ключ-значение — это лишь удобство. Более того, у нас есть в живую примеры того как эта проблема решается — современные табличные базы данных. Дело в то что хранить ключи… совсем не обязательно. Мы можем хранить значения просто по порядку, массивом, главное точно соблюсти порядок. А вот ключи мы можем не хранить в данных вовсе, лишь знать по бизнес-логике по какому номеру какие данные лежат. Чего уж там — в базу данных мы можем записать данные таким же образом, массивом, такой синтаксис поддерживается, ключи не нужны. Казало бы простая мысль, но осознаешь это не сразу, ведь пришел JSON с ключами… ну… так и сохраним. Однако когда у нас есть буффер и он очень большой — удаление ключей в процессе парсинга экономит память иногда кратно, кейс из жизни — уменьшение потребления оперативки в 2 раза. Да, для этого у нас должна быть схема данных, если это произвольный JSON — не получится, однако, за исключением специфических кейсов, всегда есть определенный формат апи, тем более в базу как-то кладем. Если там есть колонки — значит нам уже есть где поиграться с такой оптимизацией. А ещё лучше — если поставщик данных сразу пришлет данные в виде массивов, но не всегда можно влиять на поставщика.

Бонус

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

Результаты

fc52644ef75993494a5dfad9f4290f02.png

Таким вот набором шагов мы можем выживать в случае получении пачек данных в гигабайтах и выживать, при этом, без боли, без магии, без костылей — просто правильные структуры данных и алгоритмы. Обычно это нам не нужно, и всё же когда оно нужно — об этом эта статья. Без такой оптимизации работа подобного сервиса не возможна просто физически — не поместится в память, ддос базы, оверхед в перемещении в часах перемалываний. Не реализуема в лоб принципиально. А с нашими шестью шагами — быстро, эффективно, экономно. Красиво.

© Habrahabr.ru