Задача звучала как задача из учебника: перенести кредитный домен банка с 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_fraction
FROM   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: работает, потому что '' = NULL
WHERE comment IS NULL          -- находит и NULL, и ''

-- PostgreSQL: находит только NULL
WHERE comment IS NULL
-- а '' пролетает мимо, и запись "без комментария" внезапно имеет комментарий

После initial load в PostgreSQL пустых строк тоже нет — они пришли как NULL. Но потом приложение начинает писать '', и данные расслаиваются: часть «пустых» значений — NULL, часть — '', и ни один запрос не видит их вместе.

Что сделали:

  1. На уровне схемы — CHECK (col <> '') на все nullable-строки, где семантика «пусто = отсутствует». Пусть приложение падает на записи, а не тихо расслаивает данные.

  2. На уровне приложения — прошли грепом по примерно 300 местам с IS NULL / NVL на строковых колонках и заменили на NULLIF(col, '') IS NULL там, где '' мог прийти извне.

  3. На уровне 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 counters
SET    value = value + 1
WHERE  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

Где ломалось

WHERE ROWNUM <= 10

LIMIT 10

ROWNUM применяется до ORDER BY. Запрос «топ-10 по дате» в Oracle с ROWNUM возвращал 10 случайных строк, отсортированных. После миграции LIMIT вернул честный топ-10, и отчёт «изменился». Это был баг в Oracle-версии, но клиенты-то привыкли.

CONNECT BY PRIOR

WITH RECURSIVE

Иерархия кредитных продуктов и тарифных планов. Рекурсивный CTE без защиты от циклов ушёл в бесконечность на данных с битой ссылкой, которую Oracle тихо обрабатывал через NOCYCLE.

a.id = b.id(+)

LEFT JOIN

Старый синтаксис outer join. В смешанных запросах с 5+ таблицами направление (+) легко перепутать. Пара запросов после конвертации стали INNER JOIN и потеряли строки.

MERGE INTO

INSERT ... ON CONFLICT / MERGE (PG15+)

На PostgreSQL 14 PG ещё не было MERGE, переписывали на ON CONFLICT, который требует уникальный индекс — а его не везде было.

NVL, DECODE

COALESCE, CASE

Тривиально, но DECODE сравнивает NULL = NULL как истину, CASE — нет.

SYSDATE

now() / clock_timestamp()

now() фиксируется на начало транзакции. В длинных батчах, которые пишут «время обработки», все строки получили одинаковый timestamp.

Последний пункт стоит запомнить: 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 сторона, аналог пишется для Oracle
SELECT (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 h
FROM   loans
GROUP  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 через несколько недель работы означал бы потерю данных в любом случае. Страховка была на случай «всё сломалось в первые дни», не дольше.

Даунтайма для клиентов не было. Для команды — шесть ночей.

Чеклист, который я бы хотел получить до начала

  1. Профилируйте каждый NUMBER и VARCHAR2 до выбора типа. Не доверяйте автоконвертеру.

  2. '' vs NULL — это не проблема данных, это проблема кода. Ищите в приложении и PL/SQL, а не в базе.

  3. setval после загрузки. На все сиквенсы. Проверьте скриптом, не руками.

  4. Gapless-номера — отдельная задача, не сиквенс.

  5. ROWNUM без ORDER BY в подзапросе — это баг в исходной системе, который вы сейчас «исправите» и получите жалобы.

  6. now() — время начала транзакции.

  7. CDC: ключ партиции = PK, полный снапшот строки в событии, ON CONFLICT везде, план на DDL.

  8. Валидация: хэши по диапазонам + бизнес-инварианты + shadow reads. COUNT(*) — только для самоуспокоения.

  9. ANALYZE после загрузки, join_collapse_limit под ваши запросы, random_page_cost под ваши диски.

  10. PgBouncer с первого дня. Не «потом добавим».

  11. Батчи с коммитами, fillfactor, автовакуум на горячих таблицах — до первого ночного пересчёта, а не после.

  12. Shadow reads дороже всего и окупаются больше всего.

Если у вас была похожая миграция и проблемы не совпали — расскажите в комментариях, какие были ваши. Список явно не полный.