voidlearn
 

6.8Масштабирование и отказоустойчивость БД

Раздели масштабирование чтения, отказоустойчивость и распределение данных: реплики не решают все три задачи одинаково. Сравни синхронную репликацию, лаг чтения, failover, шардирование и бэкапы, а также разберись, почему распределённая транзакция часто уступает саге с явными промежуточными состояниями.

Репликация: четыре оси выбора

Реплика нужна ради двух разных вещей: снять с мастера часть чтений и иметь готовую замену, если мастер умрёт. Механика в обоих случаях одна: мастер пишет WAL, реплика его получает и повторяет у себя. Дальше начинаются варианты, и по каждой оси выбор чем-то оплачивается.

Физическая (streaming)Логическая
Что передаётсябайты WAL: «в блоке 42 файла 16384 изменить такие байты»логические изменения строк: INSERT/UPDATE/DELETE с данными
Гранулярностьвся база целиком (кластер)отдельные таблицы, публикации/подписки
Репликапобайтовая копия, только чтениеобычная база, можно писать, можно иметь свои индексы и таблицы
Версии PGобязаны совпадатьмогут различаться - отсюда апгрейд мажорной версии без даунтайма
Накладные расходыминимальныевыше: декодирование WAL, применение построчно
Ограничениянельзя реплицировать частьне реплицирует DDL, нужны PK или REPLICA IDENTITY, последовательности не синхронизируются (в PG 19 - только по команде REFRESH SEQUENCES)
ПрименениеHA, read-реплики, бэкапыCDC, интеграции, миграция между версиями, выборочная репликация в аналитику
АсинхроннаяСинхронная
Когда COMMIT отвечает клиентусразу после записи в локальный WALпосле подтверждения от реплики
Latency записине растёт+ RTT до реплики (в другой AZ это 1–2 мс, в другом регионе - десятки)
Потеря данных при падении мастеравозможна - всё, что не доехалонет (для подтверждённых коммитов)
Что если реплика недоступнаничего, мастер работаетзапись встаёт, пока подтверждение не придёт от нужного числа реплик (кворума) - доступность падает
Где применяютдефолт; read-реплики, гео-репликиденьги, платежи - всё, где нельзя потерять ни одного подтверждённого коммита (это и называется RPO = 0, определение ниже)
-- Уровни надёжности коммита: размен скорости на сохранность
SET synchronous_commit = off;          -- не ждать даже локального fsync:
                                       -- быстро, но при крахе теряем до 3× wal_writer_delay.
                                       -- При этом транзакция не «порвётся», база останется
                                       -- целостной, потеряются только последние коммиты.
SET synchronous_commit = local;        -- ждать локальный fsync, не ждать реплику
SET synchronous_commit = remote_write; -- реплика приняла и записала в ОС (не fsync)
SET synchronous_commit = on;           -- реплика зафиксировала на диск (по умолчанию)
SET synchronous_commit = remote_apply; -- реплика применила: чтение с неё сразу актуально

-- Список синхронных реплик и кворум задаются так:
synchronous_standby_names = 'ANY 1 (replica_a, replica_b, replica_c)';

-- Смотреть лаг
SELECT client_addr, state, sync_state,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes
FROM pg_stat_replication;
-- на самой реплике:
SELECT now() - pg_last_xact_replay_timestamp() AS lag_time;
Гибкость, о которой мало кто помнит

synchronous_commit настраивается на уровне транзакции, так что глобально выбирать не нужно: перевод денег выполняем с SET LOCAL synchronous_commit = 'remote_apply', а запись в лог активности - с 'off'. Один и тот же сервер, разные гарантии для разных операций. Кто вспомнит об этом на собеседовании, сразу выделится.

Master–replica, replication lag и read-your-writes

Чаще всего реплики используют так: отправляют на них чтение, оставив мастеру только запись. Выглядит как бесплатное ускорение, но вместе с ним в систему въезжает лаг: реплика всегда хоть немного отстаёт, ведь журнал надо передать по сети и применить. Первое, обо что спотыкаются, называется read-your-writes. Это гарантия, что пользователь сразу после собственной записи видит именно её, а не то, что было до. Асинхронная репликация такой гарантии не даёт. Вот как это выглядит на практике.

Replication lag и проблема «не вижу собственную запись» приложение POST /profile MASTER все записи REPLICA 1 lag 40 ms REPLICA 2 lag 2 s (долгий запрос) 1. UPDATE WAL, асинхронно 2. Сразу после ответа клиент делает GET /profile → уходит на REPLICA 2 3. Там ещё старые данные → пользователь видит, что изменение «не сохранилось» 4. Нажимает «сохранить» ещё раз → дубль, или пишет в поддержку Формально это не баг репликации, а следствие асинхронности. Чинится на уровне приложения. Что делают на практике • Sticky-to-master: N секунд после записи этот пользователь читает только с мастера • LSN-токен: запомнили lsn коммита, ждём на реплике pg_last_wal_replay_lsn() >= lsn, иначе мастер • Критичное читаем с мастера всегда (баланс, статус платежа), некритичное - с реплик • Оптимистичный UI: показать записанное значение из ответа, не перечитывая • synchronous_commit = remote_apply для критичных транзакций • Роутер должен исключать реплики с лагом выше порога из балансировки
Read-your-writes чаще всего и ломается, когда вводят read-реплики. Бесплатно реплики базу не «ускоряют»: они меняют модель консистентности, и приложение обязано учитывать это явно.
Что ещё нужно знать про лаг
  • Лаг не постоянен. Он взлетает при массовом UPDATE, миграции, VACUUM большой таблицы, при чекпоинте. Мониторить нужно не среднее, а максимум, и алертить по нему.
  • Реплика может «сопротивляться» применению WAL. Если на ней идёт длинный отчётный запрос, конфликтующие изменения (например, VACUUM удалил версии строк, которые нужны запросу) приводят либо к отмене запроса («canceling statement due to conflict with recovery»), либо, при hot_standby_feedback = on, к задержке очистки на мастере и росту bloat.
  • Реплики не масштабируют запись. Ни одна реплика не снимает нагрузку с мастера по записи, наоборот, мастеру прибавляется работа по отправке WAL. Запись масштабирует только шардирование.
  • Каскадная репликация (реплика от реплики) снижает нагрузку на мастер, но складывает лаги.

Failover и split brain

Ручной или автоматический (Patroni + etcd/Consul, repmgr, облачные managed-решения), failover идёт по одной схеме: обнаружение отказа → выбор кандидата (обычно реплика с максимальным LSN) → promote → переключение трафика (VIP, DNS, pgbouncer, service discovery) → переподключение приложений. Нетривиальной эту процедуру делает один факт: узел не может отличить «сосед умер» от «до соседа не доходит сеть».

Split brain: два мастера одновременно

Сценарий: сеть между узлами разорвалась, но оба живы. Кластер решил, что мастер умер, и повысил реплику. Старый мастер об этом не знает и продолжает принимать запись, тем более если часть клиентов осталась по его сторону разрыва. Теперь две базы расходятся: в одной заказ №100 принадлежит Ивану, в другой - Петру. Слить их обратно автоматически невозможно: конфликты по одним и тем же первичным ключам, и нет объективного критерия, чья версия правильная. Кончается это ручным разбором с потерей данных.

Защита. (1) Кворум: решение о повышении принимает только та часть кластера, в которой большинство узлов (отсюда нечётное число узлов и внешний DCS вроде etcd, Consul, ZooKeeper). (2) Fencing / STONITH: прежде чем повысить нового мастера, старый принудительно изолируют - отключают сеть, останавливают процесс, забирают VIP. (3) Демоция по потере кворума: узел, потерявший связь с DCS, сам переводит себя в read-only, не дожидаясь чужого решения; так работает Patroni. (4) Единая точка входа: клиенты ходят через прокси, который знает, кто мастер; тогда «второй мастер» просто не получает трафика. (5) Синхронная репликация дополнительно защищает от потери подтверждённых коммитов при failover.

Про RPO и RTO - терминология, которую ждут

RPO задаёт, сколько данных допустимо потерять, и меряется во времени: «не более 5 минут». RTO задаёт, за сколько система обязана вернуться в строй: «не более 15 минут». Асинхронная репликация даёт RPO порядка величины лага и RTO в десятки секунд при автоматическом failover; синхронная - RPO = 0; ежесуточный pg_dump - RPO до 24 часов и RTO, равное времени восстановления дампа (на терабайте это часы). Эти две буквы переводят разговор из «надёжно/ ненадёжно» в цифры, и именно этого ждут на системном дизайне.

Шардирование против партиционирования

Партиционирование режет большую таблицу на части внутри одного сервера; приложение этого не замечает, склейкой занимается сама СУБД. Шардирование раскладывает данные по разным серверам, у каждого свой процессор, память и диск. Кусок данных называют шардом, а колонку, по которой строку направляют в шард, - ключом шардирования. Прозрачности нет: маршрут выбирает приложение или прокси, и один запрос к одной базе может превратиться в четыре запроса к четырём.

Шардирование по ключу: как работает и где ломается приложение shard = f(user_id) SHARD 0 users 0–999k · 300 ГБ SHARD 1 users 1M–2M · 310 ГБ SHARD 2 + tenant «Магнит» · 4 ТБ SHARD 3 users 3M–4M · 295 ГБ HOT SHARD: один крупный клиент перекосил распределение - 80 % нагрузки на один узел, остальные простаивают Что легко, что дорого легко: SELECT ... WHERE user_id = 42 легко: транзакция внутри одного шарда дорого: JOIN users × orders разных шардов дорого: SELECT count(*) по всем - scatter-gather дорого: ORDER BY … LIMIT 10 глобально дорого: транзакция через два шарда → 2PC/сага нет: глобального AUTO_INCREMENT нет: глобального UNIQUE по email Решардинг - самая дорогая операция hash(key) mod N: при добавлении шарда переезжает почти ВСЁ (N=4→5 сдвигает 80 % ключей) consistent hashing: переезжает ~1/N данных виртуальные шарды: 1024 логических «бакета» на 4 физических узла - переезд = перенос бакетов, карта «бакет → узел» меняется в одном месте; это стандартное решение, закладывать сразу
Партиционирование делит таблицу внутри одного сервера, прозрачно для приложения; шардирование - между серверами, без прозрачности, зато только так масштабируется запись.
ПартиционированиеШардирование
Где живут частиодин сервер, одна БДразные серверы
Что масштабируетразмер таблицы и обслуживаниезапись, объём, CPU, память - всё
Прозрачностьполная, делает СУБДнет, логика в приложении или в прокси (Citus, Vitess, YDB)
Транзакции между частямиобычные ACID2PC или сага
JOIN между частямиобычныйдорогой scatter-gather или невозможен
Уникальностьв пределах ключа партиционированиятолько внутри шарда; глобальная - отдельный сервис или UUID
Сложность эксплуатациинизкаявысокая: N кластеров, N бэкапов, N failover
Главное решение при шардировании: какой взять ключ
  • Критерий №1: большинство запросов должны попадать в один шард. Ключом обычно становится user_id, tenant_id или account_id (то, вокруг чего строится продукт).
  • Равномерность. Шардирование по стране гарантирует перекос, а по дате сгоняет весь свежий трафик на один шард.
  • Стабильность. Ключ не должен меняться: сменить строке шард значит физически её перевезти.
  • Связанные данные держат на одном шарде. Заказы и позиции заказов шардируем по user_id, а не по order_id, чтобы их можно было соединить и обновить в одной транзакции.
  • Hot shard на практике создаёт больше всего проблем: один крупный тенант съедает узел. Лечат составным ключом (tenant_id + bucket), персональными шардами для крупных клиентов, виртуальными шардами с ручным переносом «горячих» бакетов.
  • Закладывать виртуальные шарды сразу. 1024 логических бакета на 4 физических узла ничего не стоят на старте и превращают решардинг из катастрофы в рутину.

Распределённые транзакции: 2PC

Как только данные разъехались по нескольким базам (шардам или микросервисам), встаёт вопрос: как сделать атомарной операцию, которая трогает сразу две? Внутри одной базы это решает обычный COMMIT: журнал общий, решение одно. Между базами общего журнала нет, и каждая может закоммитить или упасть независимо от соседа. Классически это решает 2PC с отдельным участником, координатором: сначала он спрашивает у всех «готов зафиксировать?» и, только получив «да» от каждого, командует фиксировать.

Two-Phase Commit: билеты + отель КООРДИНАТОР БД «Авиабилеты» БД «Отели» ФАЗА 1 · PREPARE «готов зафиксировать?» «готов зафиксировать?» блокирует места, пишет PREPARED в WAL, отвечает YES блокирует номер, пишет PREPARED в WAL, отвечает YES ФАЗА 2 · COMMIT все ответили YES → COMMIT всем; хоть один NO → ABORT всем ТОЧКА ОТКАЗА: координатор упал МЕЖДУ фазами. Участники уже сказали YES и держат блокировки - снять их сами они не имеют права: решение принимает координатор. Это блокирующий протокол.
2PC покупает атомарность блокировками. После PREPARE участник ждёт решения сколько угодно долго и сам принять его не может, поэтому падение координатора между фазами подвешивает ресурсы у всех сразу.
-- 2PC в PostgreSQL, если max_prepared_transactions > 0
BEGIN;
INSERT INTO tickets (...) VALUES (...);
PREPARE TRANSACTION 'booking-42';    -- фаза 1: транзакция «зависает» подготовленной,
                                     -- переживает рестарт сервера, держит блокировки

COMMIT PREPARED 'booking-42';        -- фаза 2
-- или
ROLLBACK PREPARED 'booking-42';

-- Забытая подготовленная транзакция как мина: вечно держит блокировки и горизонт VACUUM
SELECT gid, prepared, owner, database FROM pg_prepared_xacts;
Почему 2PC избегают в микросервисах
  • Блокирующий протокол. Если координатор упал между фазами, участники остаются с заблокированными ресурсами и без права решать. В PostgreSQL забытая PREPARED-транзакция вдобавок останавливает очистку мёртвых версий - база пухнет, пока кто-нибудь не заметит.
  • Доступность падает мультипликативно. Транзакция пройдёт, только если живы все участники и координатор: 0.999⁴ ≈ 0.996 - простой вчетверо больше, чем у каждого узла по отдельности.
  • Latency складывается минимум из двух кругов сетевого обмена со всеми участниками и двух fsync у каждого.
  • Гетерогенность. HTTP-сервис, Kafka или платёжный шлюз стороннего банка в 2PC не участвуют в принципе, а в микросервисах участники как раз такие.
  • Связность. 2PC требует, чтобы сервисы знали внутренние транзакции друг друга, а это прямо нарушает автономность, ради которой микросервисы и заводили.
// Вместо 2PC берут сагу: последовательность локальных транзакций
// с компенсирующими действиями на случай отказа.
//
//   1. bookFlight()   → ok      компенсация: cancelFlight()
//   2. bookHotel()    → ok      компенсация: cancelHotel()
//   3. chargeCard()   → ошибка
//   → выполняем компенсации в обратном порядке: cancelHotel(), cancelFlight()
//
// Свойства, о которых надо сказать:
//  • нет изоляции: между шагами другие видят промежуточное состояние
//    (место уже занято, но оплата ещё не прошла), лечится статусом «pending»
//    и таймаутом резервирования;
//  • компенсация не откат: за отменённый билет могли взять сбор;
//  • все шаги и компенсации обязаны быть идемпотентными: их будут повторять;
//  • оркестрация (отдельный сервис-дирижёр, Temporal) читается лучше,
//    чем хореография (сервисы реагируют на события друг друга);
//  • события из локальной транзакции надёжно отправляет transactional outbox.
Как отвечать на «транзакция над несколькими БД»

Сильный ответ идёт по шагам: (1) «Сначала спрошу, точно ли нужны две базы: если это два сервиса одной команды и одна консистентная сущность, возможно, границы сервисов проведены неверно, и решать надо архитектурой». (2) «Если границы верны, 2PC технически возможен: вот его механика и вот почему он блокирующий». (3) «На практике беру сагу с компенсациями плюс transactional outbox для надёжной публикации событий, идемпотентность на каждом шаге, статусы вместо блокировок». (4) «И проговорю с продуктом, что консистентность станет eventual: пользователь какое-то время видит „бронируется“, а не готовый результат».

Consistency и eventual consistency

При strong consistency любое чтение после подтверждённой записи видит эту запись: так ведут себя одиночный PostgreSQL и чтение с мастера. При eventual consistency чтение может вернуть устаревшие данные, но без новых записей все копии сходятся к одному состоянию. Так работают чтение с асинхронных реплик, кэш, Cassandra и DynamoDB с настройками по умолчанию.

Буква «C» в двух аббревиатурах значит разное, и на этом ловят. В ACID Consistency означает соблюдение инвариантов схемы (констрейнты, FK, CHECK), а в CAP - что все узлы видят одни и те же данные.

УровеньГарантияКак получить в PostgreSQL
Strong / linearizableчтение видит последнюю записьчитать с мастера; либо synchronous_commit = remote_apply
Read-your-writesсвои записи видны сразу, чужие могут отставатьsticky-to-master на N секунд или ожидание LSN на реплике
Monotonic readsвремя не «идёт назад» между запросамизакрепить пользователя за одной репликой
Eventualрано или поздно сойдётсялюбое чтение с асинхронной реплики
Как подать ответ

Консистентность выбирают не для системы целиком, а для каждого сценария. Баланс счёта и статус платежа читаем с мастера, строго. Ленту новостей, счётчик лайков, список товаров берём с реплики, eventual: отставание в секунду никого не волнует. Хотят услышать такую формулировку: «мы сознательно допускаем задержку в N секунд вот здесь, потому что за строгую консистентность пришлось бы платить ростом latency записи на M мс и падением доступности при отказе реплики».

Бэкапы: pg_dump против PITR

pg_dump / pg_dumpallPITR (базовый бэкап + WAL)
Что этологический дамп: SQL или архив с даннымифизическая копия каталога + непрерывный архив WAL
Гранулярность восстановлениямомент снятия дампалюбой момент времени - «состояние на 14:32:07»
RPOинтервал между дампами (часы, сутки)секунды
RTO на 1 ТБчасы: данные заливаются INSERT-ами, индексы строятся зановодесятки минут: копирование файлов + накат WAL
Восстановление частида - одну таблицу, одну схему, одну БДнет, только кластер целиком
Между версиями PGда, это ещё и способ мажорного апгрейданет, версия и архитектура обязаны совпадать
Влияние на проддолгая транзакция: держит горизонт VACUUMминимальное, обычно снимается с реплики
Инструментыpg_dump -Fc, pg_restore -j 8pgBackRest, WAL-G, barman
Что обязательно сказать про бэкапы
  • Нужны оба. PITR закрывает отказ железа и «удалили не ту строку в 14:31». pg_dump закрывает «нужна одна таблица из прошлого месяца», перенос между версиями и хранение долгих архивов.
  • Реплика не бэкап. DELETE FROM users доедет до реплики за миллисекунды. Реплика защищает от отказа узла, бэкап - от ошибки человека и от порчи данных.
  • Непроверенный бэкап не существует. Узнать заранее, что бэкап рабочий, можно только так: регулярно и автоматически восстанавливать его в отдельное окружение и прогонять контрольные запросы.
  • Формулировать в RPO/RTO. «PITR с архивом WAL раз в минуту даёт RPO около минуты; восстановление терабайта из базового бэкапа с накатом даёт RTO порядка часа; если бизнесу нужно 5 минут, добавляем горячую реплику и переключение на неё».
  • Хранить отдельно от прода, в другом регионе или облаке, с ограниченными правами на удаление. Иначе один скомпрометированный доступ уносит и базу, и бэкапы.

Вопросы

12
Суть: физическая передаёт байты WAL и делает побайтовую копию всего кластера - реплика идентична мастеру и доступна только на чтение. Логическая декодирует WAL в логические изменения строк и передаёт их подписчику - можно реплицировать отдельные таблицы, между разными версиями PostgreSQL, и подписчик остаётся полноценной пишущей базой.

Физическая (streaming)

  • Реплика повторяет мастер блок в блок, поэтому версии PostgreSQL и архитектура обязаны совпадать.
  • Минимальные накладные расходы: реплика просто применяет WAL.
  • Нельзя выбрать часть данных, нельзя писать на реплике, нельзя иметь на ней свои индексы.
  • Её ставят ради HA и failover, read-реплик и бэкапов без нагрузки на мастер.

Логическая

  • CREATE PUBLICATION на источнике, CREATE SUBSCRIPTION на приёмнике; передаются строки, а не блоки.
  • Работает между мажорными версиями, поэтому через неё обычно апгрейдят PostgreSQL с минимальным даунтаймом: поднимают новую версию подписчиком, ждут синхронизации, переключают трафик.
  • Реплицировать можно выборочно: например, только справочники в аналитический контур или CDC в Kafka через Debezium/wal2json.
  • Ограничения, которые обязательно назвать: не реплицируется DDL (схему надо накатывать на обе стороны руками), нужен первичный ключ или REPLICA IDENTITY FULL для UPDATE/DELETE, не синхронизируются последовательности (в PG 19 их можно досинхронизировать командой ALTER SUBSCRIPTION ... REFRESH SEQUENCES, но не на лету), конфликты (например, дубль по уникальному ключу на подписчике) останавливают репликацию до ручного вмешательства.
Общая для обоих мина: replication slot

Слот не даёт мастеру удалить WAL, пока реплика его не забрала. Если реплика умерла, а слот остался - WAL копится бесконечно, пока на мастере не кончится диск, и база встаёт. Инцидент очень частый. Смотреть pg_replication_slots, ограничивать max_slot_wal_keep_size, алертить на неактивные слоты.

Суть: асинхронная - COMMIT отвечает сразу после локальной записи, latency не растёт, но при падении мастера теряется всё, что не доехало. Синхронная - COMMIT ждёт подтверждения реплики: потерь нет, но каждая запись оплачивает сетевой круг, а при недоступности реплики запись останавливается. Это прямой размен доступности на сохранность.

Уровни synchronous_commit

ЗначениеЧто ждёт COMMITЧто теряем при сбое
offничего, даже локальный fsyncпоследние коммиты (до ~3× wal_writer_delay); целостность БД сохраняется
localлокальный fsyncничего локально; всё, что не доехало до реплики
remote_writeреплика приняла и отдала ОСпри одновременном падении ОС реплики и мастера
on (дефолт)реплика записала на дискничего из подтверждённого
remote_applyреплика примениланичего; плюс чтение с этой реплики сразу актуально

Ключевые тонкости

  • off не ломает базу. Вопреки частому заблуждению, теряются только последние транзакции: база остаётся целостной, потому что WAL всё равно пишется последовательно и восстановление корректно. Поэтому off годится для логов, метрик и импорта, где потеря секунды данных безразлична, а выигрыш в throughput кратный.
  • Настраивается на транзакцию. SET LOCAL synchronous_commit = 'remote_apply' для перевода денег и 'off' для записи в журнал активности уживаются в одной базе.
  • Кворум. С synchronous_standby_names = 'ANY 2 (r1, r2, r3)' мастер ждёт подтверждения любых двух из трёх: сохранность есть, а падение одной реплики запись не останавливает. Для прода это разумный компромисс.
  • Опасность одной синхронной реплики: если она недоступна, мастер заблокирует запись до её возвращения. Поэтому синхронных реплик держат минимум две с кворумом ANY 1 или включают автоматический перевод в асинхронный режим.
  • Гео-распределение. Синхронная реплика в другом регионе добавляет 50–100 мс к каждому коммиту, и обычно это неприемлемо. Типовая схема: синхронная в соседней AZ, асинхронная в другом регионе.
Суть: лаг - отставание реплики от мастера, измеряется в байтах WAL и во времени. При асинхронной репликации он есть всегда: обычно единицы миллисекунд, но при массовом UPDATE, миграции или долгом запросе на реплике улетает в секунды и минуты. Приложение обязано это учитывать: часть чтений остаётся на мастере, реплики с большим лагом исключаются из балансировки.
-- На мастере: кто отстаёт и насколько
SELECT client_addr, state, sync_state,
       pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn)   AS sent_lag,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag
FROM pg_stat_replication;

-- На реплике: лаг во времени
SELECT now() - pg_last_xact_replay_timestamp() AS lag;
SELECT pg_last_wal_replay_lsn();     -- нужен для ожидания собственной записи

Причины всплесков лага

  • При массовой записи (backfill, миграция, импорт) WAL генерируется быстрее, чем применяется.
  • Применение WAL на реплике однопоточное, и при большом объёме реплика физически не успевает за параллельной записью мастера.
  • Долгий запрос на реплике конфликтует с применением: без hot_standby_feedback применение ждёт запрос до max_standby_streaming_delay, а потом его отменяет («canceling statement due to conflict with recovery»). С hot_standby_feedback = on конфликта нет, зато bloat растёт на мастере.
  • Сеть между дата-центрами, диск реплики слабее мастера.
  • VACUUM большой таблицы пишет гигабайты WAL за минуты.
Как читать с реплик
  • Явно классифицировать запросы: «должно быть свежим» → мастер; «допустима задержка» → реплика. Решают это в коде осознанно, а не «все SELECT на реплику».
  • Роутер (pgpool, приложение, sidecar) должен исключать реплики с лагом выше порога - иначе одна отстающая реплика раздаёт всем старые данные.
  • Мониторить максимум лага, а не среднее, и алертить по порогу.
  • max_standby_streaming_delay задаёт, сколько реплика ждёт, прежде чем убить конфликтующий запрос. Значение подбирают под то, что важнее - свежесть или отчёты.
  • Помнить: реплики не масштабируют запись. Если упёрлись в неё, поможет шардирование, а не ещё одна реплика.
Суть: классическая проблема read-your-writes: запись ушла на мастер, а следующее чтение попало на реплику, которая ещё не применила эту транзакцию. Не баг репликации, а следствие асинхронности, и решается на уровне приложения - одним из четырёх стандартных приёмов.
РешениеКак работаетЦена
Sticky to masterпосле записи этот пользователь N секунд читает только с мастера (флаг в сессии, cookie, Redis)часть чтений возвращается на мастер; надо подобрать N по реальному лагу
Ожидание LSNзапомнили pg_current_wal_lsn() на коммите, отдали клиенту токен; при чтении реплика проверяет pg_last_wal_replay_lsn() >= токен, иначе идём на мастерсамое точное решение; требует прокидывать токен через API
Критичное - всегда с мастерабаланс, статус платежа, только что созданный объект читаем с мастера по определениюпростое и надёжное; требует классификации запросов
Оптимистичный ответAPI возвращает записанное состояние из самой операции записи, клиент не перечитываетне решает проблему для другого устройства или после перезагрузки страницы
synchronous_commit = remote_applyкоммит ждёт применения на реплике - читать с неё сразу безопаснозаметный рост latency записи; применять точечно
Как ответить «на грейд выше»

«Сначала уточню, действительно ли данные не сохранились: обычный баг надо исключить. Дальше смотрю pg_stat_replication: если лаг в секундах, ищу причину всплеска (миграция, backfill, конфликт с долгим запросом). Потом решаю на уровне продукта: профилю хватит sticky-to-master на 5 секунд, а баланс всегда читаем только с мастера. И отдельно поставлю алерт на лаг, иначе такие обращения будут повторяться, а причину каждый раз будут искать заново.»

Суть: failover - повышение реплики до мастера при отказе текущего: обнаружение → выбор кандидата (максимальный LSN) → promote → переключение трафика. Split brain - ситуация, когда мастеров становится два: сеть разорвалась, кластер повысил реплику, а старый мастер продолжает принимать запись. Данные расходятся, и автоматически слить их обратно невозможно.

Шаги failover

  1. Обнаружение. Health-check не отвечает N раз подряд. С порогом ищут компромисс: слишком чувствительный даёт ложные срабатывания на сетевом моргании, слишком терпимый увеличивает RTO.
  2. Выбор кандидата. Берут реплику с наибольшим применённым LSN: она потеряет меньше всего. Если есть синхронная реплика, выбирают её.
  3. Fencing старого мастера делают до promote, а не после.
  4. Promote. Реплика заканчивает восстановление и начинает принимать запись.
  5. Переключение трафика: VIP, обновление DNS, PAUSE/ RESUME в pgbouncer, обновление service discovery.
  6. Перестройка кластера на нового мастера. Остальные реплики просто переходят на его линию времени, а бывший мастер возвращают репликой через pg_rewind: у него могут быть «лишние» транзакции, до реплик не доехавшие.

Защита от split brain

  • Кворум и внешний DCS. Решение принимает только часть кластера с большинством узлов (etcd/Consul/ZooKeeper). Поэтому узлов нужно нечётное число, а DCS должен быть отдельной, независимо доступной системой.
  • Fencing / STONITH. Старый мастер физически изолируют: снимают VIP, останавливают сервис, блокируют порт, а в облаке отключают сетевой интерфейс. Только после этого повышают нового.
  • Самодемоция. Узел, потерявший связь с DCS и не подтвердивший свою роль в течение TTL лидерской аренды, сам переводит себя в read-only. Так работает Patroni: лидерство там хранится как ключ с TTL, который надо продлевать.
  • Единая точка входа. Клиенты ходят через прокси, знающий текущего мастера, и «второй мастер» просто не получает трафика.
  • Синхронная репликация дополнительно гарантирует, что у нового мастера есть все подтверждённые коммиты.
Почему split brain страшнее простоя

Простой виден сразу и лечится восстановлением. Split brain не виден: обе базы работают, обе отвечают, обе выдают заказы с одинаковыми id разным клиентам. Замечают его через часы, когда сети восстановились и что-то не сошлось. Автоматически расхождение не слить: критерия правоты нет, и часть данных придётся потерять или разбирать руками. Отсюда общее правило: лучше отказать в записи, чем принять её в неизвестное состояние. В терминах CAP это и есть выбор CP.

Суть: партиционирование - разбиение таблицы внутри одного сервера, прозрачно для приложения, делает сама СУБД. Шардирование - разбиение по разным серверам, каждый со своими CPU, памятью и диском; прозрачности нет, маршрутизацию делает приложение или прокси. Только шардирование масштабирует запись.

Ключевые различия

  • Что масштабируется. Партиционирование решает проблему размера таблицы и обслуживания, шардирование - проблему ресурсов сервера, когда одна машина не тянет запись, объём или память.
  • Прозрачность. Партиции остаются деталью реализации: приложение работает с одной таблицей. Шарды входят в архитектуру: приложению нужно знать, куда идти.
  • Транзакции. Между партициями работает обычный ACID, между шардами нужен 2PC или сага.
  • JOIN. Между партициями он обычный. Между шардами остаётся scatter-gather с агрегацией в приложении или дублирование справочников на все шарды.
  • Уникальность. Партиция требует ключ в PK. Шардирование вообще лишает глобального UNIQUE: чтобы email был уникален по всей системе, нужен либо отдельный сервис-реестр, либо шардирование именно по email.
  • Эксплуатация. N шардов означают N кластеров, N наборов реплик, N бэкапов, N процедур failover. Сопровождение дорожает линейно.
В каком порядке масштабировать

В хорошем ответе есть последовательность: сначала индексы и переписывание запросов → вертикальное масштабирование (железо дешевле инженеров) → read-реплики для чтения → партиционирование по времени → архивация холодных данных → и только затем шардирование. Оно идёт последним, потому что необратимо усложняет всё остальное. Кандидат, который начинает ответ с «пошардируем», отвечает хуже того, кто дошёл до этого шестым пунктом.

Суть: ключ выбирается так, чтобы (1) большинство запросов попадало в один шард, (2) данные распределялись равномерно, (3) ключ не менялся, (4) связанные сущности жили вместе. Hot shard - перекос, когда один шард получает непропорционально много данных или нагрузки; лечится составным ключом, выделенными шардами для крупных клиентов и виртуальными шардами.

Критерии выбора

  • Локальность запросов. Если 90 % запросов про «данные пользователя X», ключом берут user_id, в мультитенантном продукте tenant_id. Правильный ключ подсказывают паттерны доступа, а не структура данных.
  • Равномерность. Хеш от ключа обычно лучше диапазона: диапазон по дате отправляет весь свежий трафик на последний шард, диапазон по алфавиту - на шард с буквой «А».
  • Неизменяемость. Смена значения ключа = физический переезд строки между серверами со всеми проблемами консистентности.
  • Совместное размещение. Заказы и позиции заказов шардируем по одному ключу (user_id), чтобы JOIN и транзакция остались локальными.
  • Кардинальность. Ключ с сотней различных значений не разложится на тысячу бакетов.

Hot shard: причины и лечение

ПричинаЛечение
Один крупный тенант (в B2B это норма: «Магнит» в 1000 раз больше среднего клиента)выделить ему персональный шард; либо составной ключ tenant_id + bucket, разложив его данные по нескольким шардам
Знаменитость / популярный объект в соцсетиотдельный путь для «горячих» сущностей: кэш, отдельное хранилище, денормализация
Шардирование по датесменить ключ на хеш; дату оставить для партиционирования внутри шарда
Перекос естественного распределения (страна, регион)хеш вместо списка; либо ручная карта «ключ → шард» с балансировкой

Проблемы, о которых спросят следом

  • Кросс-шардовые запросы. «Топ-10 заказов по всей системе» превращается в scatter-gather: запросить топ-10 у каждого шарда, слить и отсортировать в приложении. Работает, но latency равна самому медленному шарду, и OFFSET по всем шардам корректно не выражается.
  • Агрегации. count(*) по всей системе требует либо обойти все шарды, либо держать отдельную аналитическую копию (ClickHouse, витрина), куда данные стекаются через CDC. На практике почти всегда второе.
  • Глобальные идентификаторы. Автоинкремента нет: UUIDv7, Snowflake ID или отдельный сервис, выдающий диапазоны.
  • Решардинг. При hash mod N добавление шарда переносит почти все ключи. Спасают consistent hashing (переезжает ~1/N) или виртуальные шарды - 1024 логических бакета на 4 физических узла, переезд = перенос бакетов, карта меняется в одном месте. Закладывать их сразу: потом это крайне дорого.
  • Справочники. Мелкие общие таблицы дублируют на все шарды и синхронизируют отдельно, чтобы не делать кросс-шардовый JOIN.
Суть: 2PC - протокол с координатором. Фаза 1 (prepare): координатор спрашивает всех участников «готов зафиксировать?», каждый выполняет работу, пишет PREPARED в свой журнал, берёт блокировки и отвечает YES/NO. Фаза 2 (commit): если все ответили YES - команда COMMIT всем, иначе ABORT всем. Даёт атомарность, но является блокирующим протоколом.

Разбор задачи «билеты + отель»

Координатор                    БД «Билеты»              БД «Отели»
    |-- PREPARE 'trip-42' ------->|                        |
    |                             | блокирует место        |
    |                             | пишет PREPARED в WAL   |
    |<----------- YES ------------|                        |
    |-- PREPARE 'trip-42' ---------------------------------->|
    |                                                      | блокирует номер
    |<--------------------------- YES ---------------------|
    |                    оба YES → решение зафиксировано у координатора
    |-- COMMIT PREPARED --------->|                        |
    |-- COMMIT PREPARED ------------------------------------>|

Где ломается

  • Координатор упал между фазами. Участники сказали YES, держат блокировки и не имеют права решать сами: они не знают, ответил ли второй участник YES. Ресурсы заблокированы до возвращения координатора. Поэтому 2PC и называют блокирующим.
  • Участник упал после YES. Он обязан после перезапуска восстановить подготовленную транзакцию из журнала и дождаться решения. В PostgreSQL PREPARE TRANSACTION переживает рестарт, и это же делает забытую подготовленную транзакцию миной: она вечно держит блокировки и горизонт VACUUM (pg_prepared_xacts).
  • Доступность. Живы должны быть все участники и координатор, поэтому доступности перемножаются.
  • Latency. Два круга обмена плюс fsync у каждого участника в каждой фазе.

Что делают вместо 2PC

  • Сага разбивает операцию на цепочку локальных транзакций с компенсациями: bookFlight → bookHotel → charge; при отказе выполняем cancelHotel, cancelFlight. Изоляции нет: промежуточное состояние видно, поэтому вводят статус «pending» и таймаут резервирования.
  • Transactional outbox пишет событие в ту же локальную транзакцию, что и бизнес-данные, а в брокер его отправляет отдельный процесс. Так закрывается «записали в БД, но не отправили в Kafka» без всякого 2PC.
  • Идемпотентность каждого шага и каждой компенсации обязательна, потому что доставка at-least-once.
  • Оркестратор (Temporal, Camunda, свой сервис-дирижёр) берут вместо хореографии, когда шагов больше трёх, иначе логика размазывается по сервисам и её невозможно отладить.
С чего начать ответ на такой вопрос

Спросить, точно ли нужны две базы. Часто «билеты» и «отели» оказываются двумя сервисами одной команды, разделёнными по недоразумению, и тогда нужна не распределённая транзакция, а пересмотр границ. Если границы верны, берут сагу. 2PC остаётся для случаев, когда участники живут в одной СУБД или классическом XA-стеке и бизнес готов платить доступностью.

Суть: eventual consistency - гарантия, что при отсутствии новых записей все копии данных рано или поздно сойдутся к одному состоянию; до этого чтение может вернуть устаревшее. C в ACID - про соблюдение инвариантов схемы (констрейнты, FK, CHECK) внутри одной транзакции. C в CAP - про то, что все узлы видят одни и те же данные. Это разные вещи, названные одинаково.

Спектр гарантий

  • Linearizable / strong. Чтение всегда видит последнюю подтверждённую запись: одиночный PostgreSQL, чтение с мастера.
  • При read-your-writes свои записи видны сразу, чужие могут отставать. Обычно это минимум для пользовательского интерфейса.
  • Monotonic reads не дают данным «идти назад» между двумя запросами одного пользователя. Гарантия ломается, если запросы попадают на разные реплики с разным лагом.
  • Causal consistency - если B зависит от A, никто не увидит B раньше A. Комментарий не появляется раньше поста.
  • Eventual гарантирует только сходимость, без обещаний по времени.

Где встречается в обычном бэкенде

  • Чтение с асинхронных реплик (самый частый случай).
  • Кэш: Redis отдаёт то, что положили минуту назад.
  • Поисковый индекс (Elasticsearch), обновляемый из БД через CDC.
  • Материализованные представления и витрины.
  • Асинхронная обработка через брокер: заказ создан, письмо ещё не отправлено.
Как формулировать выбор

Не «наша система eventual consistent», а по сценариям: «баланс и статус платежа читаем с мастера - там нужна строгая консистентность; ленту и счётчики читаем с реплик, где допустима задержка до секунды, и это осознанное решение, потому что строгая консистентность здесь стоила бы роста latency записи и падения доступности при отказе реплики». Плюс уметь назвать, что делают, когда eventual «протекает» в UI: оптимистичный рендер, статус «обрабатывается», sticky-to-master после записи.

Суть: pg_dump - логический снимок на момент времени; восстановление возвращает состояние ровно на момент дампа, зато можно достать отдельную таблицу и перенести между версиями PostgreSQL. PITR - физический базовый бэкап плюс непрерывный архив WAL; позволяет восстановиться на любой момент и даёт RPO в секунды, но только кластер целиком и только на ту же версию.

RPO и RTO

  • RPO (Recovery Point Objective) измеряют во времени: сколько данных допустимо потерять. Ежесуточный дамп → RPO до 24 часов. PITR с архивацией WAL раз в минуту → RPO около минуты. Синхронная реплика → RPO = 0.
  • RTO (Recovery Time Objective) говорит, за сколько система обязана вернуться. Терабайт из pg_dump восстанавливается часами (данные заливаются построчно, индексы строятся заново), PITR укладывается в десятки минут, переключение на горячую реплику занимает секунды.
  • Эти две цифры задаёт бизнес, а не инженер: «сколько денег стоит час простоя и сколько - потеря часа данных». Всё остальное из них следует.
Что сказать про бэкапы
  • Нужны оба типа. PITR спасает от отказа железа и от «удалили не то в 14:31». pg_dump выручает, когда «нужна одна таблица за прошлый месяц», при мажорном апгрейде и для долгих архивов.
  • Реплика не бэкап. DELETE доедет до неё за миллисекунды. Реплика защищает от отказа узла, а от человека спасает только бэкап.
  • Непроверенный бэкап не существует. Что он рабочий, покажет только регулярное автоматическое восстановление в тестовое окружение с проверочными запросами.
  • Хранить отдельно: другой регион/аккаунт, ограниченные права на удаление, версионирование объектов. Иначе один скомпрометированный доступ уносит и прод, и бэкапы.
  • Инструменты: pgBackRest, WAL-G, barman. Они умеют инкрементальные базовые бэкапы, сжатие, шифрование и параллельное восстановление, и свой скрипт архивации WAL писать не стоит.
  • pg_dump держит долгую транзакцию и удерживает горизонт VACUUM, поэтому снимать его лучше с реплики.
Суть: вопрос на инженерную дисциплину. Порядок - от самого дешёвого и обратимого к самому дорогому и необратимому: измерить → оптимизировать запросы → кэш → вертикально → read-реплики → партиционирование и архивация → и только в конце шардирование. И обязательно уточнить, во что упёрлись: чтение, запись, объём или блокировки - лечения совершенно разные.
  1. Измерить. Что именно упёрлось: CPU, диск (IOPS, latency), память, соединения, блокировки? pg_stat_statements, pg_stat_activity, метрики хоста. Без этого шага любой ответ превращается в угадывание.
  2. Оптимизировать запросы. Индексы, переписывание, устранение N+1, убрать SELECT *, keyset вместо OFFSET. Обычно даёт кратный эффект бесплатно и обратимо.
  3. Настройки. shared_buffers, work_mem, effective_cache_size, autovacuum для горячих таблиц, random_page_cost под SSD.
  4. Кэш. Redis перед горячими и редко меняющимися данными. Дёшево, но добавляет инвалидацию и eventual consistency.
  5. Вертикально. Больше CPU, памяти, быстрее диск. Инженерное время дороже железа, и этот шаг зря стесняются называть: часто он самый разумный.
  6. Read-реплики. Если упёрлись в чтение, разгружаем мастер. Помним про лаг и read-your-writes.
  7. Пул соединений / pgbouncer, если упёрлись в число соединений.
  8. Партиционирование и архивация. Если проблема в объёме: отсечение по времени, удаление старого через DROP, вынос холодных данных.
  9. Разделение нагрузок. Аналитику уводят в ClickHouse через CDC, чтобы отчёты не мешали OLTP, поиск переносят в Elasticsearch.
  10. CQRS / отдельные модели чтения в виде денормализованных витрин под конкретные экраны.
  11. Шардирование. Только если упёрлись в запись и всё предыдущее исчерпано.
Чем добить

«Отдельно проверю, не искусственная ли нагрузка: ретраи без backoff, которые множат трафик при деградации; отсутствие таймаутов; N+1, выросший вместе с размером страницы; фоновая джоба, запущенная в пик. Довольно часто „база не справляется“ означает, что приложение стреляет в неё вдвое чаще, чем нужно, и чинить надо клиента, а не масштабировать базу.»

Суть: при сетевом разделении (P) распределённая система вынуждена выбрать между консистентностью (C) и доступностью (A). Формулировка «выбери два из трёх» неточна: P - это не выбор, а данность, сети рвутся. Реальный выбор делается только в момент разделения: отказать в обслуживании ради корректности (CP) или ответить возможно устаревшими данными (AP).

Как это выглядит в PostgreSQL

  • На одном сервере распределённой системы нет, CAP не применяется, есть ACID.
  • Мастер + синхронная реплика дают CP: при потере реплики мастер останавливает запись, чтобы не потерять подтверждённые коммиты. Консистентность сохранена, доступность на запись потеряна.
  • Мастер + асинхронные реплики на чтении ближе к AP: реплики отвечают устаревшими данными, но отвечают. На запись остаётся CP - мастер один.
  • Автоматический failover с кворумом - CP по построению: меньшая часть кластера сама себя переводит в read-only, чтобы не устроить split brain.
  • Cassandra, DynamoDB по умолчанию AP: отвечают всегда. У Cassandra консистентность настраивается уровнями кворума на чтение и запись, у DynamoDB - флагом строго консистентного чтения.
Чем дополнить ответ: PACELC

CAP описывает только поведение при разделении, а систему надо характеризовать и в нормальном режиме. PACELC: «если Partition - выбор между A и C; Else - выбор между Latency и Consistency». PostgreSQL с синхронной репликацией попадает в PC/EC: и при разделении, и в норме предпочитает консистентность, платя задержкой. С асинхронной он PC/EL: в норме выбирает низкую задержку. Упомянув PACELC, покажешь, что понимаешь CAP как модель, а не как лозунг.

Что стоит запомнить

  • Чтение с реплики может отставать от записи; read-your-writes требует маршрутизации, ожидания LSN или другого явного механизма.
  • Синхронная репликация повышает подтверждённую сохранность ценой задержки и доступности при проблемах реплики.
  • Шардирование распределяет данные и нагрузку, но усложняет запросы, транзакции и изменение ключа.

Частые ошибки

  • Не называй реплики решением проблем записи без отдельного анализа нагрузки на запись.
  • Failover требует защиты от split brain и учёта того, какие подтверждённые транзакции могла потерять асинхронная реплика.
  • Реплика не заменяет резервную копию: ошибочное удаление также реплицируется.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Почему пользователь не видит свою запись

Запись успешно закоммичена на primary, затем GET направлен на асинхронную read replica, где строка пока отсутствует. Объясни причину и два возможных решения для read-your-writes.

func (r *Repo) List(ctx context.Context, afterLSN pglogrepl.LSN) ([]Row, error) {
    if sessionJustWrote(ctx) { return r.primary.Query(ctx, q) }
    return r.replica.Query(ctx, q)
}
Ответ и разбор

Реплика ещё не применила WAL с новой транзакцией, поэтому чтение допустимо устарело. Для конкретной сессии направляй чтение на primary на короткое время либо жди на реплике достижения LSN, связанного с commit. Простое увеличение числа реплик не убирает replication lag.

Код-ревью

2. Проверь план аварийного переключения

В плане написано: "При потере primary любой pod сразу повышает read replica до primary и продолжает запись". Найди риск и предложи необходимые проверки.

func (r *Repo) Promote(ctx context.Context) error {
    if !r.lease.IsHolder() { return ErrNotLeader }
    if err := r.oldPrimary.Fence(ctx); err != nil { return err }
    return r.replica.Promote(ctx)
}
Ответ и разбор

Если старый primary жив, но сеть до него потеряна, оба узла могут принимать запись: это split brain. Нужен механизм единственного лидера/lease или fencing старого primary, проверка здоровья и политики выбора реплики, оценка replay lag и возможной потери последних транзакций. Приложение должно переподключиться по стабильному адресу и не предполагать, что неоднозначный исход записи безопасно повторить.

Применение

3. Выбери ключ шарда

Почти все записи и чтения заказов идут по tenant_id, но есть глобальный отчёт по всем заказам. Обоснуй tenant_id как ключ шарда и назови цену решения.

Ответ и разбор

tenant_id локализует частые операции одного арендатора на одном шарде и упрощает транзакции внутри арендатора. Глобальный отчёт станет fan-out запросом или потребует отдельной аналитической проекции. Следи за крупными tenant, которые могут стать горячими шардами; оценка должна включать распределение размеров и план ребалансировки.

Применение

4. Выбери восстановление данных

Оператор случайно удалил строки, а репликация успела распространить DELETE на все реплики. Какой механизм поможет вернуть состояние на момент до удаления и что ещё нужно проверить?

Ответ и разбор

Нужны базовая резервная копия и архив WAL для point-in-time recovery в отдельный восстановленный экземпляр. Репликация сама по себе не помогает, потому что повторяет логическое удаление. Проверь регулярность копий, доступность WAL, целевое время восстановления и реально испытанный процесс восстановления.