Миграция с Oracle на YDB: разбираем кейс и технические грабли
Сегодня мы хотим поделиться инсайтами из нашего сотрудничества с командой Yandex Database (YDB). Конвертум специализируется на миграциях данных и бизнес-логики между разными типами СУБД. В частности, наша цель — автоматизировать миграцию со старых добрых реляционных СУБД на современные облачные и распределенные базы данных. И YDB, как одна из ключевых технологий в этой области, стала для нас важным инструментом.
Недавно мы провели тестовую миграцию структуры базы данных с Oracle на YDB и отправили отчет коллегам из Яндекса на ревью. Их обратная связь оказалась настолько полезной, что мы решили упаковать этот опыт в статью.
Здесь не будет маркетинга, только технические детали, «грабли», на которые мы наступили, и элегантные решения, которые нашли вместе.
Почему миграция в распределённые базы — это не просто «копипаст схемы»
Реляционные СУБД вроде Oracle проектировались под монолитную модель: единый сервер, сильные гарантии ACID, плотная логика прямо в БД (constraints, триггеры, процедуры).
Распределённые базы (YDB, Spanner, CockroachDB) устроены иначе:
● Горизонтальное масштабирование важнее «богатых» встроенных возможностей.
● Нагрузку балансируют по кластерам, поэтому «глобальные блокировки» (например, на уникальность) могут стать узким местом.
● Часть бизнес-логики почти всегда выносится в приложение.
Поэтому миграция с Oracle на YDB — это скорее смена парадигмы, чем механическая конверсия DDL.
Где жила бизнес-логика в Oracle
В нашем тестовом примере мы работали с классической e-commerce моделью: заказы, клиенты, товары.
● Пакет ECOMMERCE_PKG отвечал за работу с заказами и скидками.
● CUSTOMER_ORDER_PKG — за логику по управлению заказами клиента.
● Внутри были процедуры вроде CALCULATE_ORDER_DISCOUNT_AND_UPDATE, которые одновременно считали скидку и обновляли заказ.
Oracle позволял это делать «прямо в базе»: курсоры, обновление записей по результату запроса в цикле, UPDATE … RETURNING и др. Всё это работало быстро и надёжно… пока бизнес-логика и база находились в одном месте.
Что изменилось в YDB
В схеме YDB мы оставили только таблицы и индексы.
● Никаких пакетов, процедур, триггеров — YDB это не поддерживает.
● База теперь отвечает только за хранение и транзакции.
● Вся бизнес-логика должна переехать в сервисный слой.
Многие, впрочем, видят в этом самостоятельную ценность: вынос логики из БД позволяет более гибко разрабатывать и масштабировать прикладной код. И вот тут начинается самое интересное.
Вынос логики в Java
При переносе бизнес логики в java каждая конвертированная сущность Oracle превратилась в сервис-класс, каждая PL/SQL-процедура превращается в Java-метод.
● EcommercePkg.java стал сервис-классом и контейнером методов для логики «оформление заказов».
● CalculateOrderDiscountAndUpdate.java — отдельный сервис под конкретную функцию.
● CustomerOrderPkg.java — сервис-класс для работы с заказами клиентов.
Каждый сервис имеет доступ к инфраструктурным объектам:
@Autowired
private Dao dao;
@Autowired
private SequenceManager sequenceManager;
@Autowired
private TransactionStatusService transactionStatusService;
Что это значит
● Dao — заменяет прямой SQL в PL/SQL. Это слой доступа к данным, через который выполняются все операции SELECT/INSERT/UPDATE/DELETE.
● SequenceManager — эмулятор Oracle-последовательностей (в YDB их нет в классическом виде). Сервис централизованно выдаёт новые ключи.
● TransactionStatusService — управление транзакциями, аналог COMMIT/ROLLBACK в PL/SQL, только на уровне Java-сервиса.
Таким образом, бизнес-логика теперь не завязана на БД напрямую, а зависит от сервисов, которые обеспечивают тот же функционал, что раньше был встроен в Oracle.
Разница в зависимости объектов
Раньше (в Oracle):
● В пакете мы могли напрямую вызвать update_order_item_prices или calculate_discount.
● Взаимозависимости были скрыты в PL/SQL-коде.
Теперь (в Java):
● Вместо «объектов базы» у нас есть сервисы и инфраструктурные компоненты.
● Вызовы между функциями одного пакета теперь превращаются в вызовы методов класса.
● Если функция вызывала процедуру из другого пакета, то теперь один сервис может инжектить другой через Spring (DI).
То есть, зависимости стали явными, управляемыми через контейнер (Spring). Это облегчает поддержку, тестирование и замену отдельных кусков логики.
Пример:
Исходный оракл метод из пакета ECOMMERCE_PKG:
PROCEDURE create_order(p_customer_id NUMBER, p_status VARCHAR2, p_order_id OUT NUMBER) IS
BEGIN
INSERT INTO orders (order_id, customer_id, order_date, total_amount, status)
VALUES (orders_seq.NEXTVAL, p_customer_id, SYSDATE, 0, p_status)
RETURNING order_id INTO p_order_id; -- Return generated order_id into OUT parameter
COMMIT; -- Commit transaction
END create_order;
Сконвертивроанный Java метод:
// Procedure to create a new order
@TransactionStatus
@Override
@OutParameters
public Map createOrder(BigDecimal pCustomerId, String pStatus,
@Out BigDecimal pOrderId) {
Map resMap;
Map outData = new HashMap<>();
try {
resMap = dao.updateInto(
"INSERT INTO `orders` (order_id, customer_id, order_date, total_amount, status) " +
"VALUES (:seq_param, :p_customer_id, CurrentUtcDate(), CAST(:L0 AS Decimal(10,2)), :p_status) RETURNING order_id", Arrays.asList(BigDecimal.class)
, "seq_param", sequenceManager.nextVal("orders_seq"), "p_customer_id", Converter.toInteger(pCustomerId) ,"L0",new BigDecimal("0"), "p_status", pStatus);
pOrderId = QueryUtility.castTo(resMap.get(0), BigDecimal.class);
// Return generated order_id into OUT parameter
transactionStatusService.getObject().commit(); // Commit transaction
return outData;
} finally {
outData.put("pOrderId", pOrderId);
}
}
Что здесь важно
● SequenceManager эмулирует Oracle NEXTVAL. В YDB нет встроенных последовательностей, поэтому отдельный сервис управляет таблицей счётчиков и безопасно выдаёт ID.
● Dao заменяет PL/SQL INSERT. Все SQL-запросы стали частью Java-кода.
● TransactionStatusService управляет жизненным циклом транзакции. Если в Oracle мы писали COMMIT или доверяли автокоммиту, то здесь транзакции контролируются сервисом явно.
Как это отражает идею выноса логики
- В Oracle всё было «спаяно» с БД — ID, транзакции, SQL и логика жили вместе.
- В Java мы развели ответственность:
○ генерация ID → отдельный сервис,
○ транзакции → отдельный сервис,
○ SQL → DAO,
○ бизнес-правила → сервисные классы.
Так мы получили чёткий сервисный слой, где каждая зависимость описана явно и управляется через Spring.
Теперь давайте посмотрим на детали.
Ограничения: Что в базу, а что в код?
Первый блок вопросов касался классических ограничений реляционных баз: внешние ключи (Foreign Key), проверки (CHECK) и значения по умолчанию (DEFAULT).
● Foreign Keys: В Oracle это стандартный инструмент контроля ссылочной целостности. Но в распределённой базе каждая проверка FK = потенциальный cross-shard запрос. Поэтому в YDB решили вынести проверки на сервисный уровень
● CHECK: Ограничения CHECK также были исключены из структуры БД и вынесены на уровень Java-кода, где проверка выполняется в момент вставки или обновления записи. Например, так мы проверяли, что количество товара находится в нужном диапазоне:
// Пример проверки в коде приложения
dao.update(
"INSERT INTO `order_items` (order_item_id, order_id, product_id, quantity, price) " +
"VALUES (:seq_param, :p_order_id, :p_product_id, :p_quantity, :v_price) ",
"seq_param", pOrderItemsSeq"),
"p_order_id", pOrderId,
"p_product_id", pProductId,
"p_quantity", ConstraintUtil.check(ComparisonUtil.between(pQuantity, 0, 10000), pQuantity),
"v_price", vPrice);
● DEFAULT: Значения по умолчанию тоже убрали из схемы. Вместо этого они явно проставляются в INSERT-запросах, как, например, CurrentUtcDate() для поля created_at.
INSERT INTO `orders` (order_id, created_at, status) VALUES (:id, CurrentUtcDate(), 'NEW');
Фидбэк от команды YDB: В целом, подход рабочий, но с важным нюансом по NOT NULL. Изначально мы жестко добавляли NOT NULL для полей, где раньше были внешние ключи. Коллеги справедливо заметили, что это ограничение должно наследоваться из исходной схемы — ведь ссылочная целостность не всегда является обязательной. Мы согласились и внесли правки.
Уникальность и последовательности: Как не выстрелить себе в ногу
Здесь нас ждали самые интересные открытия. Производительность в распределенной системе — вещь тонкая, и необдуманные решения могут создать потенциальные «бутылочные горлышки».
UNIQUE-ограничения
Наивное решение: проверять уникальность вручную перед вставкой.
SELECT COUNT(1) FROM customers WHERE email = :p_email;
Проблема: гонки и нагрузка.
Правильное решение в YDB — использовать глобальный уникальный индекс:
CREATE TABLE customers (
customer_id Int64 NOT NULL,
email Text,
PRIMARY KEY (customer_id),
INDEX idx_email GLOBAL UNIQUE SYNC ON (email)
);
Так база сама гарантирует уникальность без лишних round-trip’ов.
Sequences (генерация ID)
Наше первое решение: В Oracle все привыкли к CREATE SEQUENCE. Для эмуляции последовательностей в YDB мы создали специальную утилитарную таблицу util_sequence_t и Java-класс SequenceManager для управления ею. Таблица хранила текущее значение, инкремент и другую метаинформацию .
Фидбэк от команды YDB: Коллеги выразили опасения, что такая реализация может стать узким местом («бутылочным горлышком») для всей системы, так как все вставки, требующие нового ID, будут блокироваться на одной таблице.
В качестве альтернативы они предложили использовать встроенный в YDB тип данных
Serial. Это специальный тип для автоинкрементных первичных ключей, который работает эффективно в распределенной среде.
Наш контр-вопрос: А как быть с миграцией существующих данных? В документации мы не нашли способа указать стартовое значение для Serial-колонки. Если мы перенесем данные с их старыми ID, а потом начнем вставлять новые записи без указания ID, то новые сгенерированные значения могут начать конфликтовать с уже существующими.
В итоге, мы пока решили оставить наше решение с утилитарной таблицей для миграции существующих схем, но взяли Serial на заметку для сценариев создания таблиц с нуля.
OUT-параметры
Проблему OUT параметров решили через возврат структуры данных, которая может содержать несколько значений. Таким образом Java-метод возвращает все необходимые данные в одной коллекции, например, Map.
Ключи карты соответствуют именам «выходных» параметров, а значения — их текущим результатам. Такой подход сохраняет семантику OUT параметров, упрощает тестирование и повышает читаемость кода, позволяя удобно работать с несколькими результатами одновременно.
Пример:
public Map calculateOrderDiscount(BigDecimal orderId, BigDecimal discount, Boolean eligible) {
Map outData = new HashMap<>();
try {
discount = ...; // бизнес-логика
eligible = ...; // бизнес-логика
return outData;
} finally {
outData.put("discount", discount);
outData.put("eligible", eligible);
}
}
Дилемма: Map или DTO?
Здесь стоит упомянуть важный момент. Возврат нетипизированной Map — это компромисс. С точки зрения чистоты Java-кода, более правильным подходом считается создание отдельного класса-результата (DTO) с типизированными полями для каждого метода.
Однако в контексте автоматической миграции мы столкнулись с выбором:
- Генерировать DTO: Получаем строгую типизацию, но проект обрастает тысячами одноразовых классов (на каждую процедуру Oracle свой класс-результат), что усложняет навигацию и поддержку.
- Использовать Map: Жертвуем строгой типизацией возврата ради компактности кода и универсальности.
Мы выбрали второй вариант, чтобы избежать лавинообразного роста количества файлов. Но если вы пишете сервис с нуля или мигрируете небольшую схему вручную, мы рекомендуем использовать DTO.
А что с представлениями (Views)?
Наш первоначальный план был прост: по умолчанию отключать представления при миграции, заменяя их на соответствующие запросы в бизнес-логике.
Фидбэк от команды YDB: «Представления должны конвертироваться в представления. Готовы помочь с активацией соответствующей фичи». Это отличный пример поддержки со стороны вендора. Мы активировали эту возможность и теперь конвертируем Views напрямую.
Выводы
Миграция с классической СУБД на распределенную систему вроде YDB — это не просто механическая замена типов и синтаксиса. Это смена парадигмы.
- Логику — на уровень приложения: Многие вещи, за которые раньше отвечала база (сложные ограничения, внешние ключи), теперь логичнее контролировать на стороне кода.
- Используйте нативные инструменты: Для уникальности — индексы, для автоинкремента — Serial. Пытаться эмулировать старое поведение в новой среде часто ведет к проблемам с производительностью.
- Внимание к деталям: Строгая типизация и различия в поведении функций требуют тщательного тестирования.
- Сотрудничество решает: Прямой диалог с командой разработчиков платформы бесценен. Он помогает избежать множества ошибок и найти самые эффективные решения.
Надеемся, наш опыт будет полезен всем, кто смотрит в сторону Yandex Database. Мы же, в свою очередь, продолжаем работать над нашими инструментами автоматической конвертации, обогащая его всеми этими знаниями.