Из MongoDB в PostgreSQL: схема появляется у данных, у которых её не было
Решение перейти с MongoDB на PostgreSQL обычно принимают не от скуки: нужны честные транзакции, JOIN без боли, отчётность, которую можно доверить бухгалтерии. Но как только доходит до самого переноса, выясняется неприятная вещь: документы в одной и той же коллекции устроены по-разному, а PostgreSQL требует, чтобы у каждой строки таблицы были одни и те же столбцы. Эта статья — про то, как пройти путь от «у нас гибкие данные» до «у нас строгая схема» без потерь и сюрпризов на проде.
Содержание
Где на самом деле возникает сложность
В реляционной базе схема — это контракт: таблица users объявляет набор столбцов, и каждая строка ему подчиняется. Вставить строку без обязательного поля или с полем, которого в таблице нет, физически нельзя — движок откажет на уровне DDL. Это не недостаток, а смысл реляционной модели: гарантия, что данные предсказуемы.
MongoDB устроена ровно наоборот. Схема существует, но она не навязана базой — это соглашение на уровне приложения. Ничто не мешает двум документам в коллекции orders иметь разный набор полей: один с discount_code, другой без, третий — с discount_code, который почему-то хранится строкой "10", а не числом. Так происходит естественно: приложение росло, поля добавлялись и удалялись, миграции старых записей никто не делал, часть данных пришла из интеграций с другим форматом, часть — результат ручных правок через mongosh.
Пока вы живёте в MongoDB, это не проблема — драйвер вернёт документ таким, какой он есть, а код приложения решит, что делать с отсутствующим полем. При переносе в PostgreSQL вам нужно заранее решить для *каждого* поля: обязательное оно или нет, какой у него тип, что делать при отсутствии, и что делать с полями, которых в целевой таблице вообще не будет. Это не техническая процедура экспорта-импорта, а проектирование модели данных заново — но с оглядкой на то, что реально лежит в базе, а не на то, что должно там лежать по задумке.
Похожий выбор между двумя моделями разбирался в статье MongoDB или PostgreSQL: что выбрать для сервера — если вы ещё сомневаетесь, стоит ли вообще мигрировать, начните оттуда.
Смотрите на данные, а не на документацию приложения
Частая ошибка — проектировать целевую схему по ORM-моделям, Swagger-схеме API или ТЗ трёхлетней давности. Формально это «схема данных», но она описывает, как приложение *должно* писать документы, а не как оно писало их всё это время, с учётом багов, ручных правок и недокатившихся миграций. Единственный надёжный источник правды — сама коллекция.
Начните с простого профилирования: посчитайте, какие поля вообще встречаются в коллекции и с какой частотой.
// mongosh, агрегация по коллекции orders
db.orders.aggregate([
{ $project: { fields: { $objectToArray: "$$ROOT" } } },
{ $unwind: "$fields" },
{ $group: { _id: "$fields.k", count: { $sum: 1 } } },
{ $sort: { count: -1 } }
])
Это сразу покажет поля, которые есть не у всех документов — если в коллекции 2 000 000 записей, а discount_code встречается в 340 000, вопрос «обязательное оно или нет» уже не философский, а вопрос конкретной доли данных.
Дальше нужно проверить не только наличие поля, но и тип значения — в схемонезависимой базе одно и то же поле легко оказывается то числом, то строкой, то null, то вложенным документом:
db.orders.aggregate([
{ $group: { _id: { $type: "$discount_code" }, count: { $sum: 1 } } }
])
Если результат — что-то вроде { "double": 1200000, "string": 15000, "null": 800000 }, это не аномалия, а факт о ваших данных, с которым придётся что-то делать: либо привести типы на этапе переноса, либо завести в PostgreSQL более широкий тип и разбираться после.
Отдельно стоит выгрузить и вручную просмотреть случайную выборку документов (db.orders.aggregate([{ $sample: { size: 200 } }])), а не только агрегаты — часто именно в редких, но реальных комбинациях полей находятся кейсы, которые ни один агрегат не покажет явно.
Правило простое: документация и код приложения говорят, каким данные *задумывались*. Только сама коллекция говорит, какие они *на самом деле*. Разница между этими двумя картинами — и есть тот риск, который убивает миграции, сделанные «по спецификации».
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПроектирование целевой схемы: от документов к таблицам
Когда реальная картина полей ясна, можно проектировать таблицы. Здесь работает не один универсальный рецепт, а комбинация трёх подходов в зависимости от того, насколько поле стабильно.
Стабильные, всегда присутствующие поля с предсказуемым типом становятся обычными столбцами с ограничениями:
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
mongo_id text UNIQUE NOT NULL, -- исходный _id для сверки
customer_id bigint NOT NULL REFERENCES customers(id),
status text NOT NULL DEFAULT 'new',
total_cents integer NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz
);
Обратите внимание на mongo_id — сохранённый исходный ObjectId почти всегда стоит держать отдельным столбцом, даже если он не нужен приложению: он даёт возможность сверить записи после переноса и найти конкретный документ при разборе расхождений.
Вложенные структуры, которые повторяются у большинства документов и имеют предсказуемый набор полей, лучше вынести в отдельные таблицы со связью один-ко-многим, а не хранить как JSON — так вы получите нормальные индексы и JOIN:
CREATE TABLE order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
sku text NOT NULL,
qty integer NOT NULL,
price_cents integer NOT NULL
);
Поля, которые действительно вариативны — присутствуют не всегда, меняли форму со временем, специфичны для конкретного канала интеграции — не нужно героически укладывать в жёсткие столбцы. Для них в PostgreSQL есть jsonb:
ALTER TABLE orders ADD COLUMN extra jsonb NOT NULL DEFAULT '{}'::jsonb;
CREATE INDEX orders_extra_gin ON orders USING gin (extra);
Это не капитуляция перед бесхребетностью Mongo, а компромисс: ядро сущности получает строгую схему, а хвост редких и нестабильных атрибутов уезжает в jsonb, откуда его всё равно можно фильтровать через extra @> '{"source": "partner_x"}'. Если позже какое-то поле из extra стабилизируется, его можно вынести в обычный столбец отдельной миграцией — это штатная эволюция схемы, а не аварийный режим.
Документы, которые не вписываются в схему
Как бы тщательно вы ни анализировали данные, всегда найдётся процент документов, которые не укладываются в схему — из-за отсутствующих обязательных полей, типов, не приводимых штатной конвертацией, или откровенно битых данных. Вопрос не в том, произойдёт ли это, а в том, что вы будете делать, когда это произойдёт.
Для отсутствующих необязательных полей ответ обычно простой — DEFAULT или NULL:
status text NOT NULL DEFAULT 'unknown'
Для отсутствующих полей, которые вы считали обязательными, а по факту они отсутствуют у заметной доли документов, есть два честных пути: либо снять с поля ограничение NOT NULL (это данные о реальном мире, а не о ваших ожиданиях), либо явно решить, что такие документы — брак, который не должен попасть в прод-таблицу.
Для второго случая — и в целом для любых документов, которые не проходят валидацию, — не стройте миграцию так, чтобы один плохой документ ронял весь батч. Заведите карантинную таблицу:
CREATE TABLE migration_rejects (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
collection text NOT NULL,
mongo_id text NOT NULL,
raw_doc jsonb NOT NULL,
reason text NOT NULL,
rejected_at timestamptz NOT NULL DEFAULT now()
);
Скрипт переноса пытается привести документ к целевой схеме, и при любой ошибке валидации не падает, а логирует документ целиком в migration_rejects с причиной и продолжает со следующим. После прогона у вас на руках не абстрактное «мы потеряли N записей», а конкретный список: что не вписалось и почему. Дальше — либо расширяете правила приведения типов и гоняете отклонённые записи повторно, либо, если это действительно мусор (тестовые заказы, дубликаты, документы без owner_id), сознательно решаете их не переносить.
Лишние поля, которых нет в целевой схеме, — самая безобидная ситуация: если под них не завели jsonb-столбец, они просто не переносятся. Стоит лишь убедиться, что среди «лишних» нет того, что вы упустили при анализе — иначе есть риск молча выбросить нужные данные только потому, что поле не встретилось в выборке, по которой проектировали схему.
Сам перенос: инструменты и практика
Для одноразового переноса среднего объёма (до нескольких десятков миллионов документов) чаще всего проще и надёжнее написать собственный ETL-скрипт на Python, чем искать универсальный инструмент под конкретную схему полей. Общая структура такая:
import pymongo, psycopg2, psycopg2.extras
from datetime import datetime, timezone
mongo = pymongo.MongoClient("mongodb://localhost:27017").mydb
pg = psycopg2.connect("dbname=mydb user=migrator")
pg.autocommit = False
BATCH = 1000
def coerce_total(doc):
val = doc.get("total")
if isinstance(val, (int, float)):
return int(round(val * 100))
if isinstance(val, str) and val.replace(".", "", 1).isdigit():
return int(round(float(val) * 100))
raise ValueError(f"total не приводится к числу: {val!r}")
batch = []
with pg.cursor() as cur:
for doc in mongo.orders.find():
try:
batch.append((
str(doc["_id"]), doc["customer_id"],
doc.get("status") or "unknown", coerce_total(doc),
doc.get("created_at", datetime.now(timezone.utc)),
))
except (KeyError, ValueError) as e:
cur.execute(
"INSERT INTO migration_rejects (collection, mongo_id, raw_doc, reason) VALUES (%s,%s,%s,%s)",
("orders", str(doc["_id"]), psycopg2.extras.Json(doc), str(e)),
)
continue
if len(batch) >= BATCH:
psycopg2.extras.execute_values(
cur,
"""INSERT INTO orders (mongo_id, customer_id, status, total_cents, created_at)
VALUES %s ON CONFLICT (mongo_id) DO NOTHING""",
batch,
)
batch.clear()
pg.commit()
Три детали принципиальны. ON CONFLICT (mongo_id) DO NOTHING делает скрипт идемпотентным — его можно безопасно перезапускать после сбоя, не боясь задвоить записи. Батчинг через execute_values нужен, потому что вставка по одной строке на десятках миллионов документов будет неприемлемо медленной. А ошибки приведения типов не останавливают процесс — документ целиком уходит в migration_rejects, и перенос продолжается со следующей записи.
Готовые инструменты вроде pgloader умеют мигрировать из MongoDB в PostgreSQL «из коробки», но их автоматическое сопоставление типов рассчитано на однородные данные — на коллекции с реальным разнообразием полей ручной донастройки правил там уходит не меньше, чем занял бы собственный скрипт, только описанной на чужом DSL. Для миграции с нетривиальной схемой это обычно не экономит время.
Если база большая и простой недопустим, важно продумать и стратегию по времени — этому посвящена отдельная статья про перенос большой базы с минимальным даунтаймом: двойная запись, дельта-синхронизация и контролируемое переключение подходят и для смены СУБД, а не только для переезда между серверами.
Тестовый прогон на копии реальных данных
Каким бы тщательным ни был анализ полей, он всегда неполный — вы работаете с агрегатами и выборками, а не со всеми документами. Реальная проверка гипотез о структуре данных происходит только на полном прогоне миграции, и делать его впервые на проде — не вариант.
Практическая последовательность:
- Снимите свежий дамп продовой MongoDB (
mongodump) и разверните его на отдельном сервере, не трогая прод. - Разверните целевой PostgreSQL с готовой DDL-схемой там же.
- Прогоните скрипт миграции целиком, от первого до последнего документа — не на выборке, а на всём объёме. Только полный прогон вскрывает редкие комбинации полей, которые не попали в сэмпл на этапе анализа.
- Сверьте количество записей: строки в
ordersплюс записи вmigration_rejectsдолжны совпасть сdb.orders.countDocuments()в Mongo. - Просмотрите
migration_rejectsвдумчиво, а не по диагонали. Отклонённые 0,1% старого тестового мусора безcustomer_id— нормально. Отклонённые 8%, среди которых живые заказы за последний месяц, — сигнал вернуться к анализу данных. - Дайте ключевым запросам приложения поработать на перенесённой копии — это ловит не только структурные, но и смысловые ошибки: например, если
statusв Mongo был строкой"1","2","3"без явного словаря значений и вы неверно расшифровали, что означает какое число.
Отдельный сервер для такого прогона — не роскошь, а необходимость: миграция многомиллионной коллекции может занять часы, и гонять её на общем стенде рискованно для чужих задач на нём же. Здесь удобно поднять временный тестовый сервер с копией боевой базы, прогнать перенос сколько нужно раз с исправлениями между прогонами и снести его, когда схема и скрипт стабилизировались. Повторный прогон на свежей копии данных всегда дешевле, чем инцидент на проде из-за неучтённого формата поля.
Только после того как тестовый прогон на актуальной копии реальных данных проходит стабильно, с приемлемым и понятным процентом отклонённых записей, и результат сходится с ожиданиями бизнес-логики — есть смысл планировать миграцию продовой базы.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли обойтись без анализа реальных данных и просто довериться документации API?
Можно, но это самый частый источник аварий: документация описывает, как данные должны выглядеть, а не как они выглядят после лет правок и ручных вмешательств. Разница между этими картинами и обнаруживается на проде, если её не найти заранее.
Стоит ли переносить все поля из MongoDB, даже неиспользуемые?
Не обязательно. Если поле нигде не читается в коде приложения и встречается у единиц процентов документов, разумнее не тащить его в схему вовсе — либо сложить всё редкое в один jsonb-столбец про запас.
Что делать, если одно и то же поле хранит числа то как int, то как строку?
Приводите тип явно в скрипте миграции, а случаи, которые не приводятся однозначно, логируйте в карантинную таблицу — это надёжнее, чем полагаться на неявное приведение типов на стороне СУБД.
Нужно ли останавливать запись в MongoDB на время тестового прогона?
Нет, он идёт на отдельной копии данных, продовая база продолжает работать штатно. Остановка записи или двойная запись понадобится только на финальном, боевом переносе.
Сколько раз нужно прогонять тестовую миграцию до боевой?
Единой нормы нет — зависит от разнородности данных конкретного проекта; ориентир не количество прогонов, а то, что процент и характер записей в migration_rejects перестал вас удивлять.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →