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

от автора

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

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

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

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

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

ссылка на оригинал статьи https://habr.com/ru/company/miro/blog/512990/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *