Почему мы выбрали MongoDB
Эта статья появилась на свет после прочтения материала «Почему вы никогда не должны использовать MongoDB». Ниже — история о том, как мы постепенно отказались от MySQL и пришли к использованию MongoDB в качестве основного хранилища данных.
Началось все где-то в 2008 году, когда было принято решение писать вторую версию нашего сайта. Уже какое-то время нам хотелось создать мультиязычную версию базы данных по играм, околоигровым компаниям, персонажам и т.п., так как существующее решение, как нам казалось, морально устарело.
Первым делом были сформулированы:
В основном это требования именно к базе данных.Из существенных можно выделить: Требование 1. Мультиязычные поля Каждая запись может иметь одно или несколько мультиязычных полей, содержащих как относительно короткие названия, так и длинные описания. Думали над разными вариантами: Вариант 1. Две таблицы на всю базу create table name ( id int not null primary key, table varchar (32) not null, field varchar (32) not null, object_id int not null, name varchar (255) not null, lang varchar (2) not null ) create table description ( id int not null primary key, table varchar (32) not null, field varchar (32) not null, object_id int not null, description text, lang varchar (2) not null, ) Соответственно, если у игры (таблица game) есть мультиязычное имя name, альтернативное имя alt_name и описание desc, то получилось бы, помимо самой игры, еще три записи на язык.Пример записей в таблице name:
id | table | field | object_id | name | lang ---|-------|----------|-----------------------|----- 1 | game | name | $game_id | $name | en 2 | game | alt_name | $game_id | $alt_name | en Пример записи в таблице description:
id | table | field | object_id | description | lang ---|-------|-------|-----------|-------------|----- 1 | game | desc | $game_id | $desc | en Так же таблицы можно было бы объединить в одну, используя для хранения названий тип text, но мне это решение не нравилось; почему — уже не помню.
Вариант 2. Для каждой основной таблицы — своя мультиязычная Для той же таблицы игр получится примерно следующее: create table game_i18n ( id int not null primary key, game_id int not null, name varchar (255) not null, alt_name varchar (255) not null, description text, lang varchar (2) not null ) Пример записи: id | game_id | name | alt_name | desc | lang ---|----------|----------|--------------|----------|----- 1 | $game_id | $name | $alt_name | $desc | en 2 | $game_id | $name_ru | $alt_name_ru | $desc_ru | ru Вариант 3. Мультиязычные поля сохранять в виде json-массива в отдельном поле основной таблицы create table game ( id int not null primary key, …, i18n text ) Пример записи: id | i18n ---|-------------------------------- 1 | {'name':[{'lang':'en','value': $name}, {'lang':'ru','value':$name_ru}], 'alt_name': […], 'desc': […]} Третий вариант — самый гибкий, но от него почти сразу отказались, так как нужны были сортировки и фильтрация по названиям, и для этого все равно пришлось бы делать что-то аналогичное варианту 1 или 2. Плюс, если нужно, скажем, удалить английское название у нескольких игр, это будет сложно сделать средствами самого SQL.В итоге мы остановились на варианте 2. Против первого варианта было то, что у мультиязычных полей могут быть свои дополнительные поля. Например, в играх нужна возможность пометить одно из названий как главное, у других объектов может быть свой набор дополнительных полей, по которым, вполне возможно, еще и нужно будет фильтровать/сортировать. Не хотелось в итоге при выборе первого варианта через пару лет прийти вот к такому:
create table name ( id int not null primary key, table varchar (32) not null, field varchar (32) not null, object_id int not null, name varchar (255) not null, lang varchar (2) not null, field1 int, field2 varchar (32), …, field9 datetime ) И потом в коде вспоминать — что такое field3 у таблицы компаний. Кроме того, как-то некомфортно, создавая очередную таблицу с пятью записями, сваливать переводы в таблицу с миллионом записей. Впрочем, последнее во всей красе проявилось тут: Требование 2. Связи Было желание иметь возможность связать любой объект из любой таблицы с любым другим объектом с сохранением направления связи.Варианты примерно такие же, как в и первом требовании: Вариант 1. Одна таблица для всех связей базы create table link ( id int not null primary key, table1 varchar (32) not null, object1_id int not null, table2 varchar (32) not null, object2_id int not null ) Пример записей: id | table1 | object1_id | table2 | obect2_id | ---|---------|------------|--------|-----------| 1 | game | $game_id | genre | $genre_id | 2 | game | $game_id | game | $game2_id | 3 | game | $game2_id | game | $game_id | Записи #2 и #3 реализуют двунаправленную связь, запись #1 — однонаправленную от игры к жанру.Вариант 2. Для каждого типа связи своя таблица К примеру, связи между похожими играми получились бы такими: create table similar_games_link ( id int not null primary key, game1_id int not null, game2_id int not null ) и так далее для каждого типа связи.Вариант 3. Храним связи в самом объекте в текстовом виде create table game ( id int not null primary key, …, links text ) Пример: id | links | ---|----------------------------------| 1 | #game:$game2_id#genre:$genre_id# | Тогда можно будет искать как-то так: select id from game where links like '%#game:$game2_id#%' Вариант 4. Аналог варианта 3, но храним json Пример: id | links | ---|------------------------------------------------------------------------------------------| 1 | [{'table':'game', 'object_id': $game2_id}, {'table': 'genre', 'object_id': $genre_id}] | Варианты 5 и 6. Для каждого типа связи свое поле. Вариация вариантов 3 и 4, но связи раскидываются по разным полям. create table game ( id int not null primary key, …, similar text, genres text ) В итоге решили хранить все в одной таблице (удобно же), плюс, в отдельных случаях можно было бы дублировать информацию о связях в полях самого объекта (варианты с третьего по шестой). Плодить для каждого типа связи свою таблицу не хотелось, и помочь мог третий вариант. Конечно, делать links like '%#game:$game2_id#%' — это ужасно, но я бы пережил. Отказались же от этого варианта потому, что удаление записей превращалось в нетривиальную задачу. Четвертый и шестой варианты сами по себе вообще бесполезны.Требование 3. Объекты с разным набором полей в одной таблице Например, новости, статьи и видеоролики хочется хранить в одной таблице, так как, во-первых, их нужно показывать на сайте в общей ленте в хронологическом порядке, во-вторых, между этими типами записей много общего (название, дата создания/изменения, текст). Но между постами есть и отличия, назовем их метаданными. Скажем, для рецензий (один из подтипов статей) можно указывать оценки, по которым неплохо было бы иметь возможность сортировать, для видео указывается разрешение оригинального ролика, длительность, показан или нет игровой процесс и т.д. В зависимости от типа поста меняется и отображение на сайте.Способы решения, как хранить метаданные, те же самые, что и выше. Создается одна таблица, в которой будут общие для всех типов записей поля. А далее вариантов немного. Можно хранить все метаданные прямо в тексте поста (или в отдельном текстовом поле) специальными тегами, как это сделано в Википедии, а при сохранении раскидывать по связанным вспомогательным таблицам. Можно сразу создать вспомогательные таблицы для каждого из типов поста и сохранять метаданные туда (этот вариант мы и выбрали, тем более что для разных типов постов все равно создавались разные формы редактирования в админке). Можно хранить метаданные в виде json или любом другом сериализованном виде (проблемы тоже все те же — сложность изменения таких сериализованных данных средствами SQL, плюс сортировки/фильтрации).
Требование 4. Сложные объекты Игра может быть выпущена на разных платформах и для каждой платформы может иметь разные издания. У релиза на платформе и у издания есть набор полей, совпадающий с самой игрой. Пример такого поля — «Название», так как, например, название издания для конкретной платформы может отличаться от каноничного названия игры. Также у платформы и издания есть набор полей, которых нет в самой игре, например, для платформы это будет собственно сама платформа, у издания — дата его релиза. Как все это хранить? В виде трех раздельных таблиц? По аналогии с тем, как решается хранение объектов с разным набором полей в требовании 3? Или хранить саму игру в виде одной записи, а все платформы и издания — в виде json в отдельном поле этой записи? И как такую радость редактировать? Делать три разные формы? У той же Pac-Man 27 платформ и более 30 изданий, редактирование такого монстра может превратиться в пытку. А как это показывать? Например, чтобы показать издание, придется для него загрузить платформу и саму игру, потому что, например, у издания может не быть своего названия. Тогда нужно смотреть общее название игры на платформе, а если там его нет, то смотреть название самой игры. При этом заранее прописать всем изданиям совпадающее название — тоже не здорово.Предварительно я остановился почти на том же варианте, что и в требовании 3 — одна таблица для игр. Но, так как типов записей было всего три, то и различающиеся поля было решено хранить в той же таблице и не плодить таблицы для метаданных.
Определившись с требованиями и предварительными вариантами их решения, мы начали разрабатывать админку. И тут (впрочем, как и всегда) начались проблемы. Например, у компании может быть название и описание. Создаем одну таблицу company_i18n с полями name и description. Пока все идет хорошо. В форме редактированияHTML форма:
Название Описание en: Bad Pixel [x] ru: Тут длинное описание на русском [x] ru: ООО «Бед Пиксел» [x] [добавить описание] [добавить название] раздельно указывается свой набор названий на разных языках и свой набор описаний, но это не проблема — названия и описания при сохранении объединяются по языку и для каждого языка создается одна запись в company_i18n.Записи в company_i18n после сохранения:
id | lang | name | description ---|------|------------------|-------------------------------- 1 | en | Bad Pixel | NULL 2 | ru | ООО «Бед Пиксел» | Тут длинное описание на русском Потом оказалось, что описаний на одном языке может быть несколько, а название должно быть строго одно для языка, и мы приходим к чему-то типа: id | lang | name | description ---|------|------------------|------------ 1 | en | Bad Pixel | NULL 2 | ru | ООО «Бед Пиксел» | Описание 1 3 | ru | NULL | Описание 2 Уже не очень хорошо выглядит, особенно если нужно удалить «Описание 2» с помощью SQL — нужно смотреть, есть ли название в поле name, и если оно есть, обновлять запись, а если нет, то удалять. Потом у названия компании появляется флаг «основное», появляется поле для корпоративных названий, которых может быть несколько на одном языке (для разных периодов времени свое), и приходит понимание, что, похоже, придется хранить имена и описания в разных таблицах.У связей между объектами почти сразу появилась сила связи. Это не было проблемой, проблемы начались, когда для разных типов связей появился свой набор дополнительных полей. Например, жанры могут быть связаны с другими жанрами и являться их поджанрами, и связь должна быть двунаправленной: в тегах одни теги являются персонажами из другого тега-вселенной, а у игр одна и та же компания может быть как разработчиком, так и издателем. И хотя проблему можно решить, добавив новые поля в таблицу link, но правильнее будет для отдельных типов связей создавать отдельные вспомогательные таблицы.
Я, конечно, понимаю, что жизнь — это боль, и разработчик (в частности) должен не забывать страдать, поэтому разработка медленно, но верно продолжалась, а вспомогательные таблицы появлялись с завидной регулярностью. Тем не менее, хотелось как-то автоматизировать процессы создания таких вот вспомогательных таблиц и сборки полного объекта из них. С такими мыслями в начале 2010 года я наткнулся на статью «Как FriendFeed использует MySQL для хранения данных без схемы».
Идея сделать NoSQL поверх MySQL выглядит не так уж и безумно даже сейчас. На тот момент MySQL развивалась уже годами и являлась надежным решением для production, а специализированные NoSQL решения только начали появляться, и не хотелось, сделав выбор в пользу чего-то одного, через пару лет оказаться один на один с не поддерживаемым продуктом. Я думаю, те, кто, как и я в свое время, сделал ставку на prototype.js, меня поймут.В то время MongoDB мы даже не рассматривали по разным причинам, в частности, ее пока не рекомендовали для production (первый production ready релиз был в конце первого квартала 2010-го). Я до сих пор сторонник такого подхода: использовать для проектов относительно устоявшиеся решения и по минимуму привлекать самописные аналоги. Но тогда устоявшихся решений еще не было (или казалось, что их нет), и для одного из своих сторонних проектов я написал что-то похожее на то, что было у FriendFeed. Поймите меня правильно: я этим не горжусь — идея сделать что-то свое может быть заманчивой ровно до тех пор, пока не придется это что-то поддерживать, фиксить «баги», оптимизировать, развивать функциональность, адаптировать к новым версиям языка/библиотек/используемых сервисов. Единственное, о чем я жалею — нужно было тогда скачать и пощупать «монгу», это дело получаса, максимум часа, а пользы — на годы. Собственно, это касается всех новых технологий: появляются они не просто так, и знание современных тенденций позволяет элементарно расширять кругозор.
Итак, была написана библиотека для работы с данными без схемы и хранением всего этого в MySQL.
Краткое описание того, что получилось. Объекты хранились в сериализованном виде в blob поле таблицы entity. Было введено дополнительное поле category (аналог таблиц в MySQL и коллекций в MongoDB), чтобы можно было разделять объекты, например игры, компании и т.п. (от варианта, когда, например, все сообщения хранятся в объекте темы, мы почти сразу отказались — для сообщений своя категория, для темы своя), плюс два поля — время создания и обновления объекта. Так как писалось все на Perl, то использовалась библиотека Storable для сериализации структур данных (обычно комбинация хэшей и массивов) из внутреннего представления в бинарный вид и обратно. В первую очередь это было сделано из-за скорости, в json данные конвертировались на порядок медленнее. Во вторую — из-за более компактного представления по сравнению с json.В качестве id испольовался uuid, 16 байт которого кодировались с помощью base 64 в текстовую строку длиной 22 байта.
На любые изменения объекта можно было вешать «триггеры» — функции, которые изменяли как сам объект, так и другие объекты, связанные с текущим. Например, при написании комментария триггер может менять общее количество комментариев в объекте темы.
Так как по таблице entity никаких запросов (кроме как по первичному ключу и категории) делать было нельзя, были введены индексы — обычные MySQL таблицы, которые создавались на основании полей сохраняемого объекта.
CREATE TABLE IF NOT EXISTS `index_platform` ( `generation` int (10) unsigned NOT NULL, `path_id` varchar (255) NOT NULL, `entity_id` binary (22) NOT NULL, KEY `generation` (`generation`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; В конфиге прописывалось: $db→index ( 'table' => 'index_platform', 'properties' => ['generation', 'path_id'], 'shard_on' => 'entity_id', 'categories' => ['platform'], )); Тогда при сохранении объекта: my $uuid = $db→put ({ 'generation' => 0, 'path_id' => 'pc', 'name' => [ {'lang' => 'en', 'value' => 'PC'} ] }); В index_platform автоматом создавалась запись: generation | path_id | entity_id -----------|---------|---------- 0 | pc | $uuid По этим индексам уже можно было делать выборки:
$db→resultset ('index_platform')→search ({ path_id => {'in' => ['pc', 'xbox']}, generation => {'in' => [0, 1]} }, { order_by => 'generation desc', limit => 10, page => 2, join => 1 # с этим любые поля, содержащие первичный ключ из таблицы entity, превращались в объект, на который они ссылаются })→all (); Альтернативный вариант того же самого запроса:
$db→resultset ('index_platform')→search ({ path_id => {'in' => ['pc', 'xbox']}, generation => {'in' => [0, 1]} })→order_by ('generation desc')→limit (10)→page (2)→join (1)→all (); Существовало только два типа взаимодействия с БД: это изменение самих объектов в таблице entity (включая удаление) и запросы к индексам как в примере выше. JOIN только программный.
При удалении объекта он только помечался как удаленный, физически не удаляясь из таблицы entity.
Примерно к середине 2010-го попробовали перейти на этот способ хранения данных.
Объекты теперь можно было сохранять вот в таком виде:
$company = { 'name' => [ {'lang' => 'ru', 'value' => 'ООО «Бед Пиксел»', 'is_primary' => true}, {'lang' => 'en', 'value' => 'Bad Pixel'}, ], 'description' => [ {'lang' => 'ru', 'value' => 'Тут длинное описание на русском»'}, ], 'link' => [ {'category' => 'tags', 'id' => $tag_uuid, 'degree' => 3}, {'category' => 'game', 'id' => $game_uuid, 'role' => 'developer'}, ] }; По полю name и link автоматически создавались записи в индексных таблицах index_name и index_link. Объекты могли быть любой сложности и вложенности, с разным набором полей для объекта из одной category. Приходилось по-прежнему создавать индексные таблицы, но стало гораздо проще. Если какого-то поля не хватало, достаточно было поменять код, а если по этому полю нужно было делать выборки — создавалась дополнительная индексная таблица или менялась существующая. Если какой-то индекс не устраивал, его можно было просто удалить и построить новый. В перспективе я хотел сделать создание таких индексных таблиц автоматически по описанию их структуры в коде.У такого хранения данных (хранилища) наряду с плюсами были и существенные минусы.Из плюсов можно было выделить:
наличие транзакций (правда, для меня это было не так важно) существование патча для mysql, который позволял обрабатывать до 750 тысяч чтений в секунду по первичному ключу масштабируемость — архитектура не мешала шардить данные по первичному ключу, при этом для приложения, работающего с хранилищем, ничего не менялось так как в качестве первичного ключа использовался uuid, можно было безболезненно проводить слияние сколь угодного количества баз без коллизий по первичному ключу генерация индексных таблиц налету Основные минусы: нужно самостоятельно развивать и поддерживать код самого хранилища отсутствие поддержки для различных языков отсутствие атомарных операций над данными; чтобы сделать $company→{'link'}→[0]→{'degree'}++, нужно загружать объект в приложение, менять и сохранять обратно в базу невозможно одним запросом изменить несколько записей, только через map-функцию в стороннем приложении транзакции, масштабируемость, генерацию индексных таблиц нужно было еще реализовать отсутствие консоли В процессе эксплуатации вылезали разные «баги» самого хранилица, из наиболее неприятных — зависимость алгоритма сериализации объектов в библиотеке Storable от операционной системы. Это решили переходом на хранение объектов в виде json со сжатием с помощью gzip. Кстати, именно во время исправления этого «бага» я четко осознал, что не важно, как хранятся сами объекты. Это может быть отдельная таблица в базе данных, а можно сохранять тупо в виде json файлов, назвав их по первичному ключу и раскидывая по подпапкам (впрочем, это лишает само приложение масштабируемости, будут возникать проблемы из-за race condition и т.п., хотя, с другой стороны, можно было бы попробовать Hadoop, но, откровенно говоря, это было бы уже лишним). Главное — иметь возможность для объектов создавать индексы, как это сделано, например, в поисковой системе Sphinx. Почему бы не воспринимать MySQL примерно так же, как и Sphinx? Почему бы хранение данных не представить в виде key-value хранилища, а для поиска, сортировки и выведения пользователю различных списков создавать подходящие для этого индексы в подходящих для этого сервисах? Конечно, если создается биллинг, то такой подход, мягко говоря, не очень оправдан, но web-приложения в основной своей массе менее требовательны к наличию того же ACID, а мучаться приходится почти так же, как и с биллингом.
Тем не менее, постепенно минусы при использовании самописного хранилища начали перевешивать, а также по-прежнему для каждого объекта нужно было рисовать свою форму в админке, одним словом — не было той универсальности, которой хотелось. Плюс ко всему в 2012 году произошел ряд скорее политических событий, таких как смена владельца сайта, руководства и менеджмента, и было принято решение писать вторую версию на языке Python, поскольку программисты в новой компании писали именно на этом языке. Вариантов было два — либо текущее хранилище оформлять как standalone сервис, либо использовать какое-либо существующее key-value хранилище.
Как бы вы себя чувствовали, если бы однажды оказалось, что кто-то создал аналог вашей библиотеки (ну или наоборот — вы создали некий аналог уже существующей библиотеки, не зная об этом), и этот аналог при сохранении существенных плюсов вашего решения еще и не обладал его минусами? Лично я порадовался. Великолепная консоль с полноценной поддержкой Javascript, атомарные операции, шардинг, автоматическое создание индексов по выбранным полям, библиотеки для основных языков программирования… На тот момент уже существовали фреймворки на Python, которые поддерживали MongoDB или были написаны специально под нее. И все это не нужно было ни поддерживать, ни развивать. Да вдобавок еще и api было похоже на api хранилища.В результате, начав с нуля (уже в качестве стартапа) разработку на Python в 2013 году, мы, правильно выбрав инструменты (одним из которых была MongoDB), за квартал сделали больше, чем раньше делали за два года. Еще одним из, как мне кажется, правильных решений был выбор админки, которая позволяла редактировать объекты любой вложенности, — из-за этого на ее разработку почти не тратилось времени.
Закончить хотелось бы вот чем. Наверное, неправильно противопоставлять MongoDB и MySQL, ибо для разных задач они подходят по-разному, и так уж получилось, что в этом проекте нам больше подошла MongoDB. Если вдруг такое случится и станет не хватать скорости или функциональности MongoDB — ничто не помешает использовать MySQL в качестве кэширующей прослойки / индекса для данных — один раз настроить и забыть.