Базы данных Репликация, шардирование и высокая доступность
0%

Репликация, шардирование и высокая доступность

Репликация, шардирование и высокая доступность

Есть две принципиально разные операции, которые постоянно склеивают в одно слово «масштабирование». Репликация — это копирование: одни и те же данные лежат на нескольких узлах целиком. Шардирование — это разрезание: каждый узел хранит свой кусок, и ни один не знает всей картины.

Они решают разные задачи и стоят разного. Репликация не увеличивает объём хранимых данных и почти не помогает записи — она даёт живучесть и параллельное чтение. Шардирование даёт запись и объём, но ломает всё, что вы любили в SQL: джойны, уникальные ограничения, транзакции, ORDER BY ... LIMIT. Почти всегда правильный порядок такой: сначала выжать одну машину, потом реплики, и только потом — шарды.

Проблема Инструмент Чем платим
Узел умер — сервис лежит Репликация + автоматический failover Лаг, риск split-brain, вторая машина
Чтений больше, чем тянет сервер Реплики только на чтение Устаревшие ответы, аномалии консистентности
Отчёты душат OLTP Выделенная реплика или CDC-поток Дублирование данных, лаг до минут
Пользователи за 200 мс RTT Гео-реплики на чтение Запись всё равно едет в лидера
Данные не влезают на диск Шардирование Кросс-шардовые запросы, респлиты
Записей больше, чем тянет лидер Шардирование Распределённые транзакции, сложность
ЦОД сгорел Мульти-ЦОД репликация Латентность коммита, стоимость трафика

Предполагается, что транзакции и уровни изоляции вы уже разобрали: без понимания аномалий на одном узле разговор про распределённые аномалии превращается в заклинания.

Часть I. Репликация

Что именно копируем: три уровня журнала

Реплика не «копирует таблицы» — она проигрывает поток изменений. Вопрос в том, на каком уровне абстракции этот поток записан. От ответа зависит буквально всё: можно ли реплицировать между разными версиями и разными СУБД, фильтровать таблицы, и переживёт ли репликация NOW() внутри запроса.

Уровень Что передаётся Плюсы Минусы Где встречается
По операторам Текст SQL: UPDATE t SET x=x+1 ... Компактно, читаемо Недетерминизм: NOW(), RAND(), триггеры, порядок при LIMIT MySQL binlog_format=STATEMENT (легаси)
По строкам «строка с PK=42 стала такой» Детерминизм, кросс-версионность, фильтрация, CDC Объём: массовый UPDATE = миллионы событий; нужен PK MySQL ROW, PostgreSQL logical replication, oplog MongoDB
Физический Байтовые изменения страниц и WAL Дёшево, точная копия вместе с индексами Только та же мажорная версия и платформа; всё или ничего PostgreSQL streaming replication, Oracle Data Guard

Следствия, которые кусают на практике:

  • Мажорный апгрейд PostgreSQL без даунтайма физической репликацией невозможен — формат WAL версионный. Делают логической репликацией или pg_upgrade с окном.
  • binlog_format=STATEMENT до сих пор встречается в легаси и до сих пор тихо расходится с мастером. Единственный правильный ответ — ROW (доки MySQL).
  • Oplog MongoDB идемпотентен: $inc превращается в присвоение конкретного значения, поэтому журнал можно безопасно переигрывать с любой точки.
  • Физическая реплика реплицирует и распухание, и повреждение страницы. Она не защищает от «удалил не ту таблицу» и от битого диска — для этого нужны бэкапы и PITR. Это самая частая и самая дорогая подмена понятий в проде.

Синхронность: где стоит подтверждение и что обещано клиенту

Момент, когда клиент получает «ОК» на COMMIT, — это и есть определение вашего RPO.

PostgreSQL даёт не «вкл/выкл», а шкалу из пяти ступеней synchronous_commit:

Значение Ждём Потеря при падении primary Потеря при падении primary + replica
off ничего до 200 мс коммитов те же
local локальный fsync всё, что не доехало (лаг) всё, что не доехало
remote_write реплика приняла в ОС ноль возможна — не на диске реплики
on (по умолчанию) реплика сделала fsync ноль ноль
remote_apply реплика применила ноль ноль, и чтение с реплики видит коммит

Разница между on и remote_apply тонкая, но принципиальная: on гарантирует, что данные не потеряются, но не гарантирует, что запрос к реплике сразу после COMMIT их увидит. remote_apply гарантирует и это — ценой ожидания применения, которое на реплике однопоточно и может встать за конфликтом с длинным читающим запросом.

Цена в цифрах, порядок величины: локальный fsync на NVMe — 0.1–0.5 мс; синхронная реплика в той же зоне доступности — плюс 0.5–2 мс; в соседней зоне — плюс 1–3 мс; в другом регионе за 1500 км — плюс 15–40 мс, и это физика, а не настройка.

MySQL решает ту же задачу через semi-sync, и там важен исторический нюанс: AFTER_COMMIT (до 5.7) подтверждал транзакцию локально до ответа реплики, из-за чего другие сессии видели коммит, который потом мог исчезнуть.

# my.cnf на источнике — semi-sync без «фантомных» чтений
rpl_semi_sync_source_enabled                = ON
rpl_semi_sync_source_wait_point             = AFTER_SYNC   # не AFTER_COMMIT
rpl_semi_sync_source_timeout                = 1000         # мс, дальше деградация в async
rpl_semi_sync_source_wait_for_replica_count = 1
gtid_mode = ON
enforce_gtid_consistency = ON
binlog_format = ROW
sync_binlog = 1
innodb_flush_log_at_trx_commit = 1

Ловушка semi-sync: при таймауте MySQL молча деградирует в асинхронный режим и продолжает принимать записи. Ваш RPO=0 действует ровно до первой сетевой икоты. PostgreSQL в синхронном режиме поступает наоборот — блокирует коммиты, пока не появится синхронная реплика. Это разные философии: MySQL выбирает доступность, PostgreSQL — сохранность. Обе защитимы, но вы обязаны знать, какую выбрали.

Отдельная мина PostgreSQL: если synchronous_standby_names указывает на единственную реплику и она уходит на обслуживание, все коммиты на мастере встают. Лечится ANY 1 (r1, r2) с двумя кандидатами или честным решением «нам подходит async».

Топологии

Топология Консистентность Отказ узла Когда НЕ брать
Single-leader Сильная на лидере, eventual на репликах Нужен failover, есть окно недоступности записи Когда нужна быстрая запись в двух регионах
Каскадная Как выше, лаг накапливается Падение промежуточного узла отрезает ветку Когда важен предсказуемый лаг
Multi-leader Конфликты неизбежны Регион работает автономно Когда есть уникальные ограничения и счётчики — почти всегда
Leaderless Настраиваемая через N/R/W Выборов нет вообще Когда нужны транзакции и джойны

Multi-leader — не «репликация посильнее», а другая модель данных. Если два региона одновременно меняют одну строку, СУБД обязана решить конфликт: last-write-wins по метке времени (тихо теряет данные и зависит от рассинхронизации часов), хранение всех версий с разрешением в приложении, или бесконфликтные типы — CRDT. Уникальный индекс на email в multi-leader топологии в принципе не обеспечивается без глобальной координации: два региона примут одинаковый email, и конфликт вы увидите постфактум. Каноническое обсуждение — глава 5 в «Designing Data-Intensive Applications» Клеппманна.

В leaderless-системах правило R + W > N гарантирует пересечение множеств реплик, но не даёт линеаризуемости: два параллельных чтения во время незавершённой записи вернут разное, а частично применённая запись не откатывается. Кворум — про вероятность увидеть свежее, а не про гарантии; подробнее в статье про NoSQL.

Что ломается из-за лага

Асинхронная реплика — машина времени, показывающая прошлое. Пользователь этого не знает, и получаются баги, невоспроизводимые локально.

  1. Read-your-writes. Меняем аватарку, COMMIT уходит на лидера, редирект на профиль читает с реплики — старая аватарка. «Я сохранил, ничего не изменилось».
  2. Monotonic reads. Два чтения попали на реплики с разным лагом: комментарий появился, обновили страницу — исчез. Время пошло назад.
  3. Consistent prefix. Реплика применила ответ раньше самого сообщения. Особенно актуально при шардировании: причинно связанные записи живут на разных шардах.

Лечится не «мощными репликами», а маршрутизацией. Рабочая техника — токен позиции журнала: после записи запоминаем LSN и не читаем с реплики, которая до него не доехала.

# Read-your-writes без «всё читаем с мастера»: маршрутизация по LSN.
# Цена: один лишний round-trip на запись, ноль накладных на 95% чтений.

def write_and_capture_lsn(primary, sql, params):
    with primary.transaction() as tx:
        tx.execute(sql, params)
        lsn = tx.execute("SELECT pg_current_wal_insert_lsn()").scalar()
    return lsn          # в сессию/куку пользователя, TTL порядка секунд


def pick_read_connection(pools, required_lsn):
    """Реплика, догнавшая required_lsn; иначе деградируем в primary."""
    if required_lsn is None:
        return pools.any_replica()               # догонять нечего
    for replica in pools.replicas_by_load():
        if replica.scalar("SELECT pg_last_wal_replay_lsn()") >= required_lsn:
            return replica
    return pools.primary()

В MySQL то же делается штатной функцией, блокирующейся до применения GTID:

-- 0 — дождались, 1 — таймаут, NULL — репликация не работает
SELECT WAIT_FOR_EXECUTED_GTID_SET('3E11FA47-71CA-11E1-9E33-C80AA9429562:1-42', 1);

Не пытайтесь сделать read-your-writes глобально. Определите 5–10 сценариев, где устаревание заметно пользователю (профиль, корзина, только что созданный заказ), и примените токен там. Ленты, каталоги, поиск и отчёты прекрасно живут с лагом в секунды, а попытка сделать консистентным всё просто вернёт нагрузку на лидера.

Как правильно мерить лаг

Главная ошибка — смотреть на лаг в секундах. На простое «секунды отставания» растут, хотя реплика идеально синхронна: новых транзакций просто нет.

-- На PRIMARY: картина по каждой реплике в байтах WAL.
SELECT application_name, state, sync_state,
       pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn) AS pending_bytes,
       pg_wal_lsn_diff(flush_lsn, replay_lsn)          AS replay_lag_bytes,
       write_lag, flush_lag, replay_lag
FROM pg_stat_replication ORDER BY replay_lag_bytes DESC;

-- На REPLICA: честные секунды, без ложной тревоги на простое.
SELECT CASE WHEN pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() THEN 0
            ELSE EXTRACT(EPOCH FROM now() - pg_last_xact_replay_timestamp())
       END AS lag_seconds;

-- Слоты: источник инцидента «на мастере кончился диск».
SELECT slot_name, active, wal_status,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots ORDER BY 4 DESC;

Про слоты отдельно, потому что это самый частый инцидент. Слот гарантирует, что WAL не удалится, пока реплика его не забрала. Реплику погасили, слот забыли — мастер копит WAL до заполнения диска и останавливается. Обязательные меры: max_slot_wal_keep_size (PG 13+), алерт на retained больше 20% диска и алерт на active = false дольше 10 минут. В MySQL аналогичная ложь — Seconds_Behind_Source: она обнуляется при разрыве соединения, смотреть надо на разницу GTID-множеств и на performance_schema.replication_applier_status_by_worker.

Анатомия отказа: из чего складываются RPO и RTO

Часть II. Шардирование

Сначала — не шардировать

Шардирование навсегда удваивает эксплуатационную сложность. Прежде чем идти туда, честно пройдите список более дешёвых вариантов.

Цифры для калибровки: машина на 96 vCPU и 768 ГБ RAM с NVMe уверенно держит 30–80 тысяч простых OLTP-транзакций в секунду на PostgreSQL и терабайты горячих данных; в облаке это порядка 4–8 тысяч долларов в месяц. Кластер из восьми машин поменьше стоит примерно столько же по железу, но добавляет шардирующий слой, респлиты, распределённые миграции схемы, дежурства и, как правило, одного-двух инженеров. Инженеры дороже железа. Вертикальное масштабирование не «непрофессионально» — оно рационально довольно долго.

Стратегии разрезания

Стратегия Плюсы Минусы Когда НЕ брать
По диапазону Эффективные range-сканы, простые сплиты Горячие точки: свежие данные всегда на последнем шарде Монотонный ключ — время, автоинкремент
По хешу Равномерность Range-запросы веером по всем шардам Когда нужны выборки по диапазону ключа
Справочник Гибкость, ручное расселение крупных клиентов Лишний хоп и точка отказа, надо кэшировать Когда ключей миллиарды
Гео / по арендатору Локальность, изоляция, резидентность данных Перекос: один клиент — половина данных Когда клиенты неравномерны и топ-10 нельзя изолировать

Range и hash часто комбинируют: хеш арендатора для равномерности, внутри — диапазон по времени для сканов. Ровно так устроен ключ в Cassandra: partition key определяет узел, clustering key — порядок внутри партиции.

Ключ шардирования — решение, которое почти нельзя отменить

Смена ключа на живой системе — это перелив всей базы с двойной записью, сверкой и окном отката. Считайте, что выбираете один раз. Хороший ключ удовлетворяет четырём условиям одновременно: высокая кардинальность; равномерность; присутствие в 90%+ запросов; совпадение с границей транзакции.

Домен Хороший ключ Плохой ключ Что сломается
B2B SaaS tenant_id user_id Отчёты по компании становятся кросс-шардовыми
Мессенджер conversation_id message_id Чтение переписки собирает данные со всех шардов
Соцсеть user_id post_id «Все посты пользователя» — веер по кластеру
E-commerce customer_id order_date Все сегодняшние заказы бьют в один шард
Метрики hash(series_id) + время голое время 100% записи в один шард

Проблема тяжёлого арендатора приходит всегда: в любой B2B-системе есть клиент в сто раз больше медианного, и после шардирования по tenant_id он один кладёт свой шард. Три рабочих ответа: справочник вместо чистого хеша с ручным переселением топ-клиентов; составной ключ (tenant_id, bucket) с солью только для крупных; либо тарифный ответ — крупные клиенты едут в изолированный инстанс, и это продаётся дороже.

Виртуальные бакеты и ребалансировка

Ключевой приём с картинки: никогда не отображайте ключ на узел напрямую. Отображайте ключ на фиксированное большое число виртуальных бакетов, а бакеты — на узлы. Число бакетов выбирается раз и навсегда (Redis Cluster — 16384 слота, Couchbase — 1024 vbucket). Тогда добавление узла — перемещение части бакетов, а не пересчёт адресации.

Маршрутизация и цена распределённости

Наименее болезненный путь для команды на PostgreSQL — Citus: шардирование остаётся внутри Postgres, приложение продолжает говорить обычным SQL.

-- Колокация связанных таблиц по одному ключу делает большинство джойнов локальными.
SELECT create_distributed_table('tenants',  'tenant_id');
SELECT create_distributed_table('projects', 'tenant_id', colocate_with => 'tenants');
SELECT create_distributed_table('events',   'tenant_id', colocate_with => 'tenants');
SELECT create_reference_table('currencies');   -- маленький справочник на все узлы

EXPLAIN (COSTS OFF)
SELECT p.name, count(*) FROM projects p JOIN events e USING (tenant_id, project_id)
WHERE p.tenant_id = 42 AND e.created_at > now() - interval '7 days'
GROUP BY p.name;

--  Custom Scan (Citus Adaptive)
--    Task Count: 1              <-- отлично: один шард, один сетевой запрос
--    ->  Task  Node: host=worker-3 port=5432
--          ->  HashAggregate ...
--
-- Без фильтра по tenant_id было бы Task Count: 32 — тридцать два сетевых запроса
-- и агрегация на координаторе. Это и есть цена «забыл ключ шардирования».

Первое, что надо построить после шардирования, — метрика доли одношардовых запросов. Упала ниже 90–95% — вы получили стоимость распределённой системы и производительность хуже одиночного сервера.

Операция На одном узле На шардах Обходной путь
JOIN больших таблиц План оптимизатора Broadcast или repartition по сети Колокация по ключу шардирования
Глобальный UNIQUE(email) Индекс Невозможен без координации Таблица-реестр или email как ключ шардирования
Автоинкремент ID bigserial Коллизии между шардами UUIDv7, Snowflake ID, диапазоны на шард
COUNT(*) по всему Скан или статистика Fan-out ко всем шардам Инкрементальные счётчики, HyperLogLog
ORDER BY x LIMIT 20 Индексный скан Взять по 20 с каждого и слить Курсорная пагинация по ключу
Транзакция на 2 шарда ACID бесплатно 2PC: плюс RTT, зависшие prepared Проектировать так, чтобы не требовалось; иначе саги
Миграция схемы Одна команда N раз, с частичными отказами Оркестратор + строгая обратная совместимость
Внешние ключи Проверяются Не существуют Проверка в приложении, регулярная сверка

Про 2PC знайте конкретный операционный риск: PREPARE TRANSACTION в PostgreSQL держит блокировки и удерживает горизонт очистки, а координатор может умереть между prepare и commit. Забытая prepared-транзакция останавливает VACUUM по всей базе и тихо съедает диск. Если 2PC используется — обязателен алерт на pg_prepared_xacts старше минуты.

Респлит без даунтайма

Алгоритм одинаков в Vitess (Reshard), Citus (citus_move_shard_placement) и в самописных решениях. Забывают обычно детали: заморозка только на один бакет, а не на весь шард; сверка сумм до переключения, а не после; идемпотентность на время двойной записи; заранее написанная и проверенная на стенде процедура отката. Респлит, который нельзя откатить, — не респлит, а ставка.

Часть III. Высокая доступность

Три задачи failover: заметить, выбрать, отрезать

Автоматический failover разваливается почти всегда на третьем шаге.

  • Заметить. Health-check не отличает «узел умер» от «сеть до узла умерла». Агрессивный таймаут даёт ложные срабатывания на каждом GC-стопе, мягкий — минуты простоя. Реалистичная база: 3 проверки по 3–5 секунд.
  • Выбрать. Выбирать лидера должно большинство, иначе две половины разорванного кластера выберут себе по лидеру. Отсюда нечётное число узлов в DCS: два не переживают ни одного отказа (большинства из двух нет), три переживают один, пять — два.
  • Отрезать (fencing). Старый лидер может быть жив и продолжать принимать записи. Его нужно принудительно демонтировать: остановить сервис, снять VIP, забрать сеть, в железе — выключить питание (STONITH). Без фенсинга вы получаете split-brain: две ветки истории, обе с реальными коммитами клиентов. Слить их автоматически невозможно.

Реальный конфиг: Patroni + etcd

Patroni — де-факто стандарт HA для PostgreSQL: он не изобретает консенсус, а арендует ключ лидерства в etcd/Consul/ZooKeeper.

scope: prod-billing
name: pg-node-1
restapi: { listen: "0.0.0.0:8008", connect_address: "10.0.1.11:8008" }
etcd3:   { hosts: "10.0.1.21:2379,10.0.1.22:2379,10.0.1.23:2379" }

bootstrap:
  dcs:
    ttl: 30                          # аренда лидерства; это нижняя граница RTO
    loop_wait: 10                    # период самопроверки узла
    retry_timeout: 10                # инвариант: ttl >= loop_wait + 2 * retry_timeout
    maximum_lag_on_failover: 1048576 # 1 МБ: отставшую реплику не повышаем
    synchronous_mode: true           # RPO = 0
    synchronous_node_count: 1        # но не блокируемся, пока жива хоть одна реплика
    postgresql:
      use_pg_rewind: true            # вернуть старого лидера без базового бэкапа
      use_slots: true
      parameters:
        wal_level: replica
        hot_standby: "on"
        max_wal_senders: 10
        max_slot_wal_keep_size: 64GB # страховка от «диск съели слоты»
        synchronous_commit: "on"
        wal_log_hints: "on"          # обязателен для pg_rewind
        archive_mode: "on"
        archive_command: "wal-g wal-push %p"

postgresql:
  listen: "0.0.0.0:5432"
  connect_address: "10.0.1.11:5432"
  data_dir: /var/lib/postgresql/16/main
  authentication:
    replication: { username: replicator, password: "${REPL_PASSWORD}" }
    superuser:   { username: postgres,   password: "${SU_PASSWORD}" }
tags: { nofailover: false, noloadbalance: false }

ttl: 30 — это ваш пол по RTO: раньше тридцати секунд кластер не признает лидера мёртвым. Снижать до 15 можно в стабильной сети внутри одной зоны; ниже 10 — приглашение к ложным failover’ам на каждом всплеске нагрузки. Клиентов маршрутизирует HAProxy, опрашивающий REST API Patroni, — именно этот слой определяет, за сколько приложение узнает о новом лидере:

listen postgres_write
    bind *:5000
    mode tcp
    option httpchk OPTIONS /primary        # 200 только на текущем лидере
    http-check expect status 200
    default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
    server pg1 10.0.1.11:5432 check port 8008
    server pg2 10.0.1.12:5432 check port 8008
    server pg3 10.0.1.13:5432 check port 8008

listen postgres_read
    bind *:5001
    mode tcp
    balance leastconn
    option httpchk GET /replica?lag=10MB   # только не отставшие реплики
    http-check expect status 200
    default-server inter 3s fall 3 rise 2
    server pg2 10.0.1.12:5432 check port 8008
    server pg3 10.0.1.13:5432 check port 8008

on-marked-down shutdown-sessions — не косметика: без него старые TCP-соединения к демонтированному лидеру живут и получают ошибки записи вместо переподключения. И отдельно: никогда не переключайте трафик БД через DNS. JVM кэширует DNS бессрочно по умолчанию, резолверы игнорируют низкий TTL, а пулы соединений вообще не перерезолвят адрес. Правильно — TCP-прокси, виртуальный IP или target_session_attrs=read-write в строке подключения с перечислением всех хостов: драйвер сам найдёт лидера.

Сравнение решений HA

Решение RPO Реалистичный RTO Fencing Замечание
Ручной failover по ранбуку зависит от режима 5–30 мин человек Честный выбор при SLA 99.9 и ночном окне
repmgr (PostgreSQL) лаг при async 30–120 с слабое, нужен свой witness Проще Patroni, но split-brain реален
pg_auto_failover 0 при sync 20–60 с через монитор Монитор сам становится точкой отказа
Patroni + etcd 0 при synchronous_mode 20–45 с демоут + pg_rewind Стандарт индустрии; нужен DCS из 3+ узлов
MySQL InnoDB Cluster 0, кворумная запись 10–30 с встроенный Чувствителен к сети; MySQL Router как маршрутизатор
Orchestrator (MySQL) лаг при async 15–60 с скриптами Отлично видит топологию, фенсинг пишете сами
MongoDB Replica Set 0 при w: majority 10–20 с встроенный Выборы в протоколе, драйвер сам находит primary
AWS RDS Multi-AZ 0, синхронная копия 60–120 с у провайдера Вы не управляете таймаутами и не видите деталей
Aurora / Spanner 0 10–60 с / прозрачно у провайдера Дорого, зато не ваша забота

Арифметика доступности

Чем больше компонентов последовательно нужны для работы, тем ниже итог: три сервиса по 99.9% в цепочке дают 99.7% — почти два часа простоя в месяц.

SLA Простой в месяц Простой в год Что для этого нужно
99% 7.2 ч 3.65 дня Один сервер и бэкапы
99.9% 43.8 мин 8.77 ч Реплика и ранбук ручного failover
99.95% 21.9 мин 4.38 ч Автофейловер и прокси-слой
99.99% 4.38 мин 52.6 мин Мульти-AZ, синхронная репликация, дежурства 24/7
99.999% 26 с 5.26 мин Мульти-регион, кворумные записи, отдельная команда

Отрезвляющий вывод: 99.99% нельзя купить настройкой СУБД. При RTO в 30 секунд один инцидент съедает 11% годового бюджета простоя. Четыре девятки — это про архитектуру приложения (retry, идемпотентность, graceful degradation, очереди на запись), а не про конфиг Patroni. И почти всегда дешевле договориться с бизнесом о 99.95%.

Про географию: скорость света в оптоволокне около 200 000 км/с, Москва — Франкфурт даёт RTT 35–40 мс, значит синхронный коммит между ними стоит минимум 40 мс независимо от бюджета. Отсюда стандартный компромисс: синхронная репликация внутри региона (между зонами доступности, RTT 0.5–2 мс), асинхронная — между регионами, с явно принятым и записанным ненулевым RPO. Кому нужен RPO=0 между регионами — идут в системы с консенсусом на уровне записи и платят той же латентностью, но получают за неё гарантии; это тема статьи про NewSQL.

Типичные ошибки в проде

  1. Реплика вместо бэкапа. DROP TABLE реплицируется за миллисекунды. Нужны PITR и отложенная реплика (recovery_min_apply_delay = '1h') как стоп-кран.
  2. Забытый слот репликации заполняет диск мастера и роняет его. Алерт на wal_status и active=false плюс max_slot_wal_keep_size.
  3. Все чтения переведены на реплики разом. Через день приходит первый баг read-your-writes и всё откатывают. Переводить надо по сценариям, с токеном LSN.
  4. Автофейловер без фенсинга. Работает год, потом сетевой раздел даёт две ветки истории. Фенсинг проверяется на учениях, а не читается в документации.
  5. DCS из двух узлов или etcd на тех же машинах, что БД. Большинства нет — кластер встаёт при любом отказе. Нужно три узла, желательно в разных зонах.
  6. Failover ни разу не проверяли. Учения раз в квартал: убить лидера в рабочее время под нагрузкой. Непротестированный failover — это не HA, а надежда.
  7. Конфликты применения WAL на реплике. Долгий аналитический запрос на hot standby блокирует применение: либо реплика отстаёт, либо запрос убивается по max_standby_streaming_delay. hot_standby_feedback=on защищает запросы ценой распухания мастера — выбор осознанный.
  8. Шардирование по автоинкременту или дате. Вся запись бьёт в последний шард. Классика, заметная только под нагрузкой.
  9. Кросс-шардовые запросы «временно». Метрика доли одношардовых запросов должна быть на дашборде с первого дня, иначе она тихо деградирует до 50%.
  10. Синхронная реплика одна. Она уходит на обслуживание — мастер перестаёт коммитить. Спасает ANY 1 (r1, r2) или synchronous_node_count.
  11. Мониторинг лага только в секундах. На простое врёт вверх, при разрыве репликации врёт вниз. Мерить в байтах и отдельно следить за самим фактом соединения.

Мини-итог

  • Репликация даёт живучесть и чтение; шардирование — объём и запись. Не путайте задачи.
  • Уровень журнала (по операторам / по строкам / физический) определяет, что вы вообще можете: кросс-версионность, фильтрацию, CDC, апгрейд без даунтайма.
  • RPO задаётся точкой подтверждения коммита, RTO — таймаутами детектора и скоростью переключения клиентов. Оптимизируйте самое длинное звено, а не самое интересное.
  • Лаг реплики — не баг, а свойство. Аномалии лечатся маршрутизацией по LSN/GTID в 5–10 сценариях, а не переводом всего трафика на лидера.
  • Шардировать — после того, как исчерпаны индексы, кэш, реплики, партиционирование и вертикальный рост. Инженеры дороже железа.
  • Ключ шардирования выбирается один раз: кардинальность, равномерность, присутствие в запросах, совпадение с границей транзакции. Виртуальные бакеты вместо прямого mod N.
  • Автоматический failover без фенсинга и без учений — это отложенный split-brain.
  • Четыре девятки покупаются архитектурой приложения, а не конфигом СУБД.

Источники

Что дальше

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

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов