Практическое применение Flyway

Практическое применение Flyway

— Управление миграциями БД для загружаемых и обновляемых приложений —

1. Введение

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

Для типичного веб-сервиса эти изменения базы данных можно выполнять в рамках конвейера развертывания. Если команда разработки управляет и серверной средой, и средой базы данных, она также может выполнять необходимые SQL-команды вместе с развертыванием приложения.

Однако платформа Vizend, над которой я работаю, предъявляет несколько иные требования.

Приложение Vizend работает не только на одном центральном сервере: пользователи могут загружать и устанавливать его в собственных средах. После установки они могут снова загрузить или обновить приложение в любое время, когда выходит новая версия.

Иными словами, приложение является доступным для скачиванияи одновременно обновляемым.

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

Например, предположим, что версия 7.1 приложения использует следующую таблицу.

user

├─ id

└─ name

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

user

├─ id

├─ name

└─ status

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

В конечном итоге для нас обновление приложения означало следующие два действия одновременно.

Обновление приложения

        │

      ├─ Обновление версии приложения

       │

      └─ Обновление схемы базы данных

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

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

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

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

2. Внедрение Flyway

Когда мы впервые применили Flyway, мы ожидали, что его роль будет относительно простой.

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

Например, мы пишем скрипты миграций следующим образом.

V7_1_1__Create_User.sql
V7_1_2__Modify_User.sql
V7_1_3__Create_Position.sql

Flyway проверяет flyway_schema_history базы данных, чтобы отличить уже выполненные миграции от еще не выполненных.

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

В нашем приложении Spring Boot мы включили Flyway и настроили Hibernate так, чтобы он только проверял схему, не изменяя ее напрямую.

spring:
  flyway:
    enabled: true
  jpa:
    hibernate:
      ddl-auto: validate

Таким образом, Flyway отвечает за изменение схемы базы данных, а Hibernate проверяет согласованность между сущностями и фактической схемой базы данных.

Сначала мы думали, что этого будет достаточно для решения большинства проблем миграции БД.

Однако, разрабатывая реальный сервис и выполняя несколько обновлений версий, мы поняли, что сложно создать безопасную миграцию, основываясь только на «автоматическом последовательном выполнении SQL-команд».

В ходе этого процесса мы пришли к выводу, что при написании миграции наиболее важны следующие три момента.

Безопасно ли повторное выполнение той же миграции?

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

Сохранены ли данные, необходимые для возврата к предыдущему состоянию?

Каждая Идемпотентность, откат, резервное копированиеявляется проблемой, связанной с

3. Первый принцип миграции: идемпотентность

Версионируемая миграция Flyway не выполняет одну и ту же миграцию повторно после её успешного завершения.

Это может вызвать вопрос о том, действительно ли необходимо писать SQL миграции идемпотентным образом.

В реальных производственных средах трудно предположить, что Flyway всегда будет запускаться из идеально согласованного состояния.

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

Если в таких ситуациях при повторном выполнении того же SQL возникнет другая ошибка, процесс восстановления станет ещё сложнее.

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

Наиболее базовые используемые нами методы — IF EXISTS и IF NOT EXISTS.

При создании таблицы мы записываем это следующим образом.

CREATE TABLE IF NOT EXISTS user_history (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL
);

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

ALTER TABLE user
ADD COLUMN IF NOT EXISTS status VARCHAR(20);

Для операций удаления вместо этого мы используем IF EXISTS.

DROP TABLE IF EXISTS user_history;

Для операторов DDL, для которых база данных напрямую не предоставляет IF EXISTS или IF NOT EXISTS, мы создали отдельные функции, чтобы с ними можно было работать таким же образом.

Однако в процессе реализации мы также усвоили один момент, требующий осторожности.

Наличие IF NOT EXISTS не гарантирует полную идемпотентность миграции.

Например, предположим, что у нас есть следующий SQL.

ALTER TABLE user
ADD COLUMN IF NOT EXISTS status VARCHAR(20);

Если столбец status уже существует в базе данных, выполнение SQL завершается без ошибки.

Однако фактически столбец мог быть создан следующим образом.

기대 상태
status VARCHAR(20)
실제 상태
status INTEGER

SQL выполнился успешно, но база данных находится не в состоянии, ожидаемом приложением.

Поэтому под идемпотентностью мы понимаем не просто отсутствие ошибки при двукратном выполнении SQL.

Совпадает ли итоговое состояние базы данныхнезависимо от того, выполняется миграция один или несколько раз,

— именно этот критерий мы используем для оценки. IF EXISTS и IF NOT EXISTS — инструменты для его реализации.

4. Одной прямой миграции было недостаточно

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

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

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

Поэтому написания только прямой миграции было недостаточно.

В настоящее время при написании инкрементной миграции мы придерживаемся принципа одновременного написания соответствующего скрипта отката.

Например, если у нас есть миграция следующего вида,

postgresql/V7_1/
└─ V7_1_3__Modify_User.sql

мы также храним соответствующий скрипт отката в отдельном каталоге.

rollback/postgresql/V7_1/
└─ V7_1_3__Modify_User_Rollback.sql

Скрипт отката не выполняется Flyway автоматически.

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

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

ALTER TABLE user
ADD COLUMN IF NOT EXISTS status VARCHAR(20);

В скрипте отката вместо этого мы удаляем столбец.

ALTER TABLE user
DROP COLUMN IF EXISTS status;

Поначалу может показаться, что этого достаточно для подготовки к откату.

Однако при миграциях, изменяющих фактические данные, одного отката схемы недостаточно для возврата системы в прежнее состояние.

5. Подумайте о резервном копировании перед созданием отката

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

Например, предположим, что мы удалили существующий столбец.

ALTER TABLE user
DROP COLUMN legacy_code;

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

ALTER TABLE user
ADD COLUMN legacy_code VARCHAR(100);

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

Однако данные, хранившиеся в существующем столбце legacy_code, уже утрачены.

Иными словами,

откат схемы и откат данных — это разные задачи.

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

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

ALTER TABLE sample
RENAME COLUMN A TO A_7_1_3;

ALTER TABLE sample
ADD COLUMN A VARCHAR(255);

UPDATE sample
SET A = A_7_1_3;

Существующий столбец A сохраняется под именем A_7_1_3.

Если в новой версии возникает проблема, удалите вновь созданный столбец и восстановите существующий столбец.

ALTER TABLE sample
DROP COLUMN IF EXISTS A;

ALTER TABLE sample
RENAME COLUMN A_7_1_3 TO A;

Если требуется Backup всей таблицы, создайте Backup Table.

CREATE TABLE user_7_1_3 AS
SELECT *
FROM user;

Во время Rollback его можно использовать для восстановления исходной таблицы.

DROP TABLE IF EXISTS user;

ALTER TABLE user_7_1_3
RENAME TO user;

Мы также используем правило, согласно которому в имя Backup Object включается Migration Version.

<column>_<major>_<minor>_<patch>

<table>_<major>_<minor>_<patch>

Например, это выглядит следующим образом.

email_7_1_3
user_7_1_3

Это позволяет определить, для какой Migration была создана Backup.

В конечном итоге сейчас мы рассматриваем одну задачу Migration как состоящую из следующих трёх частей.

Migration

     │

    ├─ Backup

    ├─ Forward Migration

    └─ Rollback

В частности, при написании Migration было важно проектировать Rollback и Backup одновременно, а не сначала создавать Forward Migration, а затем думать о том, как выполнить Rollback

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

Текущее руководство также требует создавать Backup для таких операций, как изменение имён столбцов, изменение типов данных, изменение данных, изменение структуры таблицы и удаление данных.

6. Первая попытка и поиск решения: управление Baseline

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

Проблема заключается в том, что по мере продолжения работы приложения количество Migration Scripts постоянно увеличивается.

Например, может существовать множество Migrations, как показано ниже.

V7_0_1
V7_0_2
V7_0_3
...
V7_1_1
V7_1_2
V7_1_3
...

При установке новой среды последовательный запуск всех Migrations начиная с исходной версии требует большого количества ненужной работы.

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

Сначала для решения этой проблемы мы поддерживали отдельные файлы Baseline SQL, представляющие итоговую Schema для каждой Minor Version.

baseline/

└─ postgresql/

    ├─ 7.0.sql

    └─ 7.1.sql

В Baseline мы определяли итоговую Schema для данной Minor Version, включая все Tables, Indexes, Constraints и так далее.

Иными словами, мы поддерживали ещё один Snapshot отдельно от Flyway Migration.

Поначалу это упрощало новые установки.

Однако со временем стали очевидны и недостатки такого подхода.

Самая большая проблема заключалась в том, что фактически нам приходилось управлять одной и той же Database Schema в двух местах.

Incremental Migration

    → Как изменяется существующая DB

Baseline

    → В каком состоянии создаётся новая DB

Проблемы возникают, если разработчик пишет новую Migration, но при этом не изменяет Baseline.

Результат обновления существующей Database и результат установки новой Database могут отличаться.

В конечном итоге такая структура требовала постоянного поддержания согласованности между фактической историей Migration и Snapshot Baseline.

Автоматизируя процессы с помощью Flyway, мы фактически создали ещё один элемент, которым требовалось управлять вручную.

7. Изменение структуры Baseline на Initial Migration

Чтобы исправить эту проблему, мы реорганизовали структуру Baseline.

Мы удалили отдельные файлы Baseline Snapshot и изменили процесс таким образом, чтобы первая Migration для каждой Minor Version включала полную Schema DDL для этой версии.

Например, для версии 7.1 должен существовать следующий файл.

V7_1_0__Initial.sql

Используйте версию исправления 0 в качестве начальной миграции для соответствующей минорной версии.

Общая структура выглядит следующим образом.

postgresql/

├─ V7_1/

│ ├─ V7_1_0__Initial.sql

│ ├─ V7_1_1__Create_User.sql

│ └─ V7_1_2__Modify_User.sql

└─ V7_2/

   ├─ V7_2_0__Initial.sql

   ├─ V7_2_1__Create_History.sql

   └─ V7_2_2__Modify_User.sql

Запишите в V7_1_0__Initial.sql полный DDL схемы, необходимый для установки версии 7.1 с нуля.

Когда в процессе разработки версии 7.1 добавляются инкрементные миграции и начинается разработка версии 7.2, создайте V7_2_0__Initial.sql на основе финальной схемы базы данных версии 7.1.

V7_1_0__Initial

       ↓

V7_1_1

       ↓

V7_1_2

       ↓

Финальная схема 7.1

       ↓

V7_2_0__Initial

Это позволяет каждой минорной версии иметь независимую начальную точку для новой установки.

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

Разумеется, при обновлении существующей базы данных версии 7.1 до версии 7.2 нельзя повторно выполнять весь файл V7_2_0__Initial.sql для существующей схемы.

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

Эта часть также показала, что проектирование миграций не завершается простым созданием SQL-файлов; это был пример, продемонстрировавший необходимость одновременно учитывать как путь новой установки, так и путь обновления.

8. Вторая попытка и исправление ошибок: когда несколько разработчиков пишут миграции

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

Если один человек последовательно пишет все миграции, управлять версиями несложно.

Однако в реальных проектах несколько разработчиков одновременно работают в разных функциональных ветках.

Например, предположим, что последняя миграция в настоящее время выглядит следующим образом.

V7_2_2

Разработчик A и разработчик B одновременно изменяют схему в своих ветках.

Разработчик A создаёт следующую миграцию.

V7_2_3__Create_User.sql

Разработчик B также создаёт следующую миграцию, поскольку последней версией на момент начала его работы была V7_2_2.

V7_2_3__Create_Position.sql

В обеих ветках проблем не возникает.

Проблема возникает при объединении двух веток в основную ветку.

V7_2_3__Create_User.sql
V7_2_3__Create_Position.sql

Теперь существуют две миграции с одинаковой версией.

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

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

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

Это связано с тем, что Flyway записывает версию миграции и результат выполнения в flyway_schema_history.

После столкновения с этой проблемой мы установили следующее правило: Версии миграций необходимо повторно проверять не только при написании скрипта, но и при его объединении с основной веткой.

В настоящее время мы управляем ими в соответствии со следующим процессом.

Разработка функциональности

    ↓

Написание миграции

    ↓

Обновление основной ветки

    ↓

Проверка последней миграции

    ↓

Проверка наличия дублирующихся версий

    ↓

Корректировка версии при необходимости

    ↓

Основное слияние

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

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

Предположим, что существуют две миграции.

V7_2_7
V7_2_8

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

V7_2_7

   ↓

V7_2_8

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

Может возникнуть ситуация, в которой более высокая версия V7_2_8 сначала применяется в определённой среде.

V7_2_6

   ↓

V7_2_8

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

Опасно разрешать такую ситуацию простым переименованием файлов или изменением истории.

Это связано с тем, что уже выполненные миграции связаны с фактическими изменениями схемы базы данных.

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

Поэтому сейчас мы управляем ими в соответствии со следующими принципами.

  • Перед слиянием ветки, включающей миграцию, мы проверяем версию последней миграции.

  • Мы проверяем, существует ли идентичная версия.

  • Мы следим за тем, чтобы более высокая версия не развёртывалась первой, пока более низкая версия ещё не была развёрнута.

  • Мы не изменяем произвольно версию или содержимое уже выполненной миграции.

  • Перед развёртыванием мы проверяем ожидающие миграции и порядок их выполнения.

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

Работая с Flyway, мы поняли, что помимо написания технического SQL ветки Git и процесс выпуска также являются частью миграции базы данных.

10. Принципы миграций, сформированные после внедрения

Когда мы впервые внедрили Flyway, мы рассуждали следующим образом.

Migration
    =
Application 실행 시 자동으로 수행할 SQL

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

Миграция

    =

Как изменить базу данных

+ Безопасность повторного выполнения

+ Как сохранить данные

+ Методы восстановления в случае сбоя

+ Управление версиями и порядком выполнения

При написании миграции я в настоящее время проверяю следующие пункты.

Новая установка

  • Проверить, существует ли начальная миграция для минорной версии.

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

  • После новой установки приложение должно работать нормально.

Инкрементальная миграция

  • Писать её на основе состояния существующей рабочей базы данных.

  • По возможности обеспечивать идемпотентность.

  • Не изменять миграцию, которая уже была выполнена.

  • Проверить, затрагиваются ли существующие данные изменениями базы данных.

Резервное копирование и откат

  • При написании прямой миграции одновременно писать для неё скрипт отката.

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

  • Проверить, можно ли после отката восстановить не только схему, но и данные.

Управление версиями

  • Перед слиянием в основную ветку проверить последнюю версию миграции.

  • Проверить наличие дублирующихся версий.

  • Проверить, соответствует ли фактический порядок развёртывания порядку версий миграций.

  • Не изменять произвольно версию миграции, которая уже была выполнена.

В текущем проекте мы объединили эти пункты в руководство по написанию и проверке миграций и используем его соответствующим образом.

11. Заключение

Непосредственной причиной внедрения Flyway стала специфика приложения Vizend.

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

Flyway предоставил хорошую основу для реализации этих требований.

Однако в процессе фактической реализации я понял, что использование Flyway само по себе не делает миграцию базы данных безопасной.

Flyway записывает, какие миграции были выполнены, и запускает ещё не применённые миграции в заранее определённом порядке.

Однако Flyway не определяет за вас, безопасно ли повторно запускать миграцию, можно ли восстановить изменённые данные или предусмотрен ли способ отката.

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

В конечном итоге для надёжного использования Flyway в реальном проекте наиболее важными оказались не сами возможности инструмента, а правила команды по написанию и управлению миграциями.

При написании миграции я в настоящее время проверяю прежде всего следующие три пункта.

Безопасно ли выполнить её повторно?

Можно ли отменить её, если возникнет проблема?

Останутся ли доступными данные, необходимые для отката?

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

Ссылки

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

  • Руководство по миграциям базы данных на основе Flyway

https://vizend.notion.site/Flyway-DB-Migration-2e635bc54c138022a9d0f6c6c3b47695?pvs=74

David

Site footer