Когда старые данные встречают новую схему: обратное заполнение и верификация устаревших записей

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

Это тот момент, когда многие команды осознают, что миграция схемы — это не просто добавление колонок. Это необходимость убедиться, что каждая существующая запись соответствует новым правилам. Процесс заполнения старых данных в новую структуру называется обратным заполнением (backfill). И если сделать это небрежно, можно заблокировать таблицу на часы, замедлить приложение и расстроить всех, кто ждет выполнения запросов.

Почему backfill — это не простой UPDATE

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

Backfill нужно планировать с той же тщательностью, что и любую продакшен-операцию. Первый шаг — точно определить, какие данные нужно заполнить. Предположим, вы добавили колонку status, значение которой выводится из двух существующих колонок: is_active и deleted_at. Логика вашего backfill должна совпадать с правилами, которые использует новое приложение при записи новых записей. Если приложение устанавливает status в 'active', когда is_active равно true и deleted_at равно null, ваш backfill должен делать то же самое.

Пакетная обработка: безопасный способ

Вместо одного массивного запроса обрабатывайте данные небольшими пакетами. Возьмите тысячу или десять тысяч строк, где в новой колонке все еще NULL, обновите их, затем сделайте паузу перед переходом к следующему пакету. Это дает базе данных время «перевести дух» и позволяет другим запросам выполняться без ожидания.

Некоторые базы данных имеют встроенную поддержку пакетных обновлений. PostgreSQL, например, позволяет использовать UPDATE ... WHERE ... LIMIT с подзапросом. MySQL не поддерживает LIMIT напрямую в UPDATE, но можно использовать цикл в хранимой процедуре или обрабатывать пакетирование из скрипта миграции. Ключевой момент — каждый пакет должен быть достаточно маленьким, чтобы время блокировки оставалось приемлемым для вашей рабочей нагрузки.

Вот примерный шаблон, который работает во многих базах данных:

while есть строки с NULL в новой колонке:
    выбрать пакет первичных ключей, где new_column IS NULL
    обновить эти строки вычисленным значением
    сделать паузу на короткий интервал

Следующая диаграмма иллюстрирует этот безопасный итеративный подход:

flowchart TD A[Старт] --> B{Есть строки с NULL?} B -- Да --> C[Выбрать пакет из 1000 PK, где new_column IS NULL] C --> D[Вычислить значение новой колонки для пакета] D --> E[UPDATE строк WHERE id IN (пакет)] E --> F[Пауза 100ms] F --> B B -- Нет --> G[Готово]

Для конкретного примера предположим, что вы добавили колонку status, производную от is_active и deleted_at. Пакетный UPDATE в PostgreSQL может выглядеть так:

UPDATE your_table
SET status = CASE
    WHEN is_active = true AND deleted_at IS NULL THEN 'active'
    WHEN is_active = false AND deleted_at IS NULL THEN 'inactive'
    ELSE 'deleted'
END
WHERE id IN (
    SELECT id
    FROM your_table
    WHERE status IS NULL
    LIMIT 1000
);

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

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

Два источника истины во время backfill

Пока выполняется backfill, приложение продолжает писать новые данные и в старую, и в новую структуры. Это означает, что теперь у вас есть два источника истины для одних и тех же логических данных:

  • Данные, записанные приложением (всегда консистентны, так как приложение пишет в оба места одновременно)
  • Данные, заполненные процессом backfill (должны точно соответствовать логике приложения)

Оба должны выдавать одинаковые значения для одних и тех же входных данных. Если в логике backfill есть ошибка, или если логика приложения изменилась между началом и завершением backfill, вы получите неконсистентные данные. Вот почему верификация не является опциональной.

Верификация: больше, чем проверка на NULL

После завершения backfill естественный порыв — быстро проверить: «Остались ли NULL?» Это говорит о том, что колонка заполнена, но не говорит, корректны ли значения.

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

Несоответствие может возникнуть по нескольким причинам:

  • Ошибка в логике backfill, которая вычисляет значение иначе, чем приложение
  • Данные, изменившиеся между моментом обработки backfill и моментом верификации
  • Приложение, записавшее другое значение, потому что его логика была обновлена в середине миграции
  • Состояния гонки, когда строка была обновлена приложением после backfill, но до верификации

Каждое несоответствие нужно проследить до первопричины. Иногда это простое исправление. Иногда это указывает на более глубокую проблему в плане миграции.

Прогрессивная верификация

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

Для больших таблиц полная верификация не означает чтение каждой строки по одной. Вы можете написать запрос, который подсчитывает несоответствия без выборки всех данных. Что-то вроде:

SELECT COUNT(*) FROM table
WHERE computed_value != actual_new_value

Если счетчик равен нулю — все чисто. Если нет — вы знаете, сколько строк требуют внимания, и можете исследовать их пакетами.

Когда верификация пройдена

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

Backfill и верификация — это фундамент, который делает этот переход безопасным. Без них вы переключаетесь на новую схему с неполными или некорректными данными. С ними вы точно знаете, что у вас есть.

Практический чек-лист

  • Определите точную логику вычисления нового значения, соответствующую коду приложения
  • Выберите размер пакета, при котором время блокировки приемлемо для вашей базы данных
  • Реализуйте пакетную обработку с паузой между пакетами
  • Запускайте backfill в окне технического обслуживания или в период низкого трафика
  • Верифицируйте на случайной выборке перед полной верификацией
  • Исследуйте каждое несоответствие перед продолжением
  • Документируйте логику backfill и результаты верификации для будущих справок

Конкретный вывод

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