Всем привет! Сегодня расскажем о сравнительно новой для нас теме — про перевод приложения с Oracle на Postgres Pro (далее в тексте везде сокращу до PG). В общем смысле тема не столь уж нова — многие компании этим также занимаются или даже уже прошли этот путь. Так, например, на ежегодной конференции pgConf всегда есть несколько интересных докладов по этой теме (https://pgconf.ru/). Если говорить о формальностях, то мы реализуем инициативу согласно (Приказ Министерства связи «Об утверждении плана по импортозамещению программного обеспечения» от 01.02.2015 № 96). По факту — ещё и денег экономим, слезая с "лицензионной иглы". На эту тему можно отдельную статью написать, а в этой речь пойдёт о программной стороне вопроса. Кому интересно, добро пожаловать под кат. Предвосхищая хейтеров, которые могут сказать, что, мол, PG был придуман (там-то и там-то, а конкретнее в Беркли) и вообще является open-source, отмечу, что согласен с ними. Однако в мире современного ПО такая практика сегодня более чем стандартна: берём хороший опенсорс, добавляем уникальные фичи, продаем как свой продукт, в дополнение оказывая (и/или продавая) техподдержку. Собственно, ребята из PostgresPro именно этим и занимаются (не забывая также контрибьютить в родительский проект). Формально же для нас также важно то, что ПО входит в перечень российского ПО (т.е. на законодательном уровне). Есть хорошая статья, где обзорно показано текущее состояние СУБД PG и подсвечены интересные моменты, если еще не читали — обязательно посмотрите: https://habr.com/ru/company/tensor/blog/553754/ Не буду заострять внимание на бизнес смысл, погружусь в техническую составляющую.
На старте у нас есть: приложение, которое: Обозначим основные «ценности» миграции. Они примерно таковы: Настало время поговорить о подходах. Будем честны, опыта такой миграции в нашей команде еще не было. Зато появилась хорошая экспертиза на PG, так как последние проекты разрабатываем на нем. Поэтому для начала надо оглядеться, как люди это делают, почитать, поспрашивать. Здесь нам повезло, как раз не так давно мы были на pgConf, на котором было несколько очень качественных докладов на тему миграции. Вот они, например, от 2020 года — доступны всем желающим: https://pgconf.ru/2020/talks-and-tutorials. Если не смотрели, начните например, с Анатолия Афиногенова (РЖД) — просто, доступно и познавательно. Но там и другие очень интересные. В процессе гугления интернета был найден очень интересный фолиант — Oracle to Azure Database for PostgreSQL Migration Guide. Пусть вас слово «Azure» не отпугнет, там все по делу и все про PostgreSQL. Очень подробно, 313 страниц на английском языке.  Технически же пойдем таким путем: Все максимально просто, как казалось изначально: Приложение у нас, как я называю «backend-центрированное», что означает: Раз уж существует инструмент ora2pg, то им и попробуем воспользоваться. Написан он, похоже, аж на Perl’е, что впрочем его не портит. Были какие-то мелкие заморочки с его установкой, но в целом он вполне работает.  Было решено с его помощью преобразовать только таблицы, представления же создать руками. Пакетов и процедур — минимум, их тоже руками. В целом инструмент отработал вполне достойно, выдав рабочий скрипт создания таблиц. Из минусов — все первичные ключи он мне сделал как numeric(22), пришлось переделывать на int8 (он же bigint, примерно он же bigserial).  И вот здесь возникает первая большая сложность. Стоит крайне пристально обратить внимание на типы полей. Лучше даже вообще составить таблицу по типам «было» — «стало» и ее придерживаться. Основные проблемы возникают с number — float — numeric. Также надо запомнить разницу в датах — в Oracle в дате есть время, в pg нет. Доступно про типы описано здесь. Важный организационный момент — всем объектам устроили ревизию, т. е. как минимум все таблицы и представления было решено снабдить комментарием, что это и зачем. Если комментария нет, то значит, что мы не знаем, что это за объект и как используется. Эта работа была сделана командой линейного развития проекта. Таблицы создали, надо наполнять их данными. Напомню, система живая, каждый день в ней появляются данные, основных потоков три — интеграционный, расчетный и пользовательский ввод. Здесь посчитали важным реализовать репликацию ora → pg, чтобы в каждый момент времени БД pg была максимально близка к БД ora (максимально — значит каждый день). Так родился модуль репликации. Вообще говоря, все достаточно неплохо работает и из коробки, но некоторые параметры все-таки стоит «подкрутить».  Коллеги посоветовали ссылку на такой вот симпатичный настройщик параметров:
https://pgtune.leopard.in.ua/#/ (PGTune — calculate configuration for PostgreSQL based on the maximum performance for a given hardware configuration). Будем пробовать, экспериментировать. В Oracle у нас было два сервера БД (отказоустойчивый кластер), причем в этой СУБД такой режим работы доступен «из коробки». PG в этом плане, с одной стороны встроенных таких средств не имеет, с другой – более свободен в выборе имеющихся решений, написанных для него. По настройке кластера PG мы обратились за компетенциями к одной из наших команд, подключив их к проекту. В итоге родилось следующее решение (кластер + решение по архивированию):  Итак, решили делать модуль репликации. Есть разные подходы и решения, например, SymmetricDS. Но мы им еще не пользовались, а решение хотелось проверенное и то, которое нас точно приведет к успеху. И в этот раз решили сделать его руками. Чтобы точно знать, как он работает и при необходимости дорабатывать его. В итоге он получился простым, но стабильным (также думаем опубликовать – вдруг кому-нибудь еще поможет). 
Метаданные модуля репликации Для начала о физическом соединении двух разных БД. По опыту работы мы больше «топим» за решения с использованием ETL инструментов, которых много (и многие мы знаем и умеем) — Apache Airflow, NiFi, Talend, DataStage, Informatica. Но в данном случае, так как репликация в проекте носит временный характер — решили делать максимально просто, через расширение Oracle FDW. Это расширение позволяет напрямую из БД pg соединяться с БД ora. Что ж, начнем пробовать. В стандартной поставке pg этого расширения нет, будем ставить дополнительно.
Дополнительно — это «по-взрослому», компилируя из исходников, все как принято в мире linux. Вот только досада — не поставилось. Немного поковыряв (на это ушел день) исходники расширения, установить все же получились, но как говорится «осадок остался», поэтому я взял и создал тикет в гитхабе автору расширения. Не сильно однако, веря в успех, так как это звучало как «помогите скомпилировать расширение на российской PostgresPro, на российской же операционке RedOS» (как казалось, вряд ли они все это видели), однако же разработчик отреагировал молниеносно и за день выпустил новую версию, которая устанавливалась без проблем. Я так и не понял, что это было — то ли мы побежали впереди паровоза и Oracle FDW на 13м pg еще не собирали, то ли … Но работает, и хорошо (как оказалось отлично работает).
Репликатор. Решили так — запускать каждый день ночью, реплицируем двумя принципами: На помощь приходит расширение pgCron. Функциональность полностью нас устроила, единственные проблемы в настройке были связаны с именованием хоста сервера в параметрах старта джобов (то ли localhost ему не понравилось, то ли наоборот явное указание dns-имени — но поэкспериментировав, все получилось). Вот мы и добрались до доработки приложений. Для начала давайте разберемся, где в приложениях мира java / spring живут sql запросы. Проблемы
Куда без них.
Наверное, многих будет интересовать, какие проблемы придётся решать кроме тех, которые уже обозначены (чуть ранее про типы, про установку расширений).
Первой и самой очевидной стала проблема со Spring Boot 1.5. По каким то неведомым причинам он отказался подставлять имя схемы в запросы (которые сам генерирует, не native). А так как обращения у нас идут к разным схемам, это было критично. Гугл особо не помог, но одно решение рассмотрели — использовать кастомный класс NamingStrategy, в котором учесть эти нюансы. Решение бы сработало, но решили, что это костыль и решили переходить на Spring Boot 2.
Запуск приложений у нас оформлен как сервисы (systemctl), почему то под RHEL запуск приложения работает строкой «ExecStart=/path/to/spring_boot_based.jar», тогда как под RedOS заработало только если указать «ExecStart=/bin/bash /path/to/spring_boot_based.jar».
В pg есть свой «модный» преобразователь типов — двойное двоеточие, например 1.23::int. Но вот эти двоеточия не любит hibernate, который начинает думать, что это параметры запроса. Поэтому — только старый добрый cast(x as integer).
Также pg намного (очень намного) более строго относится к типам данных. И есть еще одна проблема, которая называется «null в параметрах процедур». Т.е. если у нас в Repository присутствует запрос «select * from some_function(parameters)», то при попытке ее вызова pg кроме названия проверяет перечень и типы переданных параметров (в pg функции могут быть перегруженными), после чего вызывает нужную. Null его ставит в тупик, так как он определяет его как unknown. Возможно, тут есть какой-то workaround, но сходу мы его не нашли.
Квотированные идентификаторы
SQL язык старый, раньше было «не модно» различать большие и маленькие буквы, так что он изначально родился регистронезависимым. Потом кто-то придумал, что было бы неплохо дать возможность называть колонки не техническими именами, а хоть как — хоть по-русски, хоть с пробелами. Вот так и возникли квотированные (т. е. в кавычках) идентификаторы. Сколько ж они привносят проблем ... Так вот, если идентификатор не квотирован, то он регистронезависим. Если квотирован, то должен писаться в точности.  В Oracle — по умолчанию все «воспринимается» большими буквами. В pg наоборот — маленькими. Что здесь было у нас — модуль отчетности (о нем ниже) был настроен на точное совпадение идентификаторов колонок, поэтому во всех запросах они были квотированы. Пришлось переделывать, учить модуль, убирать квотирование. Максимум проблем тут с использованием «зарезервированных» слов в названиях колонок (да, это плохо, но это было «на старте»). Например мы пишем table.some_field. Тут все хорошо. А если table.type? Тут плохо. Слово type зарезервировано и движок БД, например во view переделает это в table."TYPE" (или в table."type" ?). А это уже — квотированный идентификатор. Поэкспериментируйте, это интересно. Фактически, основное мерило качества интеграции — идентичность данных в отчетах. Скрипты отчетов у нас оказались сложными, этому будет посвящен весь следующий раздел. Здесь было принято следующее решение — дорабатываем максимум скриптов, которые возможно, в «универсальный вид», чтобы они запускались на обоих базах. Затем убеждаемся, что «новые запросы» выдают идентичный результат (равный старому) и на oracle и на pg. Выводим эти запросы на прод oracle — чтобы потом было легче при окончательной миграции.  А как мы будем сравнивать данные отчетов, полученных из двух баз данных? Формировать отчеты стандартным способом (Excel), после чего сравнивать данные отчетов другим каким-либо способом?… Можно, но сложновато. А если много итераций сверки — то еще более сложновато. Нашли способ проще — создали «слепки» отчетов на обоих сторонах (create table as select). Можно и не создавать, а делать представления. Перелили оракловую таблицу в PG через fdw (можно и не переливать, а делать «на ходу»). А дальше дело техники — select  from таблица1 except select  from таблица2. Должна получиться пустота, если множества одинаковы. Если будете этот трюк делать в Oracle — вместо except напишите minus.  Вот мы и добрались до самой главной боли. Вроде бы SQL — язык стандартизированный, не должен сильно зависеть от конкретной СУБД «и все такое». Но мы взрослые люди, понимаем, что это неправда. Обращаю внимание, что нижесказанное приводится через призму того, что мы хотим сделать универсальные запросы для двух БД (т. е. работающие и там и там). Начнем с простого. Правило, которые надо запомнить на всю жизнь: в Oracle null + some_text = some_text.  Практически во всех других базах = null. Oracle периодически за это троллят:
Так было в версии 7, заметьте фразу — "в следующей версии Oracle7": И, более чем 20 лет спустя, в релизе 19c: С этим связан любопытный трюк, как нам безошибочно определить в какой базе мы находимся, изначально не зная в какой (при условии установленного в PG расширения orafce): nvl — просто меняем на coalesce не думая, все работает.
decode — немного ворчим о многословности, но все же переделываем в case.
Чуть сложнее. sysdate. Помним, что у Oracle — время есть, в pg — нет. Поэтому в зависимости от контекста либо current_timestamp, либо current_date.
rownum. Очень любят приложения на spring хоть какой-нибудь ключ. Часто делают "ровнум" — так как это дешево и сердито. Чем может ответить pg? А вот Oracle не пустит вам over с пустыми скобками, так что нужно добавлять order by, при этом выбирать правильную сортировку, которая не приведет к тормозам запроса.
Будьте внимательны — row_number() over () (используемый для такой цели) внутри представления неминуемо приведет к тормозам. Например вам надо из большого представления выбрать небольшое количество записей по определенному условию. В этом случае PG сначала (внутри представления) пронумерует весь объем записей, а потом отдаст вам, по вашему условию. У rownum подобных тормозов нет. А если у нас в запросах «where rownum < X». Ответ pg: limit. Но в Oracle не сработает. Компромисс для обоих баз: fetch next X rows only. Минус компромисса — не будет работать в подзапросах (rownum работает, limit скорее всего тоже нет).
Update. Апдейт «не любит указание алиса при присвоении». Т.е. update some_table t set t.some_field = … Oracle нормально, PG нет, убираем алиасы. Связано это с тем, что скорее всего поле с алиасом воспринимается как вычисленное значение и его можно использовать в правой части, но при присваивании — PG настаивает на голом поле. Не забываем, что у PG есть свой синтаксис «update from join», следует всегда использовать его — иначе будут проблемы с производительностью.
Именованные подзапросы. PG не любит безымянные подзапросы, например select * from (select …). Нужно «алиасить» подзапрос. Но where x in (select …) вполне нормально, никакой алиас не нужен.
Group by 1,2,3. Это не на тему миграции, просто отмечу — это прекрасная фича PG, которой нет в Oracle. Некоторые базы тоже ее реализуют (firebird например). Это мегаудобно.
Форматирование числовых значений. Что у нас форматированием числовых значений? Оно есть, но оно разное и там, и там. Но нам по идее немного надо — вывести число с двумя знаками после запятой, причем если число целое, то вывести без знаков. Это у нас поле «пропускная способность», т. е. Мы ее хотим вывести, например «2 Мбит/с». Получилось вот так:  Разница только в том, что в Oracle разделитель — запятая, в PG — точка. Но в данном случае приемлемо (возможно, через nls это тоже можно дорешить).
Последовательности (sequence). 
А вот тут нас ждало разочарование. Синтаксис различный и к единому его привести не удалось. Ладно хоть не так часто встречается явный вызов сиквенсов.
Синтаксис  Интересный случай с Pageable
Инструмент Pageable используется у нас достаточно широко. Одно из применений было таким — даем на вход достаточно простой запрос (написанный на HQL), затем делаем обход этого запроса предварительно разбивая на "страницы", при этом обрабатываем каждую "страницу" нужным нам способом. "Под капотом" Pageable добавляет к запросу offset и limit, чтобы таким образом извлечь нужную страницу. Так вот — если в этой истории нет order by, то все будет плохо — страницы будут разрезаны неправильно. Понятно, что БД не виновата — она имеет право отдавать в любой последовательности, если не сказано "order by". Опять же в оракле — в данном случае проблем не было. Иными словами вывод: внимательность и еще раз внимательность. И — явный order by. Почему ваши данные (скорее всего) никогда не сойдутся
Несмотря на громкий заголовок, речь здесь пойдет о простой вещи — order by. Все запросы, в которых нет order by будут выполняться "как решил сервер БД", т.е. порядок сортировки в общем случае "не задан". К сожалению это влияет на все, где нет order by — вывод списков с данными на фронте, вывод данных в отчетах. Да, в таких случаях хорошо бы было всегда использовать order by, но как быть в ситуациях, когда так не было изначально? Отмечать эти особенности как "не ошибка" и комментировать различия пользователям.
Хуже с моментами, когда существует алгоритм "выбрать одну строку из таблицы по определенному ключу, при этом записи по этому ключу не уникальны". Да, здесь тоже правильнее использовать order by — например выбирать "максимально свежую" запись и т.д. (но запись должна быть так или иначе "детерменирована"). Опять же — а если этого не было и бралась "первая попавшаяся"? Это тоже нужно объяснять. Наши запросы пестрят сложными функциями работы с датами — это и извлечения номера дня в году из даты, и определение разницы между двумя датами. Изначально мы приуныли и подумали, что нужно написать просто аналоги оракловых функций, заменив все в запросах ими (так как синтаксис этих функций сильно различен в этих СУБД). А в оракле, если потребуется, сделать такие же «заглушки», вызывающие стандартные функции.  Даже начали это делать, но зашли в тупик. Посмотрите, например, как «изящно» в оракле работает функция months_between: Реализовывать такие функции самостоятельно — можно считать это отдельным проектом.
Вовремя попалось на глаза расширение orafce — «Oracle's compatibility functions and packages», «This module allows use a well known Oracle's functions and packages inside PostgreSQL». Можно даже сказать, что оно нас в какой то мере и спасло. Не обошлось без ложки дегтя — 100% совместимости все равно нет, мы же помним, что pg более строг в типизации? Какие-то из этих функций в Oracle принимают на вход не только дату, а еще и timestamp, в pg такая фишка не проходит. Тем не менее это расширение стало для нас must have. Оно даже табличку dual эмулирует.
Идем дальше, любопытное — greatest и least. Вляпались, читаем у PG: Очень понимаем, какие такие «некоторые» СУБД вы имели ввиду. Великий и ужасный Number Самый лучший тип в Oracle. Если не знаешь формат и тип числа — «лепи» всегда number. И точность сохранится и вообще все влезет.  А что нам может предложить pg? Pg может предложить numeric, в Oracle он тоже есть.
И number и numeric могут быть в трех вариантах:
numeric(x,y); numeric(x); numeric.
Почитать об этом можно в документации. Но, как пел когда-то Сергей Шнуров, есть один нюанс, он у нас в этот раз живет в PG: Вы то, может быть, и считаете, а нам то как быть? Т.е. numeric в PG ведет себя похожим образом number’у в Oracle, но сам numeric в Oracle — ведет себя по стандарту. Попробуйте select cast(1.2345 as numeric) from dual в разных базах, почувствуйте разницу.
Так что будем указывать явно, например numeric(38,8). Вот еще интересно. В одном подзапросе у нас был подсчет числа строк (count), в его головном запросе был расчет: Изначально запрос был без приведения в numeric(38,8) (т. е. «как есть») и отрабатывал по разному: Oracle делил одно на другое как вещественные числа, PG воспринимал их как целые.
Были еще интересные моменты (так до конца и не разобрались в физике процесса), когда в одном месте Oracle округлил 87.5 к 88, а в другом уже к 87.
Засада с Boolean
Очередная проблема, которая добавила трудозатрат. Логические поля в Oracle были заведены как NUMBER(1), в Яве/Котлине/Хибернейте объявлены как Boolean. При конвертации в PG мы сделали их int2, но не тут-то было. Если Oracle спокойно «кастит» true в number(1), то PG здесь куда как более щепетилен. Особенности — вот так работает: И так работает: А так не работает: Вывод: меняем в PG все на явный Boolean, переделываем таблицы, связи, репликации.
Очень спасает конструкция: Hibernate тоже добавляет странностей: Автоматически меняет false на 0. А если через параметр, то нормально: LISTAGG  Хорошая функция, когда нужно, допустим, вывести в поле значения текста через запятую. Неприятность заключалась в том, что в pg нет возможности сделать listagg distinct, т.е. выкинуть повторяющиеся значения, пришлось делать через подзапрос. Древовидные запросы  Да, были и они. В Oracle — синтаксис совершенно свой, ни на кого не похожий (но, на мой взгляд, очень удобный). Другие базы решают проблему через CTE (common table expression). Тем не менее, как оказалось, с использованием with recursive проблем тоже не возникло. Самый сложный кейс Один из запросов «в лоб» перевести не удалось. Дело в том, что у нас используется механизм версионирования записей hibernate envers. Механизм хороший, но, как мне кажется, для других задач. Чтобы получить версии на момент времени Х приходится «джойнить» все исторические таблицы с гигантской таблицей версий записей и получать ближайшую к определенной дате. Конечно, через оконные функции с rank. Дико тормозит, даже на Oracle. Вообще, если нужна такая функциональность, она проектируется сразу и немного по-другому (вспомните, например, дату хранения итогов в 1С — это примерно на эту же тему), видимо данный отчет родился не сразу, а в процессе эволюции проекта. Как любит PG. Вменяемой (по производительности) функциональности удалось добиться через череду временных таблиц, заполняя их последовательно, создавая индексы, обязательно запуская «analyze table».  Запрос в итоге превратился в запрос вида «select * from function()» (в Oracle это аналог pipelined функции). Любопытный кейс В одном из запросов была интересная просадка производительности. Вот так медленно: Если поменять на: То нормально.
Да, пробовали и с материализацией и без (важно — обязательно прочитайте тут). Обработка ошибок и транзакции Очень важная тема о нюансах обработки транзакций в Oracle и PG. Обязательно (и очень внимательно) прочитайте статью. Очень доступно и по делу. Двойной to_date  Это была явная ошибка, но значимого влияния не оказывала. Однако очень изящно. Попробуйте в Oracle (PG, понятно, не пропустит из-за более строгой типизации): У меня результат выдал тот же день и месяц, но «нулевой» год — 0020. Хотя, вероятно, зависит от nls. Запрос с максимумом
Пожалуй пока этот запрос в моем личном топе, я даже на собеседованиях его стал задавать. Отмечу, что в pg он просто не запустился, в Oracle работает, но вопрос — что же он все-таки выдает? Предлагайте варианты в комментариях.
(для невнимательных отмечу, что в подзапросе используется max() — для поля таблицы с алиасом HR, однако в секции FROM эта таблица без алиаса, а алиас — у первоначальной, базовой таблицы). Несколько ограничений ROWNUM
Встретился такой запрос: А как я уже говорил выше — FETCH ONLY в подзапросах не работает. Решение — а зачем здесь внутренние rownum? Разве изменится результат, если их убрать? Ограничение по периоду Многие массивы данных имеют месячную динамику (чаще всего счета, начисления и все что связано с ежемесячными платежами). Выбор значений за месяц:  Лишний подзапрос к dual, отказ от использования индекса по периоду. Меняем, приводим в нужный вид (where PERIOD >= … and < ...). Oracle всемогущий, что ж ты делаешь то? Запрос очень изящный. Мне одному кажется, что стандарты SQL здесь нарушаются? Нет, не одному, PG обычно со мной солидарен, в нем не работает. На самом деле Оракл посчитал просто запрос "без каунта", а сверху еще над результатом "каунт" навесил. Изящно, но надо переделать: Результат идентичен. Как, спросите вы, всё? Нет, пока далеко не всё, проект в самом разгаре, хотя большая часть разработки уже завершена. Вчера нам удалось запуститься почти полноценно – в пользовательском интерфейсе. Ошибок много, тестируем, анализируем, исправляем. Все вопросы решаются. Также остается большая работа по организации приемо-сдаточных испытаний, опытной эксплуатации, обновления документации – т.е. всех тех, так необходимых «кровавому энтерпрайзу» артефактов.  Обещаю дополнить статью, когда наконец то дойдем до финиша.