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

Написание SQL миграции > написание кода > тестирование > релиз > мониторинг.

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

Поскольку мы в компании работаем с PostgreSQL, а серверный код пишем на Java, то примеры будут основаны на этом стеке, хотя большинство идей не зависят от используемой БД и языка программирования.

SQL-миграция


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

У нас в компании разработчики сами пишут SQL-миграции, поэтому все миграции хранятся в репозитории с основным кодом. В некоторых компаниях изменением схемы занимаются администраторы БД, в таком случае реестр миграций находится где-то у них. Так или иначе такой подход приносит следующие преимущества:

  • Всегда можно легко создать новую базу с нуля или обновить существующую до актуальной версии. Это позволяет быстро разворачивать новые тестовые среды и и локальные окружения для разработчиков.
  • Все базы имеют одинаковую схему – никаких сюрпризов при обслуживании.
  • Есть история всех изменений (версионирование).

Существует достаточно много готовых инструментов для автоматизации этого процесса, как коммерческих так и бесплатных: flyway, liquibase, sqitch и др. В этой статье я не буду заниматься сравнением и выбором лучшего инструмента – это отдельная большая тема, и вы можете найти множество статей по ней. 

Мы используем flyway, поэтому дальше будет немного информации о нем:

  • Есть 2 вида миграций: sql-based и java-based
  • SQL-миграции неизменяемы (иммутабельны). После первого выполнения SQL-миграция не может быть изменена. Flyway вычисляет контрольную сумму для содержимого файла миграции и сверяет её при каждом запуске. Для иммутабельности Java-миграций необходимы дополнительные ручные манипуляции.
  • История всех миграций хранится в таблице flyway_schema_history (ранее schema_version). Там вы можете увидеть дату и продолжительность выполнения каждой миграции, её тип, название файла, контрольную сумму.

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

Java-миграции используются только для DML, когда на чистом SQL написать не получается. Для нас типичным примером такой ситуации служат миграции по переносу данных в Postgres из другой БД (мы переезжаем из Redis в Postgres, но это уже совсем другая история). Ещё один пример — обновление данных большой таблицы, которое проводится в несколько транзакций для минимизации времени блокировки таблицы. Стоит сказать, что с 11-й версии Postgres это можно сделать с помощью SQL-процедур на plpgsql.

Когда Java-код устаревает, миграция может быть удалена, чтобы не плодить legacy (сам Java-класс миграции остаётся, но внутри он пустой). У нас это может произойти не ранее, чем через месяц после вывода миграции на production – мы считаем, что это достаточное время для того, чтобы все тестовые окружения и локальные среды разработчиков обновились. Стоит отметить, что поскольку Java-миграции используются только для DML, то их удаление никак не влияет на создание новых БД с нуля.

Важный нюанс для тех, кто использует pg_bouncer


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

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

Для Postgres он использует advisory locks в сессионном режиме, а это значит, что для корректной работы необходимо, чтобы сервер приложения работал с одним и тем же соединением во время захвата и снятия блокировки. Если вы используете pg_bouncer в транзакционном режиме (который является самым распространенным) или в режиме отдельных запросов, то для каждой транзакции он может вернуть новое соединение и flyway не сможет снять установленную блокировку. 

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

Написание кода


Миграция создана, теперь пишем код.

Можно выделить 3 подхода для работы с БД со стороны приложения:

  • Использование ORM (если говорить про Java, то hibernate де факто является стандартом)
  • Использование plain sql + jdbcTemplate и т.п.
  • Использование DSL-библиотек.

Использование ORM позволяет снизить требования к знанию SQL – многое генерируется автоматически: 
  • схема данных может быть создана по xml-описанию или по Java-entity, имеющимся в коде
  • отношения объектов определяются с помощью декларативного описания – ORM сделает join-ы за вас
  • при использовании Spring Data JPA даже более хитрые запросы также могут генерироваться автоматически по сигнатуре метода репозитория.

Ещё один «бонус» – наличие кэширования данных из коробки (для hibernate – это 3 уровня кэшей).

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

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

DSL-библиотеки


Примерно посередине между этими подходами находится ещё один, который заключается в использовании DSL-библиотек (jOOQ, Querydsl и др.). Они, как правило, гораздо легковеснее, чем ORM, но более удобны, чем полностью ручная работа с БД. Использование DSL-библиотек не так распространено, поэтому в этой статье кратко рассмотрим именно этот подход. 

Речь пойдёт про одну из библиотек — jOOQ. Что она предлагает:

  • инспектирование базы данных и автогенерация классов
  • fluent API для написания запросов.

jOOQ – это не ORM – нет ни автогенерации запросов, ни кэширования, но в тоже время часть проблем полностью ручного подхода закрываются:
  • классы для таблиц, представлений, функций и пр. объектов БД генерируются автоматически 
  • запросы пишутся на Java, это гарантирует type safe – синтаксически неправильный запрос или запрос с параметром неверного типа не скомпилируется – ваша IDE сразу подскажет об ошибке, и вам не придётся тратить время на запуск приложения, чтобы проверить корректность запроса. Это ускоряет процесс разработки и снижает вероятность ошибок.

В коде запросы выглядят примерно так:

BookRecord book = dslContext.selectFrom(BOOK)
                        .where(BOOK.LANGUAGE.eq("DE"))
                        .orderBy(BOOK.TITLE)
                        .fetchAny();

При желании можно использовать plain sql:

Result<Record> records = dslContext.fetch("SELECT * FROM BOOK WHERE LANGUAGE = ? ORDER BY TITLE LIMIT 1", "DE");

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

jOOQ Record и POJO


BookRecord в примере выше является оберткой над строкой в таблице book и реализует паттерн active record. Поскольку этот класс являются частью слоя доступа к данным (к тому же конкретной его реализации), то вы, возможно, не хотели бы передавать его в другие слои приложения, а использовать какой-то свой pojo-объект. Для удобства конвертации record <–> pojo jooq предлагает несколько механизмов: автоматические и ручной. В документации по ссылкам выше есть разнообразные примеры их использования при чтении, но нет примеров для вставки новых данных и обновления. Восполним этот пробел: 

private static final RecordUnmapper<Book, BookRecord> unmapper = 
    book -> new BookRecord(book.getTitle(), ...); // какая-то логика

public void create(Book book) {
    context.insertInto(BOOK)
            .set(unmapper.unmap(book))
            .execute();
}

Как можно увидеть, все достаточно просто.

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

Также jooq может генерировать DAO классы с набором базовых методов для упрощения работы с данными таблицы и уменьшения объема ручного кода (это очень похоже на Spring Data JPA):

public interface DAO<R extends TableRecord<R>, P, T> {
    void insert(P object) throws DataAccessException;    
    void update(P object) throws DataAccessException;
    void delete(P... objects) throws DataAccessException;
    void deleteById(T... ids) throws DataAccessException;
    boolean exists(P object) throws DataAccessException;
    ...
}

Мы в компании не используем автогенерацию DAO-классов – генерируем только обертки над объектами БД, а запросы пишем сами. Генерация оберток происходит каждый раз при пересборке отдельного мавен-модуля, в котором хранятся миграции. Чуть далее будут детали о том, как это реализовано.

Тестирование


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

Тоже самое касается и вопроса классификации тестов. В этой статье предлагается использовать следующий вариант разделения:

  • unit тестирование (модульное) 
  • интеграционное тестирование
  • end-to-end тестирование (сквозное).

Unit-тестирование подразумевает проверку функционала отдельных модулей изолированно друг от друга. Размер модуля – снова вещь неопределённая, для кого-то это отдельный метод, для кого-то класс. Изолированность означает, что все остальные модули представляют собой mocks или stubs (по-русски это имитации или заглушки, но звучит как-то не очень). По этой ссылке можно почитать статью Мартина Фаулера о разнице между ними. Unit-тесты маленькие, быстрые, но могут гарантировать только корректность логики отдельного модуля.

Интеграционные тесты в отличии от unit-тестов проверяют взаимодействие нескольких модулей между собой. Работа с БД – хороший пример, когда интеграционные тесты имеют смысл, потому что очень сложно качественно «замокать» БД, учитывая все её нюансы. Интеграционные тесты в большинстве случаев являются хорошим компромиссом между скоростью выполнения и гарантиями качества при тестировании БД в сравнении с другими видами тестирования. Поэтому в этой статье поговорим подробнее об этом виде тестирования.

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

Интеграционное тестирование


Когда речь заходит об интеграционном тестировании кода, работающего с БД, большинство разработчиков задаётся вопросами: как запускать БД, как инициализировать её состояние начальными данными и как делать это как можно быстрее?

Какое-то время назад достаточно распространённой практикой в интеграционном тестировании было использование h2. Это in-memory БД, написанная на Java, которая имеет режимы совместимости с большинством популярных БД. Отсутствие необходимости установки БД и универсальность h2 сделали её весьма удобной заменой настоящих БД, особенно если приложение не зависит от конкретной БД и использует только то, что входит в стандарт SQL (что бывает далеко не всегда). 

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

Ещё один вариант – использование embedded postgres. Это настоящий Postgres, поставляемый в виде архива и не требующий установки. Он позволяет работать как с обычной версией Postgres. 

Есть несколько реализаций, самые популярные от Yandex и openTable. Мы в компании использовали версию от Yandex. Из минусов – он достаточно медленный при запуске (каждый раз происходит распаковка архива и запуск БД – занимает 2-5 секунд в зависимости от мощности компьютера), также есть проблема с отставанием от официальной релизной версии. Ещё сталкивались с проблемой, что после попытки остановки из кода происходила какая-нибудь ошибка и процесс Postgres оставался висеть в ОС – приходилось убивать его вручную. 

testcontainers


Третий вариант – использование docker. Для Java существует библиотека testcontainers, которая предоставляет api для работы с docker-контейнерами из кода. Таким образом, любая зависимость в вашем приложении, которая имеет docker-образ, может быть заменена в тестах с помощью testcontainers. Также для для многих популярных технологий есть отдельные готовые классы, которые предоставляют более удобный api в зависимости от используемого образа:


Кстати, когда проект tescontainers стал достаточно популярным, разработчики yandex официально сообщили, что прекращают развитие проекта embedded postgres и советуют переходить на testcontainers.

Какие плюсы:

  • testcontainers быстрые (запуск пустого Postgres занимает меньше секунды)
  • postgres-сообщество выпускает официальные docker-образы для каждой новой версии
  • testcontainers имеет специальный процесс, который убивает висящие контейнеры после выключения jvm, если вы не сделали это программно
  • с помощью testcontainers можно использовать единый подход для тестирования внешних зависимостей приложения, что очевидно, упрощает работу.

Пример теста с использование Postgres:

@Test
public void testSimple() throws SQLException {
    try (PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>()) {
        postgres.start();
        ResultSet resultSet = performQuery(postgres, "SELECT 1");
        int resultSetInt = resultSet.getInt(1);
        assertEquals("A basic SELECT query succeeds", 1, resultSetInt);
    }
}

Если для образа нет отдельного класса в testcontainers, то создание контейнера выглядит примерно так:

public static GenericContainer redis = new GenericContainer("redis:3.0.2")
            .withExposedPorts(6379);

Если вы используете JUnit4, JUnit5 или Spock, то в testcontainers есть доп. поддержка для этих фреймворков, которая упрощает написание тестов.

Ускорение тестов с testcontainers


Несмотря на то, что переход с embedded postgres на testcontainers ускорил наши тесты за счёт более быстрого запуска Postgres, со временем тесты стали снова замедляться. Причиной этого послужило увеличение количества SQL-миграций, которые flyway выполняет при запуске. Когда количество миграций перевалило за сотню, время их выполнения было порядка 7-8 секунд, что значительно замедляло тесты. Это работало примерно так:

  1. перед очередным тестовым классом запускался «чистый» контейнер с Postgres
  2. flyway выполнял миграции
  3. выполнялись тесты этого класса
  4. контейнер останавливался и удалялся
  5. повтор с п. 1 для следующего тестового класса.

Очевидно, что со временем 2-й шаг занимал всё больше и больше времени.

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

  1. перед всеми тестами запускается «чистый» контейнер с Postgres
  2. flyway выполняет миграции
  3. состояние контейнера сохраняется
  4. перед очередным тестовым классом запускается ранее подготовленный контейнер
  5. выполняются тесты этого класса
  6. контейнер останавливается и удаляется
  7. повтор с п. 4 для следующего тестового класса.

Теперь время выполнения отдельного теста не зависит от количества миграций и при текущем их количестве (200+) новая схема экономит несколько минут на каждом прогоне всех тестов.

Далее немного технических деталей о том, как это реализовать


Docker имеет встроенный механизм для создания нового образа на основе запущенного контейнера с помощью команды commit. Она позволяет кастомизировать образы, например, изменяя какие-либо настройки. 

Важный нюанс, что команда не сохраняет данные примонтированных разделов. Но если взять официальный docker-образ Postgres, то директория PGDATA, в которой хранятся данные, располагается в отдельном разделе (чтобы после перезапуска контейнера данные не терялись), следовательно при выполнении commit состояние самой БД не сохраняется. 

Решение простое – не использовать раздел для PGDATA, а держать данные в памяти, что для тестов вполне нормально. Есть 2 способа как добиться этого – использовать свой dockerfile (примерно вот такой) без создания раздела, либо переопределить переменную PGDATA при запуске официального контейнера (раздел останется, но использоваться не будет). Второй путь выглядит значительно проще:

PostgreSQLContainer<?> container = ...
container.addEnv("PGDATA", "/var/lib/postgresql/data-no-mounted");
container.start();

Перед выполнением commit рекомендуется выполнить checkpoint для postgres, чтобы сбросить изменения из shared buffers на «диск» (который соответствует переопределенной переменной PGDATA):

container.execInContainer("psql", "-c", "checkpoint");

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

CommitCmd cmd = container.getDockerClient().commitCmd(container.getContainerId())
                .withMessage("Container for integration tests. ...")
                .withRepository(imageName)
                .withTag(tag);
String imageId = cmd.exec();

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

Еще пара слов об оптимизации времени сборки


Как уже было сказано ранее, при сборке отдельного мавен-модуля с миграциями помимо прочего выполняется генерация java-оберток над объектами БД. Для этого используется самописный мавен-плагин, который запускается перед компиляцией основного кода и выполняет 3 действия:

  1. Запускает «чистый» docker-контейнер с postgres
  2. Запускает Flyway, который выполняет sql-миграции для всех БД, тем самым проверяя их валидность
  3. Запускает Jooq, который инспектирует схему БД и генерирует java-классы для таблиц, представлений, функций и прочих объектов схемы.

Как можно легко увидеть, первые 2 действия идентичны тем, что выполняются при запуске тестов. Чтобы сэкономить время на запуске контейнера и прогоне миграций перед тестами, мы перенесли сохранение состояния контейнера в плагин. Таким образом, теперь сразу после пересборки модуля в локальном репозитории docker-образов появляются готовые образы для интеграционных тестов всех БД, используемых в коде.

Более подробный пример кода
@ThreadSafe
public static class PostgresContainerAdapter implements PostgresExecutable {
  private static final String ORIGINAL_IMAGE = "postgres:11.6-alpine";

  @GuardedBy("this")
  @Nullable
  private PostgreSQLContainer<?> container; // not null if it is running

  @Override
  public synchronized String start(int port, String db, String user, String password) 
  {
    Preconditions.checkState(container == null, "postgres is already running");

    PostgreSQLContainer<?> newContainer = new PostgreSQLContainer<>(ORIGINAL_IMAGE)
        .withDatabaseName(db)
        .withUsername(user)
        .withPassword(password);

    newContainer.addEnv("PGDATA", "/var/lib/postgresql/data-no-mounted");

    // workaround for using fixed port instead of random one chosen by docker
    List<String> portBindings = new ArrayList<>(newContainer.getPortBindings());
    portBindings.add(String.format("%d:%d", port, POSTGRESQL_PORT));
    newContainer.setPortBindings(portBindings);
    newContainer.start();

    container = newContainer;
    return container.getJdbcUrl();
  }

  @Override
  public synchronized void saveState(String name) {
    try {
      Preconditions.checkState(container != null, "postgres isn't started yet");

      // flush all changes
      doCheckpoint(container);

      commitContainer(container, name);
    } catch (Exception e) {
      stop();
      throw new RuntimeException("Saving postgres container state failed", e);
    }
  }

  @Override
  public synchronized void stop() {
    Preconditions.checkState(container != null, "postgres isn't started yet");

    container.stop();
    container = null;
  }

  private static void doCheckpoint(PostgreSQLContainer<?> container) {
    try {
      container.execInContainer("psql", "-c", "checkpoint");
    } catch (IOException | InterruptedException e) {
      throw new RuntimeException(e);
    }
  }

  private static void commitContainer(PostgreSQLContainer<?> container, String image)
  {
    String tag = "latest";
    container.getDockerClient().commitCmd(container.getContainerId())
        .withMessage("Container for integration tests. It uses non default location for PGDATA which is not mounted to a volume")
        .withRepository(image)
        .withTag(tag)
        .exec();
  }
  ...
}

Использование плагина:

<build>
  <plugins>
    <plugin>
      <groupId>com.miro.maven</groupId>
      <artifactId>PostgresPlugin</artifactId>
      <executions>
        <!-- running a postgres container -->
        <execution>
          <id>start-postgres</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>start</goal>
          </goals>
          
          <configuration>
            <db>${db}</db>
            <user>${dbUser}</user>
            <password>${dbPassword}</password>
            <port>${dbPort}</port>
          </configuration>
        </execution>
        
        <!-- applying migrations and generation java-classes -->
        <execution>
          <id>flyway-and-jooq</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>execute-mojo</goal>
          </goals>
          
          <configuration>
            <plugins>
              <!-- applying migrations -->
              <plugin>
                <groupId>org.flywaydb</groupId>
                <artifactId>flyway-maven-plugin</artifactId>
                <version>${flyway.version}</version>
                <executions>
                  <execution>
                    <id>migration</id>
                    <goals>
                      <goal>migrate</goal>
                    </goals>
                    
                    <configuration>
                      <url>${dbUrl}</url>
                      <user>${dbUser}</user>
                      <password>${dbPassword}</password>
                      <locations>
                        <location>filesystem:src/main/resources/migrations</location>
                      </locations>
                    </configuration>
                  </execution>
                </executions>
              </plugin>

              <!-- generation java-classes -->
              <plugin>
                <groupId>org.jooq</groupId>
                <artifactId>jooq-codegen-maven</artifactId>
                <version>${jooq.version}</version>
                <executions>
                  <execution>
                    <id>jooq-generate-sources</id>
                    <goals>
                      <goal>generate</goal>
                    </goals>
                      
                    <configuration>
                      <jdbc>
                        <url>${dbUrl}</url>
                        <user>${dbUser}</user>
                        <password>${dbPassword}</password>
                      </jdbc>
                      
                      <generator>
                        <database>
                          <name>org.jooq.meta.postgres.PostgresDatabase</name>
                          <includes>.*</includes>
                          <excludes>
                            #exclude flyway tables
                            schema_version | flyway_schema_history
                            # other excludes
                          </excludes>
                          <includePrimaryKeys>true</includePrimaryKeys>
                          <includeUniqueKeys>true</includeUniqueKeys>
                          <includeForeignKeys>true</includeForeignKeys>
                          <includeExcludeColumns>true</includeExcludeColumns>
                        </database>
                        <generate>
                          <interfaces>false</interfaces>
                          <deprecated>false</deprecated>
                          <jpaAnnotations>false</jpaAnnotations>
                          <validationAnnotations>false</validationAnnotations>
                        </generate>
                        <target>
                          <packageName>com.miro.persistence</packageName>
                          <directory>src/main/java</directory>
                        </target>
                      </generator>
                    </configuration>
                  </execution>
                </executions>
              </plugin>
            </plugins>
          </configuration>
        </execution>

        <!-- creation an image for integration tests -->
        <execution>
          <id>save-state-postgres</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>save-state</goal>
          </goals>
          
          <configuration>
            <name>postgres-it</name>
          </configuration>
        </execution>

        <!-- stopping the container -->
        <execution>
          <id>stop-postgres</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>stop</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>


Релиз


Код написан и протестирован – пора релизить. В целом, сложность релиза зависит от следующих факторов:

  • от количества БД (одна или несколько)
  • от размера БД
  • от количества серверов приложений (один или несколько)
  • бесшовный релиз или нет (допустим ли даунтайм приложения).

1й и 3й пункты накладывают на код требование обратной совместимости, поскольку в большинстве случаев невозможно одномоментно выполнить обновление всех БД и всех серверов приложений – всегда будет момент времени, когда базы будут иметь разные схемы, а сервера – разные версии кода.

Размер БД влияет на время миграции – чем больше база, тем больше вероятность, что вам потребуется провести длительную миграцию.

Бесшовность отчасти является результирующим фактором – если релиз проводится с выключением (downtime), то тогда первые 3 пункта не так важны и влияют только на время недоступности приложения.

Если говорить про наш сервис, то это:

  • примерно 30 кластеров БД

  • размер одной базы 200 — 400 Гб
  • несколько десятков серверов приложений (их количество автомасштабируется в течение суток в зависимости от нагрузки и в пике бывает больше 100), каждый сервер подключен ко всем БД
  • релизы бесшовные.

Мы используем канареечные релизы: новая версия приложения сперва выводится на небольшое количество серверов (мы называем это пре-релизом), а спустя некоторое время, если в пре-релизе не обнаруживается никаких ошибок, происходит релиз на остальные сервера. Таким образом, на production могут работать сервера на разных версиях.

Каждый сервер приложения при запуске сверяет версию БД с версиями скриптов, которые есть в исходном коде (в терминах flyway это называется validation). Если они различаются, сервер не будет запущен. Это гарантирует совместимость кода и базы данных. Не может возникнуть такая ситуация, когда, например, код работает с таблицей, которую еще не создали, потому что миграция находится в другой версии сервера.

Но это конечно не решает проблемы, когда, например, в новой версии приложения есть миграция, удаляющая столбец в таблице, который может использоваться в старой версии сервера. Сейчас мы проверяем такие ситуации только на этапе ревью (оно обязательно), но по-хорошему необходимо внедрить доп. этап с такой проверкой в CI/CD-цикл.  

Иногда миграции могут выполняться долго (например, при обновлении данных большой таблицы) и чтобы не замедлять при этом релизы, мы используем технику комбинированных миграций. Комбинированность заключается в ручном прогоне миграции на запущенном сервере (через панель администрирования, без flyway и, соответственно, без фиксирования в истории миграций), а затем «штатный» вывод такой же миграции в следующей версии сервера. На такие миграции накладываются следующие требования:

  • Во-первых, она должна быть написана таким образом, чтобы не блокировать работу приложения при долгом выполнения (основной момент здесь – не захватывать длительные блокировки на уровне БД). Для этого у нас есть внутренние рекомендации для разработчиков как писать миграции. В будущем, возможно, также поделюсь ими на Хабре.
  • Во-вторых, миграция при «штатном» запуске должна определить, что она уже была выполнена в ручном режиме и ничего не делать в таком случае – только зафиксировать новую запись в истории. Для SQL-миграций такая проверка осуществляется с помощью выполнения какого-нибудь SQL-запроса на наличие изменений. Для Java-миграций есть ещё один подход – использование хранимых boolean-флагов, которые устанавливаются после ручного прогона.


Такой подход решает 2 проблемы:
  • релиз выполняется быстро (хоть и с ручным действиями)
  • все окружения (локальные у разработчиков и тестовые) обновляются автоматически без каких-либо ручных манипуляций.

Мониторинг


После релиза цикл разработки не заканчивается. Чтобы понять работает ли новый функционал (и как он работает) необходимо «обкладываться» метриками. Их можно разделить на 2 группы: бизнесовые и системные. 

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

Метрики второй группы примерно одинаковы для всех – они определяют техническое состояние сервера: cpu, памяти, сети, БД и пр. 

Что конкретно нужно мониторить и как это делать – это тема огромного множества отдельных статей и здесь она затрагиваться не будет. Хочется напомнить лишь самые базовые (даже капитанские) вещи:

определяйте метрики заранее


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

настраивайте автоматические алерты


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

собирайте метрики со всех узлов


Метрик, как и логов, много не бывает. Наличие данных с каждого узла вашей системы (сервер приложения, БД, пулер соединений, балансировщик и пр.) позволяет иметь полную картину о её состоянии, и в случае необходимости вы сможете быстрее локализовать проблему. 

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

  • веб-сервер перегружен и долго отвечает на запросы

  • SQL-запрос стал выполняться дольше обычного
  • на пулере соединений скопилась очередь и сервер приложений долго не может получить соединение
  • проблемы в сети
  • что-то ещё

Без метрик поиск причины проблемы будет не так прост.

Вместо завершения


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