Привет, меня зовут Денис, в Arenadata я занимаюсь Greenplum — распределённой СУБД с открытым исходным кодом, разработанной на основе PostgreSQL и заточенной под аналитический профиль нагрузки. Моя работа (помимо разработки) заключается в разборе инцидентов, когда в кластерах клиентов происходит что-то непонятное для нашей технической поддержки. Такие истории обычно заканчиваются детальным внутренним разбором произошедшего, рекомендациями для клиентов и внесением правок в код Greenplum. Я расскажу вам про один из инцидентов, которым я занимался в последнее время. Хотя этот случай не привел к технически сложным доработкам, он является показательным примером того, как мы исследуем проблемы с Greenplum. Заодно я расскажу о подробностях внутреннего устройства Greenplum и PostgreSQL, которые не описаны в документации.
С чего всё началось?
Всё началось на второй линии технической поддержки. Однажды запускаемый по регламенту в кластере Greenplum запрос отработал за четырнадцать часов вместо обычных пары минут, что вызвало вопросы у заказчика. Мало того, после повторного запуска по регламенту полностью забился диск на одном из сегментов (экземпляр PostgreSQL в рамках кластера Greenplum). В результате сегмент упал, а система автоматически переключилась на его зеркало (синхронную реплику).
Перед переключением с зеркала обратно на основной сегмент проверили свободное место на диске. Удивительно, но было доступно 600 Гбайт, хотя мониторинг и логи Greenplum сигнализировали, что на момент падения свободного места не было. Также в каталоге для спилов на упавшем сегменте (место на диске, куда «тяжелые» узлы плана запроса, такие как агрегации и сортировки, вытесняют из оперативной памяти данные, выходящие за доступные лимиты памяти узла) не находилось сколь-либо заметного объёма данных.
После восстановления штатного режима работы кластера запрос запустили вручную под присмотром второй линии технической поддержки. Через некоторое время после начала работы запроса свободное место на диске того же самого, проблемного сегмента стало уменьшаться. С помощью утилиты lsof просмотрели файловые дескрипторы у бэкенда, выполняющего в этот момент свой кусок плана в сегменте.
Удалось установить, что бэкенд (дочерний процесс postmaster, обслуживающий клиентское соединение) удерживает от удаления в файловой системе около 300 файлов по 1 Гбайт каждый.
0 /data1/primary/gpseg6/base/760727/337480361 (deleted) |
Эти файлы не фигурировали в системном каталоге сегмента. Также среди файловых дескрипторов lsof было несколько десятков файлов сегментов heap-таблицы, которая не участвовала в запросе, но исполнялась в параллельном запросе на других бэкендах. С такими вводными вторая линия поддержки создала для третьей линии поддержки задачу, которую назначили на меня.
Приоритеты
Очевидно, что проблема состояла из двух частей:
Замедление запроса с пары минут до четырнадцати часов (вероятно, проблемы с планом).
Утечка свободного места на диске из-за дескрипторов, помеченных на удаление файловой системой, но удерживаемых бэкендом. Сюда же относилось попадание в бэкенд сегмента файловых дескрипторов сторонних таблиц.
Без решения второй проблемы было трудно подступиться к первой. Хотелось запустить explain analyze, который бы выполнил запрос и собрал фактическую статистику со всех узлов плана. Но мы не могли получить его вывод из-за того, что диск забивался раньше, чем выполнялся план. Конечно, можно было воспользоваться отладчиком и обойти все узлы плана вручную во время выполнения запроса, но план был достаточно большой и делать этого не хотелось. Поэтому решили начать со второй проблемы. Но перед тем как продолжить, нужно рассказать про хранение данных в таблицах Greenplum.
Append optimized и heap-таблицы
Текущая стабильная версия Greenplum написана на ядре PostgreSQL 9.4. Как следствие, рефакторинг API методов хранения в PostgreSQL 10 сюда еще не доехал. Поэтому для хранения данных в таблицах можно использовать или heap из ванильного PostgreSQL (для частых модификаций небольших порций данных), или специфичные для Greenplum append optimized (AO) таблицы (колоночные или строковые), поддерживающие сжатие.
Heap-таблицы хорошо знакомы всем пользователям PostgreSQL. Они организованы в виде цепочки сегментных файлов размером в 1 Гбайт, которые, в свою очередь, состоят из блоков фиксированного размера.
В PostgreSQL блоки выровнены по размеру страницы ОС в 8 Кбайт, в Greenplum размер блока увеличен до 32 Кбайт. Работа с блоками в бэкенде осуществляется через буфер блоков в общей памяти (shared buffers). Когда запрашиваемый блок отсутствует в буфере, бэкенду вначале нужно загрузить его в буфер. Если же в буфере недостаточно места, то бэкенду следует сначала вытеснить в кеш ОС какой-то другой блок.
Также буфер блоков участвует в учете файловых дескрипторов сегментных файлов, из которых в него загружались блоки. Это позволяет понять, какие файлы необходимо записать на диск при создании точки восстановления в журнале предзаписи.
AO-таблицы заточены под низкоконкурентную вставку больших порций данных. Устроены они крайне специфично — транзакционность, модификация данных и индексы реализованы через вспомогательные heap-таблицы. Данные же хранятся в сегментных файлах, состоящих из блоков произвольной длины, поддерживающих сжатие.
При вставке данных новые блоки записываются в конец сегментного файла AO-таблицы. Транзакционность вставки обеспечивается за счет вспомогательной heap-таблицы, в которую записывается логическое смещение конца сегментного файла. При откате транзакции в heap-таблице откатывается логическое смещение и новые данные записываются поверх старых (если не будет вызван VACUUM для подрезки файлов). Каждый процесс при записи данных эксклюзивно владеет своим сегментым файлом. Если нужно параллельно записать данные в AO-таблицу в соседнем бэкенде, то ему будет выдан свой сегментный файл под эксклюзивной блокировкой. Степень параллельности ограничена 127 сессиями. При создании AO-таблицы рядом с ней всегда создается вспомогательная heap-таблица для хранения смещений сегментных файлов.
Модификация данных (удаление и обновление строк) устроена следующим образом. Новые блоки данных записываются в конец сегментного файла, а старые версии строк закрываются битовой маской, хранящейся во вспомогательной heap-таблице. Она создается при первой модификации данных в AO-таблице (чтобы не раздувать системный каталог без необходимости). Если в сегментном файле таблицы слишком много удаленных строк, то VACUUM перенесет его живые строки в менее раздутый сегментный файл.
При создании индекса и обращениях через него к строкам в AO-таблице создается вспомогательная heap-таблица, которая содержит карту блоков данных AO-таблицы. Все блоки сегментного файла пронизаны монотонно возрастающей последовательностью номеров строк (используется механизм последовательностей PostgreSQL). Поэтому для каждого блока достаточно хранить номер сегментного файла, номер первой строки в блоке, количество строк и смещение начала блока. Индексы для AO-таблиц ссылаются именно на связку «сегментный файл — строка», так что поиск по индексу для AO-таблицы идёт через дополнительную таблицу карты блоков (у которой тоже есть свой индекс).
Важно отметить, что для AO-таблиц реализован свой собственный менеджер хранения. В результате блоки данных поднимаются в память бэкенда напрямую с диска, а не через буфер в общей памяти. В конце транзакции сегментный файл принудительно сбрасывается на диск. При этом вспомогательные heap-таблицы всё ещё продолжают работать через буфер блоков.
Что за файловые дескрипторы в бэкенде?
Необходимо было разобраться в двух подзадачах:
Что за дескрипторы в размере 300 Гбайт удерживает бэкенд от удаления файловой системой?
Как файловые дескрипторы сторонней heap-таблицы оказались в бэкенде нашего запроса?
Чтобы это выяснить, я запустил распределённый запрос и подключился отладчиком к обслуживающему его бэкенду на проблемном сегменте. Вскоре lsof показал, что в бэкенде стали копиться файловые дескрипторы от той же самой сторонней heap-таблицы, которую заметила техническая поддержка второй линии. В этот момент я добавил в отладчик точки остановки как на системные вызовы open, write, fsync, так и на подозрительные места в коде менеджеров хранения AO и heap. Сработал вызов write, и сразу стало понятно, как дескрипторы от сторонней heap-таблицы попадают в наш бэкенд.
Оказалось, что параллельно с нашим запросом в кластере каждые несколько минут запускался запрос, который загружал в эту heap-таблицу данные. Из-за неудачного ключа распределения информации по сегментам большая часть данных (несколько десятков Гбайт) всегда оказывалась в одном и том же сегменте и её блоки занимали весь доступный объём буфера в общей памяти.
При этом бэкенд нашего запроса работал с AO-таблицами, в которых ранее удалялись и обновлялись данные. Как следствие, для каждого блока AO-таблицы система искала битовую маску видимости во вспомогательной heap-таблице и обращалась к буферу, забитому блоками сторонней heap-таблицы. Чтобы поднять в буфер нужные блоки, бэкенду приходилось вытеснять блоки перекошенной таблицы в буфер ОС через системный вызов write. При этом файловые дескрипторы вытесненных блоков сохранялись в специальном системном кеше нашего бэкенда. Это объясняло вторую подзадачу, как файловые дескрипторы сторонней таблицы попадали в кеш дескрипторов нашего бэкенда.
После изучения логов также выяснилось, что при завершении загрузки данных в стороннюю heap-таблицу система вызывала агрегирующий запрос, переносила данные в другое место, а у таблицы удаляла сегментные файлы через команду truncate. При этом оставалось непонятным, почему они оставались в системном кеше бэкенда и занимали там место — ожидалось, что после этого они будут удаляться из кеша, или хотя бы их размер должен обрезаться до нулевого значения.
Чтобы объяснить причину такого поведения, нужно рассказать, как устроены системные кеши в бэкендах PostgreSQL и Greenplum, а также как их инвалидируют.
Инвалидация кешей
Запросы на бэкенде постоянно обращаются к различным системным объектам — таблицам, типам, индексам и т. д. Каждый раз опрашивать для этого таблицы системного каталога дорого, поэтому бэкенд формирует в локальной памяти кеш системных объектов и заполняет его по мере выполнения поиска. Также бэкенд держит открытыми файловые дескрипторы, с которыми он работал.
Как известно, в программировании есть только две по-настоящему сложные проблемы: именование переменных и инвалидация кешей. Если один бэкенд изменяет информацию о системном объекте, то он пишет сообщение о его инвалидации в специальный кольцевой буфер в общей памяти. Если в буфере нет свободного места, то новое сообщение мы запишем вместо самого старого в очереди, а не успевшие прочитать удалённое сообщение бэкенды будут помечены на полный сброс своих кешей.
Все бэкенды читают сообщения из буфера инвалидации системных кешей (и одновременно собирают в нём мусор) при инициализации бэкенда, начале и окончании транзакций, открытии, удалении и обрезании таблицы, при взятии блокировок, а также при получении специального сигнала инвалидации от других бэкендов, что наш бэкенд давно не обновлял кеш. При получении сигнала инвалидации бэкенд сразу же обработает сообщения из очереди, только если он простаивает без команд. В противном случае он займётся обновлением кешей только по завершении выполнения текущей исполняемой команды (или одного из перечисленных выше действий). Также при обработке сообщения об инвалидации системного объекта закрываются и его открытые файловые дескрипторы в бэкенде.
Почему дескрипторы не закрываются?
Я предположил, что в запросе очень долго выполняется команда, при этом не срабатывает инвалидация кешей по событиям вроде взятия блокировок или открытия файлов таблиц, а обработка сигналов об инвалидации от других бэкендов откладывается на конец выполнения команды. Предположение достаточно быстро подтвердилось с помощью отладчика: по всем признакам запрос в бэкенде многие часы пересылал кортежи между сегментами Greenplum при соединении таблиц с разным ключом распределения данных в кластере. Соответственно, во внешней сессии создавалось несколько десятков сегментных файлов для heap-таблицы, которые в результате вытеснения из общего буфера попадали в кеш файловых дескрипторов нашего бэкенда. Потом во внешней сессии вызывалась команда truncate, которая при фиксации транзакции должна была обрезать до нулевого размера старые файловые дескрипторы и удалять их на уровне файловой системы с помощью вызова unlink. При этом файлы через unlink не удалятся из файловой системы, пока другие процессы не закроют файловые дескрипторы удаляемых файлов. И чтобы такие файлы не занимали лишнее место, их нужно обрезать до нулевого размера перед вызовом unlink. В нашем же случае большинство файлов по какой-то причине не обрезалось.
Проблема оказалась в том, что в PostgreSQL до декабря 2020 была ошибка в коде, из-за которой при удалении обрезался до нулевого размера только первый сегментный файл таблицы. Соответственно, задача была очень простой: нужно было портировать это исправление из ванильного PostgreSQL в наш форк и Greenplum. В ходе обсуждения PR выяснилось, что с AO-таблицами была связана ошибка, при которой мы не закрывали файловые дескрипторы их сегментов, что также было исправлено. Коммиты были приняты в Greenplum в начале июля, а сам PR закрыт.
Почему тормозил запрос?
Перед началом работ по портированию исправления в качестве временного решения мы рекомендовали клиенту заменить для сторонней таблицы движок хранения с heap на AO и поменять ключ распределения. Но клиент поступил радикальнее и просто исключил из регламента проблемный запрос. Это позволило за 14 часов наконец выполнить наш тормозящий запрос без переполнения диска и получить его explain analyze.
Как выяснилось, из-за изменения данных в таблицах, участвовавших в запросе, оба планировщика Greenplum (Postgres и ORCA) начали строить примерно одинаковые и очень неэффективные планы перераспределения данных между сегментами.
-> Broadcast Motion 72:72 (slice2; segments: 72) |
Забавно, что планировщик Postgres при этом сильно ошибался в оценке количества отправляемых из сегмента кортежей (один кортеж вместо полумиллиарда), в то время как ORCA оценивала их количество ближе к реальности, но планы выходили почти идентичные. Именно Broadcast Motion с отправкой полумиллиарда кортежей из каждого сегмента (а всего сегментов 72) объяснял 14 часов выполнения запроса.
Конечно, такие вопросы нужно решать в коде планировщиков, но в бэклоге у проблем с медленными планами не самый высокий приоритет. Поэтому для решения проблемы клиента здесь и сейчас мы решили помочь планировщику построить быстрый план в «ручном режиме».
Почти сразу же выяснилось, что если использовать для построения плана планировщик ORCA с отключенным GUC optimizer_enable_indexjoin, то получается план с новой структурой узлов, который выполняется за двадцать минут.
-> Redistribute Motion 72:72 (slice3; segments: 72) |
Такое решение устроило заказчика и инцидент был исчерпан.
Итоги
Мы решили проблему, дав рекомендации по подсказкам для планировщика и указав на проблему с перекосом heap-таблицы. Но расследование привело к портированию исправления из PostgreSQL и починке ошибок внутри Greenplum по работе с файловыми дескрипторами. Самым же ценным оказалось изучение командой кода, связанного с локальными кешами и файловым дескрипторами. Если потребуется, мы сможем чинить ошибки, дорабатывать функциональность или быстрее создавать расширения для этих подсистем.
Это не единственная история с запутанным и технически сложным расследованием, которое привело к исправлениям в Greenplum. Дайте знать, если это интересно, расскажу.