Главная » Хабрахабр » Cassandra для хранения метаданных: успехи и провалы

Cassandra для хранения метаданных: успехи и провалы

Каким требованиям должно удовлетворять хранилище метаданных для облачного сервиса? Да не самого обычного, а для enterprise с поддержкой географически распределенных датацентров и Active-Active. Очевидно, система должна хорошо масштабироваться, быть отказоустойчивой и хотелось бы, чтобы было возможно реализовать настраиваемую консистентность операций.

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

Эти истории и вправду напоминают катание на горках, но на все проблемы находится решение, за которым добро пожаловать под кат. В докладе на Highload++ 2017 Андрей Смирнов (smira) решил, что о хорошем говорить неинтересно, зато подробно рассказал, про каждую проблему, с которой пришлось столкнуться: про потерю и порчу данных, про зомби и потерю производительности.

Идея состоит в том, что условно Amazon делает облако для всех, а Virtustream делает специфические вещи, которые необходимы большой компании. О спикере: Андрей Смирнов работает в компании Virtustream, реализующей облачное хранилище для enterprise.

Мы работаем в полностью удаленной небольшой команде, и занимаемся одним из облачных решений Virtustream. Это облако хранения данных.

Для тех, кто не знает, что такое S3 — это просто HTTP API, с помощью которого можно куда-то в облако загружать объекты, получать их обратно, удалять, получать список объектов и т.д. Если говорить очень просто, то это S3-совместимый API, в котором можно хранить объекты. Дальше — уже более сложные фичи на основе этих простых операций.

Одна из них — так называемые гео-регионы. У нас есть некоторые отличительные возможности, которых нет у Amazon. Регион — это по сути дата-центр, и ваши объекты никогда не покинут этот дата-центр. В обычной ситуации, когда вы создаете хранилище и говорите, что будете хранить объекты в облаке, вы должны выбрать регион. Если с ним что-то случится, то ваши объекты больше не будут доступны.

Клиент может обращаться к любому дата-центру, для него это прозрачно. Мы предлагаем гео-регионы, в которых данные находятся одновременно в нескольких дата-центрах (ДЦ), как минимум в двух, как на картинке. Это предоставляет клиенту дополнительные возможности, в том числе: Данные между ними реплицируются, то есть мы работаем в режиме «Active-Active», причем постоянно.

  1. большая надежность хранения, чтение и запись при отказе ДЦ или потере связности;
  2. доступность данных даже при отказе одного из ДЦ;
  3. перенаправление операций в «ближайший» ДЦ.

Это интересная возможность — даже если эти ДЦ далеко друг от друга географически, то какой-то из них может быть ближе к клиенту в разные моменты времени. И обращаться к данным в ближайший ДЦ просто быстрее.

Для того, чтобы конструкцию, о которой мы будем говорить, разделить на части, я представлю те объекты, которые хранятся в облаке, как два больших куска:

Первый простой кусок объекта — это данные.   1. Единственное, что с ними потом может случиться — мы их можем удалить, если они больше не нужны. Они неизменны, их один раз загрузили и все.

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

Метаданные.   2. Вся бизнес-логика, все самое интересное, связанное с конкурентностью: обращение, записи, перезаписи — в районе метаданных.

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

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

Цифры

  • Данные: 4 Пбайта.
  • Кластеры метаданных: 3.
  • Объекты: 40 миллиардов.
  • Объем метаданных: 160 Тбайт (с учетом репликации).
  • Скорость изменений (метаданных): 3000 объектов/с.

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

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

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

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

Когда перед нами встала задача, что мы хотим иметь гео-регионы, Active-Active, и нам надо где-то хранить метаданные, мы думали, что это может быть?

Очевидно, что хранилище (база данных) должна иметь следующие свойства:

  • Поддержка Active-Active;
  • Масштабируемость.

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

  • Баланс отказоустойчивости и надежности хранения.

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

  • Настраиваемая консистентность операций.

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

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

Под эти требования, с моей точки зрения, подходит Cassandra.

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

Что такое Cassandra?

Это распределенная key-value база данных. С точки зрения архитектуры и идей, которые в нее заложены, мне кажется, все классно. Если бы я делал, то делал бы то же самое. Когда мы только начинали, мы задумывались о написании своей системы хранения метаданных. Но чем дальше, тем мы больше и больше понимали, что нам придется сделать что-то очень похожее на Cassandra, и те усилия, которые мы на это потратим, того не стоят. На всю разработку у нас было всего полтора месяца. Было бы странно потратить их на написание своей базы данных.

Если Cassandra разделить по слоям, как слоеный пирог, я бы выделил 3 слоя:

Локальное KV-хранилище на каждом узле.
Это кластер из узлов, каждый из которых уметь хранить key-value данные локально.   1.

Шардирование данных по узлам (consistent hashing).
Cassandra умеет распределять данные по узлам кластера, включая репликацию, причем делает это так, что кластер может расти или уменьшаться в размерах, и данные будут перераспределяться.   2.

Координатор для перенаправления запросов к другим узлам.
Когда мы обращаемся из нашего приложения к данным по каким-то запросам, Cassandra умеет наш запрос распределить по узлам так, чтобы мы получили те данные, которые мы хотим, и с тем уровнем консистентности, который нам нужен — хотим мы их прочитать просто quorum, или хотим quorum с учетом двух ДЦ и т.д.
  3.

Начиналось все глубоко внизу, у нас был нулевой опыт работы с Cassandra. Для нас два года с Cassandra — это американские или русские горки — как хотите называйте. Мы запустились, и все было хорошо. Нам было страшно. Но дальше начинаются постоянные падения и взлеты: проблема, все плохо, мы не знаем, что делать, у нас сыпятся ошибки, потом мы проблему решаем, и т.д.

Эти американские горки, в принципе, не заканчиваются по сей день.

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

Cassandra действительно хорошая.

  • Это одна из систем, которая позволяет нам иметь время отклика в миллисекундах, то есть заведомо меньше 10 мс. Это хорошо для нас, потому что нам важно время отклика в целом. Операция с метаданными для нас является лишь частью любой операции, связанной с хранением объекта, будь то получение или запись.
  • С точки зрения записи достигается высокая масштабируемость. В Cassandra можно писать с сумасшедшей скоростью, а а некоторых ситуациях это необходимо, например, когда мы перемещаем большие объемы данных между записями.
  • Cassandra действительно отказоустойчива. Падение одного узла не приводит в ту же секунду к проблемам, правда рано или поздно они начнутся. Cassandra декларирует, что в ней нет единой точки отказа, но, по сути, точки отказа есть везде. На самом деле тот, кто работал с БД, знает, что даже падение узла — это не то, что обычно терпит до утра. Обычно, такую ситуацию надо починить быстрее.
  • Простота. Все-таки по сравнению с другими стандартными реляционными базами данных Cassandra проще в плане понимания того, что происходит. Очень часто что-то идет не так, и нам нужно понять, что происходит. С Cassandra больше шансов разобраться, дойти до малейшего винтика, наверное, чем с другой БД.

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

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

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

С точки зрения S3 есть несколько базовых вещей:

  • Bucket — его можно представить, как огромный каталог, в который пользователь заливает объект (далее бакет).
  • У каждого объекта есть имя (ключ) и связанные с ним метаданные: размер, content type и указатель на данные объекта. При этом размер бакета ничем не ограничен. То есть это может быть 10 ключей, может быть 100 млрд ключей — разницы никакой нет.
  • Возможны любые конкурентные операции, то есть может быть несколько конкурентных заливок в один и тот же ключ, может быть конкурентное удаление и т.д.

В нашей ситуации active-active, операции могут происходить, в том числе, конкурентно в разных ДЦ, не только в одном. Поэтому нам нужна какая-то схема сохранения, которая позволит реализовывать такую логику. В конечном итоге мы выбрали простую политику: побеждает последняя по времени записанная версия. Иногда происходит несколько конкурентных операций, но не обязательно, что наши клиенты специально это делают. Это может быть просто запрос, который начался, но клиент не дождался ответа, что-то еще произошло, попытался снова, и т.д.

Поэтому у нас есть две базовые таблицы:

  1. Таблица объектов. В ней пара — имя бакета и имя ключа — связывается с его текущей версией. Если объект удален, то в этой версии ничего нет. Если объект существует, там есть его текущая версия. По сути, в этой таблице мы только изменяем поле текущей версии.
  2. Таблица версий объектов. В эту таблицу мы только вставляем новые версии. Каждый раз, когда происходит загрузка нового объекта, мы вставляем новую версию в таблицу версий, даем ей некий уникальный номер, сохраняем о ней всю информацию, и в конце обновляем на нее ссылку в таблице объектов.

На рисунке пример того, как связаны таблицы объектов и версий объектов.

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

Тут возникла проблема.

В каждом ДЦ метаданные хранятся в трех копиях, то есть у нас 3+3 — всего 6 реплик. Проблема была в следующем: у нас есть active-active, два ДЦ. То есть гарантируется, что запись (или чтение) произошло в 2 реплики в локальном ДЦ. Когда к нам обращаются клиенты, мы операции выполняем с консистентностью (с точки зрения Cassandra называется LOCAL_QUORUM). Это гарантия — иначе операция не выполнится.

На самом деле, все 6 реплик будут одинаковые, но гарантированы нам 2. Cassandra всегда будет пытаться писать во все 6 реплик — 99% времени все будет хорошо.

Даже для обычных регионов, которые в одном ДЦ, мы все равно хранили вторую копию метаданных в другом ДЦ. У нас была сложная ситуация, хотя это был даже не гео-регион. Но в конечном итоге у нас был процесс очистки, который удалял ненужные версии. Это длинная история, я не буду все детали приводить.

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

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

Соответственно, процедура очистки, запущенная в первом (верхнем) ДЦ, увидела, что есть версия, на которую никто не ссылается, и ее удалила. Большую часть времени все было хорошо, пока однажды не возникла ситуация, когда в оба ДЦ отреплицировалась запись в таблицу версий, но запись в таблице объектов оказалась только в одном ДЦ, а во второй не попала. И это удаление безвозвратное. Причем удалила не только версию, но и, само собой, данные — все полностью, потому что это просто ненужный объект.

Конечно, дальше происходит «бум», потому что у нас в таблице объектов осталась запись, которая ссылается на версию, которой больше нет.

Так мы первый раз потеряли данные, и потеряли их действительно безвозвратно — благо, немного.

Решение

Что делать? В нашей ситуации все просто.

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

В Cassandra 3 все немножко лучше — есть уровень консистентности, который называется quorum в каждом ДЦ. Консистентность ALL — это особенность Cassandra 2. Во-вторых, в случае консистентности всех 6 узлов это означает, что он работает со скоростью худшего из этих 6 узлов. Но в любом случае есть проблема того, что это медленно, потому что нам, во-первых, приходится обращаться к удаленному ДЦ.

То есть когда где-то запись не прошла, этот процесс одновременно их чинит. Но одновременно происходит процесс так называемого read-repair, когда не все реплики синхронны. Так устроена Cassandra.

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

Ничего хуже, наверное, не могло бы быть, если бы мы не догадались, что дело не в этом. Вдруг мы обнаружили, что у нас 10% таких записей. Проблема была в другом.

Это полуофициальное название этой проблемы. В нашу базу данных прокрались Зомби. Для того, чтобы понять, что это такое, надо поговорить о том, как работает удаление в Cassandra.

Если мы хотим его удалить, удаление, как и любая операция в Cassandra, может быть выполнено не на всех узлах. Например, у нас есть какой-то кусок данных x, который записан и идеально реплицирован на все 6 реплик.

Пусть операция удаления выполнилась на пяти узлах, а на одном запись осталась, например, потому что узел в этот момент был недоступен.
Например, мы хотели гарантировать консистентность 2 из 3 в одном ДЦ.

То есть при чтении обратно она скажет: «О, данные есть!», хотя мы их удалили. Если мы так удалим и потом попытаемся прочитать с такой же консистентностью «хочу 2 из 3», то Cassandra, увидев значение и его отсутствие, интерпретирует это как наличие данных. Поэтому удалять таким образом нельзя.

Удаление на самом деле является записью. Cassandra удаляет по-другому. Он помечает, что данные удалены. Когда мы удаляем данные, Cassandra записывает некий маркер небольшого размера, который называется Tombstone (надгробная плита). Это то, что нужно. Таким образом, если мы читаем одновременно маркер удаления и данные, Cassandra всегда предпочитает маркер удаления в этой ситуации и говорит, что данных на самом деле нет.

Поэтому у Tombstone есть некоторое конфигурируемое время жизни. Хотя Tombstone — это маленький маркер, понятно, что, если мы удаляем и удаляем данные, когда-то надо и эти маркеры удалять, иначе они будут копиться бесконечно. Когда маркера нет, ситуация эквивалентна ситуации, когда данных нет. То есть Tombstone удаляются через gc_grace_period секунд.

Что может произойти?

Repair

В Cassandra есть процесс, который называется Repair (починка). Его задача — сделать так, чтобы все реплики были синхронны. У нас возможны разные операции в кластере, может быть, не на всех узлах они выполнились, или мы меняли размер кластера, добавляли/убавляли реплики, может быть, какой-то узел когда-то упал, жесткие диски и т.д. Реплики могут быть не консистентны. Repair делает так, чтобы они стали консистентны.

Но Repair мы пока не сделали, и оно в состоянии, как на картинке выше. Мы удалили данные, где-то появились маркеры удаления, где-то остались сами данные. Вместо них осталось пустое место, как будто данных нет.
Прошло какое-то время, и маркеры удаления исчезли — просто вышел их срок жизни.

Соответственно все 6 узлов снова будут с данными. Если после этого запустить Repair, который должен привести реплики в консистентное состояние, он увидит, что на одних узлах есть данные, на других нет — значит, надо их восстановить. Это те самые Зомби — данные, которые мы удалили, но которые вернулись в кластер.

Если на него ничто не ссылается, мы его не увидим. Обычно мы их не видим, если к ним не обращаемся — возможно, это какие-то случайные ключи. Но если мы попробуем просканировать всю базу данных, пытаясь что-то найти, как мы тогда искали, сколько у нас записей с удаленными объектами, эти Зомби очень мешают.

Решение

Решение очень простое, но довольно важное:

  • Repair в кластере нужно делать в любом случае.

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

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

Интервал repair — это то время, за которое удается сделать repair. Например, мы знаем, что мы успеваем для этого кластера провести починку за 10-20 дней, неделю, 3 дня. Но период удаления Tombstone должен быть выше этого значения, которое постигается только из практики. Если мы слишком агрессивно будем репейрить, окажется так, что кластер плохо отвечает на фронтенд-запросы.

На самом деле с этим сложно бороться. Еще одна классическая проблема для Cassandra, на которую часто наталкиваются разработчики.

Как я сказал, он может быть произвольного размера — 10 ключей, 100 млрд ключей. В S3 есть бакет. Причем список должен быть отсортирован, отдаваться, само собой, постранично, его можно листать, и он должен быть всегда консистентен с текущими операциями. Один из API, который мы должны поддерживать — это отдать список ключей в бакете. Я не могу его отложено перестроить. То есть, если я записал объект, удалил объект, беру список ключей — и он тот же самый, как после моей операции.

Как реализовать такой API?

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

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

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

Если я построю к этой таблице запрос, который мне нужен — выбрать данные из бакета, начиная с какого-то ключа, и чтобы они были отсортированы — запрос работает. Здесь я использую то, что в Cassandra называется composite key. Рад ли я? Он делает ровно то, что мне нужно. Да, я конечно рад, у меня все получилось!

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

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

Как на самом деле Cassandra его хранит? Но есть еще внутренний слой данных. Такая конструкция, которую я описал, внутри на самом деле хранится, как длинная строка, в которой каждый ключ (в данной ситуации ключ в бакете) является отдельной колонкой. Исторически он был первичный, и к нему был свой, совершенно другой API. Чем больше размер бакета, тем больше колонок в этой таблице.

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

Даже если я спрашиваю: «Дай мне 100 ключей», а там хранится миллион, в зависимости от версии, для того, чтобы построить ответ на мой вопрос, ей приходится буквально прочитать всю строку в миллион, оттуда выбрать 100, а все остальное выбросить. Но в Cassandra есть куча операций, при которых она оперирует целиком значением строки.

Если у меня есть миллион колонок в одном узле, миллион в другом, миллион в третьем, формально для того, чтобы построить ответ на запрос, невозможно сделать что-то простое. Представьте, что эти данные еще распределены по нескольким узлам (это же несколько реплик), и любой запрос — это же на самом деле не запрос к конкретной реплике, а по сути запрос, который пытается построить консистентное представление по нескольким узлам одновременно. Если же узлы не совсем совпадают, то этот запрос с лимитом с точки зрения SQL становится вовсе не тривиальным. Если я прошу дать 100 ключей, которые больше такого-то значения, и все узлы идеально совпадают, это просто.

Эта конструкция, называемая Large Partition, возникает незаметно. Cassandra пытается такую широкую строчку протянуть в память, и когда она это делает, а она написана на Java, ей становится очень плохо. Но потом начинается экспоненциальное падение с точки зрения производительности, узлы начинают падать, garbage collection не справляется и т.д. Пока данных немного — десятки, сотни, тысячи, десятки тысяч, даже сотни тысяч ключей — все хорошо. В результате получается каскадный эффект.

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

Конечно, мы сразу знали об этой проблеме и подумали, что надо что-то заранее сделать.

Я их буду называть условно партициями. Поэтому в таблице, которая используется для листинга объектов, мы заранее предусмотрели возможность разбросать данные одного бакета по нескольким колонкам. То есть партиционировать таблицу так, чтобы у нас не было Large Partition.

У нас есть два требования:

  1. чтобы каждая партиция была ограничена в размере (не больше, чем сколько-то ключей);
  2. несмотря на то, что мы распределяем данные, мы хотим получить отсортированный список ключей быстро. Это было нашей изначальной задачей, ради этого все и затевалось.

Когда мы запустились, мы так и не придумали, как распределять данные, и в качестве колонки key_hash всегда использовали 0. Как всегда бывает, фичи идут впереди любых улучшений, которые не приносят непосредственного профита с точки зрения продукта. Поэтому мы конечно же пропустили тот момент, когда партиции стали большими. У нас было несколько очень веселых месяцев, когда мы прикручивали решение к системе, которая находилась практически в состоянии агонии.

Давайте обсудим, как это можно было сделать.

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

Если выбрать слишком маленькое, будет Large Partition, слишком большое — слишком много партиций будет создано. Первая проблема — какого размера должно быть это хэширование, какое нам N выбрать? Он может изменяться: расти или уменьшаться. При этом, мы ничего не знаем о размере бакета заранее. Чтобы получить отсортированный список, надо делать запрос ко всем партициям и объединять результаты от них. Самое главное, если это просто хэширование, то получается, что свойство отсортированности пропадет, и в каждой партиции будут лежать какие-то случайные ключи. Крайне неудобно и неэффективно, особенно, если этих партиций станет много.

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

Именуем ключи не мы, а наши клиенты. Но тут опять возникает та же самая проблема — как угадать, каким образом именуются ключи? Кто-то их называет как результат md5-хеширования — это идеально подходит под такую схему, а у кого-то первые 30 символов — это константа у всех ключей, или что-то еще. Как они их называют? Эта схема работала бы хорошо, только если бы мы знали, как выглядит ключ.
Мы не можем угадать.

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

Динамическое хэшировнаие

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

  • Динамическая таблица распределения.
  • Генетический алгоритм для поиска идеального распределения и идеального перераспределения.
  • Подсчет размера патриции вне Cassandra.
  • Online-перераспределение (без остановки операций и потери консистентности).

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

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

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

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

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

Здесь можно играть с трюками, связанными с тем, что Cassandra умеет разрешать конфликты. Это довольно интересно, и это получается с Cassandra. Если мы в одну и ту же строку записали два разных значения, то выигрывает то значение, у которого timestamp больше.

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

Такие трюки позволяют сделать это онлайн.

Решение

  • Никогда, никогда не допускайте появления large partition.
  • Разбивайте данные по primary key в зависимости от задачи.

Если в схеме данных намечается что-то похожее на large partition, надо сразу попытаться что-то с этим сделать — придумать, как его разбить и как от него уйти. Рано или поздно это возникает, потому что любой инвертированный индекс рано или поздно возникает практически в любой задаче. Я уже рассказывал про такую историю — у нас есть бакет-ключ в объект, и нам нужно получить из бакета список ключей — по сути, это индекс.

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

Например, вы видите, что время отклика от Cassandra выросло, она отвечает медленно. Еще одна история, которая на самом деле постоянная — от начала до конца что-то идет не так. Никогда не бывает внешнего сигнала, что проблема именно там-то.
Как понять и разобраться, в чем проблема?

На нем видно, что у нас проблема — максимальное время отклика уперлось в 12 с — это внутренний таймаут Cassandra. Для примера приведу график — это усредненное время отклика кластера в целом. Если таймаут выше 12 с, это скорее всего означает, что работает garbage collector, и Cassandra не успевает даже ответить в нужное время. Это означает, что она таймаутится сама. Она отвечает сама по таймауту, но время отклика на большинство запросов, как я говорил, должно быть в среднем в пределах 10 мс.

Но глядя на эту картинку, невозможно понять, в чем причина. На графике среднее превысило уже сотни миллисекунд — что-то пошло не так.

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

Эта статистика со стороны приложения. Статистика по узлам изменяет картинку полностью. Когда приложение обращается к Cassandra, оно обращается к какому-то узлу, используя его как координатор. Но и здесь на самом деле очень часто сложно понять, в чем проблема. Те уже отвечают, и координатор формирует конечный ответ обратно. То есть приложение дает запрос, и координатор его перенаправляет к репликам с данными.

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

Здесь счастливая ситуация — видно, что только один узел отвечает медленно, и скорее всего, проблема именно в нем.

Сложность интерпретации

  • Время отклика координатора (сам узел vs. реплики).
  • Конкретная таблица или весь узел?
  • GC Pause? Недостаточный Thread Pool?
  • Слишком много uncompacted SSTables?

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

Для чтения Cassandra приходится, грубо говоря, перебирать все SSTables. Может быть, например, ситуация, когда у одной таблицы того, что называется в Cassandra SSTables (отдельные файлы) слишком много. Если их слишком много, то просто процесс этого перебора занимает слишком много времени, и чтение начинает проседать.

Так как Cassandra написана, к сожалению, на Java и работает на JVM, может быть, garbage collector ушел в такую паузу, что просто не успевает ответить. Решением является compaction, который уменьшает количество этих SSTables, но надо заметить, что это может быть всего на одном узле для одной конкретной таблицы. Узлы друг друга начинают считать ушедшими в down, то есть упавшими, мертвыми. Когда garbage collector уходит в паузу, не только ваши запросы тормозят, но и взаимодействие внутри кластера Cassandra между узлами начинает тормозить.

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

Например, может быть достаточно ресурсов, все хорошо и замечательно, но просто Thread Pool, число которых фиксированного размера, надо увеличить. Бывают ситуации, когда эту проблему можно решить просто с помощью правильных настроек.

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

Понимание контекста

Всегда есть какой-то контекст у проблемы — что происходит в кластере, работает ли сейчас Repair, на каком узле, в каком key spaces, в какой таблице.

Мы видели, что часть узлов работает медленно. У нас, например, были достаточно смешные проблемы с железом. По какой-то причине во время начальной установки железа так получилось, и использовалось примерно 50% ресурсов процессора по сравнению с другими узлами. Позже обнаружилось, что причина была в том, что в BIOS их процессоры стояли в энергосберегающем режиме.

Симптом такой — вроде бы узел делает compaction, но делает его медленно. Разобраться в такой проблеме может быть тяжело, на самом деле. Иногда это связано с железом, иногда нет, но это просто очередной баг Cassandra.

Чем сложнее фича в Cassandra, чем дальше она отстоит от простой записи и чтения, тем больше с ней проблем, и тем быстрее она может убить БД при достаточном количестве запросов. Поэтому мониторинг обязателен и его нужно много. Не всегда возможно — конечно, рано или поздно приходится.
Поэтому, если есть возможность, не надо смотреть на какие-то «вкусные» фишки и пытаться их использовать, лучше их избегать насколько это возможно.

В этой ситуации это произошло внутри Cassandra. Последняя история про то, как Cassandra испортила данные. Это было интересно.

Причем Cassandra валидирует данные, которые поступают к ней на вход. Мы видели, что примерно раз в неделю у нас в базе данных появляется несколько десятков испорченных строк — они буквально забиты мусором. Но в этих строках находился мусор, не utf8, и Cassandra с ним даже ничего делать не давала. Например, если это строка, то она должна быть в utf8. При попытке удалить (или что-то еще сделать), я не могу удалить значение, которое не является utf8, потому что, в частности, не могу никак вписать в WHERE, потому что ключ должен быть utf8.

Испорченные строки появляются, как вспышка, в какой-то момент, и дальше их нет опять в течение нескольких дней или недель.

Мы думали, может быть проблема в определенном узле, с которым мы возились, делали что-то с данными, SSTables копировали. Мы начали искать проблему. Может быть, у этих реплик есть общий узел, наименьший общий делитель? Может быть, все-таки у этих данных можно посмотреть их реплики? Нет, ничего подобного. Может быть, какой-то узел дает сбой?

На диске данные испортились? Может быть, что-то с диском? Опять нет.

Нет! Может быть, память? Разбросано по кластеру.

Один узел все попортил и дальше реплицировал плохое значение? Может быть, это какая-то проблема репликации? — Нет.

Наконец, может быть, это проблема приложения?

Один работал на версии 2. Причем в какой-то момент испорченные строки стали появляться в двух кластерах Cassandra. Вроде и Cassandra разные, а проблема одна и та же. 1, второй на третьей. Но верилось в это с трудом. Может, у нас сервис отправляет плохие данные? Но вдруг? Cassandra валидирует данные на входе, она не могла записать мусор.

Ничего не подходит.

Иголка нашлась!

Мы бились долго и упорно, пока не обнаружили маленькую проблему: почему у нас на узлах есть какие-то crash dump от JVM, на которые мы особо внимания не обращали? И как-то подозрительно выглядит в stack trace garbage collector… И почему-то некоторые stack trace тоже мусором забиты.

Это было единственным общим, что объединяло кластеры Cassandra на разных версиях Cassandra. В итоге мы поняли — о, мы почему-то используем JVM старой версии 2015 года.

Но после обновления все исчезло, проблема больше не возникала. Я до сих пор не знаю, в чем была проблема, потому что в официальных релиз-ноутах JVM про это ничего не написано. Причем она возникала в кластере не с первого дня, а с какого-то момента, хотя он работал на том же самом JVM довольно долго.

Восстановление данных

Какой урок мы вынесли из этого:

В момент, когда данные входили в координатор, они уже были испорчены.   ● Backup бесполезен.
Данные, как мы выяснили, портились в ту же секунду, когда они были записаны.

  ● Возможно частичное восстановление неповрежденных колонок.
Какие-то колонки были не повреждены, мы могли эти данные прочитать, частично восстановить.

Чтобы восстановить связь с объектом, мы использовали логи и т.д.   ● В конечном итоге нам приходилось делать восстановление из разных источников.
У нас был backup метаданных в объекте, но в самих данных.

  ● Логи — это бесценно!
Мы смогли восстановить все данные, которые были испорчены, но в конечном итоге очень тяжело доверять базе данных, если она ваши данные теряет даже без какого-то действия с вашей стороны.

Решение

  • Обновляйте JVM после длительного тестирования.
  • Мониторинг JVM crash.
  • Имейте независимую от Cassandra копию данных.

Это может быть решением последнего уровня. В качестве совета: Старайтесь иметь какую-то независимую от Cassandra копию данных, из которой вы можете восстановиться, если это необходимо. Пусть на это потребуется куча времени, ресурсов, но чтобы был какой-то вариант, который позволит вам вернуть данные.

  ● Низкое качество тестирования релизов
Когда вы начинаете работать с Cassandra, возникает постоянное ощущение (особенно, если вы переходите, условно говоря, с «хороших» баз данных, например, с PostgreSQL), что если в релизе исправили баг предыдущего, то обязательно добавили новый. Причем баг — это не какая-нибудь ерунда, это обычно испорченные данные или другое некорректное поведение.

  ● Постоянные проблемы со сложными фичами
Чем сложнее фича, тем больше с ней проблем, багов и т.д.

1
Знаменитый repair, о котором я рассказывал, который чинит консистентность данных, в стандартном режиме, когда он опрашивает все узлы, работает хорошо.   ● Не используйте инкрементальный repair в 2. Он был заявлен давно, формально, как фича существует, но все говорят: «Нет, в версии 2. Но не в так называемом инкрементальном режиме (когда repair пропускает данные, которые не изменились со времени предыдущего repair, что вполне логично). Он обязательно что-нибудь пропустит. 1 не используйте его никогда! В 3 мы исправим»

Есть список из 15 багов, поэтому ни в коем случае не используйте инкрементальный repair.   ● Но не используйте инкрементальный repair и в 3.x
Когда вышла третья версия, через несколько дней они сказали: «Нет, в 3-й использовать его нельзя. В 4-й мы сделаем лучше!»

А это большая проблема, особенно с ростом размера кластера. Я им не верю. Без этого с ними, к сожалению, жить невозможно. Поэтому нужно следить постоянно за их bugtracker и смотреть, что происходит.

  ● Надо следить за JIRA

Это не значит, что она плохая, просто надо быть готовым к тому, что Cassandra непредсказуема в любом смысле этого слова: и в том, как она работает, и в том, что что-то может случиться. Если разбросать все базы данных по спектру предсказуемости, для меня Cassandra находится слева в красной области.

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

Открытая встреча активистов HighLoad++

Организуем небольшой мозговой штурм по поводу программы этого года, чтобы не упустить ничего нового и важного. 31 июля в Москве, в 19:00 состоится встреча докладчиков, Программного комитета и активистов конференции разработчиков высоконагруженных систем HighLoad++ 2018. Встреча открытая, но нужно зарегистрироваться.

Call for Papers

Программный комитет ждет ваши тезисы до конца лета.
Активно идет прием заявок на доклады на Highload++ 2018.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Манифест Чистого Программиста или краткий конспект книги «Чистый Код» Роберта Мартина

Тут нет разделов о тестировании, TDD, о том какая должна быть архитектура и т.д. Данная статья является конспектом книги "Чистый Код" Роберта Мартина и моим пониманием того, каким Чистый Код должен быть. Здесь все только о том, каким должен быть ...

[Из песочницы] В топку MVPs, внедряем MVPr (минимальный жизнеспособный прототип)

Привет, Хабр! Представляю вашему вниманию перевод статьи «Ditch MVPs, Adopt Minimum Viable Prototypes (MVPr)» автора Alexandru Giuseppe Ispas. Это особенно справедливо в мире цифровых продуктов, где каждое взаимодействие и графический элемент могут стать объектом дизайнерской одержимости. Стремление к совершенству — ...