Двойная запись в старую и новую базу при переезде: схема и ловушки
Классический переезд на новую базу — это стоп-кран: гасите приложение, переносите дамп, поднимаете новую базу, включаете приложение обратно и надеетесь, что всё сошлось. Если что-то пошло не так, откатываться приходится под давлением недовольных пользователей и с горящим таймером. Двойная запись даёт другой сценарий: приложение какое-то время пишет одновременно в старую и новую базу, а вы постепенно переключаете сначала чтение, потом весь трафик — и в любой момент можете откатиться назад, потому что старая база всё это время оставалась актуальной.
Содержание
Зачем это применяется вместо единомоментного переключения
Единомоментное переключение («big bang cutover») хорошо работает, когда база маленькая, окно простоя приемлемо, а откат — это просто «включить старую версию обратно». Но как только база разрослась до десятков гигабайт, а простой в час пик стоит реальных денег, схема с одним переключением превращается в один длинный нервный вечер, где решения принимаются за минуты, а не часы.
Двойная запись разрывает связку «перенос данных» и «переключение чтения» на два независимых шага. Пока приложение пишет в обе базы, новая база постоянно актуальна — не нужно догонять её единоразовым дампом прямо перед переключением. А переключение чтения можно делать постепенно: сначала 1% трафика читает из новой базы, потом 10%, потом 100%, и на каждом шаге есть работающая старая база как страховка.
Это не единственный способ переноса данных — есть логическая репликация, CDC-инструменты вроде Debezium, миграция через триггеры СУБД. Про более простой случай, когда меняется просто сервер при той же СУБД, у нас есть отдельный разбор — про перенос большой базы с минимальным простоем, там штатная репликация почти всегда выигрывает по трудозатратам. Двойная запись на уровне кода приложения оправдана, когда:
- меняется не просто адрес сервера, а модель данных (например, при переходе с MySQL на PostgreSQL со сменой схемы или типов);
- нужен контроль над моментом переключения на уровне бизнес-логики, а не только на уровне репликации СУБД;
- старая и новая базы — разные технологии, между которыми штатной репликации не существует (реляционная база → документная, например).
Если СУБД остаётся той же — почти всегда проще и надёжнее логический слот или потоковая репликация, а не dual write в коде. Это дополнительная сложность, которую потом придётся убирать, поэтому применяйте её осознанно, а не по умолчанию.
Практическая схема: порядок записи и авторитетная база
Первое решение, которое нужно принять до единой строчки кода — какая база на переходный период считается источником истины (authoritative). Это определяет порядок записи и обработку ошибок.
Правило простое: сначала пишем в авторитетную базу, и только при успехе — в дублирующую. Если авторитетная запись не удалась, вся операция завершается ошибкой для клиента, как будто dual write не существует. Если не удалась только дублирующая запись — операция для клиента всё равно успешна, но об ошибке нужно узнать и обработать её отдельно.
def save_order(order):
# 1. Пишем в авторитетную базу — ошибка блокирует ответ клиенту
try:
primary_db.save(order)
except Exception as e:
raise OrderSaveError("primary write failed", e)
# 2. Пишем в дублирующую базу — ошибка НЕ блокирует ответ
try:
secondary_db.save(order)
except Exception as e:
log.error("dual-write secondary failed", order_id=order.id, err=e)
metrics.increment("dual_write.secondary_failed")
reconciliation_queue.push(order.id)
return order
На старте миграции авторитетна старая база — она проверена годами, на неё завязаны остальные интеграции. Ближе к концу, когда вы уже перевели чтение на новую базу и убедились в её стабильности, авторитетность меняют местами: новая база становится primary, старая — secondary, которую вот-вот отключат. Это отдельный флаг конфига, переключать его нужно быстро, без деплоя.
Отдельно продумайте многострочные транзакции: в авторитетной базе операция должна оставаться одной транзакцией СУБД (например, списание с одного счёта и зачисление на другой), а в дублирующую можно писать те же изменения уже не строго атомарно — важно лишь залогировать, что это были связанные операции, а не потерять эту связь.
Логику dual write лучше не размазывать по коду, а вынести в единый слой доступа к данным (repository/DAO) — тогда обёртка пишется один раз, а не в каждом месте записи:
class DualWriteRepository:
def __init__(self, primary, secondary, dual_write_enabled=True):
self.primary = primary
self.secondary = secondary
self.dual_write_enabled = dual_write_enabled
def save(self, entity):
result = self.primary.save(entity)
if self.dual_write_enabled:
try:
self.secondary.save(entity)
except Exception as e:
logger.error(f"secondary write failed for {entity.id}: {e}")
reconciliation_queue.enqueue(entity.table, entity.id)
return result
Альтернатива — писать только в primary и параллельно класть событие в очередь (Kafka, RabbitMQ или outbox-таблица в той же транзакции), а отдельный consumer уже пишет в secondary. Это надёжнее с точки зрения гарантий доставки — очередь можно перечитать при сбое — но добавляет ещё один движущийся компонент и задержку. Для миграции на несколько недель заводить отдельную очередь обычно избыточно; для месяцев с высокой нагрузкой на запись — оправданно.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПошаговый план: от бэкфилла до отключения старой базы
Двойная запись — не финальное состояние, а временный мост. Держать её включённой месяцами — плохая идея: это двойная нагрузка на запись и постоянный источник расхождений. Практическая последовательность:
- Первичный бэкфилл — копируете существующие данные из старой базы в новую (дамп/restore, ETL, логическая репликация на старте). Dual write ещё выключен.
- Включаете dual write, старая база — authoritative. Приложение пишет в обе базы, читает по-прежнему только из старой.
- Сверка — прогоняете скрипт, сравнивающий содержимое баз, устраняете расхождения, накопленные между бэкфиллом и включением dual write.
- Постепенный перевод чтения — часть read-запросов (по фиче-флагу, по проценту пользователей) начинает идти в новую базу.
- Полный перевод чтения, dual write продолжает работать, но теперь новая база — authoritative, а старая — secondary на случай отката.
- Отключение dual write и старой базы — после контрольного периода стабильности (обычно 1–2 недели без инцидентов).
Каждый шаг обратим сам по себе — в этом главное преимущество перед единомоментным переключением. Если на шаге 4 новая база отдаёт неверные данные, вы просто откатываете флаг чтения назад на старую, а dual write продолжает синхронизировать обе. План отката стоит прописать заранее по каждому шагу — общие принципы такого планирования разобраны в статье про план отката миграции.
Ловушка: расхождение, когда одна запись прошла, а другая нет
Это центральная проблема dual write, и полностью устранить её нельзя — можно только держать под контролем. Причина в том, что запись в две независимые базы принципиально не атомарна: между записью в primary и записью в secondary всегда есть окно, где успех первой не гарантирует успеха второй.
Сценарии, которые реально случаются:
- Таймаут на secondary — сеть моргнула, secondary-база временно недоступна, primary записал успешно. Данные разошлись.
- Сбой процесса между двумя записями — приложение упало (OOM killer, деплой, рестарт контейнера) ровно между первой и второй записью.
- Разные схемы валидации — запись прошла в старой базе (например, там нет NOT NULL на поле), но новая база её отклонила из-за более строгой схемы. Это не баг инфраструктуры, а несовпадение бизнес-правил между моделями данных — его нужно ловить при проектировании схемы новой базы, а не только на рантайме.
- Гонка при параллельных обновлениях одной сущности — два запроса на изменение пришли почти одновременно, и порядок применения в primary и secondary оказался разным при отсутствии единого источника упорядочивания (версии или timestamp).
Полностью исключить это без распределённой транзакции (two-phase commit) нельзя, а 2PC между разнородными СУБД на практике почти не используют — бьёт по производительности сильнее, чем помогает. Реалистичная стратегия — не «не допускать расхождений», а быстро их обнаруживать: каждая неудачная secondary-запись обязана попасть в очередь дозаписи и/или лог, а не тихо потеряться в except: pass:
# Плохо — расхождение теряется бесследно
try:
secondary_db.save(entity)
except Exception:
pass
# Лучше — расхождение зафиксировано и будет обработано
try:
secondary_db.save(entity)
except Exception as e:
dual_write_failures.labels(table=entity.table).inc()
logger.error("secondary_write_failed", entity_id=entity.id, error=str(e))
reconciliation_queue.push({"table": entity.table, "id": entity.id})
Держите на отдельном дашборде (в Grafana или любой другой системе мониторинга) хотя бы долю неудачных secondary-записей и размер очереди дозаписи. Алерт на резкий рост доли ошибок сигнализирует о проблеме до того, как расхождения накопятся до критической массы.
Ловушка: отладка расхождений и необходимость сверки
Даже когда вы честно логируете все неудачные secondary-записи, остаётся класс расхождений, которые не видны на уровне try/except — обе записи технически «успешны», но привели к разным результатам:
- Разное поведение при конфликте.
INSERT ... ON CONFLICT DO UPDATEв PostgreSQL иINSERT ... ON DUPLICATE KEY UPDATEв MySQL могут по-разному обработать одновременную вставку с одинаковым уникальным ключом. - Разная точность типов — DECIMAL с разным масштабом, TIMESTAMP с разной точностью, разница в обработке часовых поясов между базами (см. разбор про спор базы и приложения о часовом поясе).
- Автоинкременты и генерируемые значения. Если ID генерируется на уровне базы (SERIAL, AUTO_INCREMENT), primary и secondary почти гарантированно получат разные ID для одной сущности — и связи по этому ID в secondary окажутся сломаны, если явно не передавать сгенерированный в primary ID во вторую базу.
- Побочные эффекты триггеров и дефолтов, которые есть в одной базе и отсутствуют в другой.
Отладка «почему у нас 40 000 несовпадающих строк и непонятно с какого момента» на живой системе — занятие на несколько дней. Прежде чем включать dual write, зафиксируйте источник генерируемых значений (лучше — ID на уровне приложения, одинаковый для обеих баз) и напишите интеграционный тест, прогоняющий одинаковый набор операций через обе базы с построчным сравнением результата — тест на полсотни сценариев ловит те же классы ошибок за час, а не за дни разбора прод-инцидента.
Логирование ошибок ловит только явно провалившиеся попытки. Оно не ловит сетевые партиции с частичным успехом, ручные правки данных напрямую в одной из баз, гонки условий и баги самой логики dual write, которые вы ещё не нашли. Поэтому пока dual write включён, обязателен отдельный процесс регулярной сверки, не зависящий от кода приложения:
-- Контрольная сумма по срезу времени (пример для PostgreSQL)
SELECT count(*) AS row_count,
md5(string_agg(id::text || updated_at::text, ',' ORDER BY id)) AS checksum
FROM orders
WHERE updated_at >= now() - interval '1 day';
Такой запрос гоняется на обеих базах за один срез, и если суммы не совпали — запускается построчная сверка только за этот интервал (сравнивать всю таблицу целиком на каждой итерации слишком дорого при десятках миллионов строк). Периодичность зависит от объёма трафика: для не очень нагруженной системы достаточно ночного джоба раз в сутки, для активно пишущей — каждые несколько часов, чтобы расхождение не накапливалось до состояния, когда починка требует ручного разбора сотен записей.
| Метод сверки | Когда использовать | Стоимость |
|---|---|---|
| Контрольная сумма по срезу времени | Регулярный фоновый мониторинг | Низкая, можно гонять часто |
| Построчное сравнение по diff | После срабатывания контрольной суммы | Средняя, точечно |
| Полное построчное сравнение таблицы | Разовая проверка перед отключением dual write | Высокая, разово |
Важно: сверка должна не просто найти расхождение, а автоматически (или полуавтоматически, с ревью человеком) его чинить — переписывать secondary из primary как источника истины, пока primary авторитетна. Иначе вы получаете дашборд с растущим счётчиком нестыковок, на который через месяц никто уже не смотрит. Финальную полную сверку обязательно делайте перед отключением старой базы — именно от неё зависит, можно ли безопасно списать старую базу со счетов навсегда.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Сколько времени обычно держат dual write включённым?
Зависит от объёма данных и уверенности в новой базе, но типичный диапазон — от пары недель до пары месяцев. Затягивать дольше не стоит: чем дольше работает двойная запись, тем больше накапливается технического долга в расхождениях и тем сложнее поддерживать код, который одновременно обслуживает две модели данных.
Можно ли сразу сделать новую базу authoritative?
Технически можно, но это лишает главного преимущества схемы — возможности безопасно откатиться. Пока новая база не проверена на реальном трафике чтения, разумнее держать authoritative старую и постепенно переносить доверие на новую.
Что делать с удалением записей?
Дублировать так же, как вставку и обновление — soft delete (флаг deleted_at) обычно безопаснее hard delete на время dual write, потому что позволяет сверке отличить «запись удалили» от «запись потеряли из-за бага репликации».
Нужен ли dual write, если меняется только сервер при той же СУБД?
Обычно нет — штатная логическая или потоковая репликация решает задачу надёжнее и без изменений в коде. Подробнее о таком более простом случае — в статье про миграцию базы данных между серверами. Dual write в коде приложения оправдан прежде всего при смене СУБД или существенной смене схемы.
Как понять, что пора отключать dual write?
Три условия одновременно: весь трафик чтения переведён на новую базу, полная сверка не находит расхождений (или находит единичные, объяснимые и устранённые), и прошёл контрольный период стабильности без инцидентов.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →