Когда вы разрабатываете новый функционал с использованием базы данных, цикл разработки обычно включает следующие этапы (но не ограничивается ими):
Написание 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 в зависимости от используемого образа:
- базы данных (Postgres, Oracle, Cassandra, MongoDB и др.),
- nginx
- kafka и др.
Кстати, когда проект 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 секунд, что значительно замедляло тесты. Это работало примерно так:
- перед очередным тестовым классом запускался «чистый» контейнер с Postgres
- flyway выполнял миграции
- выполнялись тесты этого класса
- контейнер останавливался и удалялся
- повтор с п. 1 для следующего тестового класса.
Очевидно, что со временем 2-й шаг занимал всё больше и больше времени.
Пытаясь решить эту проблему, мы поняли, что миграции достаточно выполнять только один раз перед всеми тестами, сохранять состояние контейнера и затем использовать этот контейнер во всех тестах. Таким образом, алгоритм изменился:
- перед всеми тестами запускается «чистый» контейнер с Postgres
- flyway выполняет миграции
- состояние контейнера сохраняется
- перед очередным тестовым классом запускается ранее подготовленный контейнер
- выполняются тесты этого класса
- контейнер останавливается и удаляется
- повтор с п. 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 действия:
- Запускает «чистый» docker-контейнер с postgres
- Запускает Flyway, который выполняет sql-миграции для всех БД, тем самым проверяя их валидность
- Запускает Jooq, который инспектирует схему БД и генерирует java-классы для таблиц, представлений, функций и прочих объектов схемы.
Как можно легко увидеть, первые 2 действия идентичны тем, что выполняются при запуске тестов. Чтобы сэкономить время на запуске контейнера и прогоне миграций перед тестами, мы перенесли сохранение состояния контейнера в плагин. Таким образом, теперь сразу после пересборки модуля в локальном репозитории docker-образов появляются готовые образы для интеграционных тестов всех БД, используемых в коде.
Более подробный пример кода
Плагин (метод «start»):
Методы save-state и stop реализованы аналогичным образом и поэтому здесь не представлены.
Использование плагина:
@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-запрос стал выполняться дольше обычного
- на пулере соединений скопилась очередь и сервер приложений долго не может получить соединение
- проблемы в сети
- что-то ещё
Без метрик поиск причины проблемы будет не так прост.
Вместо завершения
Хочется сказать весьма банальную фразу про то, что не существует серебряной пули и выбор того или иного подхода зависит от требований конкретной задачи, и то, что хорошо работает у других, может быть не применимо у вас. Но чем больше различных подходов вы знаете, тем более основательно и качественно вы можете сделать этот выбор. Я надеюсь, что и из этой статьи вы почерпнули для себя что-то новое, что поможет вам в будущем. Буду рад комментариям о том, какие подходы вы используете для улучшения процесса работы с БД.