Работа с базами данных глазами разработчика


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

Написание 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 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();
  }
  // ...
}

Плагин (метод «start»):
@Mojo(name = "start")
public class PostgresPluginStartMojo extends AbstractMojo {
  private static final Logger logger = LoggerFactory.getLogger(PostgresPluginStartMojo.class);

  @Nullable
  static PostgresExecutable postgres;

  @Parameter(defaultValue = "5432")
  private int port;
  @Parameter(defaultValue = "dbName")
  private String db;
  @Parameter(defaultValue = "userName")
  private String user;
  @Parameter(defaultValue = "password")
  private String password;

  @Override
  public void execute() throws MojoExecutionException {
    if (postgres != null) { 
      logger.warn("Postgres already started");
      return;
    }
    logger.info("Starting Postgres");
    if (!isDockerInstalled()) {
      throw new IllegalStateException("Docker is not installed");
    }
    String url = start();
    testConnection(url, user, password);
    logger.info("Postgres started at " + url);
  }

  private String start() {
    postgres = new PostgresContainerAdapter();
    return postgres.start(port, db, user, password);
  }

  private static void testConnection(String url, String user, String password) throws MojoExecutionException {
    try (Connection conn = DriverManager.getConnection(url, user, password)) {
      conn.createStatement().execute("SELECT 1");
    } catch (SQLException e) {
      throw new MojoExecutionException("Exception occurred while testing sql connection", e);
    }
  }

  private static boolean isDockerInstalled() {
    if (CommandLine.executableExists("docker")) {
      return true;
    }
    if (CommandLine.executableExists("docker.exe")) {
      return true;
    }
    if (CommandLine.executableExists("docker-machine")) {
      return true;
    }
    if (CommandLine.executableExists("docker-machine.exe")) {
      return true;
    }
    return false;
  }
}

Методы save-state и stop реализованы аналогичным образом и поэтому здесь не представлены.

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

<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-запрос стал выполняться дольше обычного
  • на пулере соединений скопилась очередь и сервер приложений долго не может получить соединение
  • проблемы в сети
  • что-то ещё

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

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


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

Комментарии 17

    +3
    Всё четко и круто, спасибо за статью. Жирный плюс за jOOQ :)

    И интересно сколько у вас миграций (десятки, сотни, тысячи) что они начали занимать в тестах ощутимое время.

    Удивили ручными миграциями — у вас были факапы в проде? Я стараюсь отлавливать такие проблемы локально, а если никак уйти от неё нельзя — перехожу к миграциям батчами (в приложении или вне) без ломания обратной совместимости.
    Это же решает и вопрос удаления колонки — сначала релизим код, который перестал её использовать, а затем уже удаляем. Пробовали? Чем не подошло?
      +1
      Спасибо за комментарий!

      И интересно сколько у вас миграций
      на данный момент 230

      Удивили ручными миграциями — у вас были факапы в проде? Я стараюсь отлавливать такие проблемы локально, а если никак уйти от неё нельзя — перехожу к миграциям батчами (в приложении или вне) без ломания обратной совместимости.
      Я возможно не до конца понял вопрос, но постараюсь ответить. Под ручными миграциями (в комбинированном подходе) подразумевается вызов кода с миграцией из приложения (не подключение к БД и выполнение каких-либо скриптов руками). Т.е. это тот же код, что выполняет flyway, но не в момент прогона всех миграций, а в произвольное время, чтобы не замедлять релиз. В этом коде как раз часто бывают батчи. Поскольку схема данных в таких миграциях не изменяется, то, как правило, рисков нарушить обратную совместимость минимум.
      Насчет факапов при релизе миграций – не было :-)

      Это же решает и вопрос удаления колонки — сначала релизим код, который перестал её использовать, а затем уже удаляем. Пробовали? Чем не подошло?
      Именно так и делаем. Я лишь имел ввиду, что несмотря на всю очевидность подобной миграции, состоящей из 2 последовательных шагов, и наличия соответствующих требований в инструкциях по написанию миграций, всегда есть риск человеческого фактора — «новичок/уставший разработчик/любой из нас» забыл и не подумал об этом, а на ревью не заметили. К счастью, пока такого не случалось — бывало, что «забывали», но на ревью отлавливали. Но все равно эту проверку хотелось бы автоматизировать.
        0

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

          +1
          К сожалению, flyway не имеет встроенного механизма для объединения миграций. Поскольку он проверяет набор выполненных миграций и миграций в коде, то чтобы заменить первые Х миграций на одну большую придется что-то сделать с таблицей schema_version (удалить старые Х миграций, добавить запись с правильной контрольной суммой для новой миграции). Для одного окружения, например только для production, это еще можно сделать, поскольку все БД на одной версии, но непонятно как быть с локальными средами разработчиков и тестовыми окружениями, где версии могут быть какими угодно.
          В общем, мы уже думали над этой задачей, но пока приемлемого решения не нашли.
            0
            С локальными средами разработчиков всё должно быть просто — снести всё и прогнать миграции заново. На тестовых — скорее всего через коллбеки
              +1
              У большинства разработчиков в локальной БД есть какие-то тестовые данные, которые они используют (не говоря уже про отдельные тестовые окружения, где данных может быть много), и не хочется заставлять их тратить время на повторное создание этих данных. Хочется сохранить удобство текущего подхода и избежать лишних манипуляций — ты просто запускаешь сервер и база актуализируется автоматически.
              Есть определенные идеи как сделать встроенную поддержу схлапывания миграций в flyway – надеюсь получится выделить время на доработку.
                +1
                Я когда-то еще с Liqubase размышлял как организовать тестовые данные. Представлял себе, что все тест кейсы должны иметь для себя тестовые данные, кооторые можно описать, а затем засунуть в Liquibase миграцию. Последний позволял легко с помощью csv файла решать такие задачи.
                Но на практике оказалось, что всем этого не нужно, и скорее будет мешать.

                Я же пришел к выводу, если разработчику нужны тестовые данные — пусть напишет скрипт, который эти тестовые данные ему создадут. У разработчика есть много опций — в тесте через АПИ, миграцией с insert или дампом БД, новой таблицей flyway_schema_version.
                Но блокировать всю компанию из-за того, что кто-то себе локально настроил тестовые данные — звучит страшно. Возможно ваши данные создаются очень сложно, и как раз ваш подход дешевле)
                  0
                  Я согласен с вами — тестовые данные нужны только для тестов и располагать их в общих миграциях будет неправильно. С другой стороны, если для тестов также хочется иметь версионность (т.е. для разных версий схемы данных разные тестовые наборы, а не только тесты для последней версии), то можно вести отдельную систему миграций. Примерно так:
                  Основные миграции:
                  V1_create_table1.sql
                  V2_alter_table1.sql
                  V3_create_table2.sql
                  Тестовые миграции:
                  V1_create_test_data_table1.sql
                  V2_create_test_data_table1.sql
                  V3_create_test_data_table2.sql
                  А при запуске тестов в зависимости от версии схемы данных накатывать на базу соответствующую миграцию с данными.

                  Не очень понимаю, когда это может быть нужно, но теоретически, наверное, имеет право на жизнь :-)

                  +1
                  По схлопыванию, я пришел к вот такому выводу — для создания таблицы нужен отдельный файл миграции. А дальше лишь миграции `V123__alter_table_A.sql`. Схлопывание далее будет делаться вручную. Я всё никак не допишу статью на Хабр про этот способ, вот тут есть прототип как оно может выглядеть.

                  Очень интересно будет прочитать как вы решите эту задачу)
        +1

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

          +1
          Эта проблема решается хорошо в Liquibase и Python Django миграциях.
          В случаи с флайвеем можно называть миграции по датам, например V20200801__my_suer_migration.sql а затем прогнать на тест\стейдж энве, где уже есть данные. Пусть лучше свалится CI. На практике это дешевле, чем городить огород на Liquibase.
            +2
            Да, вы правы, такая проблема есть. Мы решаем ее весьма костыльным (но работающим путем) — с помощью отдельного канала в слаке, где разработчики, создающие миграцию, «бронируют» очередную версию для себя. Там же они иногда «передоговариваются» о порядке и обмениваются версиями, чтобы не тормозить последующую миграцию в случае, когда один из pull request-ов завис на ревью. В целом у нас есть рекомендация разбивать реализацию функционала на несколько реквестов, 1й из которых — только sql-миграция без бизнес-логики, чтобы минимизировать время между «бронированием» версии и мержем миграции.
            Также для того, чтобы гарантировать линейный порядок версий в мастере, мы используем плагин для bitbucket, который проверяет грубо говоря, что новый реквест имеет версию миграций +1 относительно последней миграции в мастере.

            Стоить добавить, что поскольку не все миграции зависимы друг от друга, то иногда их можно выполнять в любом порядке (например, без разницы в каком порядке вы создадите 2 индекса) – для этого flyway имеет опцию outOfOrder. Но мы решили, что подобная двойственность (часть миграций упорядочена, часть нет) – еще одно место для потенциальных проблем (для упорядоченных миграций) и поэтому не используем ее и порядок всегда одинаков.
            +1

            Мы на текущем проекте используем MySQL. У меня Windows, и поднятие чистого контейнера с docker hub занимает секунд 20, без pull. Миграций тоже около 100+, и, если не использовать tmpfs для data dir, поднятие контекста становится вечным. Ваш вариант (подготовить образ со схемой данных) тоже рассматривал, но сколкнулся с тем, что тесты все равно занимают много времени, если пишут на диск, а не в ОЗУ.
            Сколько у вас тестов и как долго они выполняются? Не удалось ли вам с подготовкой образа как-то примонтировать tmpfs (может быть, копированием data dir через init.sh в образе)?
            Если что, оставил свой путь в заметке: https://rocketscien.se/testcontainers

            +1
            Сколько у вас тестов и как долго они выполняются?
            Интеграционных тестов примерно 2 тысячи, выполняются около 5-7 минут.
            Не удалось ли вам с подготовкой образа как-то примонтировать tmpfs (может быть, копированием data dir через init.sh в образе)?
            Сконцентрировавшись на проблеме прогона миграций на запуске каждого тестового класса, мы почему-то даже не посмотрели в эту сторону, но выглядит перспективно, попробую — спасибо за идею и за вашу заметку!
              0
              Меня тут немного осенило, и я ещё ускорил свои тесты :) делюсь с вами знанием.
              rocketscien.se/testcontainers-2
                0
                Спасибо!
                Кстати, для того, чтобы быстро откатывать вашу базу в начальное состояние вы можете посмотреть в сторону «тонкого клонирования» — быстрое создание копии файловой системы на основе ZFS. Есть даже более менее готовое решение — Database Lab. Сам я его на практике не пробовал, но вероятно, мы будем рассматривать его как один из вариантов для реализации т.н. «иммутабельных» тестовых сред (когда на каждый запуск набора e2e тестов создается новая база с подготовленными тестовыми данными).

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

            Самое читаемое