Cassandra. Как не умереть, если знаешь только Oracle

    Привет, Хабр.

    Меня зовут Миша Бутримов, я хотел бы хотел немного рассказать про Cassandra. Мой рассказ будет полезен тем, кто никогда не сталкивался с NoSQL-базами, — у нее есть очень много особенностей реализации и подводных камней, про которые нужно знать. И если кроме Oracle или любой другой реляционной базы вы ничего не видели, эти вещи спасут вам жизнь.

    Чем хороша Cassandra? Это NoSQL-база данных, cпроектированная без единой точки отказа, которая хорошо масштабируется. Если вам нужно добавить пару терабайт для какой-нибудь базы, вы просто добавляете ноды в кольцо. Расширить ее на еще один дата-центр? Добавляете ноды в кластер. Увеличить обрабатываемый RPS? Добавляете ноды в кластер. В обратную сторону тоже работает.



    В чем еще она хороша? В том, чтобы обрабатывать много запросов. Но много — это сколько? 10, 20, 30, 40 тысяч запросов в секунду — это немного. 100 тысяч запросов в секунду на запись — тоже. Есть компании, которые говорили, что они держат 2 млн. запросов в секунду. Вот им, наверное, придется поверить.

    И в принципе у Cassandra есть одно большое отличие от реляционных данных — она вообще на них не похожа. И об этом очень важно помнить.

    Не все, что выглядит одинаково, работает одинаково


    Как-то ко мне пришел коллега и спросил: «Вот СQL Cassandra query language, и в нем есть select statement, в нем есть where, в нем есть and. Я пишу буквы, и не работает. Почему?». Если относиться к Cassandra как к реляционной базе данных, то это идеальный способ закончить жизнь жестоким самоубийством. И я не пропагандирую, это запрещено в России. Вы просто спроектируете что-нибудь неправильно.

    Например, к нам приходит заказчик и говорит: «Давайте построим базу данных для сериалов, или базу данных для справочника рецептов. У нас там будут блюда с продуктами или список сериалов и актеров в нем». Мы говорим радостно: «Давайте!». Это два байта переслать, пара табличек и все готово, все будет работать очень быстро, надежно. И все прекрасно, пока заказчики не приходят и не говорят, что домохозяйки решают еще и обратную задачу: у них есть список продуктов, и они хотят узнать, какое блюдо они хотят приготовить. Вы мертвы.

    Все потому, что Cassandra — гибридная база данных: она одновременно и key value, и хранит данные в широких столбцах. Если говорить на языке Java или Kotlin, это можно было бы описать вот так:

    Map<RowKey, SortedMap<ColumnKey, ColumnValue>>

    То есть мапа, внутри которой лежит еще и отсортированная мапа. Первым ключом к этой мапе является Row key или Partition key — ключ партиционирования. Второй ключ, который является ключом к уже отсортированной мапе, это Clustering key.

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



    Это число берется с помощью хеш-функции, которая применяется как раз к тому, что мы называем Partition key. Это тот столбец, который указывается в директиве Primary key, и это тот столбец, который будет первым и самым основным ключом мапы. Он определяет, на какую ноду какие данные попадут. Таблица создается в Cassandra почти с таким же синтаксисом, как в SQL:

    CREATE TABLE users (
    	user_id uu id,
    	name text,
    	year int,
    	salary float,
    	PRIMARY KEY(user_id)
    
    )
    


    Primary key в данном случае состоит из одной колонки, и она же является ключом партиционирования.

    Как у нас лягут пользователи? Часть попадет на одну ноду, часть — на другую, и часть — на третью. Получается обыкновенная хэш-таблица, она же map, она же в Python — словарь, она же — простая Key value-структура, из которой мы можем читать все значения, читать и писать по ключу.



    Select: когда allow filtering превращается в full scan, или как не надо делать


    Давайте напишем какой-нибудь select statement: select * from users where, userid = . Получается вроде бы как в Oracle: пишем select, указываем условия и все работает, пользователи достаются. Но если выбрать, например, пользователя с определенным годом рождения, Cassandra ругается, что она не может выполнить запрос. Потому что она вообще ничего не знает про то, как у нас распределяются данные о годе рождения — у нее в качестве ключа указана только одна колонка. Тогда она говорит: «Хорошо, я могу по-прежнему выполнить этот запрос. Добавьте allow filtering». Мы добавляем директиву, все работает. И в этот момент происходит страшное.

    Когда мы гоняем на тестовых данных, то все прекрасно. А когда вы выполняем запрос в продакшене, где у нас, к примеру, 4 миллиона записей, то у нас все не очень хорошо. Потому что allow filtering — это директива, которая позволяет Cassandra собрать все данные из этой таблицы со всех нод, всех дата-центров (если их много в этом кластере), и только потом уже отфильтровать. Это аналог Full Scan, и вряд ли от него кто-то в восторге.

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

    И у нее тоже есть ключ, который мы называем Сlustering Key. Этот ключ, который, в свою очередь, состоит из колонок, которые мы выберем, с помощью которого Cassandra понимает, как у нее данные физически отсортируются и будут лежать на каждой ноде. То есть, для какого-то Partition key Clustering key расскажет, как именно данные запихнуть в это дерево, какое место они там займут.

    Это реально дерево, там просто вызывается компаратор, в который мы передаем некий набор колонок в виде объекта, и задается он тоже в виде перечисления колонок.

    CREATE TABLE users_by_year_salary_id (
    	user_id uuid,
    	name text,
    	year int,
    	salary float,
    	PRIMARY KEY((year), salary, user_id)
    


    Обратите внимание на директиву Primary key, у нее первый аргумент (в нашем случае год) всегда идет Partition key. Он может состоять из одной или нескольких колонок, это не важно. Если колонок несколько, его нужно еще раз в скобки убрать, чтобы препроцессор языка понял, что это именно Primary key, а за ним все остальные колонки — Clustering key. При этом они будут в компараторе передаваться в том порядке, в котором они идут. То есть, первая колонка более значимая, вторая — менее значимая и так далее. Как мы для data classes пишем, например, поля equals: перечисляем поля, и для них пишем, какие больше, а какие меньше. В Cassandra это, условно говоря, поля data class, к которому будет применяться написанный для него equals.

    Задаем сортировку, накладываем ограничения


    Нужно помнить, что порядок сортировки (убывающая, возрастающая, не важно) задается в тот же момент, когда создается ключ, и поменять его потом будет нельзя. Он физически определяет, как будут рассортированы данные и как они будут лежать. Если нужно будет изменить Clustering key или порядок сортировки, придется создавать новую таблицу и переливать в нее данные. С уже существующей так не получится.



    Мы заполнили нашу таблицу пользователями и увидели, что они легли в кольцо сначала по году рождения, а потом внутри на каждой ноде по зарплате и по user ID. Теперь мы можем селектить, накладывая ограничения.

    Появляется снова наш работающий where, and, и пользователи нам достаются, и все снова хорошо. Но если мы попробуем использовать только часть Clustering key, причем менее значимую, то Cassandra тут же ругнется, что не может в нашей мапе найти место, где этот объект, у которого вот эти поля для компаратора null, а вот этот, который только что задали, — где он лежит. Мне придется снова поднять все данные с этой ноды и отфильтровать их. И это аналог Full Scan в рамках ноды, это плохо.

    В любой непонятной ситуации создавай новую таблицу


    Если мы хотим иметь возможность доставать пользователей по ID или по возрасту, или по зарплате, что делать? Ничего. Просто использовать две таблицы. Если надо будет доставать пользователей тремя разными способами — таблиц будет три. Прошли те времена, когда мы экономили место на винте. Это самый дешевый ресурс. Он стоит гораздо дешевле, чем время ответа, которое может быть губительным для пользователя. Пользователю гораздо приятнее получить что-то за секунду, чем за 10 минут.

    Мы обмениваем излишнее занимаемое место, денормализованные данные на возможность хорошо масштабироваться, надежно работать. Ведь на самом деле кластер, который состоит из трех дата-центров, в каждом из которых по пять нод, при приемлемом уровне сохранения данных (когда точно ничего не потеряется), способен пережить гибель одного дата-центра полностью. И еще по две ноды в каждом из двух оставшихся. И вот только после этого начнутся проблемы. Это довольно хорошее резервирование, оно стоит пары-тройки лишних ssd-накопителей и процессоров. Поэтому для того, чтобы использовать Cassandra, которая ни разу не SQL, в которой нет отношений, внешних ключей, нужно знать простые правила.

    Проектируем все от запроса. Главными становятся не данные, а то, как приложение собирается с ними работать. Если ему нужно получать разные данные разными способами или одни и те же данные разными способами, мы должны положить их так, как будет удобно приложению. Иначе мы будем проваливаться в Full Scan и никакого преимущества Cassandra нам не даст.

    Денормализовать данные — это норма. Забываем про нормальные формы, у нас больше не реляционные базы. Положим что-нибудь 100 раз, будет лежать 100 раз. Это все равно дешевле, чем стормозить.

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

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

    И самое важное: если нам нужно 100 разными способами забрать одни и те же данные, значит у нас будет 100 разных таблиц.
    QIWI
    Ведущий платёжный сервис нового поколения в России

    Комментарии 29

      +5
      Если кассандра — это всё ради скорости, а в заголовке Oracle — у вас есть боевые тесты сравнения того же оракла и кассандры?
      И да, реально пересоздать таблицу с новыми ключами, если в старую уже льётся 500000 вставок/сек?
        +1
        Пересоздать != переналить.
        Скорее всего придется импровизировать на уровне приложения с переключением потока данных и потом доливкой из старых таблиц.
          +1
          За ошибки архитектора БД Cassandra платят сисадмины и программисты, запомню.
          А как добавлять новые шарды в нагруженный работающий кластер?
          На новый шард, как понимаю, сначала должны перелиться данные от соседей и в это же время будут добавляться и новые, сеть с дисками не просядут?
          PS и как быстро меняется умершая нода или диск? Буден нужен?
            +1
            ну обычно программист, который собирается ее использовать, должен представлять — как и зачем, потому что база проектируется от запросов в первую очередь. Там нельзя потом будет просто так взять и шевельнуть, это так сказать плата за производительность.

            Добавить очень просто.
            Поднять ноду, сконфигурировать, добавить в кольцо (указать в конфигурации топологии что она теперь есть).
            Все. В зависимости от величины нагрузки конечно просядут, но не сильно. Однако если мы льем миллион рпс то возможно лучше делать это в то время суток, когда нагрузка поменьше.
            Собственно при добавлении ноды нужно все равно потом cleanup делать, чтобы удалить на всех остальных нодах диапазон неиспользуемых кластеров.
            Лучше конечно назначит эти работы на время с низкой нагрузкой.

            Тем не менее даже если предположить худший случай, что у нас 3 ноды и мы добавляем четвертую (а потом пятую в перспективе) и уровень согласованности у нас везде кворум — то это означает что и для записи и для удаления нам нужно 2 из трех подтверждения, при этом нод с данными 3 и разливка их на четвертую не будет _фатально_ тормозить чтение запись.

            Другой вопрос если кластер уже задыхается под нагрузкой и заметили это по метрикам не год — два назад а внезапно вот сейчас — тогда да, возможна просадка. Но это я не знаю как надо постараться. Типа внезапно проморгать десятикратный рост трафика и потом под ним внезапно решить начать масштабироваться?
              0
              *грустно*: сначала денег не было/прохождения финансовых комитетов, потом — тендер, потом — поставка затянулась… Короче, отстать месяцев на 6-9 от потребностей — легко.
              Спасибо за полезные ответы!
                +1
                не совсем понимаю проблему?
                типа на этапе проектирования можно не представлять что именно ты проектируешь? ну тогда это боль да.
                Тем не менее кассандра довольно специфична с точки зрения области применения. То есть обычно понятно: вот тут нужны ACID транзакции, а вот тут нужно хранить и писать огромное число примитивных данных ну и тд и тп.
                Последнее, что я хотел бы — продавать идею что кассандра может заменить оракл.
                  0
                  Я про *внезапное* масштабирование прода, которое может растянуться на годик из-за согласований и комитетов.
              0
              и как быстро меняется умершая нода или диск? Буден нужен?

              Настолько быстро, насколько быстрое железо. С учетом репликации удаление ноды остается незамеченным для клиентов. JBOD настраивать можно, но в основном рекомендации (особенно для Scylla, которая является cassandra на С++) делать все таки RAID0. И тут уже нода или диска умер значения не имеет.
          +1
          Нубский вопрос: А нельзя разрулить это отдельными таблицами индексов? Ну т.е. у тебя есть одна большая таблица со всеми данными, предположим 50 колонок, и к ней 10 таблиц с индексами по выбранным колонкам, для которых требуется возможность фильтровать записи. Таким образом из индексов получаем список нужных ключей, удовлетворяющих фильтрам, а потом уже лезем по этим ключам в нужные ноды за данными (если запрос требует что-то, что не покрыто индексами)?
          Или это слишком по-SQL-ному?
            0
            В кассандре джойнов нет))
              +1
              Можно и так делают. Проблема здесь — это требует двух запросов. Это увеличивает нагрузку на базу и время ответа т.к. их можно послать только последовательно. Поэтому в основном рекомендация — запихивать все нужные данные в одну таблицу, чтобы одним запросом получить их все, а не рассылать десять запросов, чтобы по кусочкам собрать один несчастный объект. В этом и суть денормализации — одни и теже данные лежат в куче таблиц.
              Ну и плюс консистентность. «индекс» таблицу некому поддерживать, foreign ключей нет, constraint нет. Об этом надо сразу думать, когда проектируется база. Может ли приложение переварить конфликты и как.
              +1
              И второй вопрос:
              если нам нужно 100 разными способами забрать одни и те же данные, значит у нас будет 100 разных таблиц.

              Т.е. мы должны будем одну операцию инсерта превратить в 100 операций? И все они должны быть гарантированно согласованны. Как это обеспечивается, и не становится ли инсерт слишком долгим?
                +1
                это утрирование, но в целом да. Есть батчинг операций. Не становится :)
                Ну то есть редко речь идет прям о сотне таблиц, все-таки это признак того, что что-то было не совсем верно спроектировано.
                Тем не менее денормолизация в 5 — 10 таблиц это вполне окей.
                  0
                  А чем обеспечивается согласованность данных в этих 10 таблицах, если речь идет о представлении одних и тех же данных, но в разных видах для удобства выборки?
                    0
                    вообще обычно если в них согласовано записали то согласовано и прочитают.
                      +2

                      Я в Кассандре ни разу не разбираюсь, но мне кажется, что факт того, что она noSQL означает, что у неё плохо с реляционностью данных между таблицами. Иными словами – согласованность поддерживается корректным кодом, БД же заточена под быстроту.


                      Один из ответов на stackoverflow говорит, что Кассандра может быть только AP, либо P. И там же есть ссылка на статью, которая ссылается на хабр.


                      Но опять же, я не настоящий сварщик. :)

                        0
                        Ничем. Кассандра не уповает на консистентность. Это проблему должен решать код для себя сам и обрабатывать возможные проблемы. Есть materialized view, которые эти таблицы синхронизируют автоматически, но это тоже гарантий не дает. Просто снимает с приложение необходимость слать эти 100 запросов.
                      0
                      Инсерты надо делать параллельно. Чем больше параллельных запросов, тем лучше для касандры. Надо пользоваться тем фактом, что у нас несколько серверов. Батчинг есть, но работает нормально и дает реальный профит по скорости только в определенных условиях (partition key у всех строк одинаковый). Тоже самое с выборками. Надо 100 таблиц прочитать — шлем 100 запросов одновременно. Доходит до того, что WHERE id IN (...) медленнее, чем послать для каждого значения в IN запрос параллельно.
                      0
                      Небольшая неточность
                      PRIMARY KEY((year), salary, user_id)

                      Так делать не требуется. Первое значение это всегда partition key. Такая запись эквивалентна
                      PRIMARY KEY(year, salary, user_id)


                      Скобки используют, чтобы сделать составной partition key
                        +1
                        Скобки я добавил для того, чтобы визуально подчеркнуть (препроцессор языка все равно с ними справится) где в данном случае будет какая часть ключа.
                        Эти сладйы использовались и для живого доклада на Qiwi Server Party, по каким-то причинам мне захотелось показать что с помощью скобок мы не только делаем составные ключи, но и можем использовать их чтобы визуально подчеркнуть части ключа.
                        0
                        А еще Кассандра делает задержки на каждый отдельный запрос до 150мс, рандомно похоже.
                        Если надо малое время задержки, используйте aerospike.
                          0
                          А еще Кассандра делает задержки на каждый отдельный запрос до 150мс, рандомно похоже.

                          Cassandra написана на Java. Рандомные тормоза — это похоже на GC.
                          Как альтернативу моэно еще рассмотреть Scylla, случайных задержек не заметил. Плюс производительность выше, т.к. написана на c++.
                          Преимуществом перед aerospike будет то, что Scylla, за редким исключением, полностью совместима с драйверами и языком cassandra.
                            0
                            Если бы. GC то работает непостоянно. А там постоянно под нагрузкой такое.
                            Не, не тормоза. Она выдает 100-200к, но задержки ненормированные. На том же железе кластер aerospike дает задержку меньше 5мс и 600к запросов.
                            0
                            Это очень странное утверждение, потому что без каких-то дополнительных исследований оно невозможно. Потому что в идеале не должна. Так то у меня был случай, что иногда рандомные запросы вызывали рост MUTATION DROP, росло рандомно время выполнения и число спекулятивных ретраев. Кассандра оказалась не при чем, больной порт на свиче корраптил примерно 0.7 процента случайных пакетов.
                            +1
                            Оп, привет с QIWI server party, было интересно слушать это вживую)
                            +1
                            Прошли те времена, когда мы экономили место на винте

                            Информация на винте — мёртвый груз. Прежде чем с ней можно будет что-то сделать, надо загрузить её в ОЗУ и дать команды процессору на обработку (это очень упрощенно). Ваши 100 копий одних и тех же данных при параллельной обработке будут вытеснять друг друга из ОЗУ и постоянно вновь и вновь туда считываться с диска, а это не быстро, даже для SSD. Если мы говорим о больших данных и больших нагрузках, то могут быть проблемы с таким отношением к размеру БД

                              –1
                              При параллельной обработке все эти 100 копий читать и не нужно. В идеале берется одна таблица, распиливается по token range между воркерами и делается все, что угодно. А так, это как раз основной кейс работы касандры — держать все на диске и эффективно это вычитывать. Какие бы старые и никогда не используемые данные не запрашивались, задержка будет в районе нескольких миллисекунд. От того и модель данных так сильно ограничивает, чтобы так можно было.
                              0
                              И что только ни придумают люди, лишь бы не учить SQL ))))

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                              Самое читаемое