Рассказываем, как перейти с MongoDB на Postgres без простоев и сократить расходы на 30%.

Ещё по теме: «MongoDB vs PostgreSQL: основные различия и особенности миграции»

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

Voucherify появился в 2015 году как проект хакатона выходного дня. Он был создан с помощью базы данных MongoDB, и такой выбор оказался случайным — это просто была самая распространенная база данных, которую команда использовала в своих проектах.

По мере роста у Voucherify появилась вторая база данных — PostgreSQL, так как она больше подходила для новых функций. В результате одна часть данных хранилась в Mongo, а другая — в Postgres. Но в определённый момент было решено окончательно «переехать» в Postgres. 

К тому времени успело накопиться множество данных, распределённых по нескольким инстансам. В совокупности понадобилось три месяца на переписывание и тестирование нового кода и ещё три месяца на перенос всех данных. Зачем проходить через такие неприятности? На то было две веские причины.

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

Во-вторых, Compose — SaaS-платформа для MongoDB, которую использовала команда, была очень дорогой по сравнению с альтернативами. Затраты на её обслуживание составляли значительную часть в статье расходов. 

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

  • проверка работоспособности данных в MongoDB;

  • создание дочерних таблиц с помощью Postgres Inheritance;

  • применение триггеров преобразования;

  • запуск сценариев Amazon Database Migration Service;

  • проверка работоспособности данных в дочерних таблицах;

  • перемещение «удалённых» данных из дочерней в родительскую таблицу;

  • переключение логики приложений на использование Postgres;

  • выявление аномалий;

  • перемещение «активных» данных из дочерней в родительскую таблицу;

  • остановка и удаление задач DMS.

Сервис миграции баз данных

DMS — инструмент для переноса данных между различными типами баз данных, включая Mongo и Postgres. Чтобы синхронизировать данные даже во время длительных миграций, вы можете запустить его в режиме Multi-AZ, для чего требуется всего один клик. Также DMS гарантирует удобный процесс мониторинга миграции. 

Плюсы DMS:

  • Экономия. Когда администратор AWS создаёт инстанс с требуемыми ресурсами оперативной памяти и центрального процессора, запускается счётчик платежей. В данном кейсе общая стоимость использования DMS незначительна — около 200 долларов. По сравнению с прогнозируемой экономией это было как капля в море.

  • Удобство. Когда вы создаёте инстанс репликации DMS на том же VPC, где находится ваша исходная конечная точка, вы на шаг впереди, потому что вам не нужно делать базу данных общедоступной на время миграции. Это делает DMS довольно простым в использовании.

Минусы DMS:

  • Графический интерфейс не идеален. Есть два режима: графический и json. Первый не поддерживает все возможности режима json, поэтому просмотр документации необходим для понимания всех возможных фильтров и преобразований. Однако если настроить процесс миграции в режиме json, использовать DMS становится намного удобнее. Хитрость в том, чтобы с помощью простых bash-скриптов сгенерировать большие файлы JSON с точными описаниями задач и просто вставить их в текстовую область веб-сайта DMS.

  • При создании нового задания миграции по умолчанию выбирается опция удаления целевой таблицы в целевой базе данных. Конечно, бывают случаи, когда такое поведение желательно, но почему самый опасный вариант используется по умолчанию? Риск удаления всех производственных данных из-за простой ошибки подталкивает к более безопасному, но гораздо более труднодостижимому варианту процесса миграции. Решение команды Voucherify состояло в том, чтобы перенести данные в два этапа – сначала во временные таблицы, которые могли быть случайно удалены DMS, а затем в целевые производственные таблицы.

Прежде чем команда убедилась, что DMS — подходящий инструмент, предстояло преодолеть множество технических препятствий. Изначально планировалось выполнить два этапа миграции: один для кампаний, а второй для ваучеров. Команда думала, что в каждом раунде будет только два шага для каждого проекта (рабочая область в Voucherify) — запустить DMS с текущей репликацией и переключить логический флаг в конфигурации проекта. Но всё оказалось сложнее. Разберём, как это было.

«PostgreSQL База»

Подготовка

Первым делом требовалось уточнить, какие настройки были установлены для каждого проекта перед миграцией. С точки зрения DMS-задачи каждый проект — это список кампаний и ваучеров, которые являются автономными. Выполнимо? Конечно, но если умножить это на сотни проектов, становится ясно, что вручную все настройки не применить.

Перед такими большими задачами полезно очистить код от остатков логики, чтобы упростить миграцию. В нашем случае это была проверка, все ли поля кампании и ваучера все ещё используются. Удаление старого кода — самая простая часть процесса, которая помогает сократить общее время. Но здесь работы было не так много. Хотя удаление старого кода позволило получить простые сценарии миграции ядра, что, безусловно, было одной из целей. 

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

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

Проверки

Команда Voucherify выполнила проверки работоспособности, пока данные находились во временных таблицах, куда их скопировал DMS. Чтобы лучше понять, какие типы проверок проводились, рассмотрим фрагмент кода. Он использовался, чтобы определить, свободны ли объекты от простых типов ошибок. 

+db["campaigns-TENANT-PROJECT"].count({$or: [{ campaign_type: { $exists: false } }, { deleted_at: { $exists: true } }]})+

Первое, что требовалось выяснить, — полностью ли выполнена одна из старых миграций типов ваучеров и кампаний. Было решено использовать эти типы с ограничением, отличным от NULL, в окончательной форме SQL, поэтому все записи должны были быть снабжены некоторым значением перед миграцией. Второе — существуют ли какие-либо записи со старым способом именования переменной с сохранением времени удаления deleted_at. 

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

--- wrong vouchers
SELECT * FROM voucherify.vouchers_migration WHERE
...
OR id = 'MISSING'
OR discount IS NULL
OR discount = 'null'::jsonb
OR publish ->> 'count' LIKE '%.%'
OR (jsonb_array_length(publish->'entries') > 0 AND publish::text LIKE '%$date%')

И сразу после выполнили этот же запрос к данным кампании. Когда появлялись интересные находки, команда фиксировала их в Mongo, на тот случай, если миграцию по какой-то причине придётся прервать.

«Анатомия» кода

Давайте изучим детали фрагмента кода выше. Первую строчку пока будет трудно понять — вернёмся к ней при описании основного сценария миграции. Следующие две строки сравнивают поле «скидка» с SQL-подобными «NULL» и json-подобными «null» значениями. Эти два типа NULL были первой проблемой, с которой столкнулась команда. И старый, и новый код прекрасно учитывали возможность в ответах GET, но требовалось удостовериться, что данные полностью заполнены после того, как их пропустили через скрипт миграции. Для отсутствующих данных ожидалось значение SQL NULL, однако значение json null немного удивило. Кода, который мог бы установить нулевое значение, на тот момент не было. Но, возможно, он был некоторое время назад, или нулевые значения стали результатом старых ручных действий.

Следующая строка, касающаяся количества публикаций, показывает, как проверяли, не сохранялись ли целочисленные поля в Mongo в виде строкового значения в формате float. Например, вместо ожидаемого 1 получили «1.0000». Возможно, этого бы даже не заметили, если бы не большое количество SQL-преобразований (CAST .. AS INT) в коде. Эта комбинация вызвала неожиданные ошибки после переноса пары старых тестовых проектов. Подобных случаев было мало, поэтому команда исправила их в ручном режиме. 

Последняя проверка “(jsonb_array_length(publish->'entries') > 0 И publish::text LIKE '%$date%')” предназначена для поиска ваучеров с записями публикации, сохраненными некорректным образом. На тот момент код уже игнорировал хранящиеся там данные, за исключением одного запроса, который проверял общее количество записей. Поэтому было решено перенести ваучеры со всеми записями публикации и устранить проблему позже, когда все данные будут в одной базе данных. 

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

Общая картина

Для каждого проекта нужно было перенести две коллекции Mongo. Каждую коллекцию решили переносить во временную таблицу, чтобы ограничить риск того, что одно из заданий DMS удалит целевую таблицу, полную активных кампаний или ваучеров. При подготовке к миграции решающее значение имел выбор — переходить к конечным или временным таблицам. Команда выбрала вариант, который казался наиболее безопасным, на случай, если произойдёт ошибка при настройке DMS. 

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

Пара слов о таблицах

Команда Voucherify приняла решение создавать временные таблицы для каждой коллекции Mongo и безопасно объединять их с рабочими таблицами на следующих шагах. В PostgreSQL есть функция наследования таблиц, которая помогла в этом и позволила соединить две таблицы в иерархическом порядке. В результате получилась родительская таблица с несколькими независимыми дочерними таблицами. Каждая дочерняя таблица хранит данные и индексы отдельно, но когда читаем из родительской таблицы, мы получаем агрегированные результаты из всех задействованных таблиц. 

Очевидное условие для объединения двух таблиц путём наследования — дочерние таблицы должны обладать всеми столбцами родительской таблицы, но могут иметь и больше. Хотя доступ к этим столбцам невозможен при чтении данных через родительскую таблицу, они всё равно полезны. В нашем кейсе эти дополнительные столбцы использовались для хранения необработанных данных MongoDB и _id каждого объекта.

Одно из преимуществ наследования видно в скрипте для создания дочерних таблиц — все родительские столбцы добавляются за вас, если вы даёте команду Postgres. Как это выглядит: 

CREATE TABLE 
voucherify."vouchers-test@voucherify.io-proj_f1r5Tpr0J3Ct" 
(_id varchar(200), _doc text) INHERITS (voucherify.vouchers);

Команда создаёт дочернюю таблицу с именем vouchers-test@voucherify.io-proj_f1r5Tpr0J3Ct, которая наследуется от родительской таблицы vouchers и имеет два дополнительных столбца, представляющих _id Mongodb и весь документ json (_doc), хранящийся в виде текста. В рамках кейса команда создала две дочерние таблицы для каждого проекта для кампаний и ваучеров. Когда миграция для конкретного проекта завершилась, и дочерние таблицы были пусты, их полностью удалили, чтобы сэкономить пространство CPU для VACUUM в базе данных. 

Как выглядела миграция:

  • серверы, на которых работает Voucherify, имеют пулы соединений как с базами данных Mongodb, так и с базами данных Postgres;

  • в зависимости от флагов проекта они читают и записывают в один из них;

  • DMS-задача копирует данные один раз, а затем продолжает выполнять текущую репликацию столько, сколько требуется;

  • DMS пересылала все обновления, сделанные в коллекциях Mongodb. 

Сделаем небольшую передышку и опишем, как выполнялась репликация в DMS. Чтобы репликация работала корректно, исходная база данных должна предоставлять канал с журналом изменений. В случае MongoDB эта функция называется Replica Set Oplog

db.oplog.rs.find({ ns: 
"voucherify.vouchers-test@voucherify.io-proj_f1r5Tpr0J3Ct", 
ts: { $gte: Timestamp(1575648464, 1) } })

Ручные тесты с временным инстансом MongoDB показали, что DMS может довольно хорошо реализовать текущую репликацию. Однако при тестировании URL-адресов подключений для MongoDB с платформы Compose функция не сработала. В DMS это было помечено как частично выполненная задача, потому что простое копирование данных прошло успешно. Просмотр журналов DMS-задач (доступных после включения функции CloudWatch) подтвердил подозрения о том, что возникла проблема с доступом к ленте изменений Mongodb. К счастью, можно купить дополнение, которое предоставляет дополнительный URL-адрес для MongoDB, который позволяет выполнять чтение из oplog. 

Следующий важный вопрос — как преобразовать данные между двумя разными типами баз данных. Присоединиться к миру Mongo без схемы и к миру SQL не составило труда, потому что схема была хорошо видна в коде. Плюс, решили сопоставить поля объекта по прямой линии со столбцами SQL без каких-либо модификаций. Так, текстовые поля преобразовались в текстовые столбцы, а объекты json — в столбцы json или jsonb. 

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

При работе в первом режиме DMS попытается извлечь схему из N документов и сопоставит данные с соответствующими столбцами таблицы при подключении к базе данных SQL в качестве целевой базы данных. Число N свободно выбирается создателем конечной точки и не имеет верхнего предела. На первый взгляд этот режим показался оптимальным в данном кейсе. Однако после некоторых тестов оказалось, что схема, извлеченная DMS, в большинстве случаев не соответствовала задачам, которые предстояло решить. Стало ясно, что режим документа с пользовательским сценарием преобразования — единственный вариант.‍

Основная работа

Для каждого проекта нужно было перенести две коллекции MongoDB с помощью DMS. Каждая задача копировала целые данные и после этого реплицировала изменения. Не было необходимости добавлять логику двойной записи в код — нужно было только подготовить альтернативную логику вокруг целевой базы данных с двумя логическими флагами, позволяющими использовать новую логику для кампаний и ваучеров отдельно. Чтобы сделать миграцию максимально безопасной, было решено переносить данные в два этапа. Каждую коллекцию Mongodb копировали в соответствующую временную таблицу, унаследованную от соответствующей родительской таблицы. Затем перемещали данные в родительские таблицы и переключали логические флаги. В результате удалось подключить DMS как к исходной, так и к целевой базам данных, при этом источник MongoDB работал в режиме документа. 

Основная проблема, которую нам предстояло решить дальше, заключалась в написании картографа, который связывает поля из исходных объектов json с соответствующими столбцами SQL.

У PostgreSQL есть триггерная функция. Она позволяет прикрепить фрагмент кода, который будет запускаться при определенных событиях для указанных объектов базы данных. Функции преобразования, вызываемые при вставке и обновлении строк в дочерних таблицах, казались идеальным вариантом использования. Как их подключали:

DROP TRIGGER IF EXISTS transform_voucher_trigger ON 
voucherify.vouchers_migration;
CREATE TRIGGER transform_voucher_trigger BEFORE UPDATE OR 
INSERT ON voucherify.vouchers_migration
FOR EACH ROW EXECUTE PROCEDURE 
voucherify.transform_voucher('TENANT', 'PROJECT');

Сначала скрипт удаляет триггер transform_voucher_trigger, если он уже был применен, а затем создаеттриггер, который будет запущен для таблицы vouchers_migration BEFORE UPDATE OR INSERT любойстроки. Это значит, что есть возможность изменять данные строки перед каждой вставкой или обновлением, что позволяет распаковывать данные в отдельные столбцы до проверки таких ограничений, как NOT NULL. То есть, можно применить ограничения NOT NULL и UNIQUE, уже примененные к дочерним таблицам, и проверить новые данные перед перемещением их в конечную таблицу.

Стоит отметить, что также можно применять пользовательские переменные в качестве аргументов метода при создании триггера. Это было крайне важно, потому что требовалось хранить идентификаторы клиента и проекта внутри каждой перенесенной записи. Проблема в том, что эти данные хранились только внутри имени коллекции Mongodb, а не внутри каждой записи. В DMS есть множество преобразований, которые можно использовать, но каждое из них работает только в пределах определенного уровня абстракции. Например, можно изменить имя коллекции Mongodb, сопоставив его с таблицей Postgres, или переназначить имена полей/столбцов. Но нельзя манипулировать значением выбранного столбца, например, извлекая его из имени коллекции. К счастью, триггеры в Postgres могут содержать переменные. Единственная проблема заключалась в том, как сделать это точно и быстро для каждого из тысяч проектов, которые нужно было перенести.

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

CREATE OR REPLACE FUNCTION voucherify.transform_voucher() RETURNS TRIGGER AS $$
DECLARE
    M_DATE timestamp with time zone;
    PG_DATE timestamp with time zone;
    DOC JSONB;
BEGIN
    IF TG_OP = 'UPDATE' AND OLD._doc::text != NEW._doc::text THEN
        M_DATE := GREATEST(to_timestamp((NEW._doc::jsonb->'updated_at'->>'$date')::bigint / 1000), (DOC->>'updated_at')::timestamp, to_timestamp((NEW._doc::jsonb->'deletedAt'->>'$date')::bigint / 1000), (DOC->>'deletedAt')::timestamp);
        PG_DATE := GREATEST(OLD.updated_at, OLD.deleted_at);

        IF M_DATE < PG_DATE THEN
            RAISE WARNING 'VOUCHER ANOMALY DETECTED - ID: %, Incoming time: %, Stored time: %', NEW._id, M_DATE, PG_DATE;
            RETURN NEW;
        END IF;
    END IF;

    IF NEW._doc IS NOT NULL AND (TG_OP = 'INSERT' OR OLD._doc::text != NEW._doc::text) THEN
        DOC := NEW._doc::jsonb;

        NEW.tenant_id = COALESCE(DOC->>'tenant_id', TG_ARGV[0]);
        NEW.project_id = COALESCE(DOC->>'project_id', TG_ARGV[1]);
        NEW.id = COALESCE(DOC->>'id', 'MISSING');
        NEW.code = DOC->>'code';
        NEW.deleted = COALESCE((DOC->>'deleted')::boolean, FALSE);

        <...>

        -- discount

        NEW.discount = DOC->'discount';
        IF jsonb_typeof(NEW.discount) = 'null' THEN
            NEW.discount = NULL;
        END IF;

        -- publish

        NEW.publish = COALESCE(DOC->'publish', '{}'::jsonb);
        IF jsonb_typeof(NEW.publish) = 'null' THEN
            NEW.publish = '{}'::jsonb;
        END IF;

        NEW.publish = NEW.publish || jsonb_build_object('count', COALESCE(FLOOR((NEW.publish->>'count')::float), 0));

        -- validity_day_of_week

        IF DOC->>'validity_day_of_week' IS NOT NULL THEN
            NEW.validity_day_of_week = voucherify.json_arr2int_arr(DOC->'validity_day_of_week');
        ELSE
            NEW.validity_day_of_week = NULL;
        END IF;

        -- dates

        NEW.created_at = to_timestamp(voucherify.hex_to_bigint(SUBSTR(DOC->'_id'->>'$oid', 1, 8)));
        NEW.deleted_at = COALESCE(to_timestamp((DOC->'deletedAt'->>'$date')::bigint / 1000), (DOC->>'deletedAt')::timestamp);

    END IF;

    RETURN NEW;
END;
$$ language plpgsql;

Фрагмент кода на первый взгляд может показаться немного ошеломляющим, но довольно прост, если разбить его на фрагменты. Сценарий состоит из трех частей: объявление переменных, проверка на аномалии и основное преобразование. Углубимся в логику преобразования.

Проверка «IF NEW._doc IS NOT NULL AND (TG_OP = 'INSERT' OR OLD._doc::text != NEW._doc::text)» гарантирует, что любое преобразование произойдет, только если это строка, вставленная или обновленная DMS-задачей.

В следующей строке анализируем столбец _doc в двоичном формате JSON и сохраняем его в переменной DOC. Следующая строка «NEW.tenant_id = COALESCE(DOC->>'tenant_id', TG_ARGV[0]);» показывает, что у нас было два способа доставки идентификаторов арендатора и проекта. Первый вариант заключался в том, чтобы сначала обновить данные в Mongo и отправить их уже с этими значениями. Или вызвать аргументы, описанные на предыдущих страницах. Если ни один из них не был предоставлен, вставленный документ будет отклонен ограничением NOT NULL, что приведет к сбою задачи DMS. Следующая строка «NEW.id = COALESCE(DOC->>'id', 'MISSING');» задает правильный идентификатор строки или использует «MISSING» в качестве запасного варианта. Команда Voucherify предпочла скопировать поврежденные данные и исправить их уже в Postgres. 

Следующий раздел фрагмента скрипта миграции посвящен переменной discount jsonb:

 NEW.discount = DOC->'discount';
        IF jsonb_typeof(NEW.discount) = 'null' THEN
            NEW.discount = NULL;
        END IF;

Чтобы извлечь часть документа MongoDB и сохранить её в столбце json/jsonb, достаточно использовать нотацию с одной стрелкой ->, которая не преобразуется в строку. Но будьте осторожны: отсутствующее значение даст SQL-подобное значение NULL, а json-подобное нулевое значение будет без проблем преобразовано в json-подобное нулевое значение в таблице SQL. Это может привести к сбою кода из-за недопустимых преобразований. Если не исключить нулевые значения, то использование -> или ->> вызовет ошибки в производственной среде. Решение, которое применила команда Voucherify, состояло в том, чтобы перехватывать нулевые значения в сценарии миграции и преобразовывать их в эквиваленты SQL.

Следующий фрагмент:

NEW.publish = COALESCE(DOC->'publish', '{}'::jsonb);
        IF jsonb_typeof(NEW.publish) = 'null' THEN
            NEW.publish = '{}'::jsonb;
        END IF;

        NEW.publish = NEW.publish || jsonb_build_object('count', COALESCE(FLOOR((NEW.publish->>'count')::float), 0));

Здесь есть резервное значение '{}'::jsonb. Проще говоря, на платформе возможна скидка NULL, но мы предпочитаем всегда иметь хотя бы json с обнуленным полем счётчика в столбце публикации. Далее идет та же проверка нулевых значений, что и раньше, только с другим резервным значением. Последняя строка либо устанавливает ноль по умолчанию для переменной count, либо анализирует существующее значение до целого числа, округляя его в меньшую сторону. 

Следующий раздел для поля valid_day_of_week использовался для преобразования json-подобного массива целых чисел в SQL-подобный массив целых чисел:

        IF DOC->>'validity_day_of_week' IS NOT NULL THEN
            NEW.validity_day_of_week = voucherify.json_arr2int_arr(DOC->'validity_day_of_week');
        ELSE
            NEW.validity_day_of_week = NULL;
        END IF;

Этот вид преобразования не так прост. Использование одной стрелки -> оставило бы массив как значение json/jsonb, которое нельзя назначить для этого столбца. Хитрость заключается в использовании вспомогательной функции voucherify.json_arr2int_arr, которую зарегистрировали в Postgres вместе со сценарием миграции. Интересно использование двойной стрелки в условии «IF DOC->>'validity_day_of_week' IS NOT NULL». При использовании одной стрелки нулевое значение, подобное json, приведёт к значению, отличному от NULL, что вызовет ошибку в следующей строке. Двойная стрелка сведёт возможные результаты к одному, что снова сделает этот фрагмент кода безопасным.

Последняя часть скрипта миграции имеет дело с тремя переменными— created_at, updated_at иDeleted_at. 

        NEW.created_at = to_timestamp(voucherify.hex_to_bigint(SUBSTR(DOC->'_id'->>'$oid', 1, 8)));
        NEW.updated_at = COALESCE(to_timestamp((DOC->'updated_at'->>'$date')::bigint / 1000), (DOC->>'updated_at')::timestamp);
        NEW.deleted_at = COALESCE(to_timestamp((DOC->'deletedAt'->>'$date')::bigint / 1000), (DOC->>'deletedAt')::timestamp);

В базе данных Mongo первые восемь символов поля _id фактически являются отметкой времени создания объекта. Его можно извлечь, сначала преобразовав эти знаки из шестнадцатеричного формата в большое целое число SQL с помощью вспомогательной функции voucherify.hex_to_bigint, а затем в метку времени с помощью встроенного метода to_timestamp. Поле _id не является строкой, а представляет собой объект с одним строковым свойством $oid, которое содержит текстовое значение.

Следующие два поля, в которых хранятся моменты последнего обновления и удаления объекта соответственно, имеют ту же логику преобразования. Они охватывают три формата хранения данных в Mongo и преобразует их все во временную метку SQL. Вторая часть «(DOC->>'deletedAt')::timestamp» охватывает два случая — временная метка в виде числа и строки в формате ISO, а первая часть — «to_timestamp((DOC->'updated_at'->>' $date')::bigint / 1000)» имеет дело с экзотической формой MongoDB ISODate. 

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

Код, отвечающий за проверку аномалий:

IF TG_OP = 'UPDATE' AND OLD._doc::text != NEW._doc::text THEN
    M_DATE := GREATEST(to_timestamp((NEW._doc::jsonb->'updated_at'->>'$date')::bigint / 1000), (DOC->>'updated_at')::timestamp, to_timestamp((NEW._doc::jsonb->'deletedAt'->>'$date')::bigint / 1000), (DOC->>'deletedAt')::timestamp);

    PG_DATE := GREATEST(OLD.updated_at, OLD.deleted_at);

    IF M_DATE < PG_DATE THEN
        RAISE WARNING 'VOUCHER ANOMALY DETECTED - ID: %, Incoming time: %, Stored time: %', NEW._id, M_DATE, PG_DATE;
        RETURN NEW;
    END IF;
END IF;

Клонирование данных

Осталось описать ещё две технические детали. Первая —неоднократно упоминавшаяся DMS-задача. Как выглядело описание задачи в формате JSON:

‍{
  "rules": [
    {
      "rule-type": "transformation",
      "rule-id": "1",
      "rule-name": "1",
      "rule-target": "table",
      "object-locator": {
        "schema-name": "voucherify",
        "table-name": "campaigns-TENANT-PROJECT"
      },
      "rule-action": "rename",
      "value": "campaigns_migration",
      "old-value": null
    },
    {
      "rule-type": "transformation",
      "rule-id": "2",
      "rule-name": "2",
      "rule-target": "table",
      "object-locator": {
        "schema-name": "voucherify",
        "table-name": "vouchers-TENANT-PROJECT"
      },
      "rule-action": "rename",
      "value": "vouchers_migration",
      "old-value": null
    },
    {
      "rule-type": "selection",
      "rule-id": "3",
      "rule-name": "3",
      "object-locator": {
        "schema-name": "voucherify",
        "table-name": "campaigns-TENANT-PROJECT"
      },
      "rule-action": "include",
      "filters": []
    },
    {
      "rule-type": "selection",
      "rule-id": "4",
      "rule-name": "4",
      "object-locator": {
        "schema-name": "voucherify",
        "table-name": "vouchers-TENANT-PROJECT"
      },
      "rule-action": "include",
      "filters": []
    }
  ]
}

В списке правил, применяемых к каждому заданию, отвечающему за один проект, есть четыре пункта. Это число получается из двух двойных сплитов, которые у нас есть:

  • кампании и ваучеры;

  • правила отбора и трансформации.

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

Правила, которые применяла команда Voucherify:

1. Выбор двух коллекций MongoDB:

  • campaigns-TENANT-PROJECT

  • vouchers-TENANT-PROJECT

2. Сопоставление имён коллекций с таблицами PostgreSQL (в том же порядке):

  • campaigns-TENANT-PROJECT и campaigns_migration

  • vouchers-TENANT-PROJECT и vouchers_migration

Приведенные таблицы SQL были временными дочерними таблицами. Каждое правило DMS должно иметь уникальный идентификатор. Это может быть произвольное значение, но команда Voucherify предпочла использовать возрастающие целые числа, чтобы сохранить совместимость с поведением DMS по умолчанию. Это позволило легко переключаться между режимами json и visual DMS без каких-либо различий в исходном json. 

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

WITH toberemoved AS (
         SELECT tenant_id,
                project_id,
                id
         FROM voucherify.vouchers_migration
         LIMIT 100000),

     removed AS (
         DELETE FROM voucherify.vouchers_migration x
         USING toberemoved t
         WHERE x.tenant_id = t.tenant_id
           AND x.project_id = t.project_id
           AND x.id = t.id
         RETURNING
             x.tenant_id,
             x.project_id,
             x.id,
             x.code,
             x.deleted,
             <...>
             x.discount,
             x.publish,
             x.validity_day_of_week,
             x.created_at,
             x.updated_at,
             x.deleted_at)

INSERT
INTO voucherify.vouchers (tenant_id,
                          project_id,
                          id,
                          code,
                          deleted,
                          <...>
                          discount,
                          publish,
                          validity_day_of_week,
                          created_at,
                          updated_at,
                          deleted_at)
SELECT * FROM removed
ON CONFLICT ON CONSTRAINT pk_vouchers
DO UPDATE SET deleted = excluded.deleted,
<...>
              discount = excluded.discount
              publish = excluded.publish
              validity_day_of_week = excluded.validity_day_of_week
              updated_at = excluded.updated_at
              deleted_at = excluded.deleted_at;

Всего четыре части:

  • поиск 100 тысяч сущностей для перемещения;

  • удаляем их из временной таблицы, одновременно извлекая данные.

  • вставка только что удаленных данных в итоговую таблицу;

  • если ваучер с данным идентификатором уже существует, обновляем его новыми данными.

Если по какой-либо причине вставка или обновление завершаются неудачно, то весь кусок (до) 100 000 элементов остаётся во временной таблице — скрытая транзакция SQL откатывается. 

Обращаем внимание: копирование и перемещение данных с помощью DMS расходуют баланс IOPS Burst Balance. Важно следить за этой метрикой во время миграции, чтобы случайно не вывести производство из цикла.

Подведём итоги

Соберем все части в список шагов, которые нужно выполнить в процессе миграции:

  • проверка работоспособности ваучеров и кампаний в MongoDB;

  • создание дочерних таблиц и индексов для дочерних таблиц;

  • применение триггеров преобразования;

  • создание и запуск DMS-задачи;

  • проверка работоспособности данных в дочерних таблицах в Postgres;

  • перемещение удалённых ваучеров из дочерней таблицы в родительскую;

  • переключение логики проекта на использование Postgres;

  • проверка аномалий;

  • перемещение активных ваучеров из дочерней таблицы в родительскую;

  • перемещение кампании из дочерней таблицы в родительскую;

  • остановка и удаление DMS-задач.

Пройдя весь процесс миграции, команда Voucherify добилась значительного снижения затрат (в некоторых кластерах до 50%), более чистой кодовой базы и меньшего количества проблем из-за смены поставщика. Это была непростая задача, но она того стоила. 

Самый большой вопрос, с которым столкнулась команда, заключался в том, должна ли DMS копировать данные непосредственно в целевые таблицы или во временные (как было выбрано в кейсе). Каждый вариант имеет свои минусы и риски, поэтому идеального решения не существует. Кажется, что переход к конечным таблицам проще, но его сложнее контролировать.

«PostgreSQL База»

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