Хабрахабр

Изменение схемы таблиц PostgreSQL без долгих блокировок. Лекция Яндекса

Если одновременно выполняется много операций по изменению схемы БД, сервис не может корректно работать на запись. Разработчик Владимир Колясинский объяснил, какие операции в PostgreSQL требуют длительных блокировок и как команда Яндекс.Коннекта обеспечивает почти стопроцентную доступность сервиса на запись во время выполнения подобных операций. Кроме того, вы узнаете о библиотеке для Django, которая призвана автоматизировать часть описанных процессов.

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

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

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

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

Используемый стек — это в основном сервисы, написанные на Python второй и третьей версии; Django 1. Я в настоящий момент занимаюсь разработкой Конструктора форм и Вики. 11. 9-1. Также это Celery с MongoDB и SQS в качестве брокеров. Как БД в большинстве своем PostgreSQL. Все это работает в Docker.

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

О том, как мы справляемся с такими ситуациями и как достигаем высокой доступности сервисов на чтение и запись, мы сегодня и поговорим.

Под блокировкой я имею в виду любой тип lock'а, который препятствует нормальной работе с таблицей — будь то access exclusive, который препятствует и записи, и чтению, или более слабые уровни блокировки, которые препятствуют только записи. Сначала рассмотрим, какие операции с PostgreSQL требуют длительных блокировок на таблице.

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

Операции, которые требуют долгой блокировки:

По умолчанию она не блокирует операции чтения в таблице, но все операции записи будут заблокированы на все время создания индекса, соответственно, сервис будет read only. Создание индекса.

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

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

VACUUM FULL также заблокирует все операции чтения и записи в таблицу. Также операция VACUUM FULL требует аналогичного уровня блокировки, как и предыдущие операции — это access exclusive.

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

Тут достаточно просто, его можно создавать с применением ключевого слова CONCURRENTLY. Создание индекса. Эта операция будет занимать больше времени, так как будет выполнен не один, а несколько проходов по таблице, а также она будет дожидаться завершения всех текущих операций, которые потенциально могут изменять индекс. В чем отличие? Тогда индекс пометится как невалидный, и его надо будет удалить и создать заново. И также она может закончиться неудачей — например, если при создании уникального индекса обнаружится нарушение условий уникальности. Команду REINDEX использовать не рекомендуется, так как она работает так же, как обычный CREATE INDEX, то есть заблокирует таблицу на запись.

3 удалять индекс тоже можно CONCURRENTLY для избежания блокировок во время его удаления, хотя в целом это и так быстрая операция. Касательно удаления индекса — начиная с версии 9.

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

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

Если не создаем, то не надо. После того, как все данные мы обновили, остается только выполнить SET NOT NULL, если мы создаем NOT NULL-колонку. Таким образом можно избегать перезаписи таблицы при выполнении такого рода изменений.

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

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

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

Берется блокировка на время создания. Про добавление уникальной колонки. Можем сами построить требуемый уникальный индекс с применением CONCURRENTLY. Ее можно избежать, если знать, что уникальность в PostgreSQL гарантируется за счет построения уникального индекса. После этого этого определение изначального индекса из таблицы пропадет, и результат, который покажет нам определение таблицы, будет ничем не отличаться после выполнения этих двух операций. И после построения данного индекса создать CONSTRAINT, используя данный индекс.

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

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

Операции, которые изначально быстро работают в PostgreSQL и не требуют длительных блокировок:

Потому что никакие изменения в саму таблицу не вносятся, меняются только ее мета-данные. Одна из таких операций — добавление колонки без значений по умолчанию и каких-либо ограничений. А все NULL-значения, которые мы видим в результате SELECT, подмешиваются просто на выходе.

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

Этого времени тоже можно избежать, если оно имеет значение. Также быстрая операция установки SET NOT NULL, тут она занимает немного больше времени, чем описано ранее, примерно несколько секунд на таблицу в 30 млн записей.

Удаление колонки и в целом многих сущностей в PostgreSQL тоже быстрые операции. Также к быстрым операциям относится переименование колонки, изменение длины колонки, тоже не приводит к перезаписи колонки.

Чтобы избежать блокировки на время валидации, можно выполнить прием, о котором говорили ранее — добавляем CONSTRAINT, соответствующую CHECK (column IS NOT NULL) NOT VALID, и отдельной командой валидируем ее. Касательно добавления NOT NULL-колонки.

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

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

Я тут говорю про Django, миграции. Поговорим про библиотеку. Там есть ORM-система, которая позволяет общаться с записями в БД, с таблицами, как будто они являются объектами или классами Python. В целом Django — библиотека для Python, веб-фреймворк, изначально он создавался для быстрого создания веб-сайтов по типу новостных, с тех пор он значительно прокачался. И когда мы вносим изменения в наш код на Python, то есть добавляем новые атрибуты как бы колонки в таблицу, Django во время процедуры создания миграции замечает эти изменения, и создает файлы миграции, чтобы внести зеркальные изменения в саму БД, чтобы они не расходились. То есть каждой таблице соответствует свой класс в Python.

Она работает с Django начиная с версии 1. Библиотека была написана, чтобы во время выполнения таких миграций автоматизировать некоторые из рассмотренных ранее приемов избежания долгих блокировок на таблице. 1 включительно, и Python с 2. 8 до 2. 7 включительно. 7 до 3.

В стандартной реализации Django, если мы добавляем колонку со значением по умолчанию, заблокировали таблицу, и если она большая, это может быть 40 минут блокировки по моему опыту. Касательно текущих возможностей библиотеки, это добавление колонки со значением по умолчанию без блокировок, nullable или нет, это создание индекса CONCURRENTLY, а также возможности перезапуска при падении. Прошло 30 минут — поймали connection error к базе, миграция падает, изменения не коммитятся, и приходится запускать заново, снова ждать 40 минут, вновь блокируя на это время таблицу. Блокируется таблица, и все, ждем, пока скопируется и внесутся изменения.

Ссылка на GitHub

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

Есть документация; если кратко, надо заменить engine в настройках БД Django на engine из библиотеки. Про подключение в целом. Также там есть различные mixins, если вы используете свои engines для подключения.

Тут добавляем колонки с булевым значением, True по умолчанию. Пример работы, про добавление колонки со значением по умолчанию. Выполняемые операции можно посмотреть, если запустить SQL migrate. Какие операции выполнены стандартной SchemaEditor? И полезно запускать и смотреть, будут ли выполнены ожидаемые нами операции и не попало ли туда что-то лишнее и ненужное. Это достаточно полезно, по самому виду миграции не всегда бывает понятно, что же там Django на самом деле может изменить.

Сначала в одну транзакцию добавится новая колонка, добавится значение по умолчанию. Какие команды выполнит SchemaEditor? Потом, пока такой Update не вернет, что он обновил ноль, данные будут обновляться.

Затем выставится SET NOT NULL у колонки, и значение по умолчанию будет удалено, повторяя поведение Django, который хранит значение по умолчанию не в базе, а у себя на уровне логики в коде.

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

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

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

Нужно следить, какие lock'и берет таблица, запускать SQL migrations, чтобы смотреть, какие операции применяются. Стоит обращать внимание, что с ростом БД миграции имеют неизбежное свойство замедляться. А там, где не позволяют, мы уже самостоятельно, руками фейкаем Django-миграции, запускаем свои SQL-запросы. Мы же со своей стороны в Яндекс.Коннекте применяем эту библиотеку там, где позволяют ее возможности.

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

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

Пример такой операции — добавление колонки без значения по умолчанию. Представим, что запускается операция, которая — пусть и на несколько миллисекунд — требует access exclusive. В этом случае наша операция встанет за ней в очередь. Представим, что в момент ее запуска в другой транзакции идет какая-либо другая долгая операция — скажем, SELECT с агрегацией. Это произойдет, так как access exclusive конфликтует со всеми другими типами блокировок.

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

Поэтому перед запуском ALTER TABLE или любой другой операции, которая требует блокировки access exclusive, надо смотреть, чтобы на БД в текущий момент не шли долгие запросы. Такая ситуация может создавать большие проблемы на сервисе. Тогда, если бы не удалось быстро взять lock, операция бы падала. Или можно просто вставлять очень маленький log timeout. На этом всё, спасибо. Мы бы могли просто перезапустить ее, а не блокировать таблицу на долгий срок, пока операция будет ждать выдачи ей гранта на блокировки.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть