Что нового в Greenplum 7

agk03onrxa6hfhcu7rfidgjcjsq.png

Привет! Меня зовут Василий Иванов, и я работаю в компании Arenadata. Одним из первых продуктов компании, вошедших в нашу платформу обработки больших данных, стала Arenadata DB (ADB). В ее основе лежит MPP СУБД с открытым исходным кодом Greenplum. Задача, стоящая перед нами, очень быстро переросла из «Как это собрать и запустить?» в «Почему это работает не так, как ожидалось?» и «Нам надо, чтобы работало вот так». Появилась выделенная команда разработчиков, исправления и улучшения передавались сообществу, росли компетенции и вместе с ними сложность задач, которые мы могли решать самостоятельно. Это позволило создать свой форк с открытым исходным кодом, чтобы доставлять исправления до наших заказчиков не дожидаясь благословения сообщества (порой, будем честны, стремящегося к бесконечности по времени реакции).

Наличие своего форка сыграло благотворную роль. Мы оказались готовы и к отказу принимать исправления от большей части русскоязычного сообщества в сентябре 2023 года, и к архивации публичных репозиториев вместе с прекращением открытой разработки в мае 2024 года. Всё это произошло без объяснения причин со стороны компании Broadcom, поглотившей VMWare. Автоматизированное тестирование и сборка давно уже производились нами на своей инфраструктуре.

В сентябре прошлого года после четырёх лет разработки состоялся релиз следующей мажорной версии — Greenplum 7. Свой вклад в это событие внесла и наша команда. И сейчас, в процессе подготовки к релизу ADB 7, можно обсудить те изменения, которые ждут пользователей.

Самый популярный ответ на вопрос «Что вы ожидаете от Greenplum 7?» звучит как «Postgres 12!». Greenplum является одним из многочисленных форков Postgres, и кодовая база первого наконец-то догнала ещё пока поддерживаемую двенадцатую версию второго (последний релиз состоится в ноябре текущего года). Напомню, что прошлый стабильный релиз был основан на Postgres 9.4. Работа над актуальной кодовой базой — это существенный плюс для разработчика. Однако наличие какого-либо функционала в Postgres не гарантирует его работу в рамках распределённой системы. В этой статье я начну рассказывать о функциях, которые стали доступны в новой версии, и какой путь пришлось пройти, чтобы обеспечить их работоспособность, а также о возможных ограничениях и о том, чем они вызваны.

Интерфейс табличных методов доступа (Access Methods)

В давние времена пользователям Postgres был доступен лишь один движок хранения данных — heap. Предыдущим версиям Greenplum приходилось наживую встраиваться в кодовую базу Postgres, чтобы предоставить оптимизированные для аналитической нагрузки форматы хранения — строковый и колоночный Append Optimized. С версии Postgres 12 и начался процесс абстрагирования подсистемы хранения — появился интерфейс табличных методов доступа, а параметр TYPE команды CREATE ACCESS METHOD получил новое допустимое значение TABLE (8586bf7).

Данный интерфейс представляет собой 41 (в актуальных версиях 44) функцию, реализовав которые можно попробовать научить Postgres работать с вашим форматом хранения. Почему именно попробовать? Потому что если ваш формат хранения данных радикально отличен от heap, то он имеет все шансы не влезть в его прокрустово ложе. Именно так и произошло при рефакторинге Append Optimized-таблиц. Некоторые конкретные детали мы обсудим чуть позже. В итоге количество функций было расширено до 53. И теперь один из самых популярных вопросов у новых пользователей Greenplum — «Как изменить уровень сжатия данных или формат хранения данных в таблице без её пересоздания?» — получит краткий и не обескураживающий ответ:

in1xhclm6apidadzxj8xme13imi.png

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

6zw-fzwfofdxb7of6itcytayd_i.png

Уникальные индексы для AO-таблиц

Еще одной проблемой, с которой сталкиваются пользователи, воспринимающие Greenplum как большой Postgres, является специфика работы с уникальными индексами. Во-первых, они работают на уровне сегментов (шардов с данными) и ничего не знают о строках, хранящихся на других сегментах. Именно по этой причине уникальный индекс должен быть построен по списку столбцов, обязательно начинающемуся с колонок, по которым осуществляется шардирование таблицы. Во-вторых, Append Optimized-таблицы не имели поддержки уникальных индексов. И если в первом случае всё осталось как раньше, то над второй проблемой была проделана большая работа.

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

  1. Сначала производится вставка строки в heap-таблицу с получением её уникального идентификатора в рамках таблицы экземпляра Postgres — ctid (функция tuple_insert метода доступа). Для heap-таблиц он описывает номер страницы и номер строки (line pointer) внутри страницы. Вставки строк с повторяющимся значением ключа могут выполнять параллельно несколько транзакций.

  2. Затем начинается процесс вставки записи в индекс (функция aminsert индексного метода доступа). Производится поиск целевого узла b-дерева. На этом этапе лишь первая транзакция получит на него эксклюзивную блокировку. Последующие встанут в очередь.

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

  4. Если же хотя бы один совпадающий ключ на странице найден, потребуется обратиться к таблице, чтобы проверить актуальность версии, на которую он ссылается. Это необходимо сделать, поскольку индексы в Postgres не содержат однозначной информации о состоянии версии строки, на которую они указывают. Для этих целей производится вызов серии функций, которые должен реализовывать метод доступа самой таблицы: инициализацию сканирования (index_fetch_begin), запрос версии строки по её идентификатору (index_fetch_tuple) и завершение сканирования (index_fetch_end). Используемый снимок видимости допускает «грязное чтение» — строки ещё не подтвержденных транзакций также будут возвращены.

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

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

Что здесь может пойти не так для Append Optimized-таблиц? Начать стоит с того факта, что данный вид таблиц оптимизирован под последовательные операции, будь то вставка или сканирование. Произвольный доступ по идентификатору версии строки без создания индекса невозможен в принципе, поскольку в отличие от heap-таблиц:

  1. Блоки AO-таблиц имеют произвольный размер, а значит, нет возможности сместиться на N*size байт в файле, чтобы оказаться на странице N.

  2. Исходя из пункта 1, номер блока не участвует в идентификаторе версии строки. Всё, что мы можем узнать о строке по её идентификатору — это номер сегментного файла, в котором она хранится.

Heap tuple vs Append Optimized tuple ID

Heap tuple vs Append Optimized tuple ID

И лишь желание пользователя построить индекс по таблице вынудит Greenplum создать и поддерживать карту блоков, чтобы обеспечить относительно быстрый переход к желаемому блоку с необходимой строкой. Относительно быстрый, но все ещё оптимизированный лишь на последовательное извлечение блоков в соответствии с их расположением в файле с данными.

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

Более того, нам и строка-то эта сама по себе не нужна. Нам важно знать, видима она или нет (пункты 5 и 6). И эта информация в AO-таблице хранится отдельно от данных во вспомогательных heap-таблицах.

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

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

Проверки также осуществляются со снимком видимости, допускающим «грязные чтения», чтобы видеть изменения, внесённые параллельными транзакциями. И именно опираясь на информацию о транзакции, модифицирующей карты блоков и видимости, производится ожидание согласно пункту 6. Если транзакция, записавшая информацию о новом блоке, который содержит потенциальный дубль, ещё не подтвердилась — ждём её подтверждения.

Тут возникла ещё одна проблема, потребовавшая изменений. Раньше информация о новом блоке записывалась на страницу карты блоков (minipage) после заполнения блока. Обновление изменённой страницы в буферном кеше также откладывалось до её заполнения или завершения вставки. А значит, конкурирующие транзакции не могли убедиться в уникальности добавленных ключей сразу после их вставки в таблицу. Чтобы избежать такой ситуации, транзакция теперь записывает в карту блоков перед началом вставки блок-заглушку, охватывающий весь оставшийся диапазон номеров версий строк для того сегментного файла, в который производится вставка. Информация об этом блоке будет перезаписана при записи первого же настоящего блока, но, благодаря версионности вспомогательных heap-таблиц Postgres и «грязному чтению», блок-заглушка будет видим до конца транзакции. Именно этот блок увидят «конкуренты» и пойдут ждать завершения транзакции, записавшей его.

Сам формат карты блоков также подвергся изменению (258ec96). В прошлых версиях не допускалось наличие пропусков идентификаторов версий строк между соседними блоками. Если часть сгенерированных идентификаторов версий строк не была использована в конце вставки, то информация о них включалась в последний блок при вставке следующего. Это поведение идёт вразрез с высказанной выше идеей о том, что видимость кортежа зависит от видимости блока, охватывающего диапазон идентификаторов версий строк, и было изменено.

Ещё одной специфичной историей стал VACUUM для AO-таблиц с уникальными индексами, поскольку он порождает дубликаты строк при переупаковке сегментных файлов таблицы. Транзакция, выполняющая очистку, должна ещё видеть строки из очищаемого файла и производить вставку прочитанных строк в целевой. Как следствие внутреннего устройства AO-таблиц, при переносе версий строк из одного сегментного файла в другой будет меняться их идентификатор (который содержит в себе номер сегментного файла, как было упомянуто выше). Для решения данной проблемы был добавлен флажок-костылик gp_bypass_unique_check (в Executor State), который отключает проверку уникальности при вставке. Сам вакуум дубликаты породить не может (а значит, можно не тратить ресурсы на перепроверку), а «конкуренты» буду видеть либо версии строк до очистки, либо после. Логика была реализована в патче 9dd1471.

Вопрос сферы применения уникальных индексов для AO-таблиц для меня остаётся открытым. К минусам, очевидно, относятся и увеличивающиеся накладные расходы на загрузку данных в таблицу, и дублирование данных в индексе (а ведь мы здесь собрались не ради пары десятков тысяч строк, не так ли?), и существенный размер таких индексов, которые, в отличие от самой таблицы, будут использовать буферный кеш и вымывать из него потенциально более востребованные данные. С другой стороны, наличие уникальных индексов упрощает саму процедуру загрузки данных в случаях, когда дубликаты неприемлемы, а также служит подсказкой планировщикам о характере данных. В конце концов, готовый работающий функционал лучше его отсутствия, а проверять загружаемые данные при загрузке, как приходилось делать в предыдущих версиях, никто не запретил.

INSERT ON CONFLICT (UPSERT)

В прошлой части мы обложили все таблицы уникальными индексами и теперь при попытке загрузить в них строки с повторяющимися значениями ключа пользователь будет получать сообщение об ошибке и откат транзакции. Теперь нам захочется обрабатывать такие ситуации. Начиная с Postgres 9.5 (168d580) у пользователя появилось два варианта: отбрасывать такие строки (ON CONFLICT DO NOTHING) или обновлять существующую строку, например, значениями атрибутов вставляемой строки (ON CONFLICT DO UPDATE). Оператор MERGE завезли в Postgres 15 и его в рамках данной статьи рассматривать не имеет смысла.

Познакомиться с возможностями INSERT ON CONFLICT предлагаю в документации к Postgres, которая есть даже на русском языке. Остановимся же более подробно на том, как данный функционал устроен и откуда взялись те ограничения, которые накладывает на него Greenplum.

В прошлой части мы обсудили, как btree-индекс обеспечивает уникальность ключей. Сначала добавляем новую версию строки в таблицу, затем пытаемся добавить ссылку на неё в индекс и в этот момент узнаём, что, возможно, мы вовсе не первые. В этом случае роль арбитра выполнит сам уникальный индекс — первая транзакция продолжит вставку, последующим необходимо каким-то образом откатить вставку в таблицу, дождаться подтверждения первой транзакции и выполнить предписанную ON CONFLICT-обработку (для Read Committed). Еще острее вопрос с откатом добавленной строки становится при наличии ограничения исключения (CONSTRAINT EXCLUDE), поскольку индекс в этом случае не будет обеспечивать уникальность, а поиск по нему при проверке условия будет производиться со снимком, допускающим «грязное чтение».

Для того чтобы реализовать возможность отката вставки единственной версии строки без отката всей транзакции, был предложен механизм спекулятивной вставки, которую должен поддерживать табличный метод доступа. Для этого должны быть реализованы функции tuple_insert_speculative и tuple_complete_speculative. Для heap-таблиц они позволяют, манипулируя служебными атрибутами в заголовке версии строки, вставить версию строки с указанием идентификатора транзакции, который её производит, а затем, в зависимости от результатов проверки условий ограничений, подтвердить или откатить её, сделав невидимой даже для «грязного чтения». Отмечая эти действия в журнале упреждающей записи. И все равно требуя последующей очистки с помощью VACUUM.

j5fmk_edzs3gzpavxtu9iq1gkfy.png

В примере выше девятая строка на странице была вставлена успешно, десятая была откачена.

В этот момент мы понимаем, что для АО-таблиц данный подход неприменим, поскольку Greenplum хранит вспомогательную информацию для блоков, а не для отдельных строк, и обновление уже вставленной в таблицу версии без создания новой не поддерживается. Поэтому для AO-таблиц данные методы доступа не были реализованы. Но это, пожалуй, не главное. АО-таблицы на текущий момент не поддерживают конкурентные обновления. А именно в такую операцию вырождается вставка с обработкой конфликтов обновлением. При обновлении версии строки нам необходимо её заблокировать (функция tuple_lock табличного метода доступа). Для heap-таблиц это аналогично осуществляется манипуляциями с заголовком строки1. Для AO-таблиц за отсутствием такой возможности — взятием Exclusive-блокировки на всю таблицу для операций UPDATE/DELETE, исключающей конкурентное обновление в принципе. Параллельная вставка при этом возможна за счёт записи в разные сегментные файлы. Пожалуй, можно было реализовать эксклюзивный UPSERT аналогично UPDATE, исключив саму потребность в спекулятивных вставках и блокировке строк. Нужно ли — вопрос к конечным пользователям. С другой стороны, отсутствие возможности блокировать строки мешает реализации не только UPSERT, но и триггеров на UPDATE/DELETE, логической репликации, конкурентных UPDATE/DELETE, а также запросов SELECT FOR UPDATE/SHARE.

Вот почему поддержка данной операции в актуальных версиях Greenplum 7 оставлена только для heap-таблиц. Но даже для них в поставке по умолчанию UPSERT требует эксклюзивную блокировку (893c529). Почему? Ответ кроется в распределённой природе Greenplum. Представим себе ситуацию:

  1. Транзакция A вставляет строку с ключом K1 на сегменте кластера SDW1.

  2. Транзакция B вставляет строку с ключом K2 на сегменте кластера SDW2.

  3. Транзакция A вставляет строку с ключом K2 на сегменте кластера SDW2 и, обнаружив конфликт, ожидает завершения транзакции B.

  4. Транзакция B вставляет строку с ключом K1 на сегменте кластера SDW1 и, обнаружив конфликт, ожидает завершения транзакции A.

Так как ожидания происходят на двух разных экземплярах Postgres, коими являются сегменты Greenplum, самостоятельно решить данный конфликт они не в состоянии. Данную проблему призван решить процесс Global Deadlock Detector на координаторе, который может быть активирован с помощью GUC gp_enable_global_deadlock_detector. Его использование снимет ограничения на параллельный UPSERT в heap-таблицы, но пользователь должен быть готов к откату транзакций и необходимости повторить операции заново.

Сканирование только индекса для AO, покрывающие индексы

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

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

Однако Postgres содержит оптимизацию, позволяющую избежать сканирования самой таблицы, если мы заведомо знаем, что версия строки, на которую ссылается ключ индекса, хранится на странице, содержащей только видимые строки. Для heap-таблиц эту информацию содержит карта видимости (visibility map), биты в которой проставляет процесс очистки (vacuum), а сбрасывают процессы, осуществляющие модификацию данных в таблице. Имя же самого оператора, реализующего упомянутый подход — Index Only Scan. Название оператора не должно вводить в заблуждение. Если бит, соответствующий интересующей нас странице, очищен, к таблице все же придётся обратиться, как это бы сделало обычное индексное сканирование — Index Scan.

На первый взгляд может показаться, что эта история никак не относится к Append Optimized-таблицам. Но, говоря об уникальных индексах, мы уже обсуждали возможность проверить видимость строк без обращения к самой таблице. Причём сделать это однозначно без риска откатиться к сканированию самой таблицы. Для этих целей была реализована следующая функция интерфейса табличного метода доступа, которая позволяет убедиться, что версия строки для указанного идентификатора видима — index_fetch_tuple_visible.

Во многом данный метод доступа дублирует метод проверки уникальности версий строк, используемый при вставке ключа в уникальный индекс. Отличия кроются в используемом снимке видимости, который в данном случае будет соответствовать снимку текущей команды, т.е. MVCC-снимку. И, чтобы унифицировать работу с разными типами снимков, потребовался бы рефакторинг как логики работы со снимками при работе со вспомогательными таблицами Append Optimized, так и унификация участков кода, отвечающих за сканирование и вставку в индекс на стороне Postgres. От этой задачи разработчики Greenplum решили отказаться на данном этапе.

Теперь извлечение штучных значений столбцов, покрытых индексом, из AO-таблиц по тому же индексу фактически происходит без обращения к самой АО-таблице:

dbpccolaaxeh9qlp7ji7i4corei.png

Возможно, это будет приятным бонусом, если все упомянутые доводы насчёт индексов над AO-таблицами были отвергнуты и индексы у вас уже есть.

Отдельно отмечу функционал покрывающих индексов, впервые появившийся в Postgres версии 11. Он позволяет хранить в индексе не только значения самого ключа, но и указанных пользователем столбцов, которые могут быть извлечены из индекса без обращения к самой таблице при условиях, оговоренных в этом разделе. Поддержка такого расширения индексов была реализована для планировщика ORCA (b9f43b9). Работает эта логика и для AO-таблиц, но более полезной, на мой взгляд, она будет для меньших по объёму данных справочников, которые с большей вероятностью будут храниться в виде heap-таблиц.

Добавление колонки без перезаписи всей таблицы

Давным-давно во времена Postgres 9.4, на основе которой работает Greenplum 6, добавление в таблицу нового столбца со значением по умолчанию, отличным от NULL, требовало перезаписи всей таблицы. Однако начиная с Postgres 11 (16828d5) такое значение, если оно может быть предварительно вычислено в момент изменения схемы таблицы (например, не содержит вызова волатильных функций), сохраняется в каталожном отношении pg_attribute, а сам аттрибут помечается как имеющий отсутствующие значения.

iv2bn9fujirlvmpssksdwfwsmxi.png

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

Всё это замечательно работает для heap-таблиц, а что насчёт Append Optimized? Здесь снова стоит вспомнить, что одним из направлений оптимизации было сокращение накладных расходов на хранение заголовков отдельных версий строк. Строки хранятся в формате MemTuple (см. memtuple.c). Такой формат не содержит информации о числе атрибутов, оставляя её на откуп табличного метода доступа, который извлекает её в свою очередь из каталога. Более того, чтобы сократить число промежуточных преобразований, кортежи Append Optimized-таблиц сразу передаются на обработку исполнителю запросов в «расформированном» представлении в виде массивов значений (виртуальный кортеж). При таком подходе переиспользовать машинерию Postgres не представляется возможным.

На первый взгляд, можно было определить свой тип табличного слота (TupleTableSlotOps), который реализовывал бы «ленивое» расформирование MemTuple. Число столбцов же хранить на уровне блока переменной длины, версии строк в рамках которого имеют одинаковую длину. Тем более, Postgres позволяет каждому табличному методу иметь свою реализацию слотов (см. функцию slot_callbacks). Однако разработчики пошли по иному пути (d3dcb9b).

Так как вставка в сегментные файлы AO-таблиц всегда производится в конец, Greenplum запоминает на сегментах текущее значение счётчика номеров версий строк для каждого файла в момент добавления нового столбца (поле lastrownums). Перезапись всей таблицы также не потребуется.

56sdmfmnl-_bujkvvnpcw0crjpc.png

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

Сказанное выше имеет отношение к строковым Append Optimized-таблицам. Добавление нового столбца к колоночной таблице и в прошлых версиях требовало сгенерировать лишь новый столбец с данными для всех существующих версий строк. Данное поведение было сохранено. Однако для этих целей пришлось расширить интерфейс табличных методов доступа функцией aoco_relation_add_columns (а также функцией aoco_relation_rewrite_columns для перезаписи значений только выбранных колонок, например, при изменении их типа).

Промежуточные итоги

В рамках данной статьи мы рассмотрели лишь часть изменений, которые привносит Greenplum 7. На очереди и BRIN-индексы, и JIT-компилляция, и обзор изменений в механизме ресурсных групп. Но разговор об этом мы продолжим в следующей части. Спасибо за внимание!

Благодарю за помощь в подготовке данной статьи нашего дизайнера — @kawaii_anya.

Ссылочные метки:

  1. Рогов Е.В. PostgreSQL 16 изнутри; ДМК ПРЕСС, 2024, с. 258

© Habrahabr.ru