MAATRIX / Блог / ALTER TABLE на 200 миллионов строк заблокировал прод на сорок минут

ALTER TABLE на 200 миллионов строк заблокировал прод на сорок минут

MAATRIX

На стейджинге миграция отрабатывает за долю секунды — маленькая тестовая таблица, пара тысяч строк, изменение схемы проходит незаметно. В проде та же команда на таблице в 200 миллионов строк держит эксклюзивную блокировку сорок минут, приложение не может ни читать, ни писать в эту таблицу, а дежурный смотрит на растущую очередь подключений и не понимает, почему «простой ALTER» до сих пор не завершился. Разбираем, почему время выполнения структурных изменений зависит от объёма данных, а не от сложности команды, и что с этим делать до деплоя, а не после инцидента.

Что произошло

Таблица orders в проде выросла до 214 миллионов строк — она копилась несколько лет, это основная рабочая таблица сервиса, через неё идёт добрая половина запросов приложения. Причина миграции была прозаичной: колонка id была объявлена как integer, а последовательность подбиралась к верхней границе диапазона на несколько месяцев вперёд. Решение стандартное — сменить тип колонки на более широкий целочисленный. Миграция выглядела как одна строка:

ALTER TABLE orders ALTER COLUMN id TYPE bigint;

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

В проде команда пошла в 03:10 по местному времени — время выбрали не случайно, это было минимально нагруженное окно суток. Через несколько секунд после старта метрики приложения показали резкий рост времени ответа на всех эндпоинтах, которые трогают orders, а следом — таймауты и рост очереди подключений к базе. Мигратор молча висел, не выдавая ни ошибок, ни прогресса. Через сорок минут блокировка снялась сама, миграция завершилась успешно, и всё восстановилось разом, без вмешательства дежурного — просто потому, что операция наконец закончилась.

Ключевое отличие этого случая от классического «миграция встала в очередь блокировок за чужой долгой транзакцией» — здесь никто ALTER не блокировал. Он не ждал в очереди, он всё это время реально выполнял работу: физически переписывал данные таблицы под новый тип колонки. Сорок минут — это не время ожидания, а время самой операции.

Почему длительность ALTER зависит от объёма таблицы

На маленькой тестовой таблице почти любое изменение схемы кажется бесплатным, потому что происходит мгновенно независимо от его природы. Разница проявляется только на объёме, и суть в том, что именно физически должна сделать СУБД, чтобы применить изменение.

Часть изменений схемы — чисто метаданные: они меняют то, как СУБД *интерпретирует* уже существующие на диске байты, но не трогают сами данные. Такие операции почти не зависят от размера таблицы — что на тысяче строк, что на сотне миллионов они выполняются примерно с одинаковой скоростью, потому что объём реальной работы не растёт вместе с объёмом данных.

Другая часть изменений требует, чтобы СУБД физически прошла по каждой существующей строке — либо чтобы переписать её в новом формате, либо чтобы просто проверить, удовлетворяет ли она новому правилу (например, ограничению NOT NULL или CHECK). В этом случае объём работы линейно растёт вместе с числом строк: то, что на трёх тысячах строк незаметно, на двухстах миллионах превращается в десятки минут прохода по всей таблице.

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

Важный нюанс: даже когда полная физическая перезапись не требуется, для проверки нового ограничения СУБД всё равно нередко должна пройти по всем существующим строкам хотя бы один раз — и это тоже операция, чьё время растёт вместе с объёмом таблицы, просто обычно не такое затратное, как полный рерайт.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Какие изменения обычно безопасны, а какие — нет

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

Тип измененияОбычноПочему
Добавление колонки с NULL или константным значением по умолчаниюДёшево, не зависит от объёмаЧасто реализуется как чистое изменение метаданных
Переименование колонки или таблицыДёшево, не зависит от объёмаНе трогает физические данные
Добавление индексаДорого, но многие СУБД умеют делать это в «онлайн»-режимеПолный проход по таблице для построения индекса, но без обязательной блокировки на всё время
Добавление ограничения NOT NULL или CHECK на существующую колонкуДорого, растёт с объёмомНужно проверить каждую существующую строку
Смена типа колонкиОбычно дорого, растёт с объёмомЧасто требует физической перезаписи строк и связанных индексов
Удаление колонкиПо-разному: где-то дёшево (логическое скрытие), где-то требует рерайтаЗависит от внутреннего устройства конкретной СУБД

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

Отдельная категория — операции, где безопасный путь состоит из нескольких шагов вместо одного. Например, вместо того чтобы одной командой добавить ограничение NOT NULL на большую таблицу, многие СУБД позволяют сначала добавить его в «непроверенном» состоянии (быстрая операция), а затем отдельным шагом проверить существующие данные без долгой эксклюзивной блокировки. Это удлиняет миграцию по числу шагов, зато снимает риск многоминутного простоя. Разбор похожего, но другого по механике сценария — когда быстрая сама по себе миграция всё равно уронила прод, только из-за очереди блокировок, а не из-за объёма данных — есть в статье про то, как миграция добавила одну колонку и заблокировала таблицу. Стоит прочитать оба случая вместе: они закрывают разные классы одной и той же проблемы.

Что умеют современные инструменты и где их предел

За последние годы и сами СУБД, и сторонние инструменты заметно продвинулись в сторону «бесшовных» изменений схемы. Общий принцип у большинства таких решений один и тот же, вне зависимости от конкретной реализации: вместо одной длинной операции под эксклюзивной блокировкой создаётся копия таблицы (или её теневая структура) в новом формате, данные переносятся в неё пакетами в фоне, а обычный трафик приложения продолжает идти в старую таблицу. На финальном шаге происходит быстрое переключение — обычно это единственный момент, когда всё же берётся короткая блокировка, но она измеряется секундами, а не десятками минут.

Из известных решений такого рода — сторонние утилиты для онлайн-изменения схемы у MySQL-совместимых СУБД, а также встроенные механизмы отдельных версий популярных СУБД, которые постепенно расширяют список операций без долгой блокировки. Но здесь стоит быть честным: ни один такой инструмент не покрывает все виды изменений одинаково хорошо. У фонового переноса данных есть своя цена — дополнительная нагрузка на весь период работы, которая на очень большой таблице растягивается на часы; свободное место на диске порядка размера самой таблицы; чувствительность к параллельным изменениям в исходной таблице; и не каждый тип изменения он вообще поддерживает.

Отдельно стоит sequence-проблема из примера выше: смена типа автоинкрементной колонки, участвующей в первичном ключе и внешних ключах из десятка других таблиц, — один из самых тяжёлых случаев для любого инструмента онлайн-миграций, потому что требует согласованно обновить не только саму колонку, но и все связанные структуры. В таких сценариях практичнее честно спланировать многочасовое окно с постепенным переносом, чем искать инструмент, который сделает это незаметно для приложения.

Тестирование на копии данных сравнимого объёма

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

Практический подход:

  • Держать отдельную среду с регулярно обновляемой копией продовых данных — не обязательно один в один по объёму, но сопоставимого порядка величины по строкам в затрагиваемых таблицах. Про то, зачем такая среда нужна не только для миграций, но и в целом как точка входа для проверки перед деплоем, есть отдельный материал про тестовый сервер с копией боевой базы.
  • Прогонять именно ту команду, которая пойдёт в прод, с теми же опциями, а не её упрощённый аналог — разница в отдельных опциях может ощутимо повлиять на то, какой путь выполнения выберет СУБД.
  • Замерять не только итоговое время выполнения, но и то, как долго держится самая строгая блокировка — при возможности стоит параллельно нагрузить копию тестовым трафиком, чтобы увидеть эффект на живых запросах, а не только время команды в изоляции.
  • Заложить запас: копия обычно на другом железе с другой конфигурацией диска и памяти, поэтому цифры на тесте — ориентир, а не гарантия точного времени в проде. Разумно закладывать запас в полтора-два раза от измеренного значения при планировании окна обслуживания.
  • Если объём данных растёт быстро, тест стоит повторять перед каждым релизом, который трогает крупные таблицы — таблица, что полгода назад укладывалась в пять минут, сегодня может укладываться в пятнадцать просто за счёт органического роста.

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

Минимальная нагрузка — не решение, а страховка

В разобранном инциденте миграцию запустили именно в окне минимальной нагрузки — и это было правильное решение, просто недостаточное само по себе. Час с наименьшим трафиком снижает число одновременных запросов, которые встанут в очередь за заблокированной таблицей, и сокращает видимый пользователями эффект — но он не сокращает время самой операции. Если ALTER физически переписывает 200 миллионов строк, он будет делать это одинаково долго что в пик нагрузки, что в три часа ночи; разница только в том, сколько запросов из приложения за это время успеет накопиться в очереди и сколько пользователей это заметит.

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

  • Тест на копии сопоставимого объёма — чтобы вообще знать, во что вы ввязываетесь, до того как это станет сюрпризом в проде.
  • Явный лимит времени ожидания блокировки на саму DDL-команду, если СУБД это поддерживает — чтобы при непредвиденно долгом выполнении операция управляемо прервалась, а не держала прод в неопределённости.
  • Многошаговый безопасный путь там, где он существует (раздельное добавление и валидация ограничения, онлайн-инструмент для смены типа) — вместо одной большой операции.
  • Мониторинг в реальном времени во время самого окна — не «запустили и ушли спать», а дежурный на связи с готовым планом, что делать, если операция идёт дольше, чем показал тест.
  • Явный план отката на случай, если что-то пойдёт не так посреди выполнения — какие шаги предпринять, если операцию нужно прервать, и как убедиться, что база осталась в согласованном состоянии. Как выглядит такой план в целом, разобрано в материале про план отката миграции.

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему на тесте всё было мгновенно, а в проде — сорок минут, если это одна и та же команда?

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

Можно ли заранее, не тестируя на копии, понять, что изменение будет дорогим?

Как ориентир — да: если операция меняет физическое представление данных (тип колонки, порядок сортировки) или требует проверить существующие строки на соответствие новому правилу (NOT NULL, CHECK на непроверенных данных), стоит закладывать время пропорционально объёму таблицы. Но точную цифру даёт только тест на данных сопоставимого объёма — теоретическая оценка годится для планирования, а не для точного окна обслуживания.

Если инструмент умеет делать ALTER «без блокировки», можно ли запускать его в рабочее время без окна обслуживания?

Формально многие такие операции действительно не держат таблицу заблокированной всё время работы — но у них есть своя цена: дополнительная нагрузка на диск и CPU на протяжении всего фонового переноса и как минимум короткая блокировка на финальном шаге переключения. На таблице с высоким трафиком стоит тестировать и это тоже, а не считать «онлайн» синонимом «безопасно в любой момент».

Что делать, если ALTER уже запущен в проде и явно идёт дольше, чем ожидалось?

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

Насколько универсальна таблица «дёшево / дорого» из статьи?

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

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →