Две версии приложения и одна база данных: переход через Dual-Write и Dual-Read
Представьте: ваша команда только что добавила новую колонку в таблицу на продакшене. Миграция схемы прошла гладко. Теперь нужно развернуть новую версию приложения, которая пишет в эту колонку. Но старая версия всё ещё работает, обрабатывает запросы и ничего не знает о новой колонке.
Если новое приложение начнёт писать только в новую колонку, старая версия не увидит эти данные. Пользователи, чьи запросы попадут на старую версию, получат неполные или несогласованные результаты. Нельзя мгновенно остановить старое приложение. Нельзя переключить всё на новую версию одним махом. Нужен переходный период, когда обе версии сосуществуют и используют одну базу данных.
Здесь и становятся необходимыми паттерны Dual-Write и Dual-Read. Они не элегантны. Они не просты. Но это практичный способ мигрировать структуры данных в живой системе без даунтайма.
Основная проблема: две версии, одна база
Когда вы расширяете схему, добавляя новую колонку или таблицу, база данных может хранить как старые, так и новые структуры. Но приложения, читающие и пишущие эти данные, не так гибки. Старая версия приложения понимает только старую структуру. Новая версия понимает обе, но ей нужно поддерживать работу старой версии, пока все инстансы не будут обновлены.
Наивный подход — заставить новое приложение писать только в новую колонку. Это сломает старую версию немедленно. Старое приложение читает из старой колонки, ничего не находит и падает. Правильный подход — заставить новое приложение писать в оба места, пока старая версия не будет удалена.
Следующая диаграмма последовательности иллюстрирует поток записи и чтения в переходный период:
Dual-Write: запись в два места одновременно
Dual-Write означает, что новая версия приложения при каждой операции записи пишет данные как в старую, так и в новую структуру. Когда пользователь создаёт запись, новое приложение заполняет старую колонку как обычно, а затем пишет те же данные в новую колонку.
Это звучит просто, но две детали имеют большое значение.
Вот пример на JavaScript, реализующий Dual-Write и Dual-Read для обновления профиля пользователя:
async function updateUserProfile(userId, name, email) {
// Dual-write: сначала пишем в старую колонку, затем в новую
await db.query(
'UPDATE users SET name = ?, email = ? WHERE id = ?',
[name, email, userId]
);
await db.query(
'UPDATE users SET profile_data = ? WHERE id = ?',
[JSON.stringify({ name, email }), userId]
);
}
async function getUserProfile(userId) {
// Dual-read: предпочитаем новую колонку, падаем на старую
const row = await db.query(
'SELECT profile_data, name, email FROM users WHERE id = ?',
[userId]
);
if (row.profile_data) {
return JSON.parse(row.profile_data);
}
return { name: row.name, email: row.email };
}
Во-первых, порядок записи должен быть консистентным. Сначала пишите в старую колонку, затем в новую. Если процесс упадёт после записи в старую колонку, но до записи в новую, старая версия всё равно сможет прочитать данные. В новой колонке эта запись будет отсутствовать, но это можно исправить позже обратной загрузкой (backfill). Если бы вы писали сначала в новую колонку и процесс упал, старая версия увидела бы неполные данные немедленно. Это инцидент на продакшене, который ждёт своего часа.
Во-вторых, значения должны быть идентичными. Данные, записанные в старую и новую колонки, должны представлять одну и ту же информацию. Если между двумя записями есть какая-либо трансформация или разница в логике, вы получите несогласованность данных, которую будет очень сложно отлаживать. Держите логику записи идентичной. Новая колонка может хранить данные в другом формате или структуре, но смысл должен быть тем же.
Dual-Read: чтение из двух мест с приоритетом старого
Как только Dual-Write запущен, новое приложение может писать данные, которые читают обе версии. Но как быть с чтением? Новое приложение могло бы начать читать из новой колонки немедленно, но это создаёт проблему. Старая версия всё ещё пишет данные только в старую колонку. Если новое приложение читает только из новой колонки, оно пропустит данные, записанные старой версией.
Решение — Dual-Read. Новое приложение читает из обоих мест, но во время переходного периода отдаёт приоритет старой колонке. Это гарантирует, что данные, записанные старой версией, всегда видны новой. Со временем, когда вы убедитесь, что данные корректно попадают в новую колонку, можно постепенно переключать чтение на новую колонку.
Это постепенное переключение — то место, где feature flags становятся полезными. Вы можете настроить новое приложение читать из новой колонки для небольшого процента запросов. Если ничего не ломается, увеличивайте процент. Если появляется ошибка, переключайте флаг обратно — и все чтения снова пойдут в старую колонку. Никакого повторного развёртывания не нужно.
А что насчёт данных, записанных старой версией?
Во время этого переходного периода старая версия всё ещё работает и пишет только в старую колонку. Новое приложение должно это обрабатывать. Когда новое приложение читает запись, созданную старой версией, оно находит данные только в старой колонке. Новое приложение должно уметь читать эти данные из старой колонки, использовать их и, опционально, копировать в новую колонку в рамках фонового процесса.
Это не то же самое, что Dual-Write. Это процесс обратной загрузки (backfill), который запускается отдельно, заполняя новую колонку данными, записанными до того, как новое приложение начало писать в оба места. Backfill — это пакетная операция, которая выполняется после того, как паттерны Dual-Write и Dual-Read стабилизировались.
Реальная сложность: координация перехода
Самая сложная часть этой фазы — не код. Это координация. Вам нужно знать, какие инстансы работают на какой версии. Вам нужно знать, когда последний старый инстанс был выведен из эксплуатации. Вам нужно мониторить несогласованность данных между старой и новой колонками.
Во время Dual-Write каждая операция записи превращается в две записи. Это означает большую нагрузку на базу данных, больше времени на транзакции и больше потенциальных точек отказа. Мониторьте метрики базы данных в этой фазе. Если задержка записи увеличивается, возможно, придётся пакетировать записи или использовать асинхронную репликацию.
Во время Dual-Read каждая операция чтения может потребовать проверки двух мест. Это добавляет сложности в логику запросов и может замедлить пути чтения. Используйте кэширование с осторожностью. Не кэшируйте данные из старой колонки, если новая колонка должна стать источником истины.
Практический чеклист для перехода
- Убедитесь, что расширение схемы (новая колонка или таблица) развёрнуто и проверено.
- Разверните новую версию приложения с логикой Dual-Write: сначала запись в старую колонку, затем в новую.
- Проверьте, что данные, записанные новым приложением, видны старой версии.
- Включите Dual-Read в новом приложении: по умолчанию читайте из старой колонки, но будьте готовы переключиться.
- Используйте feature flag для постепенного переключения чтения со старой колонки на новую.
- Мониторьте несогласованность данных между старой и новой колонками.
- Запустите процесс обратной загрузки (backfill) для копирования старых данных в новую колонку.
- После завершения backfill и переключения всех чтений на новую колонку удалите логику Dual-Write.
- Выведите из эксплуатации старую колонку или таблицу после подтверждения, что ни одно приложение из неё не читает.
Вывод
Dual-Write и Dual-Read — это не постоянные паттерны. Это временные мосты, которые позволяют мигрировать структуры данных, сохраняя систему работающей для всех пользователей. Цель — достичь состояния, где имеет значение только новая структура, а старую можно удалить. До тех пор каждая запись идёт в два места, каждое чтение проверяет два источника, а ваша команда остаётся начеку в поисках несогласованностей. Эта фаза некомфортна, но это единственный способ изменить схему живой базы данных без остановки мира.