Оптимизация массивных вставок MongoDB, загрузка 50 миллионов записей быстрее на 33%!

вступление

Работа со 100 000 — 1 000 000 000 000 000 000 000 000 000 000 000 000 записей базы данных почти не проблема с текущими тарифными планами Mongo Atlas. Вы получаете максимальную отдачу от этого без какой-либо суеты, просто используя достаточное оборудование, просто используйте индексы и разбиение на страницы.

Но представьте, что ваш проект недавно получил массивный набор данных о клиентах для нового конвейера ETL. Теперь нужно сделать скрипт разовой загрузки, а потом заняться расчетом KPI для всего (или части) набора данных.

Как насчет набора данных размером более 1 миллиона записей? 7 миллионов? Или даже больше 50🍋! Не все стандартные подходы будут эффективно работать для таких объемов.

Поэтому давайте посмотрим, как мы можем справиться с этим объемом, постепенно увеличивая размер набора данных и решая проблемы с производительностью!

Отказ от ответственности — Весь код находится здесь — Github

Настраивать

Чтобы упростить этот эксперимент, я решил использовать кластер M30 ​​Dedicated на Mongo Atlas. Существует план Mongo без сервера, но давайте опустим его для этого эксперимента.

Кроме того, в Atlas уже есть встроенный инструмент для загрузки образцов наборов данных, но я рекомендую следовать наряду с грубым подходом к созданию пользовательских сценариев. У вас может быть собственный сервер MongoDB без Atlas, или CLI atlas не может поддерживать ваш тип расширения набора данных, более того, вы будете контролировать поток данных самостоятельно.

Еще одна вещь, о которой стоит упомянуть, это скорость интернета. Эксперименты проводились на моем локальном Macbook Pro с Wi-Fi-соединением на скорости 25–35 Мбит/с. Результаты текущего эксперимента могут отличаться от результатов на готовом экземпляре EC2, поскольку там сеть намного лучше.

Где можно найти примерные данные?

Чтобы поиграть с генерацией данных и создать собственный набор данных, я могу порекомендовать использовать — https://generatedata.com/. Я использовал его для создания 1🍋 записей данных. На момент написания этой статьи базовый годовой план стоит 25$, и вы не пожалеете.

Этот инструмент работает в браузере, поэтому вам нужно быть готовым к большой нагрузке на ЦП во время генерации.

Не забудьте удалить пробелы из сгенерированного контента, чтобы уменьшить размер файла примерно на 20 %.

Мне потребовалось ~ 3 часа, чтобы сгенерировать набор данных 1🍋 размером 254 МБ. Это был файл JSON, который мы собираемся использовать позже, чтобы загрузить его в Mongo.

Вы найдете ссылки на готовые к использованию наборы данных в абзаце вставки.

Я рекомендую использовать этот инструмент для генерации не более 2 млн данных, так как это требует разумного времени и процессора вашего ноутбука.

Хотите больше данных с меньшими усилиями? Перейти в — Kaggle

Мой коллега порекомендовал мне отличную платформу DS с уже готовыми наборами данных для тестирования данных. Покопавшись, я нашел два набора данных, которые подходят лучше всего.

  1. Набор данных Yelp

Вы можете просмотреть все его содержимое, но в целом это один большой файл JSON размером 5 ГБ — yelp_academic_dataset_review.json он содержит 6,9 🍋 записей JSON. Используйте Dadroid для просмотра больших файлов JSON.

2. Отзывы о приложениях в магазине Steam

Это большой — 45 ГБ обзоров в Steam. Я не знал, сколько записей внутри, когда загружал его, так как все файлы были в папках с разбивкой на страницы. Позже мы узнаем, как написать скрипт, подвести итог, подсчитать все записи. Их точное количество будет раскрыто, но поверьте, это можно назвать Большими (маленькими) Данными.

Как вставить данные?

Я рекомендую поместить ваш набор данных за пределы рабочей папки. Это не позволит вашему редактору кода проиндексировать все это дерево файлов и замедлит поиск текста.

Вставка 1 миллиона записей

Давайте начнем со вставки 1m JSON, используя простую операцию updateMany. Шаблон сгенерированных данных:

Образец данных:

Я решил попробовать вставить все документы одним куском без кусков:

В этом скрипте мы просто вызываем анонимную функцию, которая будет выполняться немедленно. Я собираюсь использовать собственный драйвер mongodb, так как все остальные библиотеки построены на его основе. В строке 3 я импортировал обетованную версию fs для использования await во входных данных синтаксического анализа JSON.

Было интересно попробовать, на что способен бесплатный план, поэтому этот скрипт запускался на бесплатном кластере Atlas.

> node insert/1m/index.js                                                                                                                               
Reading json: 3.279s
Connected successfully to server
Started insertion process successfully to server
Inserting records: 2:18.415 (m:ss.mmm)

Монго даже этого не почувствовал! Узлу потребовалось ~ 3 секунды, чтобы прочитать файл, и около 2,5 минут, чтобы Mongo написал 1 миллион записей. Также для этого было создано не более 3 подключений.

Что насчет кластера M30? Давайте проверим, как он будет работать при запуске того же скрипта.

node insert/1m/index.js                                                                                                                               
Reading json: 2.874s
Connected successfully to server
Started insertion process successfully to server
Inserting records: 2:20.470 (m:ss.mmm)

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

Вставка 7 миллионов

Теперь поработаем с 7 миллионов JSON. Пример записи:

Один отзыв здесь меньше, чем одна запись в ранее сгенерированном наборе данных, тем не менее, этого объема достаточно, чтобы подвергнуть некоторой нагрузке как Node, так и Mongo.

Сработает ли предыдущий подход для такого объема данных? Давайте проверим. Я изменил путь к новому набору данных и запустил свой скрипт.

node insert/1m/index.js
RangeError [ERR_FS_FILE_TOO_LARGE]: File size (5341868833) is greater than 2 GB
    at new NodeError (node:internal/errors:372:5)
    at readFileHandle (node:internal/fs/promises:377:11)
    at async /Users/mongo-performance/insert/1m/index.js:16:36 {
  code: 'ERR_FS_FILE_TOO_LARGE'

2 ГБ — это предельный размер файлов, которые можно хранить в буфере Node.js. Вот почему мы должны использовать здесь Streams!

Краткая справка из документа:

Поток — это абстрактный интерфейс для работы с потоковыми данными в Node.js. Node.js предоставляет множество потоковых объектов. Например, запрос к HTTP-серверу и process.stdout являются экземплярами потока. Потоки могут быть доступны для чтения, записи или и того, и другого. Все потоки являются экземплярами EventEmitter.

Теперь нам нужно внести некоторые грубые изменения в сценарий.

Строка 23 — мы создали поток readable и использовали библиотеку stream-json для его трубопровода. Это было сделано, поскольку использование необработанного буфера из Node.js усложнило бы процесс чтения. Нам нужно как-то преобразовать этот буфер в строку и самостоятельно разобрать JSON из строки — stream-json сделает это за нас.

Строка 25 — поток потребляется через Event Emitter. Функция подписана на событие data и на каждую переменную data, содержащую ровно одну запись JSON из нашего 7-минутного файла набора данных.

Строка 27 — все записи помещаются в один массив-аккумулятор — arrayToInsert

В строке 29 есть оператор остаток — он используется только для запуска ветки if всякий раз, когда в массив помещаются новые 100 к записей.

Строка 30 — когда мы вошли в ветку оператора if, pipeline следует приостановить, чтобы предотвратить отправку новых массивов. Дальше ничего нового — insertMany и ждать 100мс после каждого вызова. Возобновите pipeline и делайте это до тех пор, пока end не сработает событие для выхода из скрипта.

В этом примере мы последовательно вставляем 100 КБ с небольшой задержкой. Время испытаний!

node ./insert/insert-7mil-m30.js

Connected successfully to server
arrayToInsert size - 354.85045185293717 mb
Inserting time - 100000: 45.277s
--------------

.................


arrayToInsert size - 142.23186546517442 mb
Inserting time - 6900000: 25.911s
--------------

Operation took -  1749.997  seconds = ~29 minutes


Process finished with exit code 0

Установка заняла около 29 минут. И сейчас кажется разумным слишком много времени бежать.

Как вы можете видеть здесь, мы использовали около 30% системного ЦП, а 66 операций ввода-вывода в секунду были максимальным значением во время вставки.

Согласно 29 минутам на 7 миллионов документов, когда вам нужно разместить 50 миллионов записей, это займет почти бесконечность (~ 3,5 часа на прогон)😊.

Мы определенно можем улучшить его, добавив несколько параллельных операций вставки и, возможно, увеличив размер фрагмента в 100 КБ, чтобы можно было обрабатывать больше параллельных операций:

Строки 36 - 42 — radash.parallel используется для выполнения 5 параллельных вставок фрагментом из 20 тыс. записей. Чанки были созданы методом lodash.chunk.

Позже этот подход будет повторно использоваться для более крупных вставок, поэтому не торопитесь, чтобы выяснить, как он работает.

Полученные результаты:

node insert/7m/7m-m30-per200k-5X20k.js                                                                                                                
Connected successfully to server
arrayToInsert size - 269.14077478740194 mb

id: 1664486093579 - stats: size 26.909 mb, records: 20000 - took: : 7.258s
id: 1664486093497 - stats: size 26.581 mb, records: 20000 - took: : 11.450s
................
id: 1664486950224 - stats: size 27.520 mb, records: 20000 - took: : 6.973s
id: 1664486951694 - stats: size 27.230 mb, records: 20000 - took: : 5.562s
--------------
Operation took -  867.229  seconds // ~14 minutes

Как мы видим, здесь производительность повысилась до 50%! Думаю, сейчас намного лучше. А Монго?

Системный процессор увеличился до 40%, это на 10% больше, чем при последовательной вставке 100k.

Количество операций ввода-вывода в секунду также почти удвоилось с 50 до 100, а число подключений также увеличилось с 40 до ~60.

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

Вставка более 50 🍋

Чтобы этот тест состоялся, нам нужно много данных, верно? Поэтому я выбрал steam dataset — обзоры пользователей около 50000+ игр в Steam. Кроме того, я упомянул, что прежде чем возиться со скриптом вставки, нам нужно просто знать, как именно там находятся записи.

Краткий обзор папок набора данных

Этот набор данных состоит из папок, каждая из которых содержит от 10 до 2000 файлов JSON. Каждый JSON может содержать любое количество отзывов.

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

Как видите, эта запись является самым большим документом среди сравниваемых наборов данных.

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

Строка 2 — пакет minimist использовался для облегчения чтения параметров CLI внутри процесса узла. Теперь его можно использовать следующим образомnode inde.js --from=1 --to=55

Строка 11 — список каталогов с использованием fs.promises.readdir

Строка 13 — выберите пути, которые находятся внутри папки наборов данных, и обрежьте их до точного диапазона, с которым мы будем работать.

Строка 18 — каждый путь внутри цикла содержит папку без файлов JSON, и здесь следует выполнить ту же операцию — перечислить все файлы JSON с fs.promises.readdir.

innerPaths содержит все пути к файлам JSON, которые нам нужно перебрать. Я использовал radash.parallel для более быстрого чтения файлов в каталоге. Кроме того, здесь нет потоков, так как почти каждый файл не превышает 100 КБ.

Строка 27 — разбор JSON из строки Buffer и чтение длины reviews ключа. Суммируя doneReadingReview вар.

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

node insert/50m+/read-size-of-dataset/index.js --from=1 --to=60000

 All paths 51936
done paths - 1953 done reviews 1000066
done paths - 3339 done reviews 4000078
........

done paths - 44528 done reviews 59000025
done paths - 51410 done reviews 63000010
Reviews 63199505
Reviews 63199505
Operation took -  429.955  seconds // ~ 7 minutes

Вентиляторы моего ноутбука достигли максимального числа оборотов в секунду при чтении этого набора данных. Когда у нас тут:

  • 51936 папок
  • невероятные 63 199 505 записей!
  • время чтения 7 минут (и это только с учетом длины)

Давайте посчитаем, сколько времени потребуется, чтобы вставить 63🍋, используя наши лучшие результаты на предыдущих шагах (7🍋/14m) — 2 часа (около 126 минут). Я не думаю, что мы можем отказаться от этого.

Вставка 63 миллионов записей

Как мы видели раньше — сила в распараллеливании. Используя вывод журнала из предыдущего раздела, мы должны разделить наши журналы на 6 четных частей. Затем повторно используйте части done paths в качестве входных данных --from and — toparams для нашего следующего скрипта. Это приведет к этим парам:

1 - 14454
14454 - 25104
25104 - 28687
28687 - 34044
34044 - 42245
42245 - 52000

Поскольку мы уже подготовили наш сценарий подсчета для использования диапазонов для папки, следующим шагом будет просто добавление вставки. Давайте рассмотрим готовый скрипт и его основные части!

Были изменены 3 основные части:

Строки 46–47 — мы просто запихиваем новые отзывы в массив

Строки 55–65 — когда отзывов больше порога, они вставляются в Mogno, а когда цикл на последней итерации, мы принудительно вставляем данные, чтобы убедиться, что все данные вставлены

Строки 87–100 — функция вставки, которая разбивает все отзывы по 20 кб и параллельно вставляет их.

Поскольку ранее мы уже определили пары для фрагментов одного и того же размера набора данных, я думаю, мы можем попробовать запустить скрипт вставки в 6 параллельных процессах. Для простоты давайте просто откроем 6 окон терминала и запустим его! Вы всегда можете использовать встроенные модули рабочих потоков в Node.js и инструмент pm2, но наш эксперимент может удовлетворить самое примитивное решение. Время испытаний!

Результаты каждого параллельного выполнения:

--from=1 --to=14454
Done reading reviews:  10 963 467
Script: took -  4963.341  seconds ~ 1h 22m
--------
--from=14454 --to=25104
Done reading reviews:  10 498 700
Script: took -  5016.944  seconds ~ 1h 23m
--------
--from=25104 --to=28687
Done reading reviews:  10 874 942
Script: took -  5050.838  seconds ~ 1h 24m
---------
--from=28687 --to=34044
Done reading reviews:  11 659 485
Script: took -  5088.016  seconds ~ 1h 24m
---------
--from=34044 --to=42245
Done reading reviews:  10 044 895
Script: took -  4796.953  seconds ~ 1h 19m
---------
--from=42245 --to=52000
Done reading reviews:  9 158 016
Script: took -  4197  seconds ~ 1h 9m

На вставку 63🍋 записей ушло около 1 часа 24 минут! Это на 33% быстрее, чем наш предыдущий прогноз для последовательной вставки! Мы загрузили почти 45 ГБ данных менее чем за полтора часа. А Монго?

Эти вставки довольно сильно нагрузили кластер. Макс. загрузка системы ~65%, загрузка процессора процесса составляла около 40% в течение всего периода. Соединения оставались на уровне 100 почти все время, а IOPS в среднем составлял 125. Я думаю, что эти результаты достаточно хороши!

Еще кое-что

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

Метод MongoDB insertMany имеет options параметр, который вы можете изменить, чтобы сделать вставки еще быстрее:

  • writeConcern
  • ordered

Их использование также может ускорить вставку.

Но вы должны иметь в виду, что использование: ordered: false не позволит вам полагаться на порядок вставки документа.

writeConcern: {w: 0} не требует подтверждения операции записи (источник).

Если ни один из них не соответствует требованиям приложения, вы можете его использовать!

Заворачивать!

  • вы можете легко вставить 1 м записей, используя insertMany . Это не должно сильно влиять на производительность вашего приложения.
  • загрузка от 1 м до 10 м, вероятно, потребует от вас использования Stream API и некоторого распараллеливания вставок. Это может повлиять на производительность приложения из-за увеличения времени вставки и увеличения нагрузки на запись.
  • чтобы загрузить более 10 миллионов, вам нужно распараллелить как процессы, так и вставки, чтобы сделать это быстро. Производительность приложения может резко ухудшиться, так как кластерный ЦП и IOPS часто используются. Запланируйте запуск сценария или настройте количество операций/процессов, которые могут выполняться параллельно

Огромная признательность за то, что вы дочитали до этого момента, надеюсь, это поможет вам стать лучшим разработчиком.

Следите за мной в Twitter, подключайтесь в LinkedIn и смотрите примеры кода на GitHub!