[Java, PostgreSQL, Программирование, Тестирование IT-систем] Работа с базами данных глазами разработчика
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Когда вы разрабатываете новый функционал с использованием базы данных, цикл разработки обычно включает следующие этапы (но не ограничивается ими):
Написание 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-образов появляются готовые образы для интеграционных тестов всех БД, используемых в коде.
Более подробный пример кода
SPL
@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-запрос стал выполняться дольше обычного
- на пулере соединений скопилась очередь и сервер приложений долго не может получить соединение
- проблемы в сети
- что-то ещё
Без метрик поиск причины проблемы будет не так прост.
Вместо завершения
Хочется сказать весьма банальную фразу про то, что не существует серебряной пули и выбор того или иного подхода зависит от требований конкретной задачи, и то, что хорошо работает у других, может быть не применимо у вас. Но чем больше различных подходов вы знаете, тем более основательно и качественно вы можете сделать этот выбор. Я надеюсь, что и из этой статьи вы почерпнули для себя что-то новое, что поможет вам в будущем. Буду рад комментариям о том, какие подходы вы используете для улучшения процесса работы с БД.
===========
Источник:
habr.com
===========
Похожие новости:
- [DIY или Сделай сам, Программирование микроконтроллеров, Производство и разработка электроники, Разработка игр, Схемотехника] Doom Boy ESP32. Вторая итерация
- [Java] Переосмысление DTO в Java
- [Java, Microsoft Azure, Программирование] Использование Azure Service Bus из Java
- [GitHub, JavaScript, Игры и игровые приставки, История IT] Разработчик создал из Mac OS 8 приложение для современных ПК на macOS, Windows и Linux
- [PostgreSQL, SQL, Администрирование баз данных, Визуализация данных] Вооруженным глазом: наглядно о проблемах PostgreSQL-запроса
- [Java, MongoDB, Программирование] The Testcontainers’ MongoDB Module and Spring Data MongoDB in Action
- [JavaScript, ReactJS, Тестирование веб-сервисов] Модульное и интеграционное тестирование в Redux Saga на примерах
- [Системное программирование, Программирование микроконтроллеров, Компьютерное железо] Моделируем поведение Quartus-проекта на Verilog в среде ModelSim
- [Программирование, Сетевые технологии, IT-стандарты] Закон дырявых абстракций (перевод)
- [API, JavaScript, Node.JS, Социальные сети и сообщества] Бот «Умный планировщик»: понимает с полуслова
Теги для поиска: #_java, #_postgresql, #_programmirovanie (Программирование), #_testirovanie_itsistem (Тестирование IT-систем), #_sql, #_postgresql, #_flyway, #_jooq, #_testcontainers, #_migrations, #_blog_kompanii_miro (
Блог компании Miro
), #_java, #_postgresql, #_programmirovanie (
Программирование
), #_testirovanie_itsistem (
Тестирование IT-систем
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 18:09
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Когда вы разрабатываете новый функционал с использованием базы данных, цикл разработки обычно включает следующие этапы (но не ограничивается ими): Написание SQL миграции → написание кода → тестирование → релиз → мониторинг. В этой статье я хочу поделиться некоторыми практическими советами как можно сократить время этого цикла на каждом из этапов, при этом не снизив качество, а скорее даже повысив его. Поскольку мы в компании работаем с PostgreSQL, а серверный код пишем на Java, то примеры будут основаны на этом стеке, хотя большинство идей не зависят от используемой БД и языка программирования. SQL-миграция Первый этап разработки после проектирования – это написание SQL-миграции. Основной совет – не проводите никаких ручных изменений схемы данных, а всегда делайте это через скрипты и храните их в одном месте. У нас в компании разработчики сами пишут SQL-миграции, поэтому все миграции хранятся в репозитории с основным кодом. В некоторых компаниях изменением схемы занимаются администраторы БД, в таком случае реестр миграций находится где-то у них. Так или иначе такой подход приносит следующие преимущества:
Существует достаточно много готовых инструментов для автоматизации этого процесса, как коммерческих так и бесплатных: flyway, liquibase, sqitch и др. В этой статье я не буду заниматься сравнением и выбором лучшего инструмента – это отдельная большая тема, и вы можете найти множество статей по ней. Мы используем flyway, поэтому дальше будет немного информации о нем:
По нашим внутренним договоренностям все изменения схемы данных проводятся только через 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 позволяет снизить требования к знанию SQL – многое генерируется автоматически:
Ещё один «бонус» – наличие кэширования данных из коробки (для hibernate – это 3 уровня кэшей). Но при этом важно отметить, что ORM, как и любой другой мощный инструмент, требует определенной квалификации при его использовании. Без должной настройки код, вероятнее всего, будет работать, но далеко не самым оптимальным образом. Противоположный вариант – писать SQL вручную. Это позволяет полностью контролировать запросы – выполняется ровно то, что вы написали, никаких неожиданностей. Но, очевидно, что это увеличивает объём ручного труда и повышает требования к квалификации разработчиков. DSL-библиотеки Примерно посередине между этими подходами находится ещё один, который заключается в использовании DSL-библиотек (jOOQ, Querydsl и др.). Они, как правило, гораздо легковеснее, чем ORM, но более удобны, чем полностью ручная работа с БД. Использование DSL-библиотек не так распространено, поэтому в этой статье кратко рассмотрим именно этот подход. Речь пойдёт про одну из библиотек — jOOQ. Что она предлагает:
jOOQ – это не ORM – нет ни автогенерации запросов, ни кэширования, но в тоже время часть проблем полностью ручного подхода закрываются:
В коде запросы выглядят примерно так: 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-тестирование подразумевает проверку функционала отдельных модулей изолированно друг от друга. Размер модуля – снова вещь неопределённая, для кого-то это отдельный метод, для кого-то класс. Изолированность означает, что все остальные модули представляют собой 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. Какие плюсы:
Пример теста с использование 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 секунд, что значительно замедляло тесты. Это работало примерно так:
Очевидно, что со временем 2-й шаг занимал всё больше и больше времени. Пытаясь решить эту проблему, мы поняли, что миграции достаточно выполнять только один раз перед всеми тестами, сохранять состояние контейнера и затем использовать этот контейнер во всех тестах. Таким образом, алгоритм изменился:
Теперь время выполнения отдельного теста не зависит от количества миграций и при текущем их количестве (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 действия:
Как можно легко увидеть, первые 2 действия идентичны тем, что выполняются при запуске тестов. Чтобы сэкономить время на запуске контейнера и прогоне миграций перед тестами, мы перенесли сохранение состояния контейнера в плагин. Таким образом, теперь сразу после пересборки модуля в локальном репозитории docker-образов появляются готовые образы для интеграционных тестов всех БД, используемых в коде. Более подробный пример кодаSPL@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 пункта не так важны и влияют только на время недоступности приложения. Если говорить про наш сервис, то это:
Мы используем канареечные релизы: новая версия приложения сперва выводится на небольшое количество серверов (мы называем это пре-релизом), а спустя некоторое время, если в пре-релизе не обнаруживается никаких ошибок, происходит релиз на остальные сервера. Таким образом, на production могут работать сервера на разных версиях. Каждый сервер приложения при запуске сверяет версию БД с версиями скриптов, которые есть в исходном коде (в терминах flyway это называется validation). Если они различаются, сервер не будет запущен. Это гарантирует совместимость кода и базы данных. Не может возникнуть такая ситуация, когда, например, код работает с таблицей, которую еще не создали, потому что миграция находится в другой версии сервера. Но это конечно не решает проблемы, когда, например, в новой версии приложения есть миграция, удаляющая столбец в таблице, который может использоваться в старой версии сервера. Сейчас мы проверяем такие ситуации только на этапе ревью (оно обязательно), но по-хорошему необходимо внедрить доп. этап с такой проверкой в CI/CD-цикл. Иногда миграции могут выполняться долго (например, при обновлении данных большой таблицы) и чтобы не замедлять при этом релизы, мы используем технику комбинированных миграций. Комбинированность заключается в ручном прогоне миграции на запущенном сервере (через панель администрирования, без flyway и, соответственно, без фиксирования в истории миграций), а затем «штатный» вывод такой же миграции в следующей версии сервера. На такие миграции накладываются следующие требования:
Такой подход решает 2 проблемы:
Мониторинг После релиза цикл разработки не заканчивается. Чтобы понять работает ли новый функционал (и как он работает) необходимо «обкладываться» метриками. Их можно разделить на 2 группы: бизнесовые и системные. Первая группа сильно зависит от предметной области: для почтового сервера полезно знать количество отправленных писем, для новостного ресурса – количество уникальных пользователей в сутки и т.п. Метрики второй группы примерно одинаковы для всех – они определяют техническое состояние сервера: cpu, памяти, сети, БД и пр. Что конкретно нужно мониторить и как это делать – это тема огромного множества отдельных статей и здесь она затрагиваться не будет. Хочется напомнить лишь самые базовые (даже капитанские) вещи: определяйте метрики заранее Необходимо определить перечень основных метрик. И сделать это стоит заранее, до релиза, а не после первого инцидента, когда вы не понимаете, что происходит с системой. настраивайте автоматические алерты Это ускорит время вашей реакции и сэкономит время на ручном мониторинге. В идеале, вы должны узнавать о проблемах раньше, чем их почувствуют пользователи и напишут вам. собирайте метрики со всех узлов Метрик, как и логов, много не бывает. Наличие данных с каждого узла вашей системы (сервер приложения, БД, пулер соединений, балансировщик и пр.) позволяет иметь полную картину о её состоянии, и в случае необходимости вы сможете быстрее локализовать проблему. Простой пример: загрузка данных какой-либо веб-страницы начала тормозить. Причин может быть множество:
Без метрик поиск причины проблемы будет не так прост. Вместо завершения Хочется сказать весьма банальную фразу про то, что не существует серебряной пули и выбор того или иного подхода зависит от требований конкретной задачи, и то, что хорошо работает у других, может быть не применимо у вас. Но чем больше различных подходов вы знаете, тем более основательно и качественно вы можете сделать этот выбор. Я надеюсь, что и из этой статьи вы почерпнули для себя что-то новое, что поможет вам в будущем. Буду рад комментариям о том, какие подходы вы используете для улучшения процесса работы с БД. =========== Источник: habr.com =========== Похожие новости:
Блог компании Miro ), #_java, #_postgresql, #_programmirovanie ( Программирование ), #_testirovanie_itsistem ( Тестирование IT-систем ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 18:09
Часовой пояс: UTC + 5