Майже кожна команда скаже вам, що їхні міграції оборотні. Спробуйте у вівторок після обіду, з живим трафіком, на очах у CTO. Відповідь зазвичай звучить так: «ну, ця так, але…». Більшість міграцій неможливо відкотити з простої причини: ніхто їх такими не проєктує. Їх проєктують рухатись уперед, а def downgrade() пишуть лише тому, що цього вимагає фреймворк.
Ми провели чимало ночей не з того боку міграції, яка «мала би» відкотитися. Патерни нижче — ті, що ми тепер застосовуємо за замовчуванням. Вони не нові. Це дисципліна, до якої команди промислової якості приходять після другої чи третьої поганої ночі. Сенс цього тексту — позбавити вас кількох таких ночей.
Обмеження, якого ми тримаємось наскрізь: будь-яка міграція, яку ми випускаємо, має бути оборотною без втрати даних, на парку з кількох інстансів, під навантаженням. Не в теорії. У вівторок.
Додавання колонки у дві фази
Найпоширеніший спосіб усе зламати — одноетапна міграція, яка додає колонку, заповнює її історичними даними й одразу починає з неї читати в одному розгортанні. На ноутбуці працює. На стейджі з одним інстансом працює. Ламається тієї миті, коли в парку більше одного вузла, а викочування поступове.
Патерн, який витримує: п'ять розгортань замість одного.
- Лише схема. Додаємо нову колонку, nullable, без обмежень. Старий код працює без змін. Це розгортання тривіально оборотне, бо нова колонка ще нікуди не читається й не пишеться.
- Подвійний запис. Випускаємо код, який на кожній зміні пише і в стару, і в нову колонку. Читання досі йде зі старої. Нова починає наздоганяти на нових рядках.
- Заповнення історії. Запускаємо окреме ідемпотентне завдання, яке заповнює нову колонку для історичних рядків. Принципова річ: це завдання, а не міграція — його можна зупинити, відновити й спостерігати. До цього ще повернемось.
- Перемикаємо читання. Випускаємо код, який читає з нової колонки. Стара колонка все ще пишеться як страховка. Якщо читання з нової колонки розвалюється, ви відкочуєте це єдине розгортання — і за кілька хвилин система повертається у відомий робочий стан.
- Прибираємо стару колонку. Коли нова колонка достатньо довго була шляхом читання, щоб їй довіряти, прибираємо стару колонку фінальною міграцією.
П'ять розгортань на одну логічну зміну. Компроміс у тому, що будь-яке з чотирьох оборотних розгортань відкочується незалежно. П'яте — DROP — єдиний крок, що справді фіксує зміну, і на цей момент у вас є тижні доказів, що нова колонка коректна.
Дисципліна коштує часу на кожну міграцію. Натомість повертає здатність розгортатися о 14:00, а не о 02:00.
Перейменування — це видалення плюс додавання
Ніколи не перейменовуйте колонку однією міграцією. Ні в Postgres, ні в MySQL, ні в чомусь, що тримає більше одного інстансу застосунку одночасно.
Причина — стан парку під час поступового викочування. Інстанс A досі читає customer_name. Інстанс B уже з новим кодом і колонкою full_name. Кілька хвилин, поки триває викочування, половина парку читає колонку, якої вже немає. Інша половина пише в колонку, якої ще немає в базі.
Перейменування — це таке саме додавання колонки у дві фази, як вище, з одним додатковим кроком: під час фази подвійного запису копіюємо дані. «Стара колонка» — це початкова назва. «Нова колонка» — це нова назва. П'ять розгортань, як і раніше. Перейменування концептуально виглядає найневиннішою зі змін, а операційно — однією з найнебезпечніших, саме тому, що в одноінстансовому dev-середовищі здається таким безпечним.
Якщо джуніор у команді перейменовує колонку однією міграцією — це не дрібна правка на рев'ю. Це момент навчання про те, як насправді виглядає розгортання у продакшні.
Заповнення історії — окремо від змін схеми
Зміна схеми, яка додає колонку, з правильними прапорцями, майже миттєва й повністю оборотна. Заповнення мільйонів рядків — не миттєве й не оборотне. Об'єднати їх в одне розгортання — це шлях до міграції, яка вже сорок хвилин крутиться, а ви не знаєте, чекати чи вбивати.
Правило, якого ми тримаємось: міграції схеми додавальні й швидкі. Заповнення історії — це фонові завдання.
Міграція схеми, яка займає більше секунди-двох на продакшн-базі, — це червоний прапорець. Справжні заповнення йдуть у чергу (Celery, разова management-команда — що там команда використовує) з трьома властивостями: ідемпотентність, можливість відновити, спостережуваність. Ми хочемо бачити прогрес. Хочемо мати змогу зупинити на час сплеску трафіку. Хочемо знати, що завершилось — і завершилось коректно.
Конкретно це означає, що функція приземляється в три логічні фази:
- Фаза 1: міграція схеми — додавальна, швидка, оборотна.
- Фаза 2: зміна коду, яка використовує нову форму, зі страховкою через подвійний запис.
- Фаза 3: заповнення історії, запущене як фонове завдання, простежене до завершення.
Кожна фаза оборотна незалежно. Якщо заповнення виявилося некоректним, ви зупиняєте завдання, виправляєте логіку й перезапускаєте. Ви не відкочуєте зміну схеми. Ви не перевипускаєте застосунок. Радіус ураження помилки лишається в межах тієї фази, де ця помилка живе.
Затягування обмежень — це власне розгортання
Додати NOT NULL до колонки, доданої два розгортання тому, відчувається як продовження тієї ж зміни. Це не так. Це окреме розгортання, і воно має бути останнім у серії, а не в одному пакеті з додаванням колонки.
Чому це важливо: коли обмеження затягується, площа того, «що може зламатись», розширюється. Будь-який шлях у коді, який повертає NULL для цієї колонки — зокрема код, який тиждень тому робив це абсолютно спокійно, — тепер видасть помилку. Часто йдеться про шляхи, про існування яких уже ніхто й не пам'ятав.
Правильний порядок:
- Додати колонку, nullable, з розумним типовим значенням для нових записів.
- Випустити код застосунку, який завжди заповнює цю колонку.
- Заповнити історичні рядки, вставлені до кроку 2.
- Тільки тоді додати обмеження
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 доходить висновку, що міграція справді одностороння, команда заходить з відкритими очима. Розгортання отримує справжнє вікно, реальні очі на дашбордах і протестований шлях відновлення. Ціна необоротної міграції — це планування навколо неї, сплачене наперед.
Компроміс
Усе це — більше роботи на одну міграцію. Функція, яка була б однією міграцією, перетворюється на три. Перейменування колонки перетворюється на сагу з п'яти розгортань. Команда мусить уповільнитись настільки, щоб думати, у якій фазі вона зараз.
Компроміс у тому, що «міграція зламалася, ми відкотили» перестає бути суботньою аварією і стає п'ятихвилинним інцидентом посеред звичайного робочого дня. Ціна сплачується малими передбачуваними внесками під час планування. Її не доводиться сплачувати в одну катастрофічну ніч, коли вся команда на дзвінку намагається згадати, чи резервна копія консистентна і скільки триватиме програвання журналу.
Ми були з обох боків цього компромісу. Перший варіант справді кращий. Навіть не близько.
На закриття
Більшість команд відкриває цю дисципліну для себе після поганої ночі — заднім числом. Розбір чесний, уроки гострі, наступна міграція ретельна. Та, що за нею, — теж ретельна. Третя вже трохи менш ретельна, а до шостої команда повертається до одноетапних змін, які «мають бути в нормі».
Ми натомість намагаємось вивчити цю дисципліну з чужих поганих ночей. Патерни вище — не наші. Це конвергентна відповідь, до якої доходять команди промислової якості після достатньої кількості болючих субот. Сенс у тому, щоб пропустити суботи і залишити собі відповідь.
Якщо забрати з цього тексту одну річ: розділіть зміни схеми, зміни коду, заповнення історії і зміни обмежень на різні розгортання — і ви повернете собі більшу частину тієї оборотності, яку, як вам здавалось, ви вже мали.


