Последняя минута переезда: где теряются записи в старую базу
Перенос базы данных на новый сервер почти никогда не ломается на самом переносе — дамп снят корректно, репликация догналась, данные на новом сервере совпадают со старыми на момент проверки. Ломается он в последнюю минуту, в момент переключения приложения с одного адреса на другой, когда несколько запросов оказываются «между серверами»: старый уже не считается главным, новый ещё не принял все соединения, а один-два запроса зависли где-то посередине. Разберём, откуда берутся эти потери и как закрыть именно эту минуту, а не весь процесс переноса.
Содержание
- Почему теряются именно последние секунды, а не вся база
- Сценарий первый: запрос "в полёте" в момент переключения
- Сценарий второй: открытые долгие транзакции на старой базе
- Практический подход: короткое окно read-only на обеих базах
- Что произойдёт с приложением в момент read-only окна
- Автоматизация окна: скрипт вместо ручных шагов
- Как проверить, что окно сработало и записи не потерялись
Почему теряются именно последние секунды, а не вся база
Когда переезд обсуждают заранее, внимание уходит на то, что происходит с основным объёмом данных: скорость дампа, время восстановления индексов, совпадение чексумм. Это оправданно — там действительно легко ошибиться. Но если сам перенос организован через репликацию или поэтапную синхронизацию (подробно об этом — в статье про перенос большой базы с минимальным даунтаймом), основной объём переезжает заранее и без риска: старая база продолжает работать, новая тихо копит те же данные в фоне.
Риск концентрируется в узкой полосе времени — от секунд до пары минут — когда происходит собственно переключение: 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 запрос либо успевает завершиться и получить честную ошибку (которую приложение может показать пользователю или безопасно повторить), либо блокируется сразу, не создавая двусмысленной ситуации.
Общая последовательность финального окна выглядит так:
- Перевести старую базу в 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 тихо продолжит писать). - Дождаться отсутствия активных транзакций на запись. Проверить список активных сессий (
pg_stat_activityв PostgreSQL,SHOW PROCESSLIST/information_schema.processlistв MySQL) на предмет долгих или зависших транзакций, которые могли начаться до включения read-only и всё ещё выполняются. Если такая транзакция есть — либо дождаться её естественного завершения (она либо закоммитится, либо упадёт с ошибкой read-only при попытке записи), либо, если она явно зависла, прервать её осознанно. - Дождаться нулевого отставания репликации между старой и новой базой именно после шага 1-2 — то есть когда источник данных уже гарантированно не меняется, а не в процессе, когда он ещё может измениться.
- Переключить приложение на новую базу. Только теперь менять endpoint, DNS-запись или конфиг балансировщика.
- Снять read-only с новой базы (если она была поднята как реплика в read-only режиме — типичная настройка для standby) и дать ей принимать запись.
- Держать старую базу в 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-only | SELECT 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 ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →