Всем привет! Меня зовут Станислав, я Java-разработчик в Rocket Science, outsource-компании, специализирующейся преимущественно на финтех-проектах. С 2019 года я веду проект бэкенда для внутренних мобильных приложений (которыми пользуются сотрудники) одного крупного банка. Когда мы начинали проект, у DBA были компетенции только в Oracle и MySQL, а в последние пару лет, наоборот, фокус в организации сместился в сторону PostgreSQL и появилась тенденция по переезду в него разных внутренних сервисов. Нашей команде было вполне комфортно на MySQL, но мы прикинули, что переезд в PostgreSQL позволит нам решить ряд попутных технических вопросов, и ввязались в эту авантюру.

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


Введение

Одним из стоящих перед нами технических вызовов является вынесение независимых бизнес-процессов (БП) из монолита в микросервисы. В какой-то мере это только планы, а на момент публикации статьи наш сервер всё ещё представляет собой честное монолитное приложение. Тем не менее, у нас современный стек, мало тех.долга и легаси. Несколько лет назад именно наш проект был первым, который заехал в разворачиваемые в банке кластера Kubernetes, причём с самого своего старта. Для масштабирования используется HPA, каждая реплика приложения stateless, а всё наше состояние находится исключительно в одном месте – СУБД. В какой-то мере мы избегаем необходимости иметь распределённые кэши, такие как Redis, а потребности в интеграции с каким-либо смежным проектом через Kafka тоже до сих пор не возникло...

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

Если рассматривать постепенную миграцию, то, чтобы не потерять важные данные, которые распределены между двумя СУБД, придётся дописывать много кода для жонглирования датасорсами, генераторами ID и прочими техническими сущностями. В самых радикальных случаях это логика может просачиваться даже в код бизнес-процессов, чего очень не хотелось бы. С другой стороны, отсутствует downtime, и, при выявлении проблем с нюансами производительности нового СУБД, вернуться на использование старого СУБД можно простым переключением галочки.

Если говорить про одномоментное переключение, то здесь, скорее всего, исключается необходимость синхронизации данных, уменьшается число правок в коде приложения, и, чаще всего, может выродится просто в изменение jdbc-url подключения. Зато есть даунтайм, который зависит от объёма переносимых данных. Также в случае сыгрывания рисков, связанных с необходимостью возвращения, придётся устраивать внеплановый даунтайм (страшное имя которого “инцидент”), и мигрировать данные в обратную сторону.

Для нашего проекта мы выбрали второй подход – через остановку приложения. К этому были следующие предпосылки:

  • небольшой объём данных в БД (менее 3 Гб данных и 1 Гб индексов);

  • практически все запросы написаны на JPQL, а нативных всего пара штук — мы хотели ограничиться минимальными изменениями собственно боевого кода.

Снизить риск непредвиденных ситуаций с работоспособностью приложения после переезда в PostgreSQL мы решили следующим образом:

  • Добавляем в CI/CD параллельно со сборкой и прогоном тестов на MySQL дополнительную джобу для сборки и прогона тестов на PostgreSQL. Сперва она будет падать, но общий пайплайн рушиться не будет (allow_failure: true).

  • Затем постепенно докручиваем код приложения, чтобы тесты проходили на обоих СУБД. Убираем allow_failure: true.

  • Поочерёдно переводим на PostgreSQL все наши тестовые стенды. У нас их три – первый для серверных разработчиков (чтобы мы могли его как угодно ломать, никого не афффектя), второй для мобильных разработчиков и частично QA, а третий выполняет совмещённую роль “для QA / UAT / формирование релизной сборки”.

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

Совместимость приложения с двумя разными СУБД

Я уже не первый раз пишу о нашем проекте. Ранее были опубликованы были две части заметки (раз, два) о переезде unit-тестов проекта с in-memory базы данных в Testcontainers. Со времени последней публикации утекло много воды, поэтому я постараюсь сперва очертить то, как у нас устроены тесты сейчас. Я считаю этот опыт потенциально полезным для других проектов, а также он важен в контексте именно нашей истории миграции, поскольку именно на этой стадии вскрылась большая часть проблем с кодом.

Итак, наше приложение вполне можно считать классическим: Spring Boot на servlet-стеке, некоторые элементы Spring Cloud (Sleuth, Vault), Spring Data JPA, Liquibase, тонна интеграций с другими системами по REST и SOAP, хранение файлов в S3. Для написания тестов используются JUnit 5, Mockito, Testcontainers, WireMock, AssertJ, Awaitility. Ничего необычного.

Все тесты пишутся в рамках единой концепции:

  • Подавляющая доля тестов является не unit, а end-to-end тестами, которые проверяют ветви бизнес-процессов от края до края. В тестах при необходимости мы конечно же инжектим DAO / сервисы / контроллеры, но только для того, чтобы выполнить секцию “Дано” и/или дёрнуть ассерты. Само же тестируемое действие заключается чаще всего в честных вызовах нашего API посредством http-клиента.

  • Контекст Spring у нас жирный и поэтому должен подниматься не более, чем один раз за прогон всех тестов. Попытка поднять его второй раз вызывает падение.

  • Поднимается контейнер с СУБД, файлы данных располагаются в tmpfs, контекст накатывает туда миграции Liquibase точно так, как это происходит на тестовых стендах и в боевом окружении.

  • Явно прописан spring-профиль test. Все компоненты, содержащие аннотацию @Scheduled, отключены (через @Profile("!test")). Тесты, в рамках которых нужно проверить логику шедулеров, инжектят сервисы этих шедулеров и дёргают бизнес-логику ручками.

  • Методы, помеченные @Async, выполняются синхронно, чтобы повысить вероятность не потерять возникающие в коде исключения.

  • Все внешние REST/SOAP зависимости мокаются через WireMock, а клиенты для S3 и т.п. – через Mockito. После каждого теста все моки сбрасываются.

  • Все кэши отключены.

И, на мой взгляд, самый интересный пункт:

  • После каждого теста все таблицы в БД полностью очищаются.

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

Каждый из тестов проекта наследуется от наиболее подходящего из базовых классов в иерархии:

  • BaseApplicationTest. Содержит в себе минимум утилитных методов и весь обвешан разными аннотациями, определяющими, как выглядит наш единый тестовый контекст Spring.

  • BaseStatefulTest. Минимальный родитель для тестов, которые что-то читают или сохраняют в БД.

  • BaseAuthorizedTest. Добавляет ко всем запросам к API предварительно одобренную авторизацию, подготавливая моки внешних сервисов авторизации, сохраняет в БД устройство, с которого производится оформление, и т.п..

В BaseStatefulTest происходит, собственно, интересная нам магия. Одним запросом производится определение таблиц, которые нужно почистить (чтение из information_schema.TABLES колонок TABLE_NAME и AUTO_INC). Далее на тех таблицах, которые не пусты (AUTO_INC > 1), вызывается TRUNCATE. Дополнительно чистка обёрнута в try-finally с отключением и возвратом проверки внешних ключей FOREIGN_KEY_CHECKS. TRUNCATE не вызывается на таблицах из списка исключений (DatabaeChangelog / DatabaseChangelogLock / ShedLock и таблицы со неизменяемыми каталожными данными).

У нас имеется более 500 тестов (а на момент публикации статьи уже 650+), которые покрывают около 90% критичного боевого кода (в среднем по проекту цифра около 70%). В 80% тестов за один тест вызывается менее 100 запросов в БД, остальным 20% вручную проставлен лимит около 400-500 запросов. Летом 2022 мы сделали ограничение количества запросов в БД на 1 тест с использованием spring-hibernate-query-utils и обнаружили огромные неоптимальные места. Полный прогон тестов локально или в CI занимает от 7 до 10 минут в зависимости от древности ПК (здесь где-то заплакал один Антон Кекс).

Приступаем

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

  • Мы застряли на версии Spring Boot 2.6.4. В нём используется версия Liquibase 4.5.0, а в Spring Boot 2.7.0 его обновили уже до 4.9.1. Корпоративные сканеры образов подсказывают, что обновиться было бы неплохо — в последних версиях как раз исправлено несколько критических уязвимости. Но при этом в самом Liquibase очень жёстко подкрутили валидацию ченджсетов, и некоторые наши миграции (в т. ч. за 2019 и 2020 года, например) теперь стали невалидными и их нужно поправить. Приведу пример: в скрипте addColumn было указано defaultValueBoolean="", и раньше это проглатывалось, а теперь — извините, вы тут укажите true или false, пожалуйста.

  • Миграций написано очень много — более 800 ченджсетов. При этом при выпиливании какого-нибудь устаревшего бизнес-процесса мы выпиливали и все таблицы, и миграции, которые к нему относятся. Выполнение миграций при поднятии тестового контекста занимает существенное время и никак ускорить этот процесс уже нельзя. Миграции создают таблицы, добавляют/удаляют колонки и индексы, меняют типы данных или nullability.

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

Добавляем в CI/CD

В GitLab CI на той же стадии, где производится сборка .jar монолита из исходников, мы добавляем ещё одну джобу, которая будет идентична первой, но в ней выставляем новую переменную окружения USE_POSTGRESQL = true, а полученные артефакты сборки игнорируем. Первое время успешность этой джобы нас не интересует. Пока в коде нет правок, она зелёная. В будущем, когда сборка будет запускаться на PostgreSQL, все тесты будут проходить и allow_failure будет выставлен в false, мы будем считать, что готовы переходить к миграции тестовых стендов.

Пропекание миграций Liquibase

Чтобы побороть описанные выше технические проблемы мы решили сделать первый подготовительный этап, который назвали одноимённо заголовку — "пропекание миграций Liquibase". Суть в том, что мы берём какую-то дату отсечки, 1 число месяца начала разработки, и все миграции, которые есть, накатываем на чистую схему, приводим её в порядок, и генерируем новые скрипты (liquibase-maven-plugin:generateChangeLog), которые создают конечный вид нужных таблиц.

Что означает привести в порядок: добавить колонкам remarks; удалить дублирующиеся индексы (были пара откровенно ошибочных), где-то просто их добавить. Скрипты рихтуем и раскладываем по каталогам с условным названием бизнес-процесса, которому они принадлежат. Разные БП полностью независимы друг от друга, а внутри каталога БП для соблюдения корректного создания внешних ключей файлы миграций просто сортируются при помощи числового префикса. Всего таблиц у нас порядка 90+, поэтому вручную сгруппировать и отсортировать их не составило труда. В будущем, когда мы начнём выносить функционал какого-то БП в микросервис, будем просто брать для него соответствующую папку с миграциями.

Вся прошлая история была "пропечена" в папку collapsed, а новая стала повторять заданную иерархию.
Вся прошлая история была "пропечена" в папку collapsed, а новая стала повторять заданную иерархию.

После завершения "пропекания миграций" у нас получилось около 165 ченджсетов.

Добавляем слона

В коде поднятия тестового контекста Spring смотрим на значение переменной USE_POSTGRESQL, и если она равна true, поднимаем через Testcontainers не MySQL, а PostgreSQL, а также добавляем в контекст нужные значения jdbc-url, username, password и т.п. Для этой цели написан свой наследник от ApplicationContextInitializer<ConfigurableApplicationContext> и указан атрибуте initializers аннотации @ContextConfiguration на базовом тестовом классе.

А этот инициализатор уже указан в атрибуте initializers аннотации @ContextConfiguration на самом базовом классе тестов.
А этот инициализатор уже указан в атрибуте initializers аннотации @ContextConfiguration на самом базовом классе тестов.
Абстракции абстракциями погоняем...
// Этот класс в общем для нескольких микросервисов стартере.
@Slf4j
public final class OurPostgreSqlTestContainer
        extends PostgreSQLContainer<OurPostgreSqlTestContainer>
        implements EmpTestContainer<OurPostgreSqlTestContainer> {

    public static final String IMAGE = "postgres:14.3-alpine";

    public static final Map<String, String> TMPFS_MAP = Map.of("/var/lib/postgresql/data", "rw");

    public static final String USERNAME = "user";
    public static final String PASSWORD = "test";
    public static final String DATABASE = "e2e_tests";
    public static final String SCHEMA_NAME = "e2e_public";

    public OurPostgreSqlTestContainer() {
        super(IMAGE);
        this
                .withTmpFs(TMPFS_MAP)
                .withUrlParam("currentSchema", SCHEMA_NAME)
                .withUrlParam("allowEncodingChanges", String.valueOf(true))
                .withUrlParam("reWriteBatchedInserts", String.valueOf(true))
                .withUsername(USERNAME)
                .withPassword(PASSWORD)
                .withDatabaseName(DATABASE)
                .withExposedPorts(5432);
        log.info("Instance prepared.");
    }

    @NonNull
    @Override
    public Map<String, String> getDatasourceProperties(@NonNull String datasourcePropertiesPrefix) {
        return Map.of(
                datasourcePropertiesPrefix + "url", getJdbcUrl(),
                datasourcePropertiesPrefix + "username", OurPostgreSqlTestContainer.USERNAME,
                datasourcePropertiesPrefix + "password", OurPostgreSqlTestContainer.PASSWORD);
    }
}

// Этот класс в общем для нескольких микросервисов стартере.
@RequiredArgsConstructor
@Slf4j
class AbstractPostgreSqlDatabaseInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

    private static final OurPostgreSqlTestContainer DATABASE_CONTAINER = new OurPostgreSqlTestContainer();

    private final String datasourcePropertiesPrefix;

    @Override
    public void initialize(ConfigurableApplicationContext applicationContext) {
        ConfigurableEnvironment environment = applicationContext.getEnvironment();

        DATABASE_CONTAINER.start();
        log.info("PostgreSQLContainer for tests started.");

        TestPropertyValues
                .of(DATABASE_CONTAINER.getDatasourceProperties(datasourcePropertiesPrefix))
                .applyTo(environment);
    }
}

// Похожий класс есть в каждом микросервисе.
public class OurPostgreSqlProfileDatabaseInitializer extends AbstractPostgreSqlDatabaseInitializer {

    public OurPostgreSqlProfileDatabaseInitializer() {
        super("monolith.database.postgresql-");
    }
}

Также интерес читателя может вызвать аналог для MySQL:

// Этот класс в общем для нескольких микросервисов стартере.
@Slf4j
public final class OurMySqlTestContainer
        extends MySQLContainer<OurMySqlTestContainer>
        implements EmpTestContainer<OurMySqlTestContainer> {

    public static final String IMAGE = "mysql:8.0";

    public static final String SERVER_TIMEZONE = "Asia/Novosibirsk";
    public static final Map<String, String> TMPFS_MAP = Map.of("/var/lib/mysql", "rw");

    public static final String ROOT_USERNAME = "root";
    public static final String USERNAME = "user";
    public static final String PASSWORD = "test";
    public static final String DATABASE = "e2e_tests";

    public OurMySqlTestContainer() {
        super(IMAGE);
        this
                .withTmpFs(TMPFS_MAP)
                .withUrlParam("serverTimezone", SERVER_TIMEZONE)
                // .withUrlParam("rewriteBatchedStatements", String.valueOf(true))
                .withEnv("MYSQL_ROOT_PASSWORD", PASSWORD)
                .withEnv("MYSQL_ROOT_HOST", "%")
                .withCopyFileToContainer(
                        MountableFile.forClasspathResource("mysql-image.cfg"),
                        "/etc/mysql/conf.d/mysql-image.cfg")
                .withUsername(USERNAME)
                .withPassword(PASSWORD)
                .withDatabaseName(DATABASE)
                .withExposedPorts(3306);
        log.info("Instance prepared.");
    }

    @NonNull
    @Override
    public Map<String, String> getDatasourceProperties(@NonNull String datasourcePropertiesPrefix) {
        return Map.of(
                datasourcePropertiesPrefix + "url", getJdbcUrl(),
                datasourcePropertiesPrefix + "username", OurMySqlTestContainer.ROOT_USERNAME,
                datasourcePropertiesPrefix + "password", OurMySqlTestContainer.PASSWORD);
    }
}

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

  • Не поддерживается afterColumn. Мы удалили этот атрибут из всех ченджсетов и просто руками поправили порядок колонок в наших “пропечённых” ченджсетах createTable так, чтобы логически связанные колонки находились рядом. Но в будущем нас ждёт неизбежное зло добавления новых колонок в конец.

  • Не работает выражение NOW(3), но работает CURRENT_TIMESTAMP(3) — заменили.

  • Для MySQL вручную выставлялся connection-init-string: "SET NAMES utf8mb4". Это было нужно для того, чтобы в текстовых полях, которые вводят пользователи, можно было бы корректно сохранять сложные составные смайлики. Кодировка utf8 является 3-байтовой, а utf8mb4 — 4-байтовой, и в какой-то момент пришлось это учитывать. Колонок, в которых могут быть сохранены строки от пользователей мало, раньше для них были написаны sql-ченджсеты c ALTER TABLE ... MODIFY COLUMN ..., сейчас перенесли их в createTable и указали правки через modifySql только для MySQL.

Мы убедились, что они успешно накатываются и на MySQL, и на PostgreSQL. Проверили, что схема, создаваемая с нуля на чистом СУБД идентична дампу схемы с боя с учётом внесённых изменений. Сделали дополнительную fix-миграцию на непропечённую версию схемы, которая вносила эти же правки в существующие базы данных. В этой миграции получилось около 20 ченджсетов. Проверили, что накат новых миграций на дамп схемы с боя не изменяет её. Порадовались, что незначительно выросла скорость выполнения миграций.

Другие ошибки

Немного о том, что мы поправили в коде. Код, вызываемый в @AfterEach класса BaseStatefulTest по очистке таблиц улетел за новую абстракцию DatabaseCleaner с двумя реализациями под каждый из СУБД. Оказалось, что для PostgreSQL нельзя отключить FK для всех таблиц сразу, поэтому появилось два варианта. Первый вариант подразумевал вызов TRUNCATE … RESTART IDENTITY CASCADE, но в реальности он оказался достаточно медленным. Второй вариант — сперва отключить на всех очищаемых таблицах FK (ALTER TABLE … DISABLE TRIGGER ALL), удалить данные (DELETE FROM …), вернуть FK (ALTER TABLE … ENABLE TRIGGER ALL), после чего ещё выполнить сброс sequence-у (ALTER SEQUENCE IF EXISTS %s.%s_id_seq RESTART WITH 1). И первый и второй вариант необходимо заворачивать в BEGIN; …; COMMIT, иначе ничего не выйдет. На самом деле, когда в таблице реально много данных, то и вариант с TRUNCATE имеет право на жизнь, поэтому мы оставили обе реализации, разделяя таблицы по числу строк в них после тестов (если более 1000, то используем TRUNCATE). Может быть глобально это и не имеет смысла, но сейчас просто захотелось оставить интересный код.

Также интересной задачей является определение того, какие таблицы нужно чистить после теста. Сперва мы читаем мета-таблицу information_schema.TABLES (TABLE_NAME), фильтруясь по названию БД и схемы. Затем для каждой таблицы читаем её колонки из information_schema.COLUMNS. После этого можно выполнить запрос SELECT pg_get_serial_sequence(:table, :column) и получить в результате или NULL, или название sequence-а, который привязан к этой колонке. Собираем всё это в Map<String, String> sequenceToTableMap, причём это достаточно сделать один раз в момент поднятия контекста. А непосредственно в методе очистки получаем только те таблицы, которые имеют отличный от NULL сиквенс:

P.S. И в этот момент мы потеряли все  таблицы без колонки id. В итоге накостытили подсчёт пустых через простой запрос SELECT COUNT(*) ....
P.S. И в этот момент мы потеряли все таблицы без колонки id. В итоге накостытили подсчёт пустых через простой запрос SELECT COUNT(*) ....

Большинство таблиц у нас имеет id BIGINT PRIMARY KEY, но есть штучные легаси-кейсы, где такого ключа нет, а в качестве первичного ключа используется колонка с CHAR(36), в котором по факту лежит UUID. Они не вызывают проблем с производительностью (см. https://habr.com/ru/articles/747348/) только из-за того, что сами по себе заложенные в них бизнес-процессы используются на порядки реже, чем основные.

На этом этапе мы убедились, что Liquibase успешно создаёт нужную нам схему данных. Но контекст всё ещё не поднимается, потому что у нас включён hibernate.ddl-auto = validate. Вот что ещё пришлось исправить:

  • PostgreSQL меняет тип CHAR(n) на BPCHAR(n). Решили это введением двух собственных диалектов для обоих СУБД и modifySql для PostgreSQL, меняющего "token CHAR(n)" на "token VARCHAR(n)". Во всех сущностях удалили атрибут columnDefinition = "CHAR(n)" — плюс к лаконичности кода.

  • Для свойств объектов, которые в Java у нас Instant-ы, в MySQL использовался тип TIMESTAMP(3), для PostgreSQL Liquibase стал генерировать тип TIMESTAMP(3) WITHOUT TIMEZONE, поэтому через тег modifySql с атрибутом rdbms="mysql" заменили его на TIMESTAMP(3) WITH TIMEZONE.

Теперь и контекст завёлся. Падает очень малая доля тестов, на уровне 10-15 штук из 500+, а остальные зелёные. По большому счёту это свелось к двум проблемам, которые мы решили.

Вынужденные исправления в боевом коде

Для объяснения первой приведу кусок кода с примером запроса, который выводит записи в журналы оформлений в web-админке:

Запрос для фильтрации какой-то сущности
Запрос для фильтрации какой-то сущности

Здесь dateStart и dateEnd имеют тип Instant, и драйвер PostgreSQL начал сыпать исключения вида PSQLException: ERROR: could not determine data type of parameter $5. Чуть было не переписали весь фильтр на спецификации, много игрались с кастованием к timestamp, но в итоге обошлись малой кровью: просто запретили передавать null в полях фильтров по датам. Большая часть фильтров это DTO, приходящие с фронтенда, помеченные аннотациями @lombok.Value и @lombok.Builder. Добавили на поля dateStart и dateEnd аннотацию @lombok.Builder.Default и указали значения по умолчанию в далеких прошлом/будущем.

Вторая небольшая проблема всплыла в одном нативном скрипте. Имелся SELECT с GROUP BY и выборкой столбца, не участвующего в группировке. Исправлено добавлением его в группировку.

Теперь у нас проходят все тесты! Делаем контрольную сверку миграций.

Интересная заметка: MySQL автоматически создаёт индекс на колонку с FK и именем этого FK, но если затем вручную добавить на эту колонку индекс, то автоматически созданный просто заменяется на новый, и дублирования нет. А вот PostgreSQL не создаёт индексы на колонки с FK, поэтому там, где они нужны, стоит добавить их вручную. Суммарно в рамках обоих СУБД это говорит о том, что индекс стоит добавлять всегда руками.

Также мы подняли Liquibase до последней актуальной версии, Spring Boot до 2.7.x, убедились, что всё работает, и откатили назад, потому что обновление зависимостей не входит в решаемую задачу.

Время полной сборки на достаточно старой системе (i5-4690, 32 Гб RAM, HDD) до всех наших правок на MySQL составляла ~ 9:30, на новой версией с PostgreSQL и пропечёнными миграциями ~ 6:10, т.е. ускорение порядка 35%. Очень приятное ускорение. Да и версия с MySQL при сборке в CI срезала где-то от 30 до одной до полутора минут.

Убрали allow_failure = true из job сборки, и теперь код все разрабатываемых задач обязан быть совместимым с обоими СУБД.

В CI/CD приложение обязано пройти тесты на обоих СУБД
В CI/CD приложение обязано пройти тесты на обоих СУБД

Вся разница между их настройками вынесена в отдельные файлы профилей Spring. Чтобы выполнить отладку своей задачи на PostgreSQL локально разработчику достаточно поднять контейнер с ним (в коде проекта сразу предусмотрен docker-compose.yml и с MySQL, и с PostgreSQL) и в конфигурации запуска приложения указать правильный профиль. Для прогона тестов почти аналогично – в конфигурации запуска добавить переменную окружения USE_POSTGRESQL = true. Сейчас, спустя несколько месяцев в таком режиме, могу сказать, что это потребовалось буквально несколько раз: весь бизнесовый код, который пишется, основан на JPQL, и от СУБД зависит мало.

Одни из последних данных по времени прогона набора всех тестов. 35% времени — вот сколько мы платим за то, что все наши тесты независимые и не стреляют нам в ноги. "Вызовы API" — это сколько времени выполняется непосредственно тестируемый боевой код.
Одни из последних данных по времени прогона набора всех тестов. 35% времени — вот сколько мы платим за то, что все наши тесты независимые и не стреляют нам в ноги. "Вызовы API" — это сколько времени выполняется непосредственно тестируемый боевой код.

Переезд тестовых сред

DBA выделили нам новые сервера, подняли там PostgreSQL актуальной версии, закрыли его pgBouncer-ом, настроили репликацию, выдали учётные записи. А мы приступили к миграции данных тестовых стендов.

Как я уже писал в начале, у нас три тестовых стенда. Первый принадлежит исключительно нам, backend-команде, и мы никому в нашей команде не даём гарантий относительно его стабильности, нюансов поведения или наличия влитых в его ветку задач. По факту трогаем мы его редко, потому что разработчики часто в рамка задачи поднимают приложение локально, убеждаются в работоспособности написанного кода самостоятельно, покрывают код тестами, а дальше ждут обратную связь от команд мобильной разработки и QA. Поднимать свои задачи на тестовом стенде приходится только в случае проверки какой-либо интеграции, которая доступна только из кластера, либо если хочется потыкать в тестовую сборку МП, которая смотрит на этот стенд. В общем – редко. А поскольку на стенде редко происходят активности, и уж тем более оформления по бизнес-процессам, то и данных в его БД крайне мало.

Для миграции этого стенда мы сделали следующие шаги. Остановили сервер, переключили в настройках профиль с mysql на postgresql, внесли в Vault все нужные чувствительные данные, и запустили его. Сервер поднялся, присоединился к СУБД, Liquibase накатил миграции и создал таблицы. Это был быстрый, хоть и итеративный процесс – несколько багов мы всё равно обнаружили и оперативно исправили. Например, прикрутили pre-liquibase, чтобы он создавал схему от имени серверной УЗ, потому что под нашими персональными УЗ прав на это не было.

Затем мы снова остановили стенд и перенесли все данные из mysql в postgresql руками через дамп, сделанный DataGrip. Дамп содержал простые INSERT-ы, но для вставки нам руками пришлось сделать тоже самое, что делает в тестах DatabaseCleaner – отключить и включить триггеры, поправить сиквенсы. Для этого даже в какой-то момент мы поняли, что нужно просить DBA повысить права наших УЗ, благо, никто не против, и нам их выдали до конца года. После окончания наката дампа данных снова включили сервер и всё заработало, как ожидалось.

Второй стенд был вторым на очереди. Мы выбрали позднее вечернее время, когда вся команда оффлайн, тем не менее предупредив заранее, что стенд будет недоступен. В БД его и третьего стенда в разы больше данных, потому что разработчики МП и QA работают с такими сборками МП, которые смотрят на эти сервера. Да и людей суммарно больше – например, аналитики и менеджеры могут зайти в админку и построить какие-нибудь отчёты. Отлично же!

Здесь тоже пошло не всё гладко. В снимаемом вручную дампе в паре колонок начали проскакивать сложные составные эмоджи, в том числе из относительно свежих версий Unicode. Спасибо QA за то, что вставляют их в тестируемые поля на МП! При накате дампа из пары десятков тысяч строк около 100-200 падали с ошибками. Причём, их все даже найти в дампе было проблематично. Поправили пару багов в миграциях, связанных с кодировками, но в общем и целом сделали вывод: руками переносить данные очень заморочно. Нужно не только сделать дамп, но и постоянно не забывать выполнять скрипты по подготовке БД к вставке данных.

Ещё параллельно мы постоянно думали о том, что, когда мы мигрируем бой, останется риск необходимости отката, и нам нужно иметь под рукой план, как перенести данные из PostgreSQL обратно в MySQL быстро и без потерь. Поскольку мы делаем всю эту миграцию достаточно протяжённо во времени, код сервера параллельно меняется с реализацией бизнесовых задач, появляются новые таблицы и колонки, старые пропадают. Среди таблиц есть исключения, которые нужно учитывать: некоторые из них не нужно переносить (выделил все таблицы в DataGrip – не забудь, пощелкай по тем, которые не нужны), а некоторые не имеют id, и им не нужно править тип генерации id / сиквенс. А если инцидент, кучу “если” надо будет умножить на стресс.

Всё-таки задача программиста – не делать ручную работу, а сделать кнопку, которая сделает эту работу. Здесь мы приходим к необходимости иметь инструмент, который сделает всю работу за нас. Первым делом нужно поискать в интернете готовые варианты. Мы нашли такие и попробовали несколько из них. Самым многообещающим и подходящим оказался pgLoader. В нём есть на 100% всё, что нам нужно для прямой миграции, кроме одного – он не умеет подключаться к MySQL 8, в котором используется последняя версия схемы аутентификации, вот issue. И он не решает вопрос обратной миграции.

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

Мигратор данных

Наш мигратор – это простое Spring Boot приложение с 2 датасорсами "from" и "to". Датасорсы сконфигурированы 100% с такими же настройками, как и наш монолит. Изначально в нём было добавлено 2 эндпоинта: POST /migrate и GET /status. Мы даже собирались прикрутить к нему простенькую веб-страничку, но в итоге до самого конца просто пользовались curl-ом и чтением логов. Мы смогли переиспользовать большую часть кода интроспекции схемы из нашего кода очистки БД в unit-тестах, поэтому proof-of-concept получилось сделать достаточно быстро. Работало это так: при запросе /migrate поднимаются датасорсы, создаётся контекст миграции с данными интроспекции и текущим статусом переноса данных, и в цикле по очереди итерируются все таблицы, читаются все данные из таблицы в память, затем пишутся в целевую БД. Никакого параллелизма, чтобы не читать сразу несколько таблиц в память и не лопнуть. Хотя, конечно же, и лопнули, и время миграции было неприемлемым.

Дальше наступила пора оптимизаций. Первыми появились "Стриминг" и "Батчинг": стриминг означал, что мы читаем из from-датасорса все данные одним запросом, а батчинг – что по мере поступления данных от исходного СУБД накапливаем их в батч и сохраняем в целевом СУБД запросом с мульти-INSERT-ом. Это резко сократило потребляемую память, а также уменьшило время миграции, так как чтение и запись стали практически одновременными.

Следующим шагом был добавлен простейший параллелизм по таблицам. Выделили пул потоков разумного размера, каждый поток брал случайную таблицу и мигрировал только её. Когда заканчивал, или брал следующую, или прекращался, если таблицы закончились. Время от времени мы производили замеры, копируя данные из MySQL RO-реплики боя в боевой PostgreSQL, и на этом моменте все наши данные переносились примерно за 1 час 30 минут.

Поскольку у нас была поставлена своя внутренняя моральная планка в 30 минут на полную миграцию, мы решили ещё подожимать оптимизациями код. Поигрались с размерами батчей, добавили несколько ключей для драйверов датасорсов (ДОПИСАТЬ). Так смогли спуститься по времени до 55 минут.

Одним из сильно замедляющих миграцию факторов оказалось то, что у нас есть три таблицы, имеющие каждая значительно больше данных, чем все остальные вместе взятые. Потоки, которые берут их в работу, гарантированно в последние минут 30 работают в одиночестве. Причём даже начинают работать с этими таблицами не сразу. Сортировка таблиц по числу записей срезала нам около 5 минут, но нужно было придумать что-то ещё, чтобы с начала миграции и до самого конца у нас работало всё количество выделенных потоков-соединений. Для этого мы таблицы, имеющие больше записей, чем некая выбранная константа, делим по id на 100 диапазонов. Затем мы составляем список на копирование не из названий таблиц, а кортежей (таблица; min id; max id). Список хранится за абстракцией, которая умеет отдавать эти задания на миграцию мигрирующим их потокам хитро сортируя: сперва по кол-ву потоков, которые в данный момент мигрируют эту же таблицу ASC, затем по числу оставшихся диапазонов DESC. Таким образом у нас большие таблицы начинают мигрировать сразу же и параллельно с остальными, а в конце уже все потоки вместе долбят эти три таблицы и заканчивают примерно одновременно. Так мы преодолели нашу моральную планку и справились за 25 минут.

Параллельно с сокращением времени на разработку мы также добавили новый эндпоинт POST /migrate-liquibase, который поднимал датасорсы и выполнял актуальные миграции на to-datasource. Это оказалось нужным, так как мигратор находился в разработке около 3 месяцев, и за это время в кодовой базе постоянно появлялись новые миграции. Это нужно, чтобы перед переносом данных не приходилось поднимать само серверное приложение, натравленное на to-datasource, затем останавливать его, производить миграцию и снова запускать. Вызвать последовательно два эндпоинта curl-ом гораздо проще. Поскольку у нас монорепо, мы схитрили и просто добавили папку монолит/src/main/resources/migrations как resources root в pom.xml. Это немного сносит башню IDEA, но проблем не вызывает.

И всё-таки мигратор мы писали для ещё одной цели: спасти себя от хаоса в случае, если после миграции случится форс-мажор. Страху добавляло то, что миграция была намечена на ночь с субботы на воскресение, а в воскресение мало пользователей и мало нагрузки, и какие-то проблемы могут себя не проявить. А в понедельник, особенно когда просыпается московский часовой пояс, может что-то стрельнуть. Тогда у нас будет 1+ сутки данных, наработанных уже в PostgreSQL, а кару за потерю стольких данных в финтехе представить себе просто немыслимо. Поэтому на случай инцидента из-за СУБД нам нужен план (чек-лист действий) по быстрому возвращению данных обратно в MySQL и перезапуске. Поскольку архитектурно код работы с датасорсами у нас спрятан за интерфейсами, нам не составило труда дописать нужные методы для случая, когда from-datasource у нас PostgreSQL, а to-datasource – MySQL. На самом деле мы даже заморочились, и немного ранее написали тесты на прямую и обратную миграции, чтобы быть уверенными, что в какой-то момент наши оптимизации не сломают код. На самом деле прямо сейчас нет никаких препятствий, чтобы не добавить ещё одну реализацию интерфейса для СУБД, например, Oracle Database, и не дописать тесты oracle2mysql / oracle2mysql / oracle2postgresql / postgresql2oracle.

Убедившись в выполнении всех своих внутрикомандных требований мы пошли к Product Owner-у проекта согласовывать дату для миграции и ... получили новые вводные. PO категорически отказал нам в длительности потенциального простоя в рабочее время для обратной миграции, установив новую планку – 5 минут. С учётом того, что у нас 3-4 пода в K8s как раз за такое время просто запускаются. Мы снова ушли думать и за неделю придумали и реализовали новый режим работы: дельта-копирование. Для этого режима появилось два новых эндпоинта, "начать" и "остановить". При запуске создаётся отдельная таблица для изменений, а на все таблицы, подлежащие копированию вешаются триггеры. Триггеры при создании/изменении/удалении строк добавляют записи в таблицу изменений. Мигратор непрерывно читает из этой таблицы отсортированные записи и переносит (копирует с заменой или удаляет) изменения в to-datasource. План был в том, что в тех.окно мы мигрируем данные как планировали ранее, затем меняем датасорсы местами и включаем дельта-копирование. Если случается инцидент, останавливаем сервер, ждём считанные секунды до завершения дельта-копирования, останавливаем его и запускаем сервер снова на MySQL. Потом разбираемся с проблемами и пробуем ещё раз. Дописали на это дело тесты и неделю смотрели, как оно ведёт себя на тестовых стендах.

It's time.

Мы решились на миграцию 22 мая 2023 года, в ночь с воскресения на понедельник. Майские праздники + просьба от бизнеса усиленно гарантировать работоспособность в несколько определённых дат ограничили нас в технических окнах. Выполнили все действия по чек-листу. Инцидента не случилось, всё заработало как ожидалось. Дельта-копирование идеально работало все будние дни до следующих выходных. Мигратор остановили и андеплойнули. Через две недели DBA остановили ВМ со всеми нашими MySQL-серверами.

Интересное наблюдение – в PostgreSQL тот же объём данных выглядит значительно более компактным, хотя соотношение размеров данные/индексы сохраняется.
Интересное наблюдение – в PostgreSQL тот же объём данных выглядит значительно более компактным, хотя соотношение размеров данные/индексы сохраняется.

Поработав пару дней на PostgreSQL мы попросили DBA провести с их стороны анализ запросов приложения. В целом всё было отлично, хотя нашли пару запросов с очевидно недостающими индексами.

Вот и всё. Вся проделанная работа была очень сильно размазана по времени и от первых реальных действий до отключения ВМ с MySQL прошло около 9 месяцев. При этом над задачей работали от 0 до 2 человек, чаще всего в фоне более важных основных задач, уделяя от 25% до 50% рабочего времени. В результате мы поменяли свой СУБД (хоть такое действие и выглядит маловероятным в реальном мире), привели в порядок миграции и схему данных во всех окружениях, срезали время выполнения тестов и сборку в CI/CD, обновили Liquibase (и вообще Spring Boot), а также подготовились к следующим шагам по выделению микросервисов из монолита.

Бонус: Spring Boot 2.7.14

Значительно позже при обновлении минорной версии Spring Boot с 2.7.13 на 2.7.14 перестали работать некоторые фильтры в журналах панели администрирования. Я с одной стороны, завёл на это дело баг, а с другой поправил у нас в коде запросы так, как предложили в issue:

В целом на весь проект нашлось всего лишь 13 аналогичных запросов...
В целом на весь проект нашлось всего лишь 13 аналогичных запросов...

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


⚠️ Мой традиционный Safe Harbor.

  • Всё описанное выше может оказаться дичайшим over-engineering-ом. Если вы хотите повторить это на своём проекте, пожалуйста, взвесьте все риски. Несколько раз.

Если вы нашли какие-то опечатки, не стесняйтесь подсвечивать. Статья была начала осенью 2022 года и дописывалась волнами. Я и несколько других человек, конечно, вычитали её перед публикацией несколько раз, но от ошибок никто не застрахован.

Комментарии (5)


  1. headliner1985
    03.08.2023 09:35

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


    1. SimSonic Автор
      03.08.2023 09:35

      Это, действительно, выглядит весьма правдоподобной версией. Спасибо :)


  1. murkin-kot
    03.08.2023 09:35
    +1

    но мы прикинули, что переезд в PostgreSQL позволит нам решить ряд попутных технических вопросов

    Что за вопросы? Повышение конкурентоспособности разработчиков на рынке труда путём добавления в резюме строчки с модной БД?


    1. SimSonic Автор
      03.08.2023 09:35

      Я пытался это донести в тексте, но попробую собрать ещё раз вместе.

      • Переработать миграции, схлопнув всю их историю за несколько лет, что ускорит выполнение тестов.

      • Заодно поделить миграции по бизнес-процессам, чтобы в будущем их удобно отщипывать просто копипастой в новые микросервисы.

      • Исправить проблемные миграции старых лет, которые уже не проходят валидацию на новой версии Liquibase, которая тянется Spring Boot 2.7.

      • Начать использовать плюшки, которые есть в PostgreSQL, но которых нет в MySQL. К примеру, после миграции несколько колонок перевели на тип CITEXT.

      • Модная строчка в резюме — тоже хорошая причина, спасибо за неё.

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


      1. wapmorgan
        03.08.2023 09:35

        Миграции схлопнуть можно просто записав их все в один sql-файл, которым инициализируется бд.
        А для тестов можно вообще готовить образ бд с выполненными миграциями и импортированным перед этим sql-файлом.
        У нас на каждый коммит тесты выполняются с бд, образ бд меняется только при добавлении новой миграции (тк хэш образа бд считается на основе списка миграций).