После переезда сверяем не размер базы, а содержимое: методика
Перенос базы на новый сервер прошёл без ошибок, pg_restore или mysql < dump.sql отработали чисто, SELECT COUNT(*) на старом и новом сервере дал одинаковые цифры — и на этом проверку обычно заканчивают. А зря: одинаковое число строк ничего не говорит о том, что это те же самые строки. Ниже — методика, которая проверяет не размер и не количество, а фактическое содержимое базы после переезда.
Содержание
Почему совпадение размера и количества строк не доказывает целостность
Первый инстинкт после миграции — сравнить ls -la на старом и новом файле дампа или посмотреть размер каталога данных. Это создаёт ложное чувство уверенности сразу по двум причинам.
Во-первых, размер на диске зависит не только от содержимого. У PostgreSQL после VACUUM FULL или pg_restore в новую базу файлы обычно компактнее — ушли "дыры" от удалённых строк, переупаковались индексы, изменился порядок физического хранения. У MySQL/InnoDB размер .ibd-файла зависит от innodb_page_size, фрагментации, наличия сжатия таблиц. Разница в 5-15% между старым и новым размером — это норма, а не сигнал проблемы. Но она же маскирует и обратную ситуацию: реально потерянные данные тоже укладываются в такую разницу и остаются незамеченными.
Во-вторых, и это опаснее, COUNT(*) — это мощность множества, а не его содержимое. Классический сценарий: скрипт переноса упал на середине по таймауту, часть строк ушла дважды при повторном запуске (не было ON CONFLICT DO NOTHING или INSERT IGNORE), а часть вообще не ушла из-за ошибки внешнего ключа, которую скрипт проглотил. Итоговое число строк в таблице orders совпало с исходным почти случайно — одни записи задвоились, другие пропали, баланс сошёлся. Ту же картину даёт обрыв соединения во время pg_dump без --single-transaction: часть строк снялась в одном состоянии, часть — в другом, но итоговое количество осталось прежним, потому что параллельно шли и вставки, и удаления.
Отдельная проблема — кодировка и типы. Перенос с latin1 на utf8mb4 в MySQL или несовпадение локали при сортировке (collation) в PostgreSQL может исказить конкретные байты внутри строк, не меняя ни их число, ни размер таблицы на диске сколько-нибудь заметно. Строка на месте, COUNT(*) не дрогнул, а содержимое — уже не то.
Вывод простой: количество строк и размер файла — это индикаторы уровня "дым есть — возможно, пожар", а не доказательство переноса. Доказательство даёт только проверка содержимого.
Контрольные суммы по содержимому таблиц
Правильная единица проверки — не файл дампа, а содержимое конкретной таблицы, посчитанное независимо от порядка строк на диске. Наивный вариант — сконкатенировать все строки и посчитать md5() — ломается из-за порядка: если строки физически хранятся в разном порядке (а после переноса это почти гарантировано), сумма не совпадёт, даже если содержимое идентично.
Рабочий приём — считать хеш от каждой строки отдельно, а затем агрегировать так, чтобы результат не зависел от порядка (например, суммированием). Для PostgreSQL:
-- порядконезависимая контрольная сумма таблицы orders
SELECT
count(*) AS rows,
sum(('x' || substr(md5(t::text), 1, 16))::bit(64)::bigint) AS content_hash
FROM orders t;
Выполняете этот запрос на старом и новом сервере — rows и content_hash должны совпасть побитово. Если совпало rows, но не совпало content_hash — где-то есть подмена содержимого при том же количестве строк, именно та ситуация, которую обычная проверка пропускает.
Для MySQL похожий трюк через CRC32:
SELECT
COUNT(*) AS rows,
BIT_XOR(CRC32(CONCAT_WS('|', id, customer_id, status, total, updated_at))) AS content_hash
FROM orders;
BIT_XOR тоже не зависит от порядка строк. Перечисляйте столбцы явно, а не CONCAT_WS('|', *) — так проверка переживёт добавление нового столбца в схему и не будет молча учитывать его отсутствие на одной из сторон.
Встроенный CHECKSUM TABLE orders в MySQL — более быстрый, но грубый вариант: он считает по всей строке, чувствителен к порядку хранения на некоторых движках и не даёт вам выбрать, какие столбцы учитывать (например, стоит ли включать updated_at, если она обновляется независимо на обеих сторонах). Используйте его для беглой проверки, а для решения "заводить инцидент или нет" — явный запрос с перечисленными столбцами.
Считайте контрольные суммы не по всей базе разом, а по каждой таблице отдельно, и в первую очередь — по таблицам с деньгами, заказами, пользователями и всем, что находится под юридической или финансовой ответственностью. Двадцать вспомогательных справочников можно проверить одним COUNT(*), но orders, payments, users заслуживают полной сверки содержимого.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверВыборочная построчная сверка критичных записей
Контрольная сумма таблицы отвечает на вопрос "есть расхождение или нет", но не говорит, в какой строке. Для критичных таблиц дополните её выборочной построчной сверкой — не всех строк (это дорого на больших таблицах), а осмысленной выборки.
Собирайте выборку по трём принципам:
- Свежие записи — последние N строк по времени создания или изменения. Именно они чаще всего страдают при обрыве переноса на последних секундах.
- Граничные записи — самая первая и самая последняя строка по первичному ключу. Ошибки диапазонов (
WHERE id < 1000000вместо<=) режут ровно по границе. - Случайная выборка — несколько сотен строк вразброс, чтобы поймать не системную, а точечную порчу (битый сектор, единичный сбой сети при передаче).
-- случайная выборка 500 id для последующей сверки
SELECT id FROM orders ORDER BY random() LIMIT 500; -- PostgreSQL
SELECT id FROM orders ORDER BY RAND() LIMIT 500; -- MySQL, только для некрупных таблиц
Для самой сверки не тащите данные вручную построчно — выгрузите одинаковый набор строк с обеих сторон в CSV с фиксированным порядком столбцов и сравните diff:
psql -h old-host -d mydb -c "COPY (SELECT * FROM orders WHERE id IN (12,45,...) ORDER BY id) TO STDOUT WITH CSV" > old_sample.csv
psql -h new-host -d mydb -c "COPY (SELECT * FROM orders WHERE id IN (12,45,...) ORDER BY id) TO STDOUT WITH CSV" > new_sample.csv
diff old_sample.csv new_sample.csv
Если diff молчит — выборка идентична. Если показывает расхождения — вы получаете конкретные id и конкретные столбцы, где разошлось содержимое, а не абстрактное "что-то не так". Это резко сокращает время расследования по сравнению с ситуацией, когда проблему находит пользователь через две недели после переезда.
Если у вас есть возможность временно подключить обе базы одновременно (например, через postgres_fdw или dblink в PostgreSQL, FEDERATED-таблицы в MySQL), сверку можно сделать одним запросом через EXCEPT:
-- строки, которые есть в старой базе, но отсутствуют (или отличаются) в новой
SELECT * FROM old_db.orders
EXCEPT
SELECT * FROM new_db.orders;
Пустой результат в обе стороны (old EXCEPT new и new EXCEPT old) — надёжное подтверждение идентичности выбранного среза. Это дороже по ресурсам, чем сверка суммы, поэтому применяйте на выборке или на некрупных, но критичных таблицах целиком.
Проверка целостности связей между таблицами
Отдельный класс проблем — не потерянные строки, а разорванные связи между ними. Перенос по таблицам не всегда идёт в порядке, уважающем внешние ключи: если orders загрузилась раньше, чем customers, и на целевой базе включена проверка FOREIGN KEY, часть строк orders могла быть отклонена импортом или загружена с customer_id, ссылающимся в никуда — если проверки временно отключались флагом SET FOREIGN_KEY_CHECKS=0 (MySQL) или session_replication_role = replica (PostgreSQL) и не были включены обратно с последующей проверкой.
Отключение проверок на время загрузки — нормальная и часто необходимая практика (иначе дамп с циклическими зависимостями между таблицами не загрузится вообще), но она снимает единственный автоматический барьер, который мог бы поймать разрыв связи. После включения проверок обратно нужно явно поискать сироты:
-- заказы, у которых customer_id не пуст, но такого клиента нет
SELECT o.id, o.customer_id
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.customer_id IS NOT NULL AND c.id IS NULL;
Повторите такой запрос для каждой пары таблиц, связанной внешним ключом — по одному на связь. Если в схеме их много, автоматизируйте выборку связей из системного каталога вместо ручного перечисления:
-- PostgreSQL: список всех FK-ограничений в базе
SELECT
tc.table_name, kcu.column_name,
ccu.table_name AS foreign_table, ccu.column_name AS foreign_column
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY';
По этому списку легко сгенерировать проверочные запросы автоматически, а не держать их в голове. Если на целевой базе внешние ключи вообще не были воссозданы (частая ситуация при переносе через --data-only без структуры или при ручном создании схемы заново) — сначала восстановите ограничения, а уже потом ищите сироты: без самих FOREIGN KEY СУБД не помешает будущим разрывам, даже если сейчас данные согласованы.
Отдельно стоит проверить последовательности (SEQUENCE в PostgreSQL, AUTO_INCREMENT в MySQL). После переноса они нередко остаются на старом значении, а не на максимальном id таблицы — тогда первая же новая запись после переезда попытается занять уже существующий id и упадёт по нарушению первичного ключа, причём именно в проде, а не во время проверки:
-- PostgreSQL: выровнять sequence по факту
SELECT setval('orders_id_seq', (SELECT max(id) FROM orders));
Собираем проверку в один скрипт
Ручные запросы хороши для разбора конкретной находки, но перед тем как объявить перенос завершённым, нужен единый прогон по всем критичным таблицам без риска что-то забыть. Простой каркас на bash, который считает контрольные суммы по списку таблиц на обеих базах и сравнивает построчно:
#!/usr/bin/env bash
set -euo pipefail
TABLES="orders payments customers users"
OLD="postgresql://user:pass@old-host/mydb"
NEW="postgresql://user:pass@new-host/mydb"
for t in $TABLES; do
old_sum=$(psql "$OLD" -tAc "SELECT count(*)||':'||coalesce(sum(('x'||substr(md5(t::text),1,16))::bit(64)::bigint),0) FROM $t t;")
new_sum=$(psql "$NEW" -tAc "SELECT count(*)||':'||coalesce(sum(('x'||substr(md5(t::text),1,16))::bit(64)::bigint),0) FROM $t t;")
if [ "$old_sum" == "$new_sum" ]; then
echo "OK $t: $old_sum"
else
echo "FAIL $t: старая=$old_sum новая=$new_sum"
fi
done
Запускайте его сразу после переноса, пока обе базы ещё доступны и есть возможность оперативно доперенести или исправить конкретные строки — не через неделю, когда старый сервер уже выключен. Для MySQL логика та же, меняются только команда подключения и функция хеша. Если у вас много однотипных проектов на MySQL, присмотритесь к pt-table-checksum из Percona Toolkit — он делает то же самое, плюс умеет чанковать большие таблицы, но требует отдельной настройки.
Держите этот скрипт не как одноразовый, а как часть регламента: он пригодится не только при переезде на новый сервер, но и при миграции базы данных между серверами в целом, и отдельно — после восстановления базы из бэкапа, когда та же проблема "восстановилось, но не то" встречается ничуть не реже.
Что делать, если сверка нашла расхождение
Найденное расхождение — это не повод откатывать весь перенос, если вы локализовали проблему до конкретных таблиц и строк. Порядок действий:
- Зафиксируйте старый сервер как источник истины и не трогайте его, пока не закончите разбор — если он ещё жив, это ваш эталон для точечного дозаполнения.
- Определите тип расхождения: пропущенные строки (есть в старой, нет в новой), лишние строки (задвоение при повторном запуске импорта) или изменённое содержимое (кодировка, обрезка по длине столбца, неверный
NULLвместо пустой строки). - Дозалейте точечно, а не всей таблицей:
INSERT INTO ... SELECT ... WHERE id IN (...)по списку конкретныхidиз разбора, сON CONFLICT DO UPDATEдля перезаписи испорченных записей. - Перепроверьте контрольную сумму таблицы после исправления — тем же запросом, которым нашли проблему, чтобы не полагаться на глаз.
- Найдите причину, а не только симптом: обрыв соединения при
pg_dump, отсутствие--single-transaction, гонка между переносом и продолжающейся записью в старую базу при миграции без даунтайма — тогда без отдельного механизма догона (репликация, CDC, повторный дифф после копирования) расхождение гарантировано.
Если расхождений системно много — это сигнал не чинить построчно, а повторить перенос целиком с исправленным скриптом: точечные патчи оправданы для единичных находок, а не когда сверка находит сотни несовпадений.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Сколько времени занимает содержательная сверка на большой базе?
Контрольная сумма по таблице в несколько миллионов строк на среднем VPS считается от нескольких секунд до пары минут — это агрегатный запрос с одним проходом по таблице. Выборочная построчная сверка на несколько сотен строк — секунды. Полный EXCEPT по таблице целиком заметно тяжелее, применяйте его точечно, а не по умолчанию для всех таблиц.
Нужно ли сверять вообще все таблицы или только критичные?
Полной методикой (сумма + выборка + FK) экономически оправдано сверять только таблицы с деньгами, пользователями и юридически значимыми данными. Остальные справочники и логи можно проверить одним COUNT(*) — цена ошибки там ниже.
Можно ли доверять контрольной сумме, если она сошлась?
Да, если она порядконезависимая и считается по перечисленным столбцам, а не по случайной конкатенации — тогда совпадение статистически надёжно доказывает идентичность содержимого.
Что если старый сервер уже выключен, а проблему нашли позже?
Содержательная сверка без резервной копии старой базы невозможна — отсюда практическое правило: не освобождайте старый сервер, пока полная сверка после переезда не пройдена и не подтверждена.
Как быть с бинарными данными (BLOB, файлы в базе) при подсчёте контрольной суммы?
Приведение к text в PostgreSQL корректно захватывает и bytea-столбцы, так что формула работает без изменений. Для по-настоящему больших BLOB добавьте отдельно md5(файл) по самому содержимому поля, чтобы не гонять весь объём через агрегатный запрос.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →