Ночью основной сервер базы данных вышел из строя, и сайт перестал принимать заказы. Утром выяснилось, что копия существует, но переключение на неё никто не проверял, а часть транзакций не успела до неё добраться. Восстановление заняло шесть часов, и половина этого времени ушла на выяснение того, какая из копий актуальнее. История типична, и она показывает, что репликация решает одну задачу, создавая при этом несколько новых.
Копии повышают доступность и разделяют нагрузку на чтение
Смысл репликации прост: одни и те же данные хранятся на нескольких машинах. Это даёт три выгоды.
Первая это устойчивость к отказам. Если одна машина выходит из строя, система продолжает работать на остальных. Переключение на исправный узел называется переходом при отказе.
Вторая это распределение нагрузки. Основная машина занимается записью, копии обслуживают чтение. Для сайта, где запросов на чтение в десять раз больше, чем на запись, это даёт заметный выигрыш.
Третья это близость к пользователю. Копия, стоящая в том же регионе, что и клиент, отвечает быстрее. Сетевая задержка между континентами измеряется сотнями миллисекунд, и её устранение заметно на отзывчивости.
Важно различать репликацию и резервное копирование. Копия постоянно обновляется и быстро теряет историческое состояние. Резервная копия, наоборот, остаётся неизменной долгое время. Реплика не заменяет резервную копию: ошибочное удаление таблицы мгновенно разлетится по всем копиям.
Схема с одним ведущим узлом остаётся самой распространённой
В схеме с одним ведущим узлом назначается главная машина, которая обрабатывает все операции записи. Она записывает изменения в журнал, и журнал передаётся копиям. Каждая копия подтверждает получение, после чего следующая запись может считаться завершённой.
Копии при этом обслуживают чтение. Но здесь кроется главная тонкость: копия может отдавать устаревшие данные. Задержка распространения изменений называется отставанием репликации и измеряется обычно миллисекундами, но при высокой нагрузке вырастает до секунд и минут.
Отсюда правило, которое стоит выучить наизусть: сразу после записи читать с копии нельзя. Пользователь обновил профиль, страница ушла на копию, которая ещё не получила изменение, и человек видит старые данные. Он считает, что сохранение не сработало, и повторяет действие.
Лекарство состоит в маршрутизации. Чтение данных, которые только что изменил текущий пользователь, направляется на ведущий узел. Это легко реализуется через привязку сессии: пока сессия активна, её чтения идут на главную машину.
class Router:
def __init__(self, primary, replicas, lag_limit=1.0):
self.primary = primary
self.replicas = replicas
self.lag_limit = lag_limit
def for_read(self, session):
if session.get("dirty"):
return self.primary
candidates = [
node for node in self.replicas
if node.lag_seconds() <= self.lag_limit
]
if not candidates:
return self.primary
return min(candidates, key=lambda node: node.lag_seconds())
def for_write(self, session):
session["dirty"] = True
return self.primary
Такой маршрутизатор решает сразу две задачи: убирает чтение устаревших данных после записи и отключает из выбора копии, которые сильно отстали.
Отставание копий лечится не только маршрутизацией
Задержка распространения изменений неизбежна, но её размер можно контролировать.
Первая мера это параллельная передача. Если копий несколько, журнал отправляется им одновременно, а не по очереди. Последовательная передача увеличивает отставание последней копии пропорционально их числу.
Вторая мера это ограничение числа копий, обслуживающих чтение. Десять копий не сделают сервис в десять раз быстрее, зато каждая добавит нагрузку на ведущий узел при передаче журнала.
Третья мера это разделение потоков. Часть копий используется только для переключения при отказе и не принимает пользовательские запросы. На них допустимо большее отставание, зато они всегда готовы заменить главную машину.
Четвёртая мера это мониторинг. Отставание измеряется постоянно, и при превышении порога копия исключается из обслуживания чтения автоматически. Без этого пользователи будут получать старые данные и жаловаться на странное поведение.
Пятая мера это групповая фиксация. Ведущий узел подтверждает запись не сразу, а после того, как её получила группа копий. Это повышает надёжность, но увеличивает время отклика на запись, и здесь приходится выбирать.
Кворумная запись определяет, что считается успешной операцией
Развитие схемы с одним ведущим узлом приводит к понятию кворума. Идея проста: операция считается успешной, если её подтвердило определённое число узлов.
Пусть всего узлов три. Запись подтверждается, когда её приняли два из них. Чтение опрашивает два узла и выбирает значение с наибольшей версией. Поскольку множества записи и чтения пересекаются хотя бы в одном узле, читатель гарантированно увидит последнюю запись.
Формула условия выглядит так: число подтвердивших запись плюс число опрошенных при чтении должно быть больше общего числа узлов. Для трёх узлов подходят пары два и два. Для пяти узлов хватит трёх и трёх.
Практическая ценность в том, что схема позволяет обойтись без выделенного ведущего узла. Любой узел принимает и запись, и чтение, а согласованность обеспечивается числом подтверждений.
Цена тоже понятна. Чем больше подтверждений требуется, тем выше задержка и ниже доступность при отказах. Строгий кворум на пяти узлах означает, что при отказе трёх система перестаёт принимать записи.
Теорема о компромиссе объясняет, почему нельзя получить всё сразу
Разговоры о репликации рано или поздно упираются в известную теорему, сформулированную в конце девяностых. Она утверждает, что распределённое хранилище не может одновременно обеспечить три свойства: согласованность, доступность и устойчивость к разделению сети.
Согласованность здесь означает, что любое чтение возвращает последнюю запись или ошибку. Доступность означает, что каждый запрос к работающему узлу получает ответ, пусть и не самый свежий. Устойчивость к разделению означает, что система работает, несмотря на потерю сообщений между узлами.
Ключевое уточнение сделал сам автор теоремы спустя годы. Формулировка "выберите два из трёх" вводит в заблуждение. Разделение сети случается независимо от нашего желания, поэтому выбор делается только в момент аварии: либо отменить операцию и сохранить согласованность, либо выполнить её и получить расхождение.
В спокойное время система обеспечивает все три свойства сразу. Проблема проявляется именно при обрыве связи между узлами.
Дополнение к теореме, появившееся в 2010 году, обращает внимание на второй компромисс. Даже без разделения сети существует выбор между задержкой и согласованностью. Строгая согласованность требует синхронного подтверждения от нескольких узлов, а это время. Если пожертвовать строгостью, ответ придёт быстрее.
Множественные ведущие узлы удобны и опасны одновременно
Схема с несколькими ведущими узлами позволяет принимать запись на любом узле. Это полезно, когда узлы разнесены географически: пользователь пишет в ближайший, не дожидаясь передачи через океан.
Проблема возникает при одновременной записи в разные узлы. Две машины приняли разные значения одного поля, и при обмене выясняется, что версии конфликтуют. Требуется правило разрешения.
Первое правило это приоритет по времени. Побеждает запись с более поздней меткой. Просто, но зависит от точности часов, а часы на разных машинах расходятся.
Второе правило это приоритет по узлу. Побеждает запись с более высоким номером узла. Предсказуемо, но приводит к тому, что записи одного узла регулярно затирают записи другого.
Третье правило это слияние. Значения объединяются: для счётчиков складываются, для множеств находятся все элементы. Работает не для всех типов данных.
Четвёртое правило это отказ от автоматики. Конфликт сохраняется, а решение принимает приложение. Честно, но требует дополнительной логики.
def resolve(left, right):
if left["version"] > right["version"]:
return left
if right["version"] > left["version"]:
return right
# версии совпали, объединяем множества тегов
merged_tags = sorted(set(left["tags"]) | set(right["tags"]))
return {"version": left["version"], "tags": merged_tags, "conflict": True}
Пометка о конфликте важна. Тихая перезапись данных выглядит как потеря, и разбираться с ней придётся по жалобам. Явный признак позволяет показать пользователю расхождение и предложить выбор.
Переключение при отказе требует регулярных учений
Самая частая проблема репликации обнаруживается не в теории, а в момент аварии. Копия есть, а переключиться на неё нельзя: скрипт устарел, ключи другие, приложение не умеет менять адрес.
Первое требование это автоматизация. Переключение выполняется командой или скриптом, а не вручную по инструкции на двух страницах.
Второе требование это проверка готовности. Кандидат на роль ведущего должен иметь минимальное отставание, иначе после переключения часть данных исчезнет.
Третье требование это защита от двух ведущих. Если старая машина жива и считает себя главной, а новая тоже, появится расхождение, которое невозможно слить автоматически. Это состояние называется расщеплением и считается самым неприятным следствием неудачного переключения.
Четвёртое требование это смена адреса. Приложения должны узнавать о новом ведущем узле без правки конфигурации. Обычно используется промежуточный слой или служба имён.
Пятое требование это учения. Переключение стоит проводить по плану раз в квартал на работающем контуре. Только так обнаруживаются забытые зависимости и неверные настройки.
Чтение с копий требует осознанного выбора уровней согласованности
Приложение должно само решать, что важнее в конкретном запросе.
Для лента новостей и списка товаров подойдёт чтение с копии, даже если данные отстают на секунду. Пользователь не заметит разницы.
Для баланса, остатков на складе и статуса платежа нужна строгая согласованность. Здесь чтение идёт только на ведущий узел, а цена в виде задержки принимается осознанно.
Промежуточный вариант допускает чтение с копии при условии, что она отстаёт не больше заданного порога. Такой подход даёт компромисс: свежесть в разумных пределах и разгрузка главного узла.
Полезно закрепить это правило в коде явно, чтобы выбор не зависел от настроения разработчика:
READ_POLICY = {
"feed": "replica",
"catalog": "bounded",
"balance": "primary",
"payment_status": "primary",
}
def read_target(entity, router, session):
policy = READ_POLICY.get(entity, "bounded")
if policy == "primary":
return router.primary
if policy == "replica":
return router.any_replica()
return router.for_read(session)
Такая таблица служит документацией. Новый разработчик смотрит на неё и понимает, какие данные допустимо читать с задержкой, а какие нет.
Проверка готовности к отказу состоит из практических шагов
Теория полезна, но готовность определяется проверками. Ниже набор действий, которые стоит выполнить до того, как что-то сломается.
Первый шаг это инвентаризация. Выписываются все узлы, их роли, адреса и текущее отставание. Часто уже здесь обнаруживается копия, о которой все забыли.
Второй шаг это измерение задержки передачи. Нужно понять, сколько времени занимает передача журнала при пиковой нагрузке, а не в тихий час.
Третий шаг это проверка переключения на тестовом контуре. Записывается время от команды до готовности принимать запись. Если оно превышает несколько минут, стоит автоматизировать процесс.
Четвёртый шаг это проверка возврата. Обратное переключение на основной узел часто забывают отработать, и именно на нём возникают конфликты.
Пятый шаг это проверка приложения. Сервис должен корректно переживать кратковременную недоступность базы и восстанавливать соединение без перезапуска.
Шестой шаг это проверка мониторинга. Оповещение об отставании должно приходить раньше, чем пользователи заметят проблему.
Седьмой шаг это фиксация результатов. Записанное время переключения становится ориентиром для будущих учений и планирования окна обслуживания.
Итоговая картина сводится к трём решениям
Первое решение касается роли копий. Нужно заранее определить, какие из них обслуживают чтение, какие находятся в резерве, а какие используются для аналитики.
Второе решение касается уровня согласованности для каждого типа данных. Таблица с правилами экономит споры и делает поведение предсказуемым.
Третье решение касается процедуры аварии. Кто принимает команду, как проверяется готовность, что происходит со старой машиной после возврата.
Ответы на эти три вопроса занимают страницу, а экономят часы в самый неподходящий момент. Репликация не делает систему надёжной сама по себе. Надёжной её делает готовность к тому, что однажды придётся переключиться, и уверенность, что после переключения ничего не потеряется.
Репликация базы данных получилась инструментом, который продаёт надёжность в рассрочку. Защита от отказа оплачивается задержкой копий, сложностью переключения и необходимостью разбираться с конфликтами. Тот, кто планирует эти издержки заранее, получает систему, которая переживает ночную аварию без шестичасового разбирательства. Тот, кто вспоминает о них в момент сбоя, платит ту же цену, но в худший момент.