О MongoDB

2017-09-17

После долгого перерыва я снова столкнулся с MongoDB. Не по своей воле.

В этом перерыве я тыкал разное. InfluxDB, чтобы понять, что Graphite, точнее Whisper, нифига не устарел, и вполне имеет право на жизнь. ClickHouse, чтобы окончательно решить, что для такого рода данных, когда нужно хранить разовые события, привязанные ко времени, а потом делать по ним разную хитрую аналитику, я буду использовать только ClickHouse. Но больше всего возился со старым добрым PostgreSQL, интенсивно заюзывая его модный jsonb.

MongoDB logo

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

Историческими судьбами в этой Монге сложилась странная схема данных. Есть три коллекции. В одну события складываются как есть, но живут они там только четыре часа. Появилась в Монге такая возможность, индексы с TTL, что сильно удобнее старых capped collections.

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

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

В Монге по-прежнему искусственно ограничен размер документа. И это по-прежнему 16 мегабайт. Помните, зачем? Потому что операции над документами атомарны. И при передаче по сети шестнадцати мегабайт ещё можно сделать вид, что это происходит «атомарно».

Я сразу забраковал эту схему, потому что она подразумевает постоянные апдейты документов. А старый движок Монги, который теперь называют MMAPv1, модифицировал документы «на месте». Если было место, дописывал на месте. Если не было места, ему приходилось копировать документ в конец коллекции и дописывать там. А чтобы место было, он отводил для документа на диске место с запасом. Поэтому этот движок был чудовищно жаден до диска. И очень не любил, когда документы не просто модифицировались, а ещё и увеличивались в размере. Ну прям наш случай.

Над этим движком смеялись все кому не лень. Все разработчики настоящих баз данных. Ибо это были просто memory mapped файлы. В плане скорости это давало некоторые преимущества. Ибо на диске это BSONы, ОС маппит эти BSONы в память, а движку БД остаётся только кидать BSONы из памяти в сокеты и обратно. Но в плане надёжности сохранения данных это было никак. Там, конечно, прикрутили потом журнал. Но это не особо помогло.

И вот теперь, уже вполне солидно, официально и по дефолту у Монги работает другой движок — WiredTiger. Он уже вполне похож на настоящую БД. Он — версионник, MVCC. Это значит, что он никогда не апдейтит «на месте», для него, что insert, что update — одна фигня. Он жмёт данные на диске. Очень хорошо жмёт, кстати. У него есть свой самостоятельный кэш, а не только дисковый кэш ОС.

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

«Связанный тигр» — существенный прогресс MongoDB как настоящей базы данных. Рекомендую, если приходится иметь дело с Монгой.

WiredTiger advantages

BSON. Продолжаю восхищаться этим изобретением ребят из 10gen. Все разработчики БД, которые, на волне хайпа по NoSQL, добавляют поддержку JSON. Не надо, прошу вас. Добавляйте поддержку BSON.

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

BSON — это нормальный эффективный бинарный формат. Тут для каждого значения хранится длина, а значит, можно эффективно пробежаться по документу и извлечь только то, что надо. Тут есть нормальные int и long, и массивы байтов, и timestamp. Как правильно замечает Википедия, какой-нибудь ProtoBuf может быть эффективнее. Но в ProtoBuf нужна схема. А BSON гибок как JSON.

BSON example

Кажется, Монга научилась использовать несколько индексов одновременно. Но, похоже, предпочитает по старинке брать лишь один индекс для выполнения данного запроса. Дело в том, что индексы, точнее планы запросов, по-прежнему выбираются методом честного соревнования. Если Монга не помнит, какой план запроса был лучшим, она гоняет все возможные планы, и выбирает самый быстрый, и запоминает его на будущее. Просто. Тупо. Эффективно?

Aggregation framework возмужал. Когда-то это была лишь упрощённая и ограниченная замена map-reduce. Но теперь это довольно мощная штука. Заметно мощнее обычных запросов, но почти такая же эффективная. Судя по тому, с какой скоростью в новых версиях Монги добавляют сюда новые возможности, скоро с aggregation framework можно будет делать всё.

Чаще всего aggregation pipeline собирается из $match, $project и $group.

$match — это аналог where. Здесь можно задать условия выборки документов из коллекции. Тут работают индексы, если они есть.

$project — это аналог выражений после select, проекция. Тут всё значительно мощнее, чем в проекции обычного find(). Можно не только включать и исключать поля. Можно высчитывать любые выражения по содержимому документа. Например, можно взять, и сделать $filter и даже $map по содержимому массива в документе.

$group — это аналог group by. По данному ключу (а в частности, по ключу null, т.е. для всех документов) можно собрать агрегацию: сумму, минимум, максимум, всё такое.

Есть и другие интересные операторы. $unwind позволяет развернуть массив в виде последовательности документов. Примерно так, как это может делать PostgreSQL со своими массивами. $lookup позволяет вытащить из другой коллекции целый документ и воткнуть сюда. По сути, это join.

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

Всё это достаточно мощно, что люди всерьёз пишут конверторы из SQL в монговские aggregation pipeline.

Aggreration pipeline

Схема данных, как всегда, имеет значение. Не обольщайтесь schemaless, это вовсе не thoughtless для разработчика. В данном случае засада оказалась в количестве «горячих» документов.

С коллекцией номер раз всё понятно. Мы туда просто инсертим, и всё. Никакие документы не модифицируются.

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

Проблемы случились с коллекцией номер три. Агрегаты за неделю, по второму измерению. Средний размер документа тут маленький — килобайты. Хотя встречаются документы, вплотную приближающиеся к заветным 16 мегабайтам. Но вот мощность множества значений этого второго измерения — миллионы. Миллионы «горячих» документов, килобайтного размера. Это, по-хорошему, уже нужен кэш в несколько гигабайт.

Памяти добавили, ядер добавили, IOPS добавили. А не помогало.

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

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

И оказалось, что апдейты этой третьей коллекции по неизвестной причине (возможно из-за необходимости распаковывать и запаковывать документы) очень жрут CPU на сервере. Вставляем через одну коннекцию — упираемся в один поток на сервере, который выжирает лишь одно ядро CPU. И всё медленно.

Ок. Сделаем четыре потока на нашей клиентской стороне, нам же всё равно, у нас же ExecutorService. Получаем четыре коннекции к Монге. А там — четыре потока на четырёх ядрах CPU, которые занимаются тем, чем они хотят заниматься. Работает? Работает. Проблема решена. Тупой подход: «Тормозит? Добавь потоков!» — сработал.

Ну, конечно, есть нюансы, которые решились по ходу дела. По-хорошему, нужно не давать разным потокам на Монге лезть в одни и те же документы. Чтобы не порождать конфликтов и блокировок. Делается это переупорядочиванием входящих событий и грамотным созданием заданий в ExecutorService.

ClickHouse logo

И всё-таки, для этой задачи я бы взял ClickHouse. Прям руки чешутся. Зачем нужны все эти предварительные агрегации? Зачем хранить данные в трёх экземплярах? Если можно за приемлемое время быстренько прогрепать всё, что было за указанный период.

Для сравнения, те же самые события мы, ради Истории, решили записывать в логи и складывать в архив. CPU совсем прохлаждается, памяти не нужно, IOPSов тоже не нужно, ибо последовательная запись в один поток. Нужно место на диске. И тут по компактности WiredTiger побеждает gzip :) Подозреваю, ClickHouse уделает обоих. А извлекать данные будет сильно удобнее, чем с помощью zgrep.

UPD
Ухты. WriteConcern сильно влияет на CPU у MongoDB.
У меня было { w: 0 }, т.е. никаких подтверждений и результатов выполнения. Заслали команду по сети, и ок. И работало, со средней загрузкой всех ядер где-то в 60%.
Поставил { w: 1 }, т.е. давай, давай результат выполнения. И Монга сдохла, CPU не хватило.