Задача звучала как задача из учебника: перенести кредитный домен банка с Oracle на PostgreSQL. 70+ таблиц, чуть больше терабайта данных, 500–3000 RPS на чтение и 50–300 на запись в пике. Одно ограничение: система обслуживает клиентов и не может остановиться. Ни на ночь, ни на выходные, ни «на 15 минут на переключение».
Эта статья — не туториал «как мигрировать за 10 шагов». Таких на Хабре десятки, и половина из них сводится к «запустите ora2pg». Здесь — список мест, где у нас всё ломалось, в порядке от «это все знают, но всё равно наступают» до «об этом мы узнали за неделю до переключения».
Если вы планируете такую миграцию, читайте как чеклист. Если уже прошли — сверьте, сколько совпало.
Дисклеймер: проект под NDA, поэтому названия, точные объёмы и часть деталей изменены. Порядок величин и сами проблемы — настоящие.
Почему не big bang
Классический сценарий миграции: остановить запись, выгрузить, загрузить, переключить. На терабайте это часы даже с идеальным пайплайном, а с валидацией — сутки. Для кредитного домена это означает: не работают начисления, не проходят платежи, не открываются счета. Вариант отпал сразу.
Вместо этого мы пошли по фазам:
┌──────────┐ initial load ┌──────────────┐ │ Oracle │ ──────────────────► │ PostgreSQL │ │ (source) │ │ (target) │ └────┬─────┘ └──────▲───────┘ │ CDC │ ▼ │ ┌──────────┐ change events ┌──────┴───────┐ │ CDC │ ──────────────────► │ Kafka │ │ connector│ │ consumers │ └──────────┘ └──────────────┘ Фазы: 1. Схема + initial load (снапшот на момент T0) 2. CDC-догон: всё, что изменилось после T0, летит через Kafka 3. Shadow reads: читаем из обеих, сравниваем, пишем расхождения 4. Переключение чтения по доменам (справочники → расписания → транзакции) 5. Переключение записи 6. Oracle в read-only, потом decommission
Kafka здесь не для красоты. CDC-события с Oracle (Debezium с LogMiner-коннектором; GoldenGate отпал из-за лицензии, свой поллер по журнальным таблицам — из-за нагрузки на источник) складывались в топики по таблицам, консьюмеры применяли их к PostgreSQL. Это дало две вещи: лаг репликации, который мы видели в метриках, и возможность остановить/переиграть поток при ошибке, не трогая источник.
Проблема 1. NUMBER без точности — это не bigint
В Oracle NUMBER без (p, s) — это число произвольной точности, до 38 знаков, с плавающей десятичной точкой. В нашей схеме таких колонок было около 60, и в них лежало всё: идентификаторы, суммы, проценты, флаги 0/1.
Ora2pg по умолчанию превращает голый NUMBER в numeric. Это корректно и медленно: numeric в PostgreSQL — не нативный тип, арифметика на нём в разы дороже, чем на bigint, а индекс по numeric занимает больше места. Для ID это неприемлемо.
Пришлось профилировать каждую колонку:
-- Oracle: что реально лежит в колонкеSELECT MAX(LENGTH(TRUNC(col))) AS int_digits, MAX(LENGTH(col - TRUNC(col))) - 2 AS frac_digits, COUNT(*) FILTER (WHERE col != TRUNC(col)) AS has_fractionFROM schema.table;
Правила, к которым пришли:
-
целые без дробной части, до 18 знаков →
bigint -
NUMBER(1)с значениями 0/1 →boolean(и это отдельная боль в PL/SQL, см. проблема 5) -
суммы и ставки →
numeric(19, 4)илиnumeric(10, 6), точность фиксируем явно -
всё, что «мы не уверены» →
numericбез ограничения, с пометкой на ревью
DATE в Oracle содержит время до секунды. Это знают все, и всё равно у нас нашлась таблица, где DATE мапнули в date, потеряли время, и график платежей начал сортироваться неправильно в пределах дня. Правило: Oracle DATE → PostgreSQL timestamp(0), всегда, потом сужаем осознанно.
VARCHAR2(100) в Oracle — это по умолчанию 100 байт, а не символов, если NLS_LENGTH_SEMANTICS = BYTE. Кириллица в UTF-8 — два байта на символ. При переносе в varchar(100) PostgreSQL (символы) данные влезут, но приложение, которое рассчитывало на ограничение в байтах, получит другой лимит. У нас это выстрелило на поле даты последнего контакта с клиентом, которое приезжало из CRM в виде строки.
Проблема 2. Пустая строка — это NULL. Или нет
Самое известное отличие Oracle и самая недооценённая проблема. В Oracle '' и NULL — одно и то же. В PostgreSQL — нет.
Пока данные лежат в Oracle, разница невидима: там просто нет пустых строк, они все NULL. Проблема — в коде приложения и в PL/SQL, которые за 15 лет привыкли к этому:
-- Oracle: работает, потому что '' = NULLWHERE comment IS NULL -- находит и NULL, и ''-- PostgreSQL: находит только NULLWHERE comment IS NULL-- а '' пролетает мимо, и запись "без комментария" внезапно имеет комментарий
После initial load в PostgreSQL пустых строк тоже нет — они пришли как NULL. Но потом приложение начинает писать '', и данные расслаиваются: часть «пустых» значений — NULL, часть — '', и ни один запрос не видит их вместе.
Что сделали:
-
На уровне схемы —
CHECK (col <> '')на все nullable-строки, где семантика «пусто = отсутствует». Пусть приложение падает на записи, а не тихо расслаивает данные. -
На уровне приложения — прошли грепом по примерно 300 местам с
IS NULL/NVLна строковых колонках и заменили наNULLIF(col, '') IS NULLтам, где''мог прийти извне. -
На уровне CDC — консьюмер нормализовал
''→NULLдля колонок из списка. Это костыль, но он ловил то, что пропустили в п. 2.
Отдельный сюрприз: NOT NULL-колонки, в которые Oracle-код писал '' и получал ошибку, а PostgreSQL — принимал. Проверка, которая была в базе, исчезла. Пришлось добавить CHECK явно.
Проблема 3. Sequences: кэш, дыры и номера кредитных договоров
Oracle-сиквенсы создаются с CACHE 20 по умолчанию, и на каждом рестарте инстанса кэш теряется — дыры в номерах. В кредитном домене есть сущности, где дыры недопустимы по регламенту: номера кредитных договоров и платёжных поручений. В Oracle это решалось отдельной таблицей счётчиков с блокировкой строки.
В PostgreSQL:
-
обычные ID →
bigint GENERATED BY DEFAULT AS IDENTITY. Неserial— identity стандартнее и чище в дампах. -
после initial load — обязательно
setvalнаMAX(id) + 1для каждой таблицы. Мы забыли для двух из семидесяти и словилиduplicate keyна первой же записи через CDC. -
gapless-счётчики → таблица счётчиков с
UPDATE ... RETURNINGв той же транзакции. Это сериализует вставки, но для нескольких десятков договоров в минуту это не проблема.
-- gapless-номер в одной транзакцииUPDATE countersSET value = value + 1WHERE name = 'contract_number'RETURNING value;
Нюанс, который стоил нам двух дней разбирательств: пока идёт фаза dual-write, ID генерируются в Oracle и приезжают через CDC. Значит, identity в PostgreSQL должен быть BY DEFAULT, а не ALWAYS, иначе INSERT с явным ID упадёт. После переключения записи — можно ужесточить.
Проблема 4. ROWNUM, CONNECT BY, (+) и другие идиомы
Автоматические конвертеры справляются с 80% SQL. Оставшиеся 20% — это то, ради чего вам платят.
|
Oracle |
PostgreSQL |
Где ломалось |
|---|---|---|
|
|
|
|
|
|
|
Иерархия кредитных продуктов и тарифных планов. Рекурсивный CTE без защиты от циклов ушёл в бесконечность на данных с битой ссылкой, которую Oracle тихо обрабатывал через |
|
|
Старый синтаксис outer join. В смешанных запросах с 5+ таблицами направление |
|
|
|
|
На |
|
|
|
Тривиально, но |
|
|
|
|
Последний пункт стоит запомнить: now() в PostgreSQL — это время начала транзакции, не текущее время. Для аудита и логов в батчах нужен clock_timestamp().
Проблема 5. PL/SQL → PL/pgSQL: пакеты и автономные транзакции
В схеме было 23 пакета PL/SQL общим объёмом около 40 тысяч строк. Ежедневный пересчёт процентов, графиков платежей и резервов под просрочку жил именно там.
Три вещи, у которых нет прямого аналога:
Пакеты. В PostgreSQL нет пакетов с состоянием. Package-level переменные, которые живут всю сессию, стали либо временными таблицами, либо set_config / current_setting с кастомными GUC. Второе быстрее, но ограничено строками.
Автономные транзакции. PRAGMA AUTONOMOUS_TRANSACTION — классика для логирования ошибок «мимо» основной транзакции. В PostgreSQL этого нет. Варианты: dblink на самого себя (работает, но это отдельное соединение на каждый вызов), или вынести логирование в приложение. Мы выбрали второе: логирование ошибок ушло в приложение, в PL/pgSQL остались только чистые расчёты без побочных эффектов.
Boolean. Если вы мапнули NUMBER(1) в boolean (проблема 1), то весь PL/SQL с IF flag = 1 THEN перестаёт компилироваться. Это не сложно, но объёмно.
И общий совет: не переносите бизнес-логику из PL/SQL в PL/pgSQL один-в-один, если есть возможность вынести её в приложение. Мы перенесли примерно 60% в сервисный слой и не пожалели: это тестируется, версионируется и деплоится без миграций.
Проблема 6. CDC через Kafka: порядок, идемпотентность и DDL
Пайплайн синхронизации выглядит просто, пока не начинает работать с реальной нагрузкой.
Порядок. События по одной строке обязаны применяться в порядке возникновения. Ключ партиционирования Kafka — первичный ключ таблицы. Но у 11 таблиц был составной ключ, а у двух — не было первичного ключа вообще (да, в банковской системе, 2010 года рождения). Для них ключом стал суррогатный bigint, добавленный в Oracle за месяц до старта, с бэкфиллом по ROWID.
Идемпотентность. Консьюмер может получить событие дважды. INSERT должен быть ON CONFLICT DO UPDATE, DELETE — не падать на отсутствующей строке, UPDATE — быть полным снапшотом строки, а не дельтой. Последнее важно: если CDC отдаёт только изменённые колонки, а событие применилось не по порядку, получаете смесь старой и новой версий.
DDL. За четыре месяца фазы CDC в Oracle приехало 17 изменений схемы от параллельных релизов. Каждое — ручная остановка консьюмера, миграция схемы в PostgreSQL, перезапуск. Мы не автоматизировали это и, оглядываясь назад, зря: один раз из-за добавленной NOT NULL-колонки без дефолта консьюмер упал ночью, и к утру отставание было шесть часов.
Лаг. Целевой лаг репликации — секунды. Метрика в Grafana: max(event_ts) - now() по каждому топику. Алерт на 30 секунд. В пике батчевого пересчёта в Oracle лаг вырастал до 20–25 минут, потому что консьюмер применял изменения построчно. Решение — батчевое применение: копим 1000 событий или 500 мс, применяем одним INSERT ... ON CONFLICT через unnest.
Проблема 7. Валидация: COUNT(*) — это не проверка
После initial load и догона по CDC хочется сравнить COUNT(*) и выдохнуть. Мы так и сделали. Потом нашли около 200 расхождений в данных, которые не влияли на количество строк.
Что реально ловит расхождения:
Хэш по строкам. Для каждой таблицы — md5 от конкатенации всех колонок в каноническом формате, сгруппированный по диапазонам PK. Сравниваем агрегированный хэш по 10 000 строк с каждой стороны, при расхождении спускаемся внутрь диапазона. Канонический формат — самое сложное: даты в ISO, числа без trailing zeros, NULL и '' как одно значение (проблема 2 снова).
-- PostgreSQL сторона, аналог пишется для OracleSELECT (id / 10000) AS bucket, md5(string_agg( coalesce(id::text, '') || '|' || coalesce(amount::text, '') || '|' || coalesce(to_char(created_at, 'YYYY-MM-DD HH24:MI:SS'), ''), ',' ORDER BY id )) AS hFROM loansGROUP BY 1;
Бизнес-инварианты. Сумма остатков по всем кредитам. Сумма платежей за день. Количество активных договоров по продуктам. Это дешёвые запросы, и они ловят системные ошибки (потерянная партиция, сломанный маппинг колонки), которые хэши покажут только после долгого сравнения.
Shadow reads. Самое дорогое и самое полезное. Приложение читает из обеих баз, отдаёт клиенту ответ из Oracle, а расхождение с PostgreSQL пишет в лог. За три недели shadow-режима поймали: разницу в округлении numeric при делении (Oracle округляет до 38 знаков, PostgreSQL — по-другому), разный порядок строк без ORDER BY (никогда не полагайтесь на неявный порядок, но код 2010 года полагался), и таймзоны в поле планового времени платежа: Oracle хранил его в локальной зоне, а приложение считало его UTC.
Проблема 8. Производительность: Oracle прощал то, что PostgreSQL — нет
Схема была нормализована до третьей формы человеком, который любил своё дело. Типичный запрос на карточку кредита — 12–15 join’ов. Oracle с его оптимизатором и хинтами, которыми обвешали запросы за 15 лет, вытягивал это в 40–60 мс. PostgreSQL на тех же запросах после переноса выдавал 300–500 мс, местами больше секунды.
Что подкрутили, в порядке эффекта:
Статистика и планировщик. default_statistics_target с 100 до 500 на ключевых таблицах, ANALYZE после initial load (без этого планировщик считает, что таблицы пустые), random_page_cost = 1.1 для SSD вместо дефолтных 4. Одно это дало примерно 30–40% на самых тяжёлых запросах.
join_collapse_limit. По умолчанию 8: если в запросе больше 8 таблиц, PostgreSQL перестаёт искать оптимальный порядок join’ов и берёт порядок из текста запроса. Для запросов с 12+ join’ами подняли до 12–16. Планирование стало дороже, но выполнение — заметно быстрее. Для самых тяжёлых запросов порядок join’ов зафиксировали вручную и оставили лимит низким.
Индексы. Oracle-индексы перенесли один-в-один, потом половину переделали. Добавили partial-индексы на статусные поля (WHERE status = 'ACTIVE' — 5% таблицы), covering-индексы с INCLUDE для запросов, которые читают 2–3 колонки, и убрали около 20 индексов, которые в Oracle были нужны для его специфики, а в PostgreSQL просто замедляли запись.
Партиционирование. Таблицы транзакций и графиков платежей — по месяцам, декларативное партиционирование. Это дало partition pruning на запросах с датой и, что важнее, возможность держать горячие партиции в shared_buffers.
Пул соединений. Oracle спокойно держит тысячи сессий. PostgreSQL — процесс на соединение, и 2000 коннектов от приложения его убьют. PgBouncer в transaction mode перед базой, max_connections = 200. Побочный эффект: prepared statements в transaction mode не работают без pgbouncer 1.21+ с поддержкой протокольных prepared statements; на старой версии пришлось временно отключать prepared в драйвере.
Итоговые цифры: 500–3000 RPS на чтение, 50–300 на запись, p95 для пользовательских операций < 200 мс. Oracle на том же железе давал сравнимые p95 на чтении и заметно худшие на батчевой записи из-за старого стораджа.
Проблема 9. MVCC: bloat, автовакуум и батчи на миллионы строк
Ежедневный пересчёт процентов и резервов — это UPDATE на миллионы строк. В Oracle это сколько-то undo и всё. В PostgreSQL каждый UPDATE — это новая версия строки, старая остаётся до вакуума. Миллионы обновлений за ночь — таблица распухает вдвое, индексы тоже, и утром запросы, которые вчера летали, начинают читать в два раза больше страниц.
Что сделали:
-
батчи по 10–50 тысяч строк с коммитом, не одна транзакция на всё. Длинная транзакция ещё и блокирует вакуум для всей базы;
-
fillfactor = 80на таблицах с частымUPDATE, чтобы работал HOT-update и не трогались индексы; -
агрессивный автовакуум на горячих таблицах:
autovacuum_vacuum_scale_factor = 0.02вместо дефолтных 0.2, иначе на таблице в 100M строк вакуум запускается после 20M мёртвых версий; -
для расчётов, которые меняют большую долю таблицы — вместо
UPDATEпишем в новую таблицу и делаемALTER TABLE ... RENAME. Дороже по месту, но без bloat; -
COPYдля загрузки, а неINSERT. Первый прогон ночной загрузки черезINSERTзанял около 12 часов и не уложился в окно, черезCOPY— чуть больше часа.
И алерт на idle in transaction дольше минуты. Одно зависшее соединение из пула с открытой транзакцией — и автовакуум не может почистить ничего, что было изменено после неё.
Переключение
Самое страшное оказалось самым скучным, потому что к нему готовились дольше всего.
Переключали чтение по доменам: сначала справочники (низкий риск, легко откатить), потом графики платежей, потом транзакции. Каждый домен — неделя в shadow-режиме, потом переключение фича-флагом, откат — тот же флаг.
Запись переключали тоже по доменам, тем же путём: справочники, графики, транзакции в ночное окно с воскресенья на понедельник, когда нет клиринга. Порядок: остановить запись в приложении на 10–15 секунд → дождаться нулевого лага CDC → переключить флаг записи → запустить. Oracle остался в read-only на полтора месяца как страховка. CDC в обратную сторону не делали: это ещё один пайплайн, который тоже надо валидировать, а откат на Oracle через несколько недель работы означал бы потерю данных в любом случае. Страховка была на случай «всё сломалось в первые дни», не дольше.
Даунтайма для клиентов не было. Для команды — шесть ночей.
Чеклист, который я бы хотел получить до начала
-
Профилируйте каждый
NUMBERиVARCHAR2до выбора типа. Не доверяйте автоконвертеру. -
''vsNULL— это не проблема данных, это проблема кода. Ищите в приложении и PL/SQL, а не в базе. -
setvalпосле загрузки. На все сиквенсы. Проверьте скриптом, не руками. -
Gapless-номера — отдельная задача, не сиквенс.
-
ROWNUMбезORDER BYв подзапросе — это баг в исходной системе, который вы сейчас «исправите» и получите жалобы. -
now()— время начала транзакции. -
CDC: ключ партиции = PK, полный снапшот строки в событии,
ON CONFLICTвезде, план на DDL. -
Валидация: хэши по диапазонам + бизнес-инварианты + shadow reads.
COUNT(*)— только для самоуспокоения. -
ANALYZEпосле загрузки,join_collapse_limitпод ваши запросы,random_page_costпод ваши диски. -
PgBouncer с первого дня. Не «потом добавим».
-
Батчи с коммитами,
fillfactor, автовакуум на горячих таблицах — до первого ночного пересчёта, а не после. -
Shadow reads дороже всего и окупаются больше всего.
Если у вас была похожая миграция и проблемы не совпали — расскажите в комментариях, какие были ваши. Список явно не полный.
ссылка на оригинал статьи https://habr.com/ru/articles/1074552/