Это вторая лекция Я.
Субботник по базам данных - первый мы опубликовали пару недель назад. Руководитель группы СУБД общего назначения Дмитрий Сарафанников рассказал об эволюции хранилища данных в Яндексе: как мы решили сделать S3-совместимый интерфейс, почему выбрали PostgreSQL, на какие ошибки наступили и как с ними справились .
- Всем привет! Меня зовут Дима, я работаю с базами данных в Яндексе.
Я расскажу вам, как мы делали S3, как мы пришли к созданию S3 и какие хранилища у нас были раньше.
Первый из них — Elliptics, он имеет открытый исходный код и доступен на GitHub. Многие, возможно, сталкивались с этим.
По сути, это распределенная хеш-таблица с 512-битным ключом, результат SHA-512. Он образует связку ключей, которая случайным образом распределяется между машинами.
Если вы хотите добавить туда машины, происходит перераспределение ключей и происходит ребалансировка.
У этого хранилища есть свои проблемы, связанные, в частности, с ребалансировкой.
Если у вас достаточно большое количество ключей, то при постоянно растущих объемах вам нужно постоянно добавлять туда машины, а при очень большом количестве ключей ребалансировка может просто не сойтись.
Это была довольно большая проблема.
Но в то же время это хранилище отлично подходит для более-менее статичных данных, когда вы единоразово загружаете большой том, а затем гоните на него нагрузку только для чтения.
Это идеальный вариант для таких решений.
Давайте двигаться дальше.
Проблемы с ребалансировкой были достаточно серьезные, поэтому появилось следующее хранилище.
В чем его суть? Это не хранилище значений ключей, это хранилище значений.
Когда вы загружаете туда какой-то объект или файл, он отвечает вам ключом, с помощью которого вы потом можете забрать этот файл.
Что это дает? Теоретически 100% доступность записи при наличии свободного места в хранилище.
Если у вас завалялись одни машинки, вы просто пишете на другие, которые не валяются, на которых есть свободное место, получаете другие ключи и спокойно забираете с них свои данные.
Это хранилище очень легко масштабируется, можно в него кинуть железо, оно будет работать.
Это очень просто и надежно.
Единственный его недостаток: клиент не управляет ключом, и все клиенты должны где-то хранить свои ключи и хранить сопоставление своих ключей.
Это неудобно для всех.
По сути, это очень схожая задача для всех клиентов, и каждый решает ее по-своему в своих метабазах и т. д. Это неудобно.
Но в то же время мне не хочется терять надежность и простоту этого хранилища; на самом деле он работает на скорости сети.
Потом мы начали смотреть на S3. Это хранилище «ключ-значение», клиент управляет ключом, все хранилище разделено на так называемые сегменты.
В каждом сегменте ключевое пространство находится в диапазоне от минус бесконечности до плюс бесконечности.
Ключ — это какая-то текстовая строка.
И мы остановились на этом варианте.
Почему S3? Все довольно просто.
На данный момент уже написано много готовых клиентов для различных языков программирования, уже написано много готовых инструментов для хранения чего-либо в S3, скажем, резервных копий баз данных.
Андрей сказал об одном из примеров.
Уже есть достаточно продуманное API, проверенное годами на клиентах, и не нужно там ничего придумывать.
API имеет множество удобных функций, таких как списки, загрузка нескольких частей и т. д. Поэтому мы решили на этом остановиться.
Как сделать S3 из нашего хранилища? Что приходит на ум? Поскольку клиенты сами хранят маппинг ключей, мы просто возьмем его, поместим рядом с базой данных и сохраним в ней маппинг этих ключей.
При чтении мы просто найдём ключи и хранилище в нашей базе, и дадим клиенту то, что он хочет. Если изобразить это схематично, как происходит наполнение?
Есть некая сущность, здесь она называется Proxy, так называемый бэкенд. Он принимает файл, загружает его в хранилище, получает оттуда ключ и сохраняет его в базе данных.
Все довольно просто.
Как вы это получите? Прокси находит нужный ключ в базе данных, идет с ключом в хранилище, скачивает оттуда объект и отдает его клиенту.
Все также просто.
Как происходит удаление? При удалении напрямую из хранилища прокси не работает, так как сложно согласовать базу и хранилище, поэтому он просто идет в базу, сообщает ей, что этот объект удален, там объект перемещается в очередь на удаление , а затем специально обученный специалист робот в фоновом режиме забирает эти ключи, удаляет их из хранилища и из базы данных.
Здесь тоже все довольно просто.
В качестве базы данных для этой метабазы мы выбрали PostgreSQL. Вы уже знаете, что мы его очень любим.
С переездом Яндекс.
Почты мы накопили достаточную экспертизу в PostgreSQL, а когда переехали разные почтовые сервисы, разработали несколько так называемых шаблонов шардинга.
S3 хорошо вписался в один из них с небольшими доработками, но и туда вписался хорошо.
Какие есть варианты шардинга? Это большое хранилище, в масштабах Яндекса сразу надо думать, что объектов будет очень много, нужно сразу думать, как это все шардировать.
Можно шардировать по хешу от имени объекта, это самый надежный способ, но здесь он не подойдет, потому что у S3 есть, например, листинги, которые должны показывать список ключей в отсортированном порядке, при хешировании все сортировки исчезнет, вам необходимо удалить все объекты, чтобы вывод соответствовал спецификации API. Следующий вариант — сегментировать по хешу, используя имя или идентификатор корзины.
Одно ведро может находиться внутри одного сегмента базы данных.
Другой вариант — сегментировать по диапазонам ключей.
Внутри ведра пространство от минус бесконечности до плюс бесконечности, мы можем разделить его на сколько угодно диапазонов, мы называем этот диапазон чанком, он может жить только в одном шарде.
Мы выбрали третий вариант — шардинг по кускам, потому что теоретически в одном ведре может содержаться бесконечное количество объектов, и в одно железо оно просто не поместится.
Тут будут большие проблемы, поэтому разрежем и разложим по осколкам как захотим.
Все здесь.
Что случилось? Вся база данных состоит из трех компонентов.
S3 Proxy — это группа хостов, которая также содержит базу данных.
PL/Proxy находится под балансировщиком, туда отправляются запросы от того бэкенда.
Далее идет S3Meta, басовая группа, хранящая информацию о сегментах и чанках.
И S3DB, шарды, где хранятся объекты, очередь на удаление.
Если изобразить это схематически, то это выглядит так.
Приходит запрос на S3Proxy, он идёт на S3Meta и S3DB и отправляет информацию вверх.
Давайте посмотрим поближе.
S3Proxy, внутри него функции созданы на процедурном языке PLProxy, это язык, позволяющий удаленно выполнять хранимые процедуры или запросы.
Вот как выглядит код функции ObjectInfo, по сути, это запрос Get.
В кластере LProxy есть оператор Cluster, в данном случае это db_ro. Что это значит?
В типичной конфигурации сегмента базы данных есть мастер и две реплики.
Мастер является частью кластера db_rw, все три хоста входят в состав db-ro, именно сюда можно отправить запрос только на чтение, а на db_rw отправляется запрос на запись.
Кластер db_rw включает в себя все мастера всех шардов.
Следующий оператор — RUN ON, он принимает либо значение all, что означает выполнение на всех шардах, либо на массиве, либо на каком-то шарде.
В данном случае на вход он принимает результат функции get_object_shard — это номер шарда, на котором лежит этот объект. И target — какую функцию вызывать на удаленном шарде.
Он вызовет эту функцию и подставит туда аргументы, поступившие в эту функцию.
Функция get_object_shard также написана на PLProxy, уже кластере мета_ро, запрос полетит к шарду S3Meta, который вернет эту функцию get_bucket_meta_shard.
S3Meta тоже можно шардировать, мы это тоже планировали, пока не актуально, но возможность есть.
И вызовет функцию get_object_shard в S3Meta.
get_bucket_meta_shard — это просто хэш текста от имени бакета; мы сегментировали S3Meta просто по хешу имени корзины.
Давайте посмотрим на S3Meta и что в ней происходит. Самая важная информация, которая есть, — это таблица с кусками.
Вырезал немного ненужной информации, осталось самое главное — это Bucket_id, начальный ключ, конечный ключ и шард, в котором лежит этот чанк.
Как будет выглядеть запрос на основе такой таблицы, который вернет нам фрагмент, содержащий, например, тестовый объект? Что-то вроде этого.
Минус бесконечность в текстовом виде мы представили как нулевое значение, есть такие тонкие моменты, что нужно проверять start_key и end_key на предмет наличия Null.
Запрос выглядит не очень хорошо, а план выглядит еще хуже.
В качестве одного из вариантов такого плана запроса BitmapOr. И стоит такой план 6000 затрат.
Как может быть иначе? В PostgreSQL есть такая замечательная вещь, как gist index, которая может индексировать тип диапазона, диапазон — это, по сути, то, что нам нужно.
Мы создали этот тип, функция s3.to_keyrange по сути возвращает нам диапазон.
Мы можем использовать оператор contains, чтобы проверить и найти фрагмент, содержащий наш ключ.
Именно так здесь построено ограничение исключения, которое гарантирует, что эти куски не пересекаются.
Нам нужно разрешить, желательно на уровне базы данных, какое-то ограничение, чтобы гарантировать, что куски не могут пересекаться друг с другом, чтобы в ответ на запрос возвращалась только одна строка.
Иначе будет не то, что мы хотели.
Вот как выглядит план такого запроса, обычный index_scan. Это условие полностью укладывается в индексное условие, а затрат у такого плана всего 700, в 10 раз меньше.
Что такое ограничение исключения?
Давайте создадим тестовую таблицу с двумя столбцами и поместим в нее два ограничения: одно уникальное, которое все знают, и одно ограничение исключения, имеющее равные параметры, такие как операторы.
Приравняв два оператора, была построена следующая таблица.
Далее пытаемся вставить две одинаковые строки, получаем ошибку о нарушении уникальности ключа по первому ограничению.
Если мы отбросим его, то мы уже нарушили ограничение исключения.
Это общий случай уникального ограничения.
По сути, ограничение уникальности — это то же самое ограничение исключения с операторами равенства, но в случае ограничения исключения можно сконструировать несколько более общих случаев.
У нас есть такие индексы.
Если вы присмотритесь, то увидите, что это оба индекса по сути, и в целом они одинаковы.
Вы, вероятно, спросите, зачем вообще дублировать этот вопрос.
Я вам скажу.
Индексы — это такая штука, особенно gist index, что таблица живет своей жизнью, происходят обновления, происходят обновления и так далее, индекс там ухудшается и перестает быть оптимальным.
И есть такая практика, в частности расширение pg repack, индексы перестраиваются периодически, через раз перестраиваются.
Как перестроить индекс под уникальным ограничением? Создайте индекс создания в данный момент, создайте один и тот же индекс незаметно рядом, без блокировки, а затем используйте выражение таблицы изменения из ограничения user_index такого-то и такого-то.
И все, здесь все четко и хорошо, работает. В случае ограничения исключения перестроить его можно только через переиндексацию путем блокировки; точнее, ваш индекс будет эксклюзивно заблокирован, и по сути все запросы у вас останутся.
Это неприемлемо; Построение индекса сущности может занять довольно много времени.
Поэтому мы держим рядом второй индекс, который меньше по объему, занимает меньше места, планер его использует, и мы можем конкурентно перестроить этот индекс, не блокируясь.
Вот график потребления процессора.
Зеленая линия — потребление процессора в user_space, оно скачет от 50% до 60%.
В этот момент потребление резко падает, это момент перестройки индекса.
Мы перестроили индекс, удалили старый, и потребление процессора у нас резко снизилось.
Это основная проблема индекса, она существует, и это наглядный пример того, как это может произойти.
Когда мы все это сделали, мы начали с версии 9.5 S3DB, по плану мы планировали укладывать по 10 миллиардов объектов в каждый шард. Как известно, больше 1 миллиарда и даже раньше начинаются проблемы, когда в таблице много строк, все становится намного хуже.
Существует практика разделения.
На тот момент было два варианта, либо стандартный через наследование, но это работает не очень хорошо, так как есть линейная скорость выбора раздела.
И судя по количеству объектов, партиций нам понадобится очень много.
Ребята из PostgreSQL тогда активно работали над расширением pg_pathman.
Мы выбрали pg_pathman, другого выбора у нас не было.
Даже версия 1.4. И как видите, мы используем 256 разделов.
Всю таблицу объектов мы разделили на 256 разделов.
Что делает pg_pathman? С помощью этого выражения вы можете создать 256 разделов, которые будут разделены по хешу из столбца ставки.
Как работает pg_pathman?
Он регистрирует свои хуки в планировщике, а затем по сути подменяет план на запросах.
Видим, что при обычном поисковом запросе объекта с именем test он не искал в 256 партициях, а сразу определил, что нужно зайти в таблицу Objects_54, но здесь не все гладко, у pg_pathman есть свои проблемы.
Во-первых, поначалу, пока его вырезали, было довольно много ошибок, но спасибо ребятам из PostgreSQL, они их оперативно починили и исправили.
Первая проблема — сложность его обновления.
Вторая проблема — подготовленные заявления.
Давайте посмотрим поближе.
В частности обновление.
Из чего состоит pg_pathman?
По сути, он состоит из кода C, который упакован в библиотеку.
И состоит он из SQL-части, всяких функций по созданию разделов и так далее.
Кроме того, в библиотеке есть интерфейсы для функций C. Эти две части не могут обновляться одновременно.
Это приводит к осложнениям, что-то вроде такого алгоритма обновления версии pg_pathman: мы сначала выкатываем новый пакет с новой версией, но у PostgreSQL все равно в памяти загружены старые версии, он ее использует. В любом случае базу данных необходимо сразу перезапустить.
Далее мы вызываем функцию set_enable_parent, она включает функцию в родительской таблице, которая по умолчанию отключена.
Потом отключаем pathman, перезапускаем базу, говорим ALTER EXTENSION UPDATE, в это время все попадает в родительскую таблицу.
Далее включаем pathman и запускаем функцию, которая есть в расширении, которая за этот короткий промежуток времени смещает атаковавшие туда объекты из родительской таблицы, смещает их обратно в те таблицы, где они должны быть.
А затем отключаем использование родительской таблицы и ищем в ней.
Следующая проблема — подготовленные заявления.
Если мы настроим тот же обычный запрос, поиск по ставке и ключу, мы попробуем его выполнить.
Делаем пять раз — все нормально.
Выполняем шестое – видим этот план.
И в этом плане мы видим всего 256 разделов.
Если внимательно посмотреть на эти условия, то мы видим здесь доллар 1, доллар 2, это так называемый родовой план, генеральный план.
Первые пять планов запросов строились индивидуально, для этих параметров использовались индивидуальные планы, pg_pathman мог сразу определить, поскольку параметр был известен заранее, он мог сразу определить таблицу, куда идти.
В данном случае он не может этого сделать.
Соответственно, в плане должны быть все 256 разделов, и когда исполнитель пойдет это исполнять, он пойдет и возьмет общую блокировку на все 256 разделов, и производительность такого решения сразу непригодна.
Он просто теряет все свои преимущества, и любой запрос выполняется безумно долго.
Как мы вышли из этой ситуации? Пришлось все внутри хранимых процедур завернуть в выполнение, в динамический SQL, чтобы не использовались подготовленные операторы и каждый раз строился план.
Вот как все это работает. Минус в том, что вам придется запихивать весь код в конструкции, влияющие на эти таблицы.
Здесь сложнее читать.
Как распределяются объекты? В каждом шарде S3DB хранятся счетчики чанков, также есть информация о том, какие чанки находятся в этом шарде, и для них хранятся счетчики.
Для каждой операции изменения объекта — добавления, удаления, изменения, перезаписи — эти счетчики чанка изменяются.
Чтобы не обновлять одну и ту же строку при активном заполнении этого чанка, мы используем достаточно стандартный прием, когда вставляем дельта-счетчик в отдельную таблицу, и раз в минуту специальный робот проходит и агрегирует все это, обновляя счетчики в чанке.
Потом эти счетчики доставляются в S3Meta с некоторой задержкой, уже есть полная картина сколько счетчиков в каком чанке, далее можно посмотреть распределение по шардам, сколько объектов в каком шарде и на основании этого принимать решение создается там, где идет новый кусок.
Когда вы создаете сегмент, для сегмента по умолчанию создается один чанк от минус бесконечности до плюс бесконечности, в зависимости от текущего распределения объектов, которые, как известно S3Meta, попадают в какой-либо сегмент. Когда вы загружаете данные в этот бакет, все эти данные перетекают в этот чанк, и когда он достигает определенного размера, приходит специальный робот и делит этот чанк.
Следим, чтобы эти куски были небольшими.
Мы делаем это для того, чтобы в случае чего этот небольшой кусок можно было перетащить на другой шард. Как происходит разделение куска? Вот обычный робот, он идет и разбивает этот кусок в S3DB с двухфазным коммитом и обновляет информацию в S3Meta.
Передача фрагмента — немного более сложная операция; это двухфазная фиксация трех баз данных: S3Meta и двух шардов S3DB, перенесенных из одной и добавленной в другую.
В S3 есть такая функция как листинги, это самая сложная вещь, и с ней тоже были проблемы.
По сути, в списках вы говорите S3 — покажите мне предметы, которые у меня лежат. Параметр, который в настоящее время имеет значение NULL, выделен красным.
Этот параметр, разделитель, разделитель, вы можете указать листинги с каким разделителем вы хотите.
Что это значит? Если разделитель не указан, мы видим, что нам просто дан список файлов.
Если мы установим разделитель, S3, по сути, должен показать нам папки.
Он должен понимать, что здесь есть такие папки, и фактически показывает все папки и файлы в текущей папке.
Текущая папка указывается префиксом, этот параметр имеет значение Null. Видим, что там 10 папок.
Все ключи не хранятся в какой-то иерархической древовидной структуре, как в файловой системе.
Каждый объект хранится в виде строки и имеет простой общий префикс.
S3 должен сам понять, что это приклад.
Эта логика довольно плохо сочетается с декларативным SQL; это довольно легко описать с помощью императивного кода.
Первый вариант был сделан именно так, просто хранимые процедуры в PL/pgSQL. Он императивно обрабатывал эту логику в цикле и требовал повторяемого уровня чтения.
Мы должны видеть только один снимок, все запросы должны выполняться с одним снимком.
В противном случае, если кто-то загрузит туда что-то после первого запроса, мы получим противоречивые листинги.
Потом удалось все это переписать в Recursive CTE, получилось очень громоздко со сложной логикой, без пол-литра не разберешься, и все это завернуто в выполнение внутри PL/pgSQL. Но мы получили ускорение, в некоторых случаях до ста раз.
Вот, например, графики процентилей и времени отклика функции списка объектов.
Что было до и после.
Эффект заметен визуально, в том числе и по нагрузке.
Оптимизацию мы проводили в несколько этапов.
Вот еще один график другой оптимизации, где наши высокие квантили просто упали до низких.
Для тестирования мы используем Docker, pro. Вести себя и тестирование поведения — это замечательно отчет Александра Клюева.
Обязательно посмотрите, все очень удобно, понятно, писать тесты теперь в радость.
Нам еще есть что оптимизировать.
Самая острая проблема, как я вам показал, — это загрузка ЦП на S3Meta. Индекс Gist потребляет много ресурсов ЦП, особенно когда он становится неоптимальным после многочисленных обновлений и делитов.
Процессора на S3Meta явно не хватает. Можно штамповать реплики, но это будет неэффективное использование железа.
У нас есть группа хостов с PLProxy под балансировщиком, которые стоят и удаленно вызывают функции на S3Meta и S3DB. Фактически там процессор можно заставить сжечь прокси.
Для этого вам необходимо организовать логическую репликацию этих чанков из S3Meta на все прокси.
В принципе, мы планируем это сделать.
В логической репликации существует ряд проблем, которые мы решим и попытаемся перенести в восходящий поток.
Второй вариант — отказаться от сути и попробовать поместить этот текстовый диапазон в btree. Это не одномерный тип, а btree работает только с одномерными типами.
Но условие, что наши чанки не должны пересекаться, позволит разместить наш кейс в btree. Буквально вчера мы сделали прототип, который работает. Это реализовано с использованием функций PL/pgSQL. Мы получили заметное ускорение, будем оптимизировать в этом направлении.
Теги: #Администрирование баз данных #postgresql #s3 #хранилище объектов
-
Поглощение Intrinsity
19 Oct, 24 -
Подарил Бизнесмену
19 Oct, 24 -
Chromium Не Любит Webmoney?
19 Oct, 24 -
Сайт Шанцева Подешевел До 1,4 Млн Рублей
19 Oct, 24 -
Бета-Версия Google Chrome
19 Oct, 24 -
Симула – 50 Лет Ооп
19 Oct, 24