Один сервер базы не выдерживает чтения, а его поломка останавливает всё: два повода держать не одну копию данных, а несколько. Это и есть репликация. Обычно так: один узел (ведущий, он же мастер) принимает все записи, а остальные (реплики, они же ведомые) держат копию тех же данных и обслуживают чтение. Чтения раскидывают по репликам и выдерживают больше нагрузки, а если мастер выйдет из строя, одна из реплик его заменит.
Как это настраивается на практике — в статьях про репликацию в PostgreSQL и replica set в MongoDB. Здесь уровень выше и без привязки к конкретной базе: что вообще ломается, когда копий несколько, и какие бывают модели репликации. Их три, и различаются они ответом на один вопрос: кто принимает записи и кто разбирается с конфликтами. Начнём с самой простой и частой модели — один ведущий, много реплик, — потому что почти все грабли живут именно в ней.
Реплика не копирует состояние целиком — она проигрывает записи журнала мастера строго по порядку. Пока реплика B не дошла до записи #2, она честно отдаёт старое значение: это и есть отставание, из которого растут все три аномалии чтения.
Почему реплика отстаётспросят на собеседовании
Когда вы пишете что-то в мастер, изменение не появляется на репликах мгновенно — оно должно до них «доехать» по сети. Обычно это доли секунды, и никто не замечает. Но под нагрузкой, при всплеске трафика или проблемах с сетью реплика может отстать на секунды, а иногда и на минуты.
Тут же прячется главный переключатель репликации — ждать реплику или нет. При асинхронной репликации мастер отвечает «сохранено» сразу, не дожидаясь никого: быстро, но записи последних миллисекунд живут в одном экземпляре и при отказе мастера пропадут. При синхронной мастер ждёт подтверждения хотя бы одной реплики: ни одна подтверждённая запись не потеряется, зато каждая оплачивается лишним походом по сети, а зависшая реплика останавливает запись вообще. Отсюда и обычный компромисс: одну реплику делают синхронной, остальные — асинхронными.
Такой режим — «реплики отстают, но догонят, если перестать писать» — называют конечной согласованностью (eventual consistency). Слово «конечная» звучит как обещание, но на деле оно ни к чему не обязывает: никто не говорит, когда именно реплики догонят.
Само по себе отставание — не беда. Беда в том, что пользователь прочитает данные с отставшей реплики и увидит что-то странное. Странностей ровно три, и у каждой своё «противоядие» — гарантия, которую включают.
Что именно едет в реплику
«Журнал мастера» — это три разных вещи, и выбор между ними определяет, что реплика умеет.
Инструкции. Реплике пересылают сами команды изменения (UPDATE … WHERE …), и она выполняет их у себя. Просто и компактно, но ломается на всём недетерминированном: now(), случайные числа, автоинкремент, порядок при конкурентных изменениях. Поэтому сегодня так почти не делают.
Физический журнал. Пересылают записи журнала предзаписи — то есть изменения на уровне байтов и страниц. Реплика получает точную копию, отставание минимально, накладные расходы малы. Цена: копия побайтовая, значит версия и раскладка на диске должны совпадать — между мажорными версиями так реплицировать нельзя, и выборочно «только одну таблицу» тоже.
Логические строки. Пересылают факты «в таблице X строка с ключом K стала такой» — без привязки к физическому устройству. Это дороже по объёму и по обработке, зато работает между версиями, между разными базами, по выбранным таблицам и годится для захвата изменений в аналитику или в поисковый индекс.
Отсюда простое правило: резервный сервер того же кластера — физическая репликация; всё, что уезжает наружу (другая версия, другая система, часть таблиц), — логическая.
Как подключают новую реплику
Процедура одна и та же почти везде, и понимать её стоит, потому что на ней же строится захват изменений.
Сначала снимают согласованный снимок данных — копию на определённый момент. Вместе с ним фиксируют позицию в журнале, которая этому моменту соответствует. Потом реплика применяет всё, что было записано после этой позиции, и догоняет мастер. Ключевое здесь — что снимок и позиция взяты согласованно: если позицию взять позже снимка, потеряются изменения между ними; если раньше — часть изменений применится дважды (и это переживаемо, только если применение идемпотентно).
Отсюда два практических следствия. Во время подключения новой реплики мастер обязан хранить журнал с нужной позиции — иначе догонять будет нечего, и всё начнётся заново (как в статьях про WAL и репликацию в PostgreSQL). И тот же порядок «снимок плюс позиция» используется для переезда данных в другое хранилище — об этом в статье про производные данные.
Чем измеряют отставание
Двумя числами, и они отвечают на разные вопросы.
В байтах — сколько журнала реплика ещё не применила. Это техническая метрика: она показывает, справляется ли реплика и не растёт ли очередь. По ней же видно, сколько места держит слот репликации на мастере: слот, чей потребитель отстал или отключился, не даёт удалять журнал, и диск мастера заполняется — самая частая авария, связанная с репликацией.
В секундах — насколько старые данные видит читатель. Это прикладная метрика: именно она отвечает на вопрос «можно ли показывать пользователю то, что он только что записал».
Оповещения ставят на обе: рост байтов означает «реплика не справляется», рост секунд — «читатели видят устаревшее». И отдельно — на неактивный слот: он опаснее всего, потому что тихо съедает диск мастера.
Когда с реплик не читают вовсе
Совет «выберите гарантию под сценарий» стоит довести до списка случаев, где ответ всегда один — читать с мастера.
Деньги и остатки. Решение о списании принимают по актуальному состоянию; отставание в секунду означает двойное списание или продажу последнего товара дважды.
Проверка перед записью. Любое «прочитал, проверил, записал» на реплике бессмысленно: между чтением и записью состояние уже другое, и проверка ничего не гарантирует.
Сразу после записи. Пользователь сохранил и открыл список — он обязан увидеть своё. Либо читать с мастера, либо возвращать данные прямо из ответа на запись, либо ждать позиции журнала.
Идемпотентность и повторы. Проверка «не обрабатывали ли мы это сообщение» по реплике пропустит недавно обработанное и выполнит работу дважды.
Практическое правило: с реплики читают то, что показывают, и не читают то, на основании чего принимают решения. Подробно про механику — в статьях про репликацию в PostgreSQL и про согласованность и консенсус, где разобрано, почему ведущий один и почему кворум сам по себе не даёт линеаризуемости.
Три аномалии отставания — и как с ними боротьсяспросят на собеседовании
1. «Я не вижу собственную запись». Вы оставили комментарий (запись ушла на мастер), обновили страницу (чтение попало на отставшую реплику) — а комментария нет. Выглядит так, будто данные потерялись, хотя они на месте, просто ещё не доехали до этой реплики.
Противоядие — гарантия «читай свои записи» (read-your-writes): свои собственные данные пользователь должен читать либо с мастера, либо с реплики, которая уже догнала момент его записи. Тогда «пропавший комментарий» не случится. Как это делают в PostgreSQL — в статье про репликацию.
2. «Время идёт назад». Вы обновляете страницу два раза подряд. Первый запрос попал на реплику, которая почти догнала мастер, — комментарий виден. Второй запрос попал на другую реплику, отставшую сильнее, — и комментарий пропал. Данные будто откатились в прошлое.
Противоядие — монотонные чтения (monotonic reads): каждое следующее чтение не должно быть «старее» предыдущего. Самый простой способ добиться этого — стабильно отправлять одного и того же пользователя на одну и ту же реплику (например, выбирая реплику по хешу его id), чтобы он не прыгал между «более свежей» и «более отставшей».
3. «Ответ раньше вопроса». В переписке видно ответ на сообщение, но самого сообщения ещё нет: реплика с ответом догнала мастер быстрее, чем реплика с вопросом. Причина и следствие поменялись местами.
Противоядие — согласованное префиксное чтение (consistent prefix reads): если записи произошли в каком-то порядке, то и читаться они должны в том же порядке. Особенно болезненно это в системах, где данные разрезаны на части (шарды): у разных частей нет общего порядка записей, и «склеить» их в правильной последовательности сложнее.
Рабочее правило: решая читать с реплик, спросите — какая из трёх странностей ударит по вашему сценарию? — и включите ровно ту гарантию. Самые загадочные баги рождаются, когда реплики отстают, а код написан так, будто система мгновенная.
Механика видна на маленькой программе: журнал мастера — список записей с номерами, реплика применяет их подряд и останавливается там, докуда доехала. А проверка «читай свои записи» — сравнение двух чисел: позиции реплики и позиции моей записи.
живой пример
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class ReplicaLag {
record Entry(long position, String key, String value) {}
static final List<Entry> log = List.of(
new Entry(1, "comment-7", "черновик"),
new Entry(2, "comment-7", "опубликован"),
new Entry(3, "comment-8", "черновик"));
static Map<String, String> applyUpTo(long position) {
Map<String, String> state = new LinkedHashMap<>();
for (Entry entry : log) {
if (entry.position() > position) break;
state.put(entry.key(), entry.value());
}
return state;
}
public static void main(String[] args) {
long myWrite = 2;
long replicaA = 3;
long replicaB = 1;
System.out.println("мастер (позиция 3): " + applyUpTo(3).get("comment-7"));
System.out.println("реплика A (позиция " + replicaA + "): " + applyUpTo(replicaA).get("comment-7"));
System.out.println("реплика B (позиция " + replicaB + "): " + applyUpTo(replicaB).get("comment-7"));
for (long position : new long[]{replicaB, replicaA}) {
System.out.println("позиция " + position + " годится для «читай свои записи»? "
+ (position >= myWrite));
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import "fmt"
type entry struct {
position int64
key, value string
}
var log = []entry{{1, "comment-7", "черновик"}, {2, "comment-7", "опубликован"}, {3, "comment-8", "черновик"}}
func applyUpTo(position int64) map[string]string {
state := map[string]string{}
for _, e := range log {
if e.position > position {
break
}
state[e.key] = e.value
}
return state
}
func main() {
const myWrite, replicaA, replicaB = int64(2), int64(3), int64(1)
fmt.Println("мастер (позиция 3):", applyUpTo(3)["comment-7"])
fmt.Printf("реплика A (позиция %d): %s\n", replicaA, applyUpTo(replicaA)["comment-7"])
fmt.Printf("реплика B (позиция %d): %s\n", replicaB, applyUpTo(replicaB)["comment-7"])
for _, position := range []int64{replicaB, replicaA} {
fmt.Printf("позиция %d годится для «читай свои записи»? %t\n", position, position >= myWrite)
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
const log = [
{ position: 1, key: 'comment-7', value: 'черновик' },
{ position: 2, key: 'comment-7', value: 'опубликован' },
{ position: 3, key: 'comment-8', value: 'черновик' },
];
function applyUpTo(position) {
const state = new Map();
for (const entry of log) {
if (entry.position > position) break;
state.set(entry.key, entry.value);
}
return state;
}
const myWrite = 2, replicaA = 3, replicaB = 1;
console.log('мастер (позиция 3): ' + applyUpTo(3).get('comment-7'));
console.log(`реплика A (позиция ${replicaA}): ` + applyUpTo(replicaA).get('comment-7'));
console.log(`реплика B (позиция ${replicaB}): ` + applyUpTo(replicaB).get('comment-7'));
for (const position of [replicaB, replicaA]) {
console.log(`позиция ${position} годится для «читай свои записи»? ${position >= myWrite}`);
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
from dataclasses import dataclass
@dataclass(frozen=True)
class Entry:
position: int
key: str
value: str
log = [Entry(1, "comment-7", "черновик"), Entry(2, "comment-7", "опубликован"), Entry(3, "comment-8", "черновик")]
def apply_up_to(position: int) -> dict[str, str]:
state: dict[str, str] = {}
for entry in log:
if entry.position > position:
break
state[entry.key] = entry.value
return state
my_write, replica_a, replica_b = 2, 3, 1
print("мастер (позиция 3): " + apply_up_to(3)["comment-7"])
print(f"реплика A (позиция {replica_a}): " + apply_up_to(replica_a)["comment-7"])
print(f"реплика B (позиция {replica_b}): " + apply_up_to(replica_b)["comment-7"])
for position in (replica_b, replica_a):
print(f"позиция {position} годится для «читай свои записи»? {str(position >= my_write).lower()}")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Когда падает мастер: failover и его ловушкиспросят на собеседовании
Раз все записи идут через один мастер, что будет, если он выйдет из строя? Одну из реплик повышают до нового мастера — это failover (переключение при отказе). Звучит просто, но здесь две классические ловушки.
Потерянные записи. Если репликация была асинхронной, у старого мастера могли остаться записи, которые он ещё не успел отправить репликам. При повышении отставшей реплики эти записи обычно выбрасывают — они просто исчезают. Реальный случай из практики GitHub: повышенная отставшая реплика начала заново раздавать уже использованные идентификаторы записей, и данные показались не тем пользователям.
Расщепление мозга (split brain). Иногда старый мастер не умер, а просто на время пропал из сети — и вот уже два узла считают себя мастером и оба принимают записи. Данные расходятся, и потом их приходится мучительно сводить. Само собой это не рассасывается: чтобы мастер в каждый момент был один, нужен внешний арбитр — отдельный сервис, который решает, кто теперь ведущий, и отрезает старого от записи (в PostgreSQL эту роль обычно берёт Patroni). Без арбитра асинхронная репликация двух мастеров как раз и получает.
Переключение при отказе не бесплатное, и его надёжность проверяют заранее, а не в момент аварии.
Несколько ведущих (multi-leader): свобода ценой конфликтов
Иногда один мастер мешает не из-за нагрузки, а из-за географии. Тогда ведущих делают несколько. Три типичных сценария:
- Несколько дата-центров. В каждом ЦОДе свой мастер: запись обрабатывается локально и потом не спеша реплицируется в остальные. Пользователю не надо ждать, пока запрос сбегает на другой континент и обратно, а отказ целого дата-центра не останавливает приём записей.
- Офлайн-клиенты. Календарь на телефоне и на ноутбуке принимает записи даже без сети. По сути каждое устройство — маленький мастер со своей локальной базой, а синхронизация — та же репликация, только с задержкой в часы.
- Совместное редактирование. Google Docs — та же идея на пределе: своя локальная копия в каждой вкладке.
За эту свободу приходится платить, и цена серьёзная — конфликты записи. Два мастера одновременно приняли изменение одной записи, оба ответили «сохранено», а при обмене изменениями выяснилось, что правки несовместимы. Спросить пользователя, что он имел в виду, уже поздно. Значит, база обязана разрешить конфликт сама — и так, чтобы все копии в итоге пришли к одному значению. Способов три:
- «Выигрывает последний» (last write wins, LWW). У каждой записи есть метка времени, побеждает та, что «позже». Просто и распространено (в Cassandra так разрешается конфликт для обычных колонок — счётчики складываются, а условная запись идёт отдельным механизмом лёгких транзакций). Но у этого способа злая цена — тихая потеря данных: проигравшая запись, за которую клиент уже получил «сохранено», молча исчезает. Для кеша сойдёт; для всего, что жалко терять, — нет.
- Слияние. Сохранить оба варианта и потом слить: либо кодом приложения при следующем чтении, либо автоматически — специальными структурами данных CRDT (они спроектированы так, что одновременные изменения сливаются без потерь: счётчики, множества, тексты).
- Не допускать конфликтов. Самый практичный вариант — сделать так, чтобы все изменения одной записи всегда шли через один и тот же мастер (данные пользователя — всегда в его «родной» дата-центр). Тогда для этой записи система фактически «один ведущий».
Полезно понять, что вообще считается конфликтом. Ключевое понятие — «произошло до» (happens-before): операция Б зависит от А, если в момент Б уже было известно про А. Если же ни одна операция не знала про другую — они одновременные (конкурентные), и вот тогда нужен механизм разрешения. Определять этот порядок по обычным часам ненадёжно — часы на разных серверах слегка расходятся, — поэтому базы отслеживают зависимости не временем, а специальными счётчиками версий.
Два ведущих приняли разные значения одной записи в одну минуту; при обмене копии расходятся, и «выигрывает последний» молча выбрасывает одну из них.
Без ведущего (leaderless): кворумы вместо мастераспросят на собеседовании
Третья модель убирает мастера совсем (так устроены базы «в стиле Dynamo» — Cassandra и ScyllaDB; исторически сюда же относят Riak, который сегодня почти не развивается). Идея: клиент отправляет запись сразу нескольким репликам параллельно и считает её успешной, когда пришло подтверждение от какого-то числа узлов. Читает тоже сразу с нескольких и сравнивает, у кого версия свежее.
Чтобы это работало, договариваются о числах. Пусть всего реплик n, запись считается успешной после w подтверждений, а чтение опрашивает r узлов. Магическое условие — кворум: если w + r > n, то множество узлов, куда мы записали, и множество, откуда читаем, обязательно пересекаются хотя бы в одном узле — а значит, при чтении мы наткнёмся на узел со свежими данными. «Точно» — пока запись успела дойти до конца и никто не правит тот же ключ одновременно; оговорки ниже. Типичный набор: n=3, w=2, r=2. Одна реплика может лежать, а запись и чтение продолжают работать — и отдельная процедура failover тут вообще не нужна.
Запись легла на два узла из трёх, чтение опрашивает два: хотя бы один узел у них общий, на нём и лежит свежее значение.
Отставшие узлы догоняют двумя способами: исправление при чтении (read repair — клиент, заметив у одного узла устаревший ответ, тут же дописывает туда свежее значение) и фоновый процесс анти-энтропии, который сверяет реплики между собой и докатывает разницу.
Только не считайте кворум железной гарантией — у него есть оговорки:
- Нестрогий кворум (sloppy quorum): при проблемах с сетью запись могут временно принять «не те» узлы (с последующей досылкой на правильные — hinted handoff). В этот момент
wиrмогут и не пересечься. - Одновременные записи всё равно требуют разрешения конфликтов — и часто это тот же LWW с той же тихой потерей.
- Следить за «отставанием» тут сложнее, чем в модели с одним мастером, где есть чёткая позиция в журнале.
Честный итог: кворумные базы по своей природе конечно-согласованные, а числа w и r управляют не гарантией, а вероятностью прочитать устаревшее.
Где это применяется
Выбор модели репликации — это выбор, где именно будет болеть:
- Один ведущий (single-leader) — прост и даёт понятный порядок записей, но требует аккуратного failover и упирается в один узел по нагрузке на запись.
- Несколько ведущих (multi-leader) — развязывает географию и офлайн, но приносит конфликты записи.
- Без ведущего (leaderless) — убирает failover, но взамен даёт кворумную арифметику и лишь вероятностные гарантии.
В обычном бэкенде выбор по умолчанию — один ведущий (PostgreSQL) с осознанно выбранными гарантиями чтения.
Где спотыкаются начинающие:
- Читают с реплики всё подряд — и ловят «пропавшие комментарии» и «время назад». Гарантии чтения выбирают сознательно, под конкретный сценарий, а не «как получится».
- Верят в LWW. «Выигрывает последний» звучит безобидно, а на деле означает «проигравшие записи исчезают молча, хотя клиент получил подтверждение».
- Считают
w+r>nабсолютной гарантией — нестрогие кворумы, одновременные записи и восстановление из старой реплики оставляют щели. - Включают multi-leader ради «надёжности» внутри одного дата-центра — и получают конфликты записи без всякой выгоды: в одном ЦОДе честнее держать одного ведущего.
Глубже: резервные копии распределённых хранилищ: RPO, RTO и проверка восстановлениярасширенное
Репликация защищает от отказа узла, но не от ошибки: DELETE без WHERE реплика повторит за секунду. От этого защищает резервная копия, и у распределённых хранилищ она устроена не так, как pg_dump, о чём каждая статья фазы говорит одним абзацем. Здесь общая рамка.
Два числа задают требования. RPO, сколько данных допустимо потерять, определяет частоту копий: снимок раз в сутки означает потерю до суток, а суточный снимок плюс журнал изменений (WAL, oplog, binlog) даёт восстановление на любой момент, и RPO измеряется минутами. RTO, за сколько нужно подняться, определяет способ: восстановление терабазы из логической копии (mongodump, neo4j-admin dump) идёт часами, из файлового снимка минутами. Числа записывают до выбора инструмента, потому что «бэкап есть» без них ничего не значит.
Чем распределённая копия отличается от одной базы. Данные лежат на многих узлах, и снимок каждого узла сделан в свой момент; между ними секунды разницы, и после восстановления кластер не в точно согласованном состоянии. Cassandra это принимает: nodetool snapshot на каждом узле делает жёсткие ссылки на SSTable (мгновенно и без места), восстановление это вернуть файлы и запустить repair, чтобы реплики сошлись. MongoDB и Elasticsearch координируют: у Mongo копию снимают с одного узла реплики (файловый снимок или mongodump), а в шардированном кластере останавливают балансировщик и снимают все шарды плюс конфигурационные серверы близко по времени; у Elasticsearch снимок делает сам кластер в репозиторий на S3, инкрементально по сегментам. ClickHouse умеет BACKUP TABLE ... TO S3(...) целиком или инкрементально, Redis это копия файла RDB. Neo4j в бесплатной редакции копируется только остановленным, горячая копия живой базы в платной.
Что общего у всех: копия не считается существующей, пока из неё не восстановились. Проверка это регулярное, по расписанию, восстановление на отдельный стенд с проверкой числа строк и нескольких контрольных запросов, и измеренное время этого восстановления сравнивают с RTO. Копии хранят в другом регионе или хотя бы другом хранилище, чем сама база, потому что бакет с копиями в том же аккаунте удаляется тем же неверным скриптом. И версия движка: снимок SSTable или файлов данных восстанавливается только в ту же мажорную версию, поэтому обновление версии начинают со свежей копии, а старые копии хранят вместе с образом старой версии.
Коротко
- Конечная согласованность обещает, что реплики догонят мастер, но не говорит когда: гарантии на время нет, поэтому отставание меряют в байтах (справляется ли реплика, не растёт ли слот) и в секундах (насколько старое видит читатель).
- Отставание даёт три аномалии чтения, и на каждую своя гарантия: «читай свои записи», монотонные чтения, согласованный префикс.
- При отказе мастера записи, не доехавшие до реплик, обычно выбрасывают; второй риск — два мастера сразу, расщепление мозга.
- Несколько ведущих развязывают географию и офлайн, но приносят конфликты записи; LWW разрешает их ценой тихой потери данных.
- Кворум
w + r > nгарантирует пересечение множеств записи и чтения — пока не вмешался нестрогий кворум или одновременные записи. - Выбор по умолчанию — один ведущий и осознанно выбранная гарантия чтения; остальные модели включают под конкретное требование.
- Резервная копия распределённого хранилища: RPO задаёт частоту, RTO способ; снимки узлов не согласованы между собой (Cassandra чинит
repair, Mongo снимает шарды с остановленным балансировщиком); копия существует, только если из неё регулярно восстанавливались. - В реплику едут либо инструкции (ненадёжно), либо физический журнал (точно, но одна версия и весь кластер), либо логические строки (между версиями и системами, выборочно).
- Новую реплику подключают согласованным снимком плюс позицией журнала; тем же способом стартует захват изменений и переезд в другое хранилище.
- С реплики читают то, что показывают, и не читают то, на чём принимают решения: деньги, остатки, проверка перед записью, чтение сразу после записи, проверка повторов.
Что почитать дальше
- Репликация в PostgreSQL — один ведущий на практике: «читай свои записи» и мониторинг отставания.
- Репликация и шардинг в MongoDB — replica set, кворумы и failover в одной базе.
- Секционирование — вторая половина распределения данных: репликация копирует, секционирование режет.
- Строительные блоки системного дизайна — как репликация встраивается в общую картину.