«Нужно бежать со всех ног, чтобы только оставаться на месте,
а чтобы куда-то попасть, надо бежать как минимум вдвое быстрее!»
(с) Алиса в стране чудес Некоторое время назад меня попросили прочитать лекцию аналитикам нашей компании на тему проектирования моделей данных, ведь сидя долгое время на проектах (порою по нескольку лет) мы упускаем из виду происходящее вокруг в мире ИТ-технологий. В нашей компании (уж так получилось) на многих проектах не используются NoSQL-базы данных (по крайней мере пока), поэтому в своей лекции я отдельно уделил им некоторое внимание на примере HBase и постарался ориентировать изложение материала на тех, кто с ними никогда не работал. В частности, я иллюстрировал некоторые особенности проектирования модели данных на примере, который несколько лет назад прочитал в статье «Introduction to HB ase Schema Design» by Amandeep Khurana. Разбирая примеры, я сравнивал между собой несколько вариантов решения одной и той же задачи, чтобы лучше донести до слушателей основные идеи.  Недавно, «от нечего делать», я задался вопросом (длинные майские выходные в режиме карантина к этому особенно располагают), насколько теоретические выкладки будут соответствовать практике? Собственно, так и родилась идея этой статьи. Разработчик, который не первый день работает с NoSQL, возможно и не почерпнет из нее что-то новое (и поэтому может сразу промотать полстатьи). Но для аналитиков, которые еще не работали плотно с NoSQL, полагаю, она будет полезна для получения базовых представлений об особенностях проектирования моделей данных для HBase. На мой взгляд, прежде чем начать использовать NoSQL базы данных, необходимо хорошо подумать и взвесить «за» и «против». Часто задачу скорее всего можно решить и на традиционных реляционных СУБД. Поэтому лучше не использовать NoSQL без существенных на то оснований. Если все же было принято решение использовать NoSQL базу данных, то следует учесть, что подходы к проектированию здесь несколько отличаются. Особенно некоторые из них могут быть непривычны тем, кто до этого имел дело только с реляционными СУБД (по моим наблюдениям). Так, в «реляционном» мире мы обычно идем от моделирования предметной области, и уже потом при необходимости проводим денормализацию модели. В NoSQL же мы сразу должны учитывать предполагаемые сценарии работы с данными и изначально денормализовывать данные. Кроме того, есть ряд других отличий, о которых будет написано ниже. Рассмотрим следующую «синтетическую» задачу, с которой и будем далее работать: Конечно же, вариантов решения задачи множество. В обычной реляционной БД мы бы скорее всего просто сделали бы таблицу связей (возможно, типизированную, если, например, требуется хранить пользовательскую группу: семья, работа и т.п., в которую входит данный «друг»), а для оптимизации скорости доступа добавили бы индексы/партиционирование. Скорее всего итоговая таблица выглядела бы примерно вот так: здесь и далее для наглядности и лучшего понимания вместо ID буду указывать имена В случае же с HBase мы знаем, что: Поэтому ID пользователя мы вынуждены использовать как ключ. А первой мыслью на тему «где и как хранить ID друзей?» может быть идея хранения их в колонках. Этот самый очевидный и «наивный» вариант будет выглядеть примерно так (назовем его Вариант 1 (default), чтобы ссылаться в дальнейшем): Здесь каждая строка соответствует одному пользователю сети. Колонки имеют имена: 1, 2, … — по количеству друзей, и в колонках хранятся ID друзей. Важно заметить, что у каждой строки будет разное число колонок. В примере на рисунке выше одна строка имеет три колонки (1, 2 и 3), а вторая – только две (1 и 2) – здесь мы сами воспользовались двумя свойствами HBase, которых нет у реляционных БД: Проверим нашу структуру на соответствие требованиям задачи: Давайте теперь оценим, насколько данные алгоритмы, которые нам необходимо будет реализовывать на стороне «условного приложения», будут производительны, используя О-символику. Обозначим размер нашей гипотетической социальной сети как n. Тогда максимальное число друзей у одного пользователя может быть (n-1). Этой (-1) мы можем в дальнейшем пренебречь для наших целей, так как в рамках использования О-символики она несущественна. Итого, во всех сценариях мы получили асимптотическую вычислительную сложность O(n).
Наверное, вы уже заметили, что нам приходится почти всегда вычитывать из базы всю строку целиком, причем в двух случаях из трех только для того, чтобы перебрать все колонки и посчитать общее кол-во друзей. Поэтому в качестве попытки оптимизации можно добавить колонку «count», в которой хранить общее число друзей каждого пользователя сети. В этом случае мы можем не вычитывать всю строку целиком для подсчета общего кол-ва друзей, а прочитать только одну колонку «count». Главное, не забывать обновлять «count» при манипуляции с данными. Т.о. получаем улучшенный Вариант 2 (count): По сравнению с первым вариантом: В целом вариант 2 видится чуть более оптимальным, но это скорее «эволюция вместо революции». Для совершения «революции» нам понадобится Вариант 3 (col).
Перевернем все «с ног на голову»: назначим именем колонки идентификатор пользователя! Что будет записано в самой колонке – для нас уже не суть важно, пусть будет цифра 1 (вообще, из полезного там можно хранить, например, группу «семья/друзья/и т.п.»). Данный подход может удивить неподготовленного «обывателя», который до этого не имел опыта работы с NoSQL-базами, но именно он позволяет использовать потенциал HBase в данной задаче намного эффективнее: Здесь мы получаем сразу несколько преимуществ. Чтобы их понять, проанализируем новую структуру и оценим вычислительную сложность: Как видим, существенным преимуществом такой модели хранения является то, что мы во всех необходимых нам сценариях оперируем только одной колонкой, избегая вычитывания из базы всей строки и тем более, перебора всех колонок этой строки. На этом можно было бы остановиться, но… Можно озадачиться и пойти еще немного дальше по пути оптимизации производительности и уменьшения операций ввода-вывода при обращении к базе. Что если хранить полную информацию о связи непосредственно в самом ключе строки? То есть сделать ключ составным вида userID.friendID? В этом случае нам вообще можно даже не вычитывать колонки строки (Вариант 4(row)): Очевидно, что оценка всех сценариев манипуляции с данными в такой структуре также, как и в предыдущем варианте будет О(1). Разница с вариантом 3 будет уже исключительно в эффективности операций ввода-вывода в БД. Ну и последний «бантик». Легко заметить, что в варианте 4 у нас ключ строки будет иметь переменную длину, что, возможно, может повлиять на производительность (тут вспоминаем, что HBase хранит данные как набор байтов и строки в таблицах отсортированы по ключу). Плюс у нас есть разделитель, который в некоторых сценариях может потребоваться обрабатывать. Чтобы исключить это влияние, можно использовать хэши от userID и friendID, и так как оба хэша будут иметь постоянную длину, то можно просто конкатенировать их, без разделителя. Тогда данные в таблице будут выглядеть так (Вариант 5(hash)): Очевидно, что алгоритмическая сложность работы с такой структурой по рассматриваемом нами сценариями, будет такая же, как у варианта 4 – то есть О(1).
Итого, сведем все наши оценки вычислительной сложности в одну таблицу: Как видно, варианты 3-5 выглядят наиболее предпочтительным и теоретически обеспечивает выполнение всех необходимых сценариев манипуляции с данными за константное время. В условии нашей задачи нет явного требования по получению списка всех друзей пользователя, но в реальной проектной деятельности нам, как хорошим аналитикам, хорошо бы «предвидеть», что подобная задача может возникнуть и «подстелить соломку». Поэтому мои симпатии на стороне варианта 3. Но вполне вероятно, что в реальном проекте данный запрос могл быть уже решен иными средствами, поэтому без общего видения всей задачи лучше не делать окончательных выводов. Вышеизложенные теоретические рассуждения хотелось бы проверить на практике – это и было целью возникшей на долгих выходных задумки. Для этого необходимо оценить скорость работы нашего «условного приложения» во всех описанных сценариях использования базы, а также рост этого времени с ростом размера социальной сети (n). Целевым параметром, который нас интересует и который мы будем замерять в ходе эксперимента, является время, затраченное «условным приложением», на выполнение одной «бизнес-операции». Под «бизнес-операцией» мы понимаем одну из следующих: Таким образом, с учетом обозначенных в изначальной постановке требований, сценарий проверки вырисовывается следующий: Сценарии необходимо прогнать для каждого из 5 вариантов моделей данных и для разных размеров социальной сети, чтобы посмотреть, как меняется время с ее ростом. В рамках одного n связи в сети и список пользователей для проверки должны быть, естественно, одинаковыми для всех 5 вариантов.
Для лучшего понимания ниже привожу пример сгенерированных данных для n= 5. Написанный «генератор» дает на выходе три словаря ID-шников: Как можно заметить, все ID, большие 10 000 в словаре для проверки – это как раз те, которые заведомо дадут ответ False. Вставка, проверка и удаление «друзей» производятся именно в указанной в словаре последовательности. Эксперимент проводился на ноутбуке под управлением Windows 10, где в одном докер-контейнере была запущена база HBase, а в другом – Python с Jupyter Notebook. Докеру было выделено 2 ядра CPU и 2 Гб оперативной памяти. Вся логика, как и эмуляции работы «условного приложения», так и «обвязка» для генерации тестовых данных и замера времени были написаны на Python. Для работы с HBase использовалась библиотека happybase, для вычисления хэшей (MD5) для варианта 5 — hashlib С учетом вычислительной мощности конкретного ноутбука экспериментально был выбран запуск для n = 10, 30, …. 170 – когда общее время работы полного цикла тестирования (все сценарии для всех вариантов для всех n) было еще более-менее разумным и умещалось во время одного чаепития (в среднем 15 минут). Тут необходимо сделать ремарку, что в данном эксперименте мы в первую очередь оцениваем не абсолютные цифры производительности. Даже относительное сравнение разных двух вариантов может быть не совсем корректным. Сейчас нас интересует именно характер изменения времени в зависимости от n, так как с учетом указанной выше конфигурации «тестового стенда» получить временные оценки, «очищенные» от влияния случайных и прочих факторов, очень сложно (да и такой задачи не ставилось). Первый тест – как меняется время, затрачиваемое на заполнение списка друзей. Результат – на графике ниже.

Варианты 3-5 ожидаемо показывают практически константное время «бизнес-операции», которое не зависит от роста размера сети и неотличимую разницу в производительности.
Вариант 2 показывает тоже константную, но чуть худшую производительность, причем практически ровно в 2 раза относительно вариантов 3-5. И это не может не радовать, так как соотноситься с теорией – в этом варианте количество операций ввода-вывода в/из HBase как раз в 2 раза больше. Это может служить косвенным свидетельством, что наш тестовый стенд в принципе дает неплохую точность.
Вариант 1 так же ожидаемо оказывается самым медленным и демонстрирует линейный от размера сети рост времени, затрачиваемого на добавление одно друга.
Посмотрим теперь результаты второго теста.

Варианты 3-5 опять же ведет себя ожидаемо – константное время, не зависящее от размера сети. Варианты 1 и 2 демонстрируют линейный рост времени при росте размера сети и схожую производительность. Причем вариант 2 оказывается чуть медленнее – по всей видимости из-за необходимости вычитки и обработки дополнительной колонки «count», что при росте n становится более заметным. Но я все же воздержусь от каких-либо выводов, так как точность данного сравнения относительно невысока. Кроме того, данные соотношения (какой вариант, 1 или 2, быстрее) менялись от запуска к запуску (при этом сохраняя характер зависимости и "идя ноздря в ноздрю"). Ну и последний график – результат тестирования удаления.  Здесь опять же без сюрпризов. Варианты 3-5 осуществляют удаление за константное время.
Причем, что интересно, варианты 4 и 5, в отличии от предыдущих сценариев, показывают заметную чуть худшую производительность, чем вариант 3. По всей видимости, операция удаления строки – более затратная, нежели операция удаления колонки, что в целом логично. Варианты 1 и 2, ожидаемо, демонстрируют линейный рост времени. При этом вариант 2 стабильно медленнее варианта 1 – из-за дополнительной операции ввода-вывода по «обслуживанию» колонки count. Общие выводы эксперимента: Проведенные грубые эксперименты не следует воспринимать как абсолютную истину. Есть множество факторов, которые не были учтены и вносили искажения в результаты (особенно хорошо эти флуктуации видны на графиках при небольшом размере сети). Например, скорость работы thrift, который используется happybase, объем и способ реализации логики, которая у меня была написана на Python (не берусь утверждать, что код был написан оптимально и эффективно использовал возможности всех компонент), возможно особенности кэширования HBase, фоновая активность Windows 10 на моем ноутбуке и т.п. В целом можно считать, что все теоретические выкладки экспериментально показали свою состоятельность. Ну или как минимум опровергнуть их таким вот «наскоком в лоб» не получилось. В заключении — рекомендации всем, кто только начинает проектировать модели данных в HBase: абстрагируйтесь от предыдущего опыта работы с реляционными базами и помните «заповеди»: Архитектор и инженер данных