Миграция с 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 или доверяли автокоммиту, то здесь транзакции контролируются сервисом явно.

Как это отражает идею выноса логики

  1. В Oracle всё было «спаяно» с БД — ID, транзакции, SQL и логика жили вместе.
  2. В 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) с типизированными полями для каждого метода.

Однако в контексте автоматической миграции мы столкнулись с выбором:

  1. Генерировать DTO: Получаем строгую типизацию, но проект обрастает тысячами одноразовых классов (на каждую процедуру Oracle свой класс-результат), что усложняет навигацию и поддержку.
  2. Использовать Map: Жертвуем строгой типизацией возврата ради компактности кода и универсальности.

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

А что с представлениями (Views)?

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

Фидбэк от команды YDB: «Представления должны конвертироваться в представления. Готовы помочь с активацией соответствующей фичи». Это отличный пример поддержки со стороны вендора. Мы активировали эту возможность и теперь конвертируем Views напрямую.

Выводы

Миграция с классической СУБД на распределенную систему вроде YDB — это не просто механическая замена типов и синтаксиса. Это смена парадигмы.

  1. Логику — на уровень приложения: Многие вещи, за которые раньше отвечала база (сложные ограничения, внешние ключи), теперь логичнее контролировать на стороне кода.
  2. Используйте нативные инструменты: Для уникальности — индексы, для автоинкремента — Serial. Пытаться эмулировать старое поведение в новой среде часто ведет к проблемам с производительностью.
  3. Внимание к деталям: Строгая типизация и различия в поведении функций требуют тщательного тестирования.
  4. Сотрудничество решает: Прямой диалог с командой разработчиков платформы бесценен. Он помогает избежать множества ошибок и найти самые эффективные решения.

Надеемся, наш опыт будет полезен всем, кто смотрит в сторону Yandex Database. Мы же, в свою очередь, продолжаем работать над нашими инструментами автоматической конвертации, обогащая его всеми этими знаниями.

66
11