Houdrik
Engineering
· 11 березня 2026 р.· 11 min read

Міграції, які насправді можна відкотити

Майже кожна команда скаже вам, що їхні міграції оборотні. Спробуйте у вівторок після обіду. Ось патерни, які перетворюють відкат на рутинну подію, а не на суботню аварію.

Cover · migrations-you-can-actually-roll-back

Майже кожна команда скаже вам, що їхні міграції оборотні. Спробуйте у вівторок після обіду, з живим трафіком, на очах у CTO. Відповідь зазвичай звучить так: «ну, ця так, але…». Більшість міграцій неможливо відкотити з простої причини: ніхто їх такими не проєктує. Їх проєктують рухатись уперед, а def downgrade() пишуть лише тому, що цього вимагає фреймворк.

Ми провели чимало ночей не з того боку міграції, яка «мала би» відкотитися. Патерни нижче — ті, що ми тепер застосовуємо за замовчуванням. Вони не нові. Це дисципліна, до якої команди промислової якості приходять після другої чи третьої поганої ночі. Сенс цього тексту — позбавити вас кількох таких ночей.

Обмеження, якого ми тримаємось наскрізь: будь-яка міграція, яку ми випускаємо, має бути оборотною без втрати даних, на парку з кількох інстансів, під навантаженням. Не в теорії. У вівторок.

Додавання колонки у дві фази

Найпоширеніший спосіб усе зламати — одноетапна міграція, яка додає колонку, заповнює її історичними даними й одразу починає з неї читати в одному розгортанні. На ноутбуці працює. На стейджі з одним інстансом працює. Ламається тієї миті, коли в парку більше одного вузла, а викочування поступове.

Патерн, який витримує: п'ять розгортань замість одного.

  1. Лише схема. Додаємо нову колонку, nullable, без обмежень. Старий код працює без змін. Це розгортання тривіально оборотне, бо нова колонка ще нікуди не читається й не пишеться.
  2. Подвійний запис. Випускаємо код, який на кожній зміні пише і в стару, і в нову колонку. Читання досі йде зі старої. Нова починає наздоганяти на нових рядках.
  3. Заповнення історії. Запускаємо окреме ідемпотентне завдання, яке заповнює нову колонку для історичних рядків. Принципова річ: це завдання, а не міграція — його можна зупинити, відновити й спостерігати. До цього ще повернемось.
  4. Перемикаємо читання. Випускаємо код, який читає з нової колонки. Стара колонка все ще пишеться як страховка. Якщо читання з нової колонки розвалюється, ви відкочуєте це єдине розгортання — і за кілька хвилин система повертається у відомий робочий стан.
  5. Прибираємо стару колонку. Коли нова колонка достатньо довго була шляхом читання, щоб їй довіряти, прибираємо стару колонку фінальною міграцією.

П'ять розгортань на одну логічну зміну. Компроміс у тому, що будь-яке з чотирьох оборотних розгортань відкочується незалежно. П'яте — DROP — єдиний крок, що справді фіксує зміну, і на цей момент у вас є тижні доказів, що нова колонка коректна.

Дисципліна коштує часу на кожну міграцію. Натомість повертає здатність розгортатися о 14:00, а не о 02:00.

Перейменування — це видалення плюс додавання

Ніколи не перейменовуйте колонку однією міграцією. Ні в Postgres, ні в MySQL, ні в чомусь, що тримає більше одного інстансу застосунку одночасно.

Причина — стан парку під час поступового викочування. Інстанс A досі читає customer_name. Інстанс B уже з новим кодом і колонкою full_name. Кілька хвилин, поки триває викочування, половина парку читає колонку, якої вже немає. Інша половина пише в колонку, якої ще немає в базі.

Перейменування — це таке саме додавання колонки у дві фази, як вище, з одним додатковим кроком: під час фази подвійного запису копіюємо дані. «Стара колонка» — це початкова назва. «Нова колонка» — це нова назва. П'ять розгортань, як і раніше. Перейменування концептуально виглядає найневиннішою зі змін, а операційно — однією з найнебезпечніших, саме тому, що в одноінстансовому dev-середовищі здається таким безпечним.

Якщо джуніор у команді перейменовує колонку однією міграцією — це не дрібна правка на рев'ю. Це момент навчання про те, як насправді виглядає розгортання у продакшні.

Заповнення історії — окремо від змін схеми

Зміна схеми, яка додає колонку, з правильними прапорцями, майже миттєва й повністю оборотна. Заповнення мільйонів рядків — не миттєве й не оборотне. Об'єднати їх в одне розгортання — це шлях до міграції, яка вже сорок хвилин крутиться, а ви не знаєте, чекати чи вбивати.

Правило, якого ми тримаємось: міграції схеми додавальні й швидкі. Заповнення історії — це фонові завдання.

Міграція схеми, яка займає більше секунди-двох на продакшн-базі, — це червоний прапорець. Справжні заповнення йдуть у чергу (Celery, разова management-команда — що там команда використовує) з трьома властивостями: ідемпотентність, можливість відновити, спостережуваність. Ми хочемо бачити прогрес. Хочемо мати змогу зупинити на час сплеску трафіку. Хочемо знати, що завершилось — і завершилось коректно.

Конкретно це означає, що функція приземляється в три логічні фази:

  • Фаза 1: міграція схеми — додавальна, швидка, оборотна.
  • Фаза 2: зміна коду, яка використовує нову форму, зі страховкою через подвійний запис.
  • Фаза 3: заповнення історії, запущене як фонове завдання, простежене до завершення.

Кожна фаза оборотна незалежно. Якщо заповнення виявилося некоректним, ви зупиняєте завдання, виправляєте логіку й перезапускаєте. Ви не відкочуєте зміну схеми. Ви не перевипускаєте застосунок. Радіус ураження помилки лишається в межах тієї фази, де ця помилка живе.

Затягування обмежень — це власне розгортання

Додати NOT NULL до колонки, доданої два розгортання тому, відчувається як продовження тієї ж зміни. Це не так. Це окреме розгортання, і воно має бути останнім у серії, а не в одному пакеті з додаванням колонки.

Чому це важливо: коли обмеження затягується, площа того, «що може зламатись», розширюється. Будь-який шлях у коді, який повертає NULL для цієї колонки — зокрема код, який тиждень тому робив це абсолютно спокійно, — тепер видасть помилку. Часто йдеться про шляхи, про існування яких уже ніхто й не пам'ятав.

Правильний порядок:

  1. Додати колонку, nullable, з розумним типовим значенням для нових записів.
  2. Випустити код застосунку, який завжди заповнює цю колонку.
  3. Заповнити історичні рядки, вставлені до кроку 2.
  4. Тільки тоді додати обмеження NOT NULL.

Кожен крок оборотний незалежно. Розгортання, що затягує обмеження, — найризикованіше в серії, і саме тому воно заслуговує на власне deploy window: воно з найбільшою ймовірністю виявить те, чого ніхто не чекав. Якщо щось ламається — ви відкочуєте обмеження, застосунок далі працює, і ви маєте чистий сигнал, що саме треба виправити.

Версія цього правила, яку ми вбиваємо джуніорам у голову: обмеження — це розгортання, а не міграція.

Зовнішні ключі через NOT VALID, потім VALIDATE

Випадок, специфічний для Postgres, який варто знати. Додавання зовнішнього ключа до великої таблиці виглядає невинно і бере ексклюзивний lock на достатньо довго, щоб покласти застосунок. Ми бачили, як це відбувається. Двічі.

Патерн, який цього не робить:

ALTER TABLE orders
  ADD CONSTRAINT orders_customer_fk
  FOREIGN KEY (customer_id) REFERENCES customers(id)
  NOT VALID;

NOT VALID каже Postgres застосовувати обмеження до нових рядків, але не сканувати наявні. Lock короткий. Міграція завершується за мілісекунди.

Потім, окремо:

ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_fk;

VALIDATE бере значно слабший lock і сканує наявні рядки у фоні. Це можна запустити в тиху годину. Якщо валідація падає — бо в якомусь історичному рядку висить customer_id, — ви дізнаєтесь і виправляєте дані, а не схему. Нові записи вже захищені обмеженням.

Дві міграції, одна логічна зміна, без падіння у продакшні. Цей патерн узагальнюється: будь-яке обмеження, яке вимагає сканування таблиці, треба додавати з еквівалентом «поки не скануй наявні рядки» і валідувати окремо.

Міграція, яку справді не можна відкотити

Ми не стверджуємо, що відкотити можна будь-яку міграцію. Деякі справді односторонні. Видалення колонки — одностороннє. Видалення таблиці — одностороннє. Згортання двох значень enum в одне — одностороннє, до того ж із втратою даних. Розбиття однієї колонки на три після зміни парсингу — на практиці одностороннє.

Дисципліна не в тому, щоб робити вигляд, що таких міграцій не існує. Вона в тому, щоб назвати їх — заздалегідь, на письмі.

Наше правило: будь-яка міграція, яку справді не можна відкотити, потребує короткого ADR перед злиттям. ADR у трьох-чотирьох пунктах каже:

  • Що змінюється і що втрачається.
  • Чому ми на це йдемо (тобто чому міграція у дві фази недоступна).
  • Який план відновлення, якщо розгортання піде не так (зазвичай: відновлення з резервної копії, програвання X годин записів).
  • На яке deploy window ми це плануємо.

Це не папір заради паперу. Це момент, коли хтось може помітити, що міграція насправді не мусила бути односторонньою, і команда обирає нудний оборотний шлях. Приблизно в третині випадків саме так і відбувається.

Коли ADR доходить висновку, що міграція справді одностороння, команда заходить з відкритими очима. Розгортання отримує справжнє вікно, реальні очі на дашбордах і протестований шлях відновлення. Ціна необоротної міграції — це планування навколо неї, сплачене наперед.

Компроміс

Усе це — більше роботи на одну міграцію. Функція, яка була б однією міграцією, перетворюється на три. Перейменування колонки перетворюється на сагу з п'яти розгортань. Команда мусить уповільнитись настільки, щоб думати, у якій фазі вона зараз.

Компроміс у тому, що «міграція зламалася, ми відкотили» перестає бути суботньою аварією і стає п'ятихвилинним інцидентом посеред звичайного робочого дня. Ціна сплачується малими передбачуваними внесками під час планування. Її не доводиться сплачувати в одну катастрофічну ніч, коли вся команда на дзвінку намагається згадати, чи резервна копія консистентна і скільки триватиме програвання журналу.

Ми були з обох боків цього компромісу. Перший варіант справді кращий. Навіть не близько.

На закриття

Більшість команд відкриває цю дисципліну для себе після поганої ночі — заднім числом. Розбір чесний, уроки гострі, наступна міграція ретельна. Та, що за нею, — теж ретельна. Третя вже трохи менш ретельна, а до шостої команда повертається до одноетапних змін, які «мають бути в нормі».

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

Якщо забрати з цього тексту одну річ: розділіть зміни схеми, зміни коду, заповнення історії і зміни обмежень на різні розгортання — і ви повернете собі більшу частину тієї оборотності, яку, як вам здавалось, ви вже мали.

Маєте додаток, який має жити?

Виведіть його з прототипу в продакшн.

Відповідаємо протягом одного робочого дня. MVP, написаний на відчуттях, чернетка від AI, недороблений проєкт або робочий продукт, що починає тріщати — усе приймається.

Запустити проєкт