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

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


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


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

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

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

k-sdczg1lnedmrhjfyujg6dkiyu.jpeg

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

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

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

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

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

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

zpwvxagaus_ajxsaqixpwe-w7sw.jpeg

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

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

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

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

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

hl7juizqgpqqzpnhr_byptzs_xw.jpeg

jiw4lt2-5fcmeyd9qfururhscs4.jpeg

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

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

vyyiw8m8oz-dn5gmy7-v9q8m32i.jpeg

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

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

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

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

e-ypgjv5j7u5skjfmixtw_ie9oq.jpeg

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

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

3nn3geu-vzcexvckvzsoomxx1ns.jpeg

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

o4uzltwdxqnag3c0uwvvo9hw7uw.jpeg

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

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

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

l1z1tq5nwf1l9jhoushtujix-t8.jpeg

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

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

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

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

fqrq0zlgqqbszyo7aajzsmzrtjy.jpeg

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

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

qjoysbcnasbr3xz0komagdri2oe.jpeg

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

v375n5xmrwzhinew6obppnsdgeq.jpeg

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

Библиотека была написана, чтобы во время выполнения таких миграций автоматизировать некоторые из рассмотренных ранее приемов избежания долгих блокировок на таблице. Она работает с Django начиная с версии 1.8 до 2.1 включительно, и Python с 2.7 до 3.7 включительно.

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

vgxylzlxerxosettf26nbtrqo9u.jpeg

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

kpd2bpbqlehlnxps9ozwj3iuv4g.jpeg

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

x1fcghzaqinnjq-hela3v1aarie.jpeg

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

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

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

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

jea8h37vcuylfp-eut209sdpsse.jpeg

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

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

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

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

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

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

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

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

© Habrahabr.ru