Ещё лет 5 назад на собеседованиях с backend-разработчиками был популярен вопрос: как переименовать колонку в высоконагруженной таблице (возможны вариации: как сделать любой другой alter table)? Ответ мог быть примерно следующий:

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

Вопрос всё ещё часто звучит на собесах. И актуальность его выросла, так как за последние 5 лет стало намного больше масштабных проектов с высокой нагрузкой.

В MySQL существуют специальные инструменты для решения подобных задач: pt-online-schema-change и gh-ost. Принцип их действия практически такой же, как описанный выше.

Хочу рассказать, как мы делали alter table в нашем микросервисе с высоконагруженной таблицей (golang + postgres, ~1500rps, ~15 млн. записей).

Постановка задачи

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

Шаг 1. Добавляем колонку

Для начала мы создали колонку и повесили на неё индекс:

ALTER TABLE my_table ADD COLUMN IF NOT EXISTS field_encrypted TEXT;

CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS field_encrypted_unique_idx 
	ON my_table (field_encrypted);

Выполненные операции не создавали никаких блокировок.

Шаг 2. Переливаем старые данные

Перезалив данных из старой колонки в новую мы реализовали через cron библиотеку языка golang. Такое решение приняли из-за удобного логирования возникающих проблем и возможности быстрого реагирования на них. Да и сторонников делать через логику базы данных у нас не нашлось.

Конечно, сразу же возникли трудности следующего вида. В принципе, аффектить базу данных сильно нельзя — это увеличивает время ответа сервиса (>1 sec — это уже плохо) и количество блокировок. Зависимые сервисы могут не обработать ответы, появится неконсистентность. Или может спровоцироваться ещё более сильная нагрузка на базу данных, и сервис может не выдержать.

Среднее время ответа сервиса
Среднее время ответа сервиса

Поэтому:

  • Все изменения для новой колонки делались батчами, а не по одной записи (batch insert, batch update).

  • У нас запущено несколько инстансов этого микросервиса. Поэтому необходимо было не допустить многократный запуск этого скрипта в одно время. Тут нам помогла advisory lock для postgres (каждый инстанс проверял блокировку и только 1 выполнял джобу):

SELECT pg_try_advisory_xact_lock(1);
  • Периодически для базы запускался достаточно тяжелый VACUUM:

Время работы vacum'a
Время работы vacum'a

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

SELECT check_vacuum('my_table');

Сама функция check_vacuum:

CREATE FUNCTION check_vacuum(name text) RETURNS boolean
    LANGUAGE sql SECURITY DEFINER
AS $_$
SELECT count(*)::int > 0
FROM pg_stat_progress_vacuum
WHERE relid::regclass = $1::regclass OR
        relid::regclass::text in (
        SELECT reltoastrelid::regclass::text FROM pg_class WHERE relname = $1
    );
$_$;

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

ШАГ 3. Вешаем constraint

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

ALTER TABLE my_table ADD CONSTRAINT field_ecnrypted_not_null
    CHECK (field_encrypted IS NOT NULL) NOT VALID;

Он позволяет повесить ограничение not null, но не валидировать существующие записи сразу, а сделать это следующим шагом и без блокировок:

ALTER TABLE my_table VALIDATE CONSTRAINT field_ecnrypted_not_null;

Ну и в заключение мы сделали то, к чему шли:

ALTER TABLE my_table DROP COLUMN old_field;

Заключение

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

Интересно узнать, как вы решаете похожие задачи на MySQL. Пишите в комменты, с удовольствием почитаю и подключусь к обсуждению.

Комментарии (29)


  1. rrrad
    18.08.2021 11:51
    +1

    https://habr.com/ru/company/yandex/blog/435880/

    https://habr.com/ru/company/miro/blog/540500/

    Хотя объективно, идея мониторить vacuum-ы в процессе миграции
    - это что-то новое.


  1. egor_nullptr
    18.08.2021 12:43
    +3

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

    ALTER TABLE tbl
    CHANGE old_col_name new_col_name data_type,
    ALGORITHM=INPLACE, LOCK=NONE;

    С рядом ограничений, но работает.


    1. Akina
      18.08.2021 21:25
      -1

      Это зачем так сложно? CHANGE - потенциально, при неправильном задании типа данных, способна принести проблем.

      ALTER TABLE table_name
          RENAME COLUMN old_col_name TO new_col_name;

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

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


      1. slava-a Автор
        18.08.2021 21:32

        Проблема с клиентами решалась через временный костыль - сначала работа сервиса осуществлялась через новый столбик, а в случае эксепшена - через старый.

        Про триггер - пункт 2. Вместо триггера был скрипт, который обновлял только старые данные. Свежие данные записывались уже правильно, без необходимости обновления.

        p.s. целью статьи было описать сложности с БД. имхо проблемы с кодом это не так интересно и решается тысячью способами довольно просто


  1. GooG2e
    18.08.2021 15:01

    Я может что-то фундаментально не понимаю, но допустим понятно что влечёт за собой добавление столбца, изменение типов данных, добавление ограничений на столбец, но что за собой влечёт изменение названия колонки?


    1. slava-a Автор
      18.08.2021 15:37

      Само переименование в чистом виде наверняка никаких проблем не влечет. ИМХО такая задача не несет ценности и кроме того, вы можете положить прод на ровном месте.

      Подобные вещи выполняются с какими-то другими, уже более осмысленными действиями - как вы сказали, например, изменение типов данных.


  1. bgnx
    18.08.2021 15:13
    -3

    Ещё лет 5 назад на собеседованиях с backend-разработчиками был популярен вопрос: как переименовать колонку в высоконагруженной таблице (возможны вариации: как сделать любой другой alter table)? Ответ мог быть примерно следующий:

    Зачем такие сложности с создаем новой колонки и постепенной миграцией? Почему бы просто не задать всем колонкам уникальное имя которое никогда не захочется поменять (случайный айдишник) а уже в коде хранить соответствие имя -> id при работе с этой бд. Тогда переименование колонки сведется к простейшей операции замены одного имени на другое в коде вообще без каких-либо затрат со стороны бд


    1. slava-a Автор
      18.08.2021 15:31

      Под переименованием обычно понимается простейшая alter table. Чтобы понять, знает ли собеседуемый про блокировки и имеет ли подобный опыт.

      По сути просто переименование колонки является бессмысленной операцией. Это всегда часть какой-то другой задачи. Как, например, добавить новую колонку созависимую со старой. Или, как конкретно нашем случае, нужно было помимо переименования колонки поменять ее тип с uuid на text.


    1. AndyKorg
      19.08.2021 06:19
      +1

      Возможно так же надо отказаться от осмысленных имен таблиц. Да, что там таблиц! Можно и имена БД чисто произвольными задавать. И еще дальше - имена серверов и т.д. :)

      Интересно почему так никто не делает? :)


      1. Necessitudo
        19.08.2021 08:02
        +3

        Делает-делает.

        Фирма 1С в своем одноименном продукте.


      1. x_shader
        19.08.2021 11:04
        +1

        И имена переменных в коде тоже можно рандомно генерить.


  1. iwannabeacoder
    18.08.2021 15:47
    +1

    Интересная тема, но есть пара концептуальных замечаний. Проблема одной таблицы с высокой нагрузкой в том, что приходится искать компромисс между скоростью вставки и выборки. В моем проекте есть таблицы, оптимизированные для вставки и для чтения. Переброска данных осуществляется по крону, в mysql есть для этих целей EVENT. Таким образом, есть возможность конфигурировать для разных таблиц разные диски (ssd/hdd) и теоретически онлайн миграция может быть с небольшим количеством ограничений: сначала миграция таблицы для записи и эвента, после копирование таблицы для чтения и любая по длительности миграция. Теоретически - это потому, что практически у меня kubernetes и при миграции поднимается новый инстанс базы с новой версией базы, на которой копия актуальной базы подвеглась миграции. При старте нового инстанса остается только синхронизировать данные с момента снятия дампа старой базы.

    Но, обратно к теме онлайна:

    На тестовом сервере есть две таблицы executed_orders_0 и executed_orders_1. №1 содержит 42 милиона записей, №0 какое-то количество сделок на бирже за последние 2 минуты. Это все на диске занимает приблизительно 6Гб, mysql ограничен по ресурсам на 2 ядра и 1.4Гб памяти, чтобы более или менее эмулировать raspberry pi 4, на котором работает релизная сборка.

    alter table executed_orders_1 add column test int, ALGORITHM=INSTANT; выполняется 0.218 секунды.

    alter table executed_orders_1 drop column test, ALGORITHM=INPLACE, LOCK=NONE; выполняется 679.312 секунд. Графана показывает полную загрузку 1 ядра и 700 iops на диске.

    Я так побаловался пару раз с add/drop и телеметрия трех микросервисов, которые пишут в базу не показала никаких проблем со скоростью записи. В логах сервера тоже никаких проблем с эвентом, который перебрасывает записи из одной таблицы в другую. Думаю, что это все будет работать и с прямым чтением/записью единственной таблицы.


    1. slava-a Автор
      18.08.2021 15:52

      Интересно еще было бы попробовать добавить индексы и констрейнты к этой колонке


      1. iwannabeacoder
        18.08.2021 16:16
        +1

        Судя по документации, foreign key поддерживается с ALGORITHM=INPLACE

        MySQL :: MySQL 8.0 Reference Manual :: 15.12.1 Online DDL Operations

        А на

        alter table executed_orders_1 add constraint test_c check (test is null) enforced, ALGORITHM=COPY;

        сервер крепко задумался, скорее всего будет выполняться те же 10 минут, если не дольше.


        1. iwannabeacoder
          18.08.2021 16:51

          P.S. Время выполнения около 10 минут, сервер достиг ограничения в 860 iops. С более производительным диском запрос должен выполниться быстрее. Блокировки таблиц вроде не наблюдались, но это не 100%, так как снова смотреть логи я не осилил.


      1. korva
        19.08.2021 11:25

        В постгресе есть параллельные индексы:

        https://postgrespro.ru/docs/postgresql/9.4/sql-createindex#SQL-CREATEINDEX-CONCURRENTLY


        1. slava-a Автор
          19.08.2021 11:54

          Здесь про MySQL речь

          В постгресе мы такие и использовали как раз


  1. dph
    18.08.2021 21:04

    Но ALTER TABLE my_table ADD COLUMN"требует блокировки ACCESS EXCLUSIVE. В большинстве случаев это может быть не заметным, но если идет выполнение длинной транзакции, то данное требование приведет к весьма заметной недоступности таблицы.Поэтому указанный подход нужно использовать очень аккуратно и не стоит использовать в ответах на собеседовании.


    1. slava-a Автор
      18.08.2021 21:25

      Как иначе тогда без 'alter table add column'? Создавать новую таблицу?


      1. select_artur
        18.08.2021 23:16

        Лучше сначала брать блокировку EXCLUSIVE, который не блокирует читателей. А еще лучше дополнительно использовать lock_timeout и пробовать выполнить операцию в цикле.


        1. dph
          22.08.2021 14:49

          А можно описать, а чем этот подход поможет?


          1. select_artur
            22.08.2021 17:06

            EXCLUSIVE поможет вам подождать все транзакции ROW SHARE и выше, не блокируя читателей.

            Когда вы взяли блокировку EXCLUSIVE вам уже нужно будет выполнить ALTER TABLE и ждать, когда завершатся транзакции ACCESS SHARE перед вами. Это конечно может быть не очень полезно, если у вас в основном только ACCESS SHARE, но полезно, если есть долгие записи.


      1. dph
        22.08.2021 14:48

        Просто нужно уметь возможность на момент миграции отключать "длинные транзакции" (например те же отчеты или другие миграции на этой же таблице).
        Если есть возможность или необходимость - можно оценивать, какие вообще транзакции идут сейчас по данной таблице и откладывать миграцию DDL на момент "все тихо", но это уже сложное решение.
        Или принудительно делать rollback для всех транзакций, а потом уже делать ALTER TABLE


    1. bigtrot
      20.08.2021 13:36

      Rename тоже требует ACCESS EXCLUSIVE. И почему эта операция, как и ADD в большинстве случаев может быть не незаметной?


      1. dph
        22.08.2021 14:54

        Можешь погуглить, это популярная тема.
        Если коротко, то при попытке взятия ACCESS EXCLUSIVE во время выполнения другой длинной транзакции все новые транзакции будут выстраиваться в очередь после выполнения AE, а она будет ждать завершения этой длинной транзакции.
        В худшем случае можно получить простой сроком в несколько минут (если такие длинные транзакции разрешены конкретными настройками, обычно разрешены).


        1. bigtrot
          23.08.2021 08:40

          Так и ADD COLUMN создаст такие же проблемы. Тогда в чем преимущество добавление колонки по сравнению с её переименованием, если и там и там требуется ACCESS EXCLUSIVE, только после переименования таблица готова для работы с новой колонкой, а после добавления требуются дополнительные манипуляции по переносу данных?


  1. LaRN
    18.08.2021 21:45
    -1

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

    Так и схема на сервере обновиться и сервис, который на новую схему заточен не рухнет, и старый сервис со старой версией БД спокойно доработает.


  1. ainu
    18.08.2021 22:22
    -1

    Чисто ради обсуждения спрашиваю. Повесить табличку "ведутся технические работы" и без нагрузки быстро-быстро сделать альтер. Чем плохо?


    1. slava-a Автор
      19.08.2021 00:08

      С бизнесом трудно договориться)