MAATRIX / Блог / Последняя минута переезда: где теряются записи в старую базу

Последняя минута переезда: где теряются записи в старую базу

MAATRIX

Перенос базы данных на новый сервер почти никогда не ломается на самом переносе — дамп снят корректно, репликация догналась, данные на новом сервере совпадают со старыми на момент проверки. Ломается он в последнюю минуту, в момент переключения приложения с одного адреса на другой, когда несколько запросов оказываются «между серверами»: старый уже не считается главным, новый ещё не принял все соединения, а один-два запроса зависли где-то посередине. Разберём, откуда берутся эти потери и как закрыть именно эту минуту, а не весь процесс переноса.

Почему теряются именно последние секунды, а не вся база

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

Риск концентрируется в узкой полосе времени — от секунд до пары минут — когда происходит собственно переключение: DNS/балансировщик/строка подключения приложения меняются со старого адреса на новый. В этой полосе одновременно верны два утверждения: старая база ещё принимает и обрабатывает запросы (потому что клиенты с открытыми соединениями не узнают о переключении мгновенно), и новая база уже считается источником истины для новых запросов и для последующего чтения. Если в этот момент какой-то запрос успел записать данные в старую базу, а решение «переезд завершён» уже принято — эта запись рискует остаться только в старой базе, откуда её никто больше не прочитает.

Важно: это не про потерю данных из-за плохого дампа или порчи файла. Это про запросы, которые физически выполнились, но оказались не в том месте, где их потом ищет приложение. С точки зрения пользователя это выглядит как «я оформил заказ, а он пропал» — хотя технически заказ записался, просто в базу, которая через минуту перестала быть актуальной.

Сценарий первый: запрос "в полёте" в момент переключения

Возьмём конкретный таймлайн. Приложение держит пул соединений к старому серверу базы. В 14:00:00.000 вы меняете endpoint в конфиге и перезапускаете (или перегружаете конфиг) приложения. С этого момента новые соединения идут на новый сервер. Но соединения, которые уже были открыты и в момент переключения выполняли запрос — например, INSERT в таблицу заказов — не обрываются мгновенно. Драйвер СУБД честно доводит запрос до конца, получает подтверждение commit от старого сервера и возвращает управление коду приложения.

Дальше развилка, от которой зависит, потеряны данные или нет:

  • Если приложение получило подтверждение записи от старого сервера и корректно её обработало (например, вернуло пользователю ID заказа, записанный именно в старой базе) — данные физически есть, но только в старой базе. Если после переключения никто больше туда не заглянет — а именно так обычно и происходит, старый сервер выключают или архивируют через день-два — запись фактически потеряна для системы, даже будучи технически сохранённой.
  • Если соединение к старому серверу оборвалось на середине запроса (сеть, таймаут, принудительное закрытие пула) — само приложение обычно получает ошибку и может повторить запрос на новом сервере. Здесь риск не потери, а дублирования: если старый сервер всё-таки успел закоммитить транзакцию до разрыва, а приложение решило, что запрос не удался, и повторило его на новом — вы получаете запись дважды в разных базах.

Обе ветки — следствие одного и того же факта: между «клиент начал запрос к серверу A» и «клиент получил и обработал ответ от сервера A» проходит время, и если решение о переключении принимается внутри этого окна без учёта уже выполняющихся запросов, результат непредсказуем. Проблема не в конкретной СУБД и не в конкретном драйвере — она возникает в любой системе, где переключение происходит по времени или по внешнему сигналу, а не по факту завершения всех операций.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Сценарий второй: открытые долгие транзакции на старой базе

Второй источник потерь — не короткие одиночные запросы, а транзакции, которые длятся дольше, чем кажется на первый взгляд: фоновые джобы, батчевые обновления, экспорт отчётов внутри транзакции, ORM, который открывает транзакцию в начале HTTP-запроса и держит её до самого конца рендеринга страницы. Такая транзакция может стартовать за пару секунд до переключения и закоммититься через 5-10 секунд после — то есть уже после того, как вы объявили миграцию завершённой и, возможно, начали снимать финальный снапшот старой базы для архива или для сверки.

Здесь есть нюанс с точки зрения того, что именно видит триггер переключения. Если вы ориентируетесь на «отставание репликации равно нулю» как на сигнал готовности к финальному шагу, это отставание считается по уже закоммиченным транзакциям. Открытая, но ещё не закоммиченная транзакция в это отставание не входит — реплика физически не может её увидеть, пока не будет коммита в WAL (PostgreSQL) или в binlog (MySQL). Это значит, что чисто по метрике «lag = 0» можно ошибочно решить, что старая и новая базы полностью синхронны, в то время как в старой базе висит открытая транзакция, которая через несколько секунд допишет данные, никогда не попадающие в новую базу через уже остановленный поток репликации.

Механику того, что происходит в момент коммита и почему это не мгновенная точка, а процесс с несколькими шагами, разбирали отдельно в статье о том, что происходит при commit транзакции — то же понимание полезно и здесь: пока транзакция не закоммичена, её результат недоступен ни для чтения, ни для репликации, но ресурсы (блокировки, место в WAL) она уже заняла, и с точки зрения приложения запрос уже "выполняется".

Практический подход: короткое окно read-only на обеих базах

Единственный надёжный способ исключить оба сценария — не пытаться угадать момент, когда «все запросы точно завершились», а гарантировать это административно: на несколько секунд или минут вручную перевести старую базу в режим только для чтения одновременно с тем, как новая база ещё не начала принимать пользовательский трафик на запись. Идея простая: если запись физически невозможна ни там, ни там, то in-flight запрос либо успевает завершиться и получить честную ошибку (которую приложение может показать пользователю или безопасно повторить), либо блокируется сразу, не создавая двусмысленной ситуации.

Общая последовательность финального окна выглядит так:

  1. Перевести старую базу в read-only. Для PostgreSQL это ALTER SYSTEM SET default_transaction_read_only = on; с последующим SELECT pg_reload_conf();, либо более жёстко — ALTER DATABASE mydb SET default_transaction_read_only = on; для конкретной базы. Для MySQL/MariaDB — SET GLOBAL read_only = ON; SET GLOBAL super_read_only = ON; (второе исключает даже операции от привилегированных пользователей, что важно — иначе фоновый cron с правами root тихо продолжит писать).
  2. Дождаться отсутствия активных транзакций на запись. Проверить список активных сессий (pg_stat_activity в PostgreSQL, SHOW PROCESSLIST / information_schema.processlist в MySQL) на предмет долгих или зависших транзакций, которые могли начаться до включения read-only и всё ещё выполняются. Если такая транзакция есть — либо дождаться её естественного завершения (она либо закоммитится, либо упадёт с ошибкой read-only при попытке записи), либо, если она явно зависла, прервать её осознанно.
  3. Дождаться нулевого отставания репликации между старой и новой базой именно после шага 1-2 — то есть когда источник данных уже гарантированно не меняется, а не в процессе, когда он ещё может измениться.
  4. Переключить приложение на новую базу. Только теперь менять endpoint, DNS-запись или конфиг балансировщика.
  5. Снять read-only с новой базы (если она была поднята как реплика в read-only режиме — типичная настройка для standby) и дать ей принимать запись.
  6. Держать старую базу в read-only ещё какое-то время (часы, иногда день-два) как страховочную копию для сверки — не выключать и не удалять сразу.

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

Что произойдёт с приложением в момент read-only окна

Здесь важно трезво оценить последствия: на время read-only окна операции записи в приложении будут завершаться с ошибкой. Для интернет-магазина это значит, что оформление заказа в этот момент вернёт ошибку; для SaaS-приложения — что сохранение формы не пройдёт. Это осознанный компромисс: вы меняете риск незаметной потери данных на видимую, короткую и предсказуемую ошибку, которую приложение может корректно обработать — показать пользователю «повторите через несколько секунд» вместо того, чтобы молча проглотить и потерять запись.

Продолжительность окна на практике обычно укладывается в единицы-десятки секунд для баз с разумным объёмом активных транзакций — но это ориентир, а не гарантия: если в системе есть фоновые задачи с долгими транзакциями (пакетная обработка, построение отчётов), окно может растянуться до тех пор, пока такая транзакция не завершится или не будет прервана. Проверить заранее, какие процессы способны держать долгие транзакции, и приостановить их перед началом финального шага — то, что определяет, будет окно секундами или минутами.

Для критичных операций (оплата, оформление заказа) стоит на время окна показывать на фронтенде явный баннер техобслуживания вместо голого 500 — это надёжнее, чем полагаться на то, что каждый клиент сам красиво обработает ошибку от базы.

Автоматизация окна: скрипт вместо ручных шагов

Ручное выполнение шагов 1-5 из предыдущего раздела в момент реального переезда — источник лишней нервотрёпки и ошибок именно потому, что это тот момент, когда хочется всё сделать быстро. Разумно заранее оформить последовательность в скрипт и прогнать его на тестовом стенде несколько раз, чтобы измерить реальное время выполнения каждого шага. Пример каркаса для PostgreSQL (адаптируется под конкретную инфраструктуру):

#!/usr/bin/env bash
set -euo pipefail

OLD_HOST="old-db.internal"
NEW_HOST="new-db.internal"

echo "[1/5] Включаем read-only на старой базе"
psql -h "$OLD_HOST" -U postgres -c "ALTER SYSTEM SET default_transaction_read_only = on;"
psql -h "$OLD_HOST" -U postgres -c "SELECT pg_reload_conf();"

echo "[2/5] Ждём завершения активных транзакций на запись"
while psql -h "$OLD_HOST" -U postgres -tAc \
  "SELECT count(*) FROM pg_stat_activity WHERE state = 'active' AND query !~* '^\s*(SELECT|SHOW)' AND pid <> pg_backend_pid();" \
  | grep -qv '^0$'; do
  echo "  ещё есть активные записи, ждём..."
  sleep 1
done

echo "[3/5] Проверяем отставание репликации"
LAG=$(psql -h "$NEW_HOST" -U postgres -tAc \
  "SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()));")
echo "  отставание: ${LAG}s"

echo "[4/5] Переключаем приложение на новую базу (пример — правка конфига через API/consul/etc)"
# curl -X PUT ... / kubectl set env ... / consul kv put ...

echo "[5/5] Снимаем read-only с новой базы, если она была standby"
psql -h "$NEW_HOST" -U postgres -c "SELECT pg_promote();" || true

echo "Готово. Старую базу оставляем в read-only для сверки."

Каркас условный — конкретные команды переключения зависят от того, как организован доступ приложения к базе (прямой connection string, service discovery, DNS с низким TTL). Но сама последовательность шагов и обязательное ожидание отсутствия активных записей на шаге 2 переносимы на любую СУБД. Отдельно стоит заранее продумать план на случай, если шаг 2 не завершается (транзакция зависла) — иметь список процессов, которые можно безопасно прервать. Общий план действий на случай, если что-то пошло не так, — тема статьи про план отката миграции.

Как проверить, что окно сработало и записи не потерялись

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

ПроверкаКак выполнитьЧто ищем
Счётчик записей за последние N минутSELECT count(*) FROM orders WHERE created_at > now() - interval '10 minutes' на старой и новой базеСовпадение количества (с поправкой на реальный трафик)
Максимальный ID/timestamp на старой базе после read-onlySELECT max(id), max(created_at) FROM ordersНичего не должно появиться после момента включения read-only
Логи приложения на ошибки записи в окне переключенияgrep по логам за период read-only окнаОшибки должны быть учтены как "нормальные" за это окно, не как аномалия
Расхождение по бизнес-ключам (не по автоинкременту)Сравнение по внешним ID заказа/платежа, если они генерируются на стороне клиентаОтсутствие записей, которые есть в одной базе и нет в другой

Последняя проверка особенно важна, если в системе есть операции, которые могли задублироваться в сценарии с разрывом соединения (описанном в первом разделе) — дубли по автоинкрементному ID не найти, а вот по бизнес-ключу (номер заказа, идентификатор платежа от платёжного шлюза) — вполне.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Сколько на практике длится read-only окно?

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

Можно ли обойтись без read-only окна, если использовать двустороннюю репликацию?

Технически можно строить схемы с двусторонней (multi-master) репликацией, но они добавляют собственный класс проблем — конфликты записи, если один и тот же ID записан на обеих сторонах почти одновременно. Для разовой миграции это обычно избыточная сложность по сравнению с коротким read-only окном.

Что делать, если приложение не умеет корректно обрабатывать ошибку записи в базу?

Тогда стоит на уровне балансировщика или API-шлюза на время окна возвращать явную ошибку 503 с Retry-After, не пропуская запрос до приложения вообще — это надёжнее, чем рассчитывать, что каждый эндпоинт корректно проглотит ошибку от драйвера базы.

Нужно ли включать read-only на новой базе тоже, пока идёт финальная проверка?

Да, если новая база до этого была репликой (standby) — она физически в read-only, пока не выполнен pg_promote() или аналог. Важно не промоутить её раньше, чем старая база гарантированно перестала писать, иначе на короткое время обе базы окажутся доступны на запись одновременно — а это уже риск split-brain для одной и той же логической базы.

Как быть с очередями и фоновыми воркерами, а не только с веб-запросами?

Их тоже нужно либо остановить на время окна, либо перевести в режим накопления без обработки (поставить на паузу consumer), иначе воркер, который в момент переключения читает задачу из очереди и пишет результат в базу, — тот же in-flight запрос, только не от HTTP-клиента, а от фонового процесса.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →