Программа импортозамещения в области программного обеспечения стартовала 16 ноября 2015 г. выходом Постановления Правительства РФ №1236 «Об установлении запрета на допуск программного обеспечения, происходящего из иностранных государств…». Практические ограничения использования продуктов Oracle в РФ начались в феврале 2018 года, когда компания уведомила российских партнеров о новых санкционных требованиях Вашингтона в отношении нефтегазовых заказчиков американских продуктов. 2 марта 2022 года Oracle сообщила о приостановке деятельности в России.

Таким образом, необходимость миграции данных из СУБД Oracle в другие СУБД становилась все острее и в конце концов приобрела неотвратимый характер.

Перед нашей командой была поставлена задача осуществить миграцию из Oracle в PostgreSQL большой (несколько десятков терабайт) работающей базы с нагрузкой порядка 20 миллионов dml-операций в час и генерацией 2ГБ редо-логов в минуту. Особенность задачи состояла в том, что миграция должна была происходить не один-в-один, а по частям в разные шарды. А какие данные в какие шарды и в каком порядке поедут, определялось бы в процессе самой миграции, по результатам этой миграции, в режиме, так сказать, ручного управления.

Сразу наметилась общая архитектура решения из двух подзадач:

1 – т.н. историческая часть, перенос в шарды Постгреса определенных частей данных.

2 – т.н. инкрементальная часть, перенос данных, которые возникли за время переноса исторической части.

Ниже речь пойдет именно об инкрементальной части процесса миграции.

Решение на основе триггеров не подходило по соображениям производительности и безопасности, поэтому рассматривались только подходы на основе чтения редо-логов. Инструменты типа Oracle GoldenGate, Debezium + Kafka по сумме причин не подошли (существенную роль сыграло требование гибко распределять данные по целевым шардам). В итоге остановились на решении на базе встроенного в Oracle механизма LogMiner и расширения oracle_fdw для PostgreSQL.

Идея такая: образующийся инкремент читаем ЛогМайнером и складываем в транспортную таблицу. Приемник (PostgreSQL) видит, что появилась новая порция инкремента, вычитывает эту порцию и по завершении сообщает Ораклу, что порция вычитана и можно высвобождать ресурсы.

Механизм непосредственного переноса данных в Приемник рассматриваться не будет, он почти такой же, как механизм переноса исторической части. В этой статье будет рассмотрен механизм извлечения и подготовки инкремента средствами Oracle LogMiner.

Итак.

Важно, чтобы контейнерная архитектура стенда разработки повторяла архитектуру продуктовой базы, т.к. запускать ЛогМайнер можно только из корневой базы (указав условие фильтрации select … from V$LOGMNR_CONTENTS where SRC_CON_NAME = ‘имя_контейнерной_базы’…).

Трудно себе представить продуктовую базу, на которой не включен хотя бы минимальный уровень дополнительного логирования, но на стенде разработки такое может быть. Поэтому в первую очередь нужно проверить

select SUPPLEMENTAL_LOG_DATA_MIN from V$DATABASE 

И если не YES, выполнить

alter database add supplemental log data

Для складирования подготовленного инкремента понадобится инфраструктура. Изменения данных на складах логировать не нужно и даже вредно. В случае аварии, мы сможем заново вычитать инкремент из редо или арк-логов, а записывать в редо-логи то, что мы из этих же редо-логов читаем — это только пустая трата ресурсов. Поэтому все инфраструктурные объекты, табличные пространства, таблицы и индексы создаются с параметром nologging.

Скрипт создания табличного пространства, пользователя и выдачи ему нужных прав.
CREATE TABLESPACE ORA2PG_TABLESPACE NOLOGGING DATAFILE 'полное_имя_файла_табличного_пространства' SIZE 50M AUTOEXTEND ON NEXT 128M MAXSIZE unlimited;
CREATE USER USER4MIGRATE IDENTIFIED BY пароль;
ALTER USER USER4MIGRATE DEFAULT TABLESPACE ORA2PG_TABLESPACE;
GRANT CREATE SESSION TO USER4MIGRATE;
GRANT CONNECT TO USER4MIGRATE;
GRANT LOGMINING TO USER4MIGRATE;
GRANT SELECT ANY dictionary TO USER4MIGRATE;
GRANT CREATE PROCEDURE TO USER4MIGRATE;
GRANT EXECUTE ON DBMS_LOGMNR TO USER4MIGRATE;
GRANT CREATE TABLE TO USER4MIGRATE;
ALTER USER USER4MIGRATE QUOTA UNLIMITED ON ORA2PG_TABLESPACE;
GRANT CREATE JOB TO USER4MIGRATE;
GRANT EXECUTE ON DBMS_SCHEDULER TO USER4MIGRATE;
GRANT EXECUTE ON DBMS_ISCHED TO USER4MIGRATE;
GRANT QUERY REWRITE TO USER4MIGRATE;

Альфой и омегой всего, что связанно с какими-либо изменениями в Оракле, является чекпоинт, он же SCN, он же system change number. Поэтому вся логика построена вокруг него, на него опирается и на него равняется.

Текущий чекпоинт базы данных извлекается просто:

select CURRENT_SCN from V$DATABASE; -- (1)

Если текущий чекпоинт базы отличается от последнего обработанного (на нагруженной базе так будет всегда), то пора вычитывать фрагмент редо-логов с чекпоинтами в диапазоне между последним обработанным и текущим.

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

Для вычитывания фрагмента редо-логов сначала необходимо подключить к ЛогМайнеру логи, содержащие чекпоинты из нужного диапазона, затем его инициализировать. После этого данные станут доступны в системном представлении (view) V$LOGMNR_CONTENTS, которые можно будет сгрузить в транспортную таблицу (индексы добавить по вкусу):

create table TRANSPORT_TABLE nologging tablespace ORA2PG_TABLESPACE as 
select SCN, OPERATION, SEG_OWNER, TABLE_NAME, ROW_ID 
from V$LOGMNR_CONTENTS where 1=2; -- (2)

По сути в транспортную таблицу будут складироваться указатели «где»  (SEG_OWNER (схема), TABLE_NAME (таблица), ROW_ID (идентификатор строки)), «как» (OPERATION) и «когда» (SCN) изменились данные, а Приемник по этим указателям будет забирать обновленные данные.

Пусть SCN_START – последний обработанный чекпоинт, а SCN_END – текущий, тогда запрос

select F.MEMBER 
from (select max(MEMBER) as MEMBER, GROUP# 
      from V$LOGFILE where TYPE='ONLINE' 
      group by GROUP#) F 
join V$LOG L on L.GROUP# = F.GROUP# 
where L.NEXT_CHANGE# >= SCN_START and L.FIRST_CHANGE# <= SCN_END; -- (3)

вернет множество редо-логов, содержащих операции с чекпоинтами из диапазона [SCN_START;  SCN_END]. Подзапрос с group by GROUP# нужен для того, чтобы взять из редо-группы только один (любой) файл, иначе произойдет «ORA-01289: невозможно добавить дубликат файла журнала…».

Если чекпоинт «уехал» в арк-логи, то нужные арк-логи можно получить похожим способом:

select NAME 
from V$ARCHIVED_LOG 
where NEXT_CHANGE# >= SCN_START and FIRST_CHANGE# <= SCN_END; -- (4)

Файлы логов подключаются к ЛогМайнеру вызовом

DBMS_LOGMNR.ADD_LOGFILE(LOGFILENAME => <файл_ лога>);

После чего нужно запустить ЛогМайнер примерно так:

DBMS_LOGMNR.START_LOGMNR(OPTIONS => DBMS_LOGMNR.DICT_FROM_ONLINE_CATALOG 
                         + DBMS_LOGMNR.COMMITTED_DATA_ONLY, 
                         startScn => SCN_START, endScn => SCN_END); -- (5)

Почему «примерно»? Потому что тут начинается самое интересное. Дело в том, что использовать именно этот вызов можно только в том случае, если осуществляется последовательное вычитывание порций. Тогда, записав в транспортную таблицу новую порцию

insert /*+ APPEND */ into TRANSPORT_TABLE 
select SCN, OPERATION, SEG_OWNER, TABLE_NAME, ROW_ID 
from V$LOGMNR_CONTENTS 
where OPERATION in ('INSERT', 'DELETE', 'UPDATE');

последний обработанный чекпоинт извлекается честным выполнением

select max(SCN) from TRANSPORT_TABLE; -- (6)

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

Если же вычитывание порций происходит параллельно, то старт новой, как правило, происходит раньше, чем завершение предыдущей, и получить последний обработанный чекпоинт честным способом (6), чтобы с него начать новую порцию, невозможно. Если же попытаться использовать в качестве граничного чекпоинт полученный запросом (1), то в совокупности с опцией DBMS_LOGMNR.COMMITTED_DATA_ONLY часто будет возникать «интересная» ситуация.

Простейшая DML-операция в V$LOGMNR_CONTENTS представлена тремя записями: START, ОПЕРАЦИЯ, COMMIT. При запуске ЛогМайнера с опцией DBMS_LOGMNR.COMMITTED_DATA_ONLY это выглядит, например, так:

Между операциями START и COMMIT с чекпоинтами 21231563736 и 21231563752 находятся другие операции.

В приведенном примере запросом (1) был получен чекпоинт 21231563745, который лежит между чекпоинтами START и COMMIT. При этом порции, извлеченные после запусков ЛогМанера вызовами

DBMS_LOGMNR.START_LOGMNR(OPTIONS => DBMS_LOGMNR.COMMITTED_DATA_ONLY …, 
                         startScn => …, endScn => 21231563745); 

и

DBMS_LOGMNR.START_LOGMNR(OPTIONS => DBMS_LOGMNR.COMMITTED_DATA_ONLY …, 
                         startScn => 21231563745, endScn => …);

этот INSERT не содержали.

Дело в том, что при включенной опции DBMS_LOGMNR.COMMITTED_DATA_ONLY ЛогМайнер отдает блок записей, относящихся к одной транзакции только целиком. Поэтому в первой порции он не отдал эту операцию, потому что COMMIT_SCN > 21231563745, а во второй не отдал, потому что SCN < 21231563745.  Это то самое «неожиданное место», о котором упоминалось ранее.

Разрешить эту ситуацию можно по-разному, но проще всего запускать ЛогМайнер, добавив к startScn и endScn некоторый «запас» (DELTA), который обеспечит непрерывность на стыках порций:

DBMS_LOGMNR.START_LOGMNR(OPTIONS => DBMS_LOGMNR.COMMITTED_DATA_ONLY …, 
                         startScn => SCN_START - DELTA, endScn => SCN_END + DELTA);

(Запросы (3) и (4) подкорректировать соответственно.)

Отрезать лишнее можно будет на этапе чтения:

select … from V$LOGMNR_CONTENTS where SCN between SCN_START and SCN_END …

Или не отрезать, а сгруппировать повторяющиеся операции. Или ничего не делать, а дважды вычитать одни и те же данные. В общем, по вкусу.

После того как порция инкремента записана в транспортную таблицу, сеанс работы ЛогМайнера завершается вызовом

DBMS_LOGMNR.END_LOGMNR();

В итоге, после вычитывания фрагмента, получается таблица указателей, по которым Приемник забирает обновленные данные.

Самый простой и надежный способ однозначно сопоставлять данные между Источником и Приемником – это для каждой таблицы в Приемнике [на время миграции] добавить колонку, содержащую значение ораклового ROWID (и навесить на нее уникальный индекс).

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

DBMS_LOGMNR.MINE_VALUE(redo_value, SEG_OWNER||'.'||L.TABLE_NAME||'.<ключевое_поле>')

Особого внимания требуют таблицы с полями типа LOB. ЛогМайнер отображает операции по таблицам с такими полями иначе, чем без них. А в случае UPDATE LOB-поля может не отдать ROWID строки.

Если в простую таблицу

create table  TEST_CLOB (
	ID number primary key, 
	K number, 
	CL clob);

вставить строку даже со значением CL=null

insert into TEST_CLOB (id, k, cl) values (100, 99, null); commit;

то ЛогМайнер отдаст эту операцию

select L.SCN, L.OPERATION, L.TABLE_NAME, L.ROW_ID, 
DBMS_LOGMNR.MINE_VALUE(L.redo_value, L.SEG_OWNER||'.'||L.TABLE_NAME||'.ID') idv,
L.SQL_REDO
from V$LOGMNR_CONTENTS L
where L.table_name like 'TEST_CLOB';

таким образом:

Сначала идет INSERT с пустым ROW_ID (chartorowid(‘AAAAAAAAAAAAAAAAAA’)  = null), а следом UPDATE, в котором устанавливается не пустое значение ROW_ID.

Если Приемник обрабатывает данные подобно оракловому MERGE (для PostgreSQL это insert … on conflict … do update …), то проблем нет. В противном случае нужно будет что-то делать, например, «починить» пустые  ROW_ID , восстановив их по извлеченному ID.

Похожая ситуация возникает, если сделать UPDATE только поля типа LOB, записав в него «длинное» значение (если значение будет недостаточно длинное, то ЛогМайнер отобразит такую операцию как с обычной строкой):

Здесь ROW_ID не определен ни в одной строке, но ID в строке UPDATE извлечен.

Итого.

Извлекать инкремент с помощью ЛогМайнера не сложно.

Если нагрузка и производительность позволяют, то достаточно последовательного извлечения порций инкремента, где целостность потока инкремента будет обеспечиваться извлечением на каждой итерации последнего обработанного чекпоинта, с которого будет начинаться следующая итерация. При распараллеливании же придется специально озаботиться обеспечением целостности потока инкремента на стыках порций, т.к. выборка следующей порции начинается раньше, чем можно будет извлечь последний обработанный чекпоинт, вследствие чего стартовый чекпоинт следующей порции придется определять «творчески».

Извлеченный инкремент представляет собой множество указателей схема.таблица.идентификатор_строки, по которым Приемник забирает обновленные данные.

ЛогМайнер отдает идентификатор_строки в виде системного идентификатора ROWID. Но иногда ЛогМайнер не отдает ROWID, например, при операциях с полями типа LOB. В таком случае для идентификации строк используются значения ключевых полей, извлекаемые функцией MINE_VALUE.

Разность между значениями необязательных параметров endScn и startScn функции запуска ЛогМайнера START_LOGMNR линейно влияет на скорость выполнения select … from V$LOGMNR_CONTENTS, поэтому задавать их значения очень полезно.

Комментарии (0)