Cказ о том, как мы с Oracle на PostgreSQL переехали

от автора

Исходные условия и цель переезда

Привет, Хабр! Меня зовут Даша Александрова, я Java-разработчик. Хочу поделиться опытом миграции данных из Oracle в PostgreSQL без простоя сервисов.

Причина миграции — импортозамещение.

Теперь немного про сам проект. В его основе — микросервисная архитектура на Java 11/17 и Spring Boot 2/3. В качестве основной базы данных использовалась Oracle с несколькими схемами. В коде сочетаются нативные SQL‑запросы и Hibernate, вся бизнес‑логика живет на уровне приложения — без процедур, триггеров и другой логики в базе. Идентификаторы генерируются через sequence. Проект активно развивается, регулярно выпускаются релизы. Система ориентирована на клиентские приложения — мобильное и веб, при этом нагрузка остается умеренной и не относится к highload‑сценариям.

Ключевое нефункциональное требование — выполнить миграцию без простоя системы и без заметного влияния на пользователей.

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

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

Варианты миграции и выбор подхода

1. Остановка системы и офлайн-миграция

Самый простой сценарий — подготовиться к миграции, остановить приложение и перенести все данные. Для этого существует множество инструментов: например, ora2pg (который часто используют и для переноса структуры базы), fdw и другие решения. Подробно останавливаться на этом подходе не буду, так как он не удовлетворяет ключевому нефункциональному требованию.

2. Онлайн-миграция с CDC

Следующий вариант — миграция без визуального простоя для пользователя с использованием CDC. В этом сценарии текущая версия приложения продолжает работать с Oracle, а параллельно запускается процесс переноса данных в PostgreSQL. После перелива основного объема включается репликация изменений с небольшим лагом, и в какой-то момент разворачиваются новые версии сервисов, уже работающие с PostgreSQL. Далее происходит переключение трафика на новые поды.

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

3. Миграция с двойной записью (dual write)

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

Такой подход требует дополнительных доработок приложения — нужно поддерживать dual write и управлять потоками записи, — но взамен он дает более предсказуемое поведение системы. Если интересно, подробнее про это можно почитать в этой статье.

Какой вариант был в итоге выбран

В итоге был выбран второй вариант — использование CDC без двойной записи — как осознанный компромисс. Требования к консистентности в момент миграции для нашего проекта оказались ниже, чем стоимость внедрения и поддержки dual write.

Допускался небольшой лаг между Oracle и PostgreSQL (порядка нескольких секунд), тем более что сама миграция планировалась на наименее активные часы работы системы. Также изначально принимался риск отсутствия простого отката на Oracle в случае проблем. Вместо этого основной акцент был сделан на предварительном тестировании и проработке возможных сценариев ошибок.

Выбор CDC-решения

Change Data Capture (CDC) — это подход, позволяющий отслеживать изменения в базе данных в реальном времени и передавать их в другие системы. В нашем проекте ранее CDC не использовался, поэтому его внедрение потребовало отдельного анализа доступных решений и погружения в их особенности.

К решению предъявлялся вполне практический набор требований: оно должно быть бесплатным или с open source лицензией, поддерживать репликацию из Oracle в PostgreSQL, уметь выполнять как начальную загрузку данных, так и последующую онлайн-репликацию изменений, работать с уже существующими таблицами и позволять при необходимости трансформировать данные.

В качестве основных кандидатов рассматривались Oracle GoldenGate и Debezium.

Oracle GoldenGate — коммерческое решение. У него есть бесплатная версия, однако на этапе проверки гипотез она поддерживала только сценарии Oracle → Oracle, что делало ее неприменимой. Попытка развернуть полную версию локально тоже оказалась не самой простой: установка и запуск требовали значительных усилий, часть окружений поддерживалась ограниченно, а документация не всегда помогала быстро дойти до рабочего сценария.

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

План миграции

План работ выстроился в несколько последовательных этапов:

  1. Анализ таблиц, проработка типов данных и очистка устаревших записей.

  2. Миграция схемы с помощью Liquibase.

  3. Доработка прикладного кода.

  4. Миграция данных через Debezium.

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

Далее разберём каждый из этих шагов подробнее.

Анализ таблиц, проработка типов и очистка данных

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

Особое внимание стоит уделить типам данных: между Oracle и PostgreSQL нет полного соответствия, и часть типов потребует явного преобразования. Подробный разбор различий приведен в этой статье.

Для первичной оценки распределения типов можно использовать следующий запрос:

select distinct DATA_TYPEfrom ALL_TAB_COLUMNSwhere owner = 'схема'order by DATA_TYPE

По итогам анализа была составлена таблица соответствий типов, которая выглядит следующим образом:

Таблица соответствия типов между Oracle и PostgreSQL

Таблица соответствия типов между Oracle и PostgreSQL

Миграция схемы с помощью Liquibase

Простая замена datasource на PostgreSQL в свойствах приложения не работает — миграционные скрипты упадут с ошибками. Основные причины — различия в типах данных (например, number(1,0) → boolean), работа с sequence и прочие специфические конструкции.

Для упрощения DDL и DML операции были разделены с помощью context: на время миграции DML отключались. Процесс велся итеративно: скрипты правились до стабильного запуска приложения, проводилось smoke-тестирование и сравнение схем Oracle и PostgreSQL. Вспомогательный скрипт выгружал метаданные баз, после каждого цикла changelog Liquibase очищался, миграции прогонялись заново, проверялась корректность выполнения preconditions.

В результате целевая схема PostgreSQL была сформирована в рамках одного PR.

Доработка прикладного кода

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

  1. Заменить @Lob на @Column(columnDefinition = «text»).

  2. Корректно обрабатывать null в нативных запросах, особенно в IN.

  3. Учитывать, что в Oracle пустая строка воспринимается как null, а в PostgreSQL — нет.

  4. Перейти с 0/1 на boolean.

  5. Поправить прочие нативные SQL-запросы.

Особое значение имеет работа с sequence. Если не принимать во внимание их текущее состояние, генерация идентификаторов в PostgreSQL может начаться со стартовых значений, что приведет к конфликтам при миграции исторических данных. Чтобы избежать этого:

  1. Во время старта приложения для каждой sequence проверяется текущее значение.

  2. Если оно меньше ожидаемого (относительно Oracle), выполняется сдвиг с запасом.

  3. Если больше — оставляется без изменений.

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

Миграция данных с помощью Debezium

Debezium доступен в трёх вариантах: классический через Kafka Connect, standalone-сервис и движок — подробности см. здесь. Для реализации был выбран классический вариант с Kafka Connect.

Снимок экрана 2026-04-06 в 07.22.26.png

Debezium Kafka Connect

Архитектура решения выглядит следующим образом: source-коннекторы, например Debezium Oracle, отправляют изменения в Kafka, а sink-коннекторы передают их из Kafka в другие системы. По умолчанию каждая таблица пишет изменения в отдельный топик с именем таблицы. На момент написания статьи source-коннекторы поддерживают MongoDB, MariaDB, MySQL, PostgreSQL, Oracle, SQL Server, Db2, Cassandra, Spanner, Vitess и Informix (инкубационный режим), а sink-коннекторы — JDBC и MongoDB Sink.

Как это работает на практике?

Oracle фиксирует все изменения в redo log. При первом запуске Debezium выполняет initial snapshot — считывает полное состояние таблиц. После этого коннектор продолжает стримить изменения из redo log, превращая INSERT / UPDATE / DELETE в упорядоченные CDC-события, которые отправляются в Kafka. Затем sink-коннектор применяет эти изменения к PostgreSQL в том же порядке, обеспечивая постепенную синхронизацию PostgreSQL с Oracle.

Для работы с Oracle в Debezium по умолчанию используется LogMiner, который позволяет коннектору корректно читать redo log и формировать CDC-события, сохраняя порядок изменений и поддержку транзакций. Существуют и другие подходы, о которых можно прочитать здесь.

Для старта миграции потребовались следующие образы:

UI от команды Debezium летом 2025 был сырым. К февралю 2026 репозиторий UI уже архивирован, появилась новая платформа с UI: — проект активно развивается, но пока находится на ранней стадии, поэтому всю настройку будем выполнять с помощью curl.

Документация Debezium очень подробная и понятная. В целом, это инструмент тонкой настройки, и если «подружиться» с ним, он позволяет гибко управлять потоками данных.

Описание процесса работы с Debezium

Для разработчика процесс выглядит следующим образом:

  1. Формируется JSON-запрос с конфигурацией source-коннектора. После публикации запроса в Kafka создаются топики в соответствии с настройками, и они начинают заполняться данными (по умолчанию один топик соответствует одной таблице).

  2. Формируется JSON-запрос с конфигурацией sink-коннектора. Данные начинают перетекать в PostgreSQL.

По сути, задача сводится к созданию парочки «правильных JSON», потому что объединить все таблицы под одну конфигурацию удается не всегда: некоторые таблицы, например, содержат композитные ключи, требующие отдельной настройки. Далее будут приведены примеры для наглядности.

Подготовка баз данных к миграции

Чтобы Debezium смог слушать события из Oracle, необходимо выполнить несколько шагов:

  1. Настроить пользователя и выдать нужные роли. Подробности см. здесь.

  2. Включить архивное логирование. Проверить командой: SELECT LOG_MODE FROM V$DATABASE; Если результат — NOARCHIVELOG, нужно выполнить действия по документации для подготовки базы данных.

Для включения архивного логирования требуется перезапуск СУБД. Если перезапуск недопустим, можно использовать альтернативные режимы чтения — incremental fetch / read by id/ timestamp. Такой подход требует более аккуратной обработки событий.

Совет для локального тестирования в Docker

Если запускаете образ Oracle в Docker, то после SHUTDOWN включить архивное логирование может не получиться. Решением служит статическая регистрация SID в файле /opt/oracle/oradata/dbconfig/ORCLCDB/listener.ora. Пример конфигурации:

SID_LIST_LISTENER = (SID_LIST =(SID_DESC =(ORACLE_HOME = /opt/oracle/product/19c/dbhome_1)(SID_NAME = ORCLCDB)))

После внесения изменений необходимо перезапустить listener командой lsnrctl stop и lsnrctl start, а затем повторить настройку архивного логирования.

Напоследок. Перед началом работы с Debezium стоит учесть несколько важных моментов:

  1. Имена таблиц и колонок должны быть не длиннее 30 символов.

  2. Каждая таблица должна иметь первичный ключ (желательно).

  3. Все необходимые таблицы должны существовать в PostgreSQL.

  4. Отключение внешних ключ в PostgreSQL на время миграции данных предотвращает конфликты при параллельной вставке данных из разных топиков.

  5. На время миграции рекомендуется отключить индексы в PostgreSQL для повышения производительности вставки.

Пример настройки Debezium

Приступим к настройке Debezium. Предположим, что наша схема называется “Pokemon”.

Пример конфигурации source-коннектора Oracle (LogMiner)

{  "name": "logminer-connector-oracle-topics-pokemon",  "config": {    "connector.class": "io.debezium.connector.oracle.OracleConnector",    "database.connection.adapter": "logminer",    "database.dbname": "имя",    "database.user": "тренер",    "database.password": "пароль",    "database.url": "урл",    "tasks.max": "1",    "log.mining.batch.size.min": "10000",    "log.mining.batch.size.default": "100000",    "log.mining.strategy": "online_catalog",    "schema.history.internal.kafka.bootstrap.servers": "kafka:9092",    "schema.history.internal.kafka.topic": "schema-changes",    "topic.prefix": "POKEMON",    "table.include.list": "POKEMON.*",    "key.converter": "io.apicurio.registry.utils.converter.AvroConverter",    "key.converter.apicurio.registry.url": "<http://apicurio:8080/apis/registry/v2>",    "key.converter.apicurio.registry.auto-register": "true",    "key.converter.apicurio.registry.find-latest": "true",    "value.converter": "io.apicurio.registry.utils.converter.AvroConverter",    "value.converter.apicurio.registry.url": "<http://apicurio:8080/apis/registry/v2>",    "value.converter.apicurio.registry.auto-register": "true",    "value.converter.apicurio.registry.find-latest": "true",    "schema.name.adjustment.mode": "avro"  }}

Публикация этого JSON (POST localhost:8083/connectors) настраивает Debezium Oracle Source Connector через LogMiner для стриминга изменений из схемы POKEMON в топики с префиксом POKEMON. Коннектор читает redo log, формирует CDC-события и отправляет их в Kafka. Настройки key/value converter позволяют работать с Avro и автоматически регистрировать схемы в Apicurio Registry, при этом включена опция find-latest для работы с последними версиями схем. Параметр table.include.list задаёт, какие таблицы реплицировать, а размеры батчей и стратегия LogMiner (online_catalog) помогают оптимально управлять потоком данных и отслеживать изменения структуры таблиц.

Наблюдение за процессом

Через Kafka Connect UI можно наблюдать за состоянием коннектора: ошибки помечаются красным с небольшой задержкой, но актуальный статус и логи всегда доступны в контейнере:

1.png

Отображение в Kafka Connect UI

В Kafka UI отображаются топики с заданным префиксом, а также служебные, которые создаёт Debezium для внутренних нужд. В каждый топик можно «зайти» и просмотреть сообщения, смещения (offsets) и другую информацию — всё как в стандартной Kafka:

2.png

Отображение в Kafka UI

Если включено сжатие и используется схема в Apicurio Registry, в сообщениях остаётся только идентификатор схемы и данные в бинарном формате, недоступном для чтения. В простых конфигурациях по умолчанию сообщения передаются в виде JSON, что позволяет видеть фактическое содержимое строк данных.

Каждое CDC-сообщение содержит:

  1. До/после состояния изменённой строки

  2. Метаданные операции (тип изменения, timestamp, идентификатор транзакции)

  3. Информацию о схеме данных

Далее настраивается коннектор для считывания событий из Kafka и вставки их в PostgreSQL. Это можно делать параллельно с миграцией данных или последовательно. Коннектор работает с offset, поэтому его можно пересоздавать, очищать топики или смещать offset при необходимости, не опасаясь потерять консистентность: конечный результат останется прежним.

Пример конфигурации sink-коннектора PostgreSQL

{  "name": "jdbc-postgres-connector-pokemon-all",  "config": {    "connector.class": "io.debezium.connector.jdbc.JdbcSinkConnector",    "tasks.max": "1",    "connection.url": "урл",    "connection.username": "тренер",    "connection.password": "пароль",    "insert.mode": "upsert",    "delete.enabled": "true",    "hibernate.dialect": "org.hibernate.dialect.PostgreSQLDialect",    "primary.key.mode": "record_key",    "primary.key.fields": "разные ключи primary в схеме",    "schema.evolution": "basic",    "topics": "POKEMON.таблицы",    "transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",    "transforms.unwrap.drop.tombstones": "false",    "transforms.unwrap.delete.handling.mode": "drop",    "transforms.unwrap.field": "after",    "value.converter.schemas.enable": "true",    "key.converter.schemas.enable": "true",    "transforms": "unwrap, renameField, castBoolean, route",    "transforms.renameField.type": "org.apache.kafka.connect.transforms.ReplaceField$Value",    "transforms.renameField.renames": "oldcolumnname:newcolumnname",    "transforms.route.type": "org.apache.kafka.connect.transforms.RegexRouter",    "transforms.route.regex": "POKEMON\\\\.(\\\\w+)\\\\.(\\\\w+)",    "transforms.route.replacement": "$2",    "table.name.format": "pokemon.${topic}",    "transforms.castBoolean.type": "org.apache.kafka.connect.transforms.Cast$Value",    "transforms.castBoolean.spec": "IS_DELETED:boolean",    "key.converter.apicurio.registry.url": "<http://apicurio:8080/apis/registry/v2>",    "value.converter.apicurio.registry.url": "<http://apicurio:8080/apis/registry/v2>",    "value.converter.apicurio.registry.auto-register": "true",    "value.converter.apicurio.registry.find-latest": "true",    "schema.name.adjustment.mode": "avro",    "value.converter": "io.apicurio.registry.utils.converter.AvroConverter",    "key.converter.apicurio.registry.auto-register": "true",    "key.converter": "io.apicurio.registry.utils.converter.AvroConverter",    "key.converter.apicurio.registry.find-latest": "true"  }}

Публикуя этот JSON (POST localhost:8083/connectors), настраивается JDBC Sink Connector, который считывает CDC-события из Kafka и применяет их в PostgreSQL. Коннектор работает в режиме upsert, объединяя вставки и обновления, при этом поддерживается обработка удалений (delete.enabled: true). Debezium генерирует tombstone-сообщения для удалённых записей — специальные события с ключом записи и пустым значением, позволяющие Kafka корректно очищать старые ключи и отслеживать удаление данных при репликации. Первичные ключи задаются через primary.key.mode и primary.key.fields, что гарантирует правильное наложение изменений на существующие записи.

Трансформации позволяют гибко управлять данными:

  1. unwrap — извлекает актуальное состояние записи из CDC-сообщений;

  2. renameField — переименовывает колонки при необходимости;

  3. castBoolean — явно преобразует указанные поля в boolean;

  4. regexRouter — маршрутизирует события в нужные таблицы PostgreSQL в соответствии с именами топиков.

Подробнее о каждом параметре советую читать в документации JDBC Sink Connector 

Конвертеры key/value с Apicurio Registry обеспечивают поддержку формата Avro, а параметр table.name.format задаёт шаблон для имен таблиц при вставке данных. В Kafka UI можно увидеть созданный Consumer и текущие значения offset для соответствующих топиков, что позволяет отслеживать задержку обработки событий и контролировать процесс синхронизации в реальном времени:

Снимок экрана 2026-04-08 в 20.16.17.png

Состояние Consumer-ов

Управление коннекторами

Коннекторы можно пересоздавать при необходимости — их состояние хранится в Kafka. При изменении конфигурации рекомендуется:

  1. Остановить коннектор

  2. Внести изменения

  3. Запустить заново

На практике я замечала, что не всегда корректно применяются новые настройки, поэтому чаще всего проще пересоздать коннектор полностью с новым именем. Если таблица имеет специфичные правила, следует создать отдельный коннектор только для неё.

Практические кейсы и настройки

Если у таблицы нет PK, есть два варианта:

  1. Таблица редко обновляется (например, справочник) — можно использовать insert mode. В этом случае при изменениях просто создаются новые строки, и это обычно не критично.

  2. Добавить PK в целевой базе и использовать upsert. Физическое удаление станет недоступным, но мягкое удаление через update сохранится.

Для таблиц с простым первичным ключом (например, ID) подойдёт следующий вариант:

"primary.key.mode": "record_key","primary.key.fields": "ID"

А для таблиц с составным ключом такой:

"message.key.columns": "схема.таблица:ключ1,ключ2"

При изменении имени колонки применяется трансформация ReplaceField:

"transforms.renameField.type": "org.apache.kafka.connect.transforms.ReplaceField$Value","transforms.renameField.renames": "ENDDATE:end_date"

Для колонок, требующих явного преобразования, например, в boolean, подойдет:

"transforms.castBoolean.type": "org.apache.kafka.connect.transforms.Cast$Value","transforms.castBoolean.spec": "IS_DELETED:boolean"

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

После запуска стоит убедиться, что все операции — вставки, обновления и удаления — применяются корректно, поля интерпретируются правильно, данные попадают в нужные таблицы и нет расхождений между Oracle и PostgreSQL. В целом процесс миграции сводится к подготовке нескольких JSON-конфигураций и контролю за их выполнением через Kafka UI и Connect UI.

Как выстраивался процесс миграции сервисов

Важно заранее согласовать порядок вывода микросервисов. Подходов здесь несколько: можно начинать с самых простых, двигаться от средних или сразу брать сложные. Под «сложными» подразумеваются не только объем данных, но и критичность их потери для бизнеса — например, сервисы, отвечающие за ключевой функционал или персональные данные. В то же время есть категории данных, потеря которых не приведет к серьезным последствиям — условный административный контент.

В проекте сначала был перенесён наиболее простой сервис — историй приложения. Он отличался небольшим объёмом данных и отсутствием критичных сценариев, что позволило быстро прогнать весь процесс, проверить гипотезы, оценить временные затраты и подготовить инфраструктуру. После успешного пилота в релизы добавлялась комбинация средних и простых сервисов, а три действительно сложных сервиса переносились отдельно. В среднем за один релиз мигрировал один сервис, иногда — до трёх; всего в контуре насчитывалось около 15 сервисов.

Релизный процесс был построен с разделением бизнес-релизов и технической миграции. Для каждого сервиса создавалась отдельная ветка, например postgres-release-V, с изменениями под PostgreSQL. Технические релизы выполнялись отдельно, в выделенные окна, чтобы не увеличивать риски при выпуске основной версии.

На dev-стенде основной поток разработки продолжал существовать в master с Oracle, рядом разворачивался отдельный namespace с сервисами, уже переведёнными на PostgreSQL. Постепенно этот namespace «заражался» новой базой. Такой подход исключал необходимость поддержки двойного кода, не усложнял сборку релизов и не мешал привычному циклу тестирования, чётко разделяя два независимых процесса:

Снимок экрана 2026-04-08 в 19.57.55.png
Доработки тестового окружения

Второй namespace потребовал доработок. В нём отключались джобы и слушатели очередей, чтобы избежать расхождений в поведении из-за возможного отставания кодовой базы. Для джоб решение было реализовано через around-аспект с feature toggle, который управляет их выполнением. Слушатели очередей, например в RabbitMQ, запускались с autoStartup=false, а управление ими осуществлялось через отдельную джобу с использованием RabbitListenerContainerRegistry.

В итоге процесс выглядел следующим образом:

  1. Поднимаются поды на ветке PostgreSQL.

  2. Отключаются внешние ключи и тяжёлые индексы на время миграции.

  3. Запускается перенос данных через Debezium.

  4. Включаются обратно внешние ключи и тяжелые индексы.

Для препрода и прода порядок действий немного менялся — критично сначала загрузить исторический слой данных:

  1. Подготавливаются таблицы на основе схемы с dev-стенда.

  2. Запускается миграция основного объёма данных через Debezium с мониторингом лага и состоянием СУБД.

  3. Поднимаются поды на новой версии. Лаг выравнивается до нуля, то есть PostgreSQL окончательно синхронизируется с Oracle и оказывается полностью «на шаг впереди» по сравнению с источником.

  4. Восстанавливаются индексы и ограничения.

  5. Проводятся smoke-тесты, отслеживаются логи и метрики.

  6. Ветка мерджится в master.

Что могло пойти не так:

  1. Ошибки логики и форматов данных.

    При смене СУБД почти неизбежны всплывающие баги. Исправляли это сочетанием тестов, регресса и быстрых хотфиксов.

  2. Проблемы с производительностью.

    Новая база иногда ведёт себя иначе: привычные запросы могут работать иначе. Здесь важен постоянный мониторинг метрик и оперативная реакция.

  3. Временное расхождение данных.

    Во время миграции и догоняющей репликации Oracle и PostgreSQL могли быть не полностью синхронизированы — лаг в несколько секунд. Для CDC это нормальное явление, главное — контролировать его и убедиться, что данные в итоге сходятся.

Также был подготовлен дамп старой базы как последний рубеж безопасности на случай непредвиденных ситуаций.

Ошибки, которые можно было избежать

Если проходить этот путь заново, несколько вещей точно стоило бы сделать иначе.

Во-первых, сразу настроить Avro и сжатие.

По умолчанию Debezium отправляет данные в JSON, и в каждом сообщении дублируется схема — названия полей, структура. На малых объемах это почти незаметно, но на масштабе миллион сообщений легко превращается в ~3 ГБ трафика из-за повторяющихся данных. Решение оказалось простым: использовать Avro и Schema Registry. Схема хранится отдельно, а в сообщениях передаются только бинарные данные и ссылка на schemaId. При настройке Apicurio Registry и Avro-конвертеров в Kafka Connect и коннекторах удалось добиться сжатия примерно в 10–15 раз.

Во-вторых, учитывать не только объем, но и характер данных.

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

В-третьих, контролировать соединения с базой.

PostgreSQL имеет ограничения на количество соединений. Без учета лимитов Debezium быстро может занять значительную часть пула, что отражается на работе приложения. Поэтому лимиты на стороне коннекторов стоит задавать явно. Даже при остановке коннекторов соединения не всегда освобождаются мгновенно — иногда требовался перезапуск контейнера.

В-четвертых, внимательно следить за логами Debezium.

Некоторые ограничения не очевидны. Например, Oracle LogMiner не отслеживает таблицы и колонки с именами длиннее 30 символов — такие объекты просто не участвуют в онлайн-миграции. Также важно проверять наличие первичных ключей: без них обработка изменений становится сложнее, а update-сценарии могут оказаться невозможными. Для будущих запусков полезно заранее сканировать схему на подобные ограничения.

Результаты и выводы

В итоге сама миграция оказалась менее длительной, чем можно было ожидать. Самые «тяжелые» сервисы переливали снимок фоном за пару часов, а самые легкие укладывались в несколько минут.

На начало 2026 года я могу констатировать, что миграция 10+ микросервисов разной сложности прошла успешно. Выбранный подход себя оправдал: по ходу процесса он донастраивался, улучшался и в итоге превратился в воспроизводимую схему. Экспертиза не осталась точечной — команда в целом понимала, что и зачем делается, а процесс удалось тиражировать от сервиса к сервису с учетом особенностей и накопленных исторических нюансов. Значительную роль сыграла найденная «золотая середина» между удобством для бизнеса и допустимым уровнем технического усложнения.

Когда-то мне сказали, что главное правило любой технической статьи — чтобы это была интересная и поучительная история. Хочется верить, что получилось именно так!

Полезные ссылки

https://habr.com/ru/companies/yandex/articles/801415/

https://habr.com/ru/companies/postgrespro/articles/676792/ 

https://github.com/debezium/debezium

https://debezium.io/documentation/reference/3.4/configuration/avro.html

https://debezium.io/blog/2022/09/30/debezium-oracle-series-part-1/

https://debezium.io/documentation/reference/stable/architecture.html

https://debezium.io/documentation/reference/stable/connectors/oracle.html

https://debezium.io/documentation/reference/stable/connectors/jdbc.html

https://habr.com/ru/companies/yoomoney/articles/326998/

https://habr.com/ru/companies/magnit/articles/938164/

https://habr.com/ru/companies/sberbank/articles/967240/

https://habr.com/ru/companies/ibs/articles/822545/

ссылка на оригинал статьи https://habr.com/ru/articles/1025336/