Модуль 2. Лаг реплики: read-your-writes и маршрутизация чтения

Цель модуля: воспроизвести инцидент «пользователь не видит свой заказ», отличить остановленный replay от лага и спроектировать маршрутизацию чтения без зависимости от удачи.

Перед началом: PostgreSQL на уровне pg-clickhouse m2 (WAL, MVCC, репликация). Инструменты: Docker Compose v2, psql, curl, make. Трудоёмкость: ~1.5 ч (разбор 40 мин · практика 50 мин).

🔥 Инцидент: «я оплатил, а заказа нет»

18:40, пятница. Клиент оплатил заказ, API вернул 201 с id, а через минуту в «Моих заказах» нет только что созданного. Поддержка открывает заказ во внутренней панели и видит его; у других клиентов старые заказы на месте. Симптом — у автора действия.

Дежурный проверяет привычное: /healthz и /readyz — 200, Grafana — RPS, p99 и 5xx в норме, панели про реплику ничего не знают. В логах api — 404 на GET /orders/{id}, в базе заказ есть. ?nocache=1 даёт тот же 404, а SQL показывает: replay на паузе. Паузу поставили час назад «на пять минут» и забыли снять; заказ не доедет до реплики без pg_wal_replay_resume().

📖 2.1. Как устроена streaming-реплика в стенде

Реплика в стенде — физическая streaming-реплика PostgreSQL 16. Мастер пишет WAL, walsender отправляет его, walreceiver принимает сегменты, а startup-процесс применяет их (replay). Реплика в recovery: pg_is_in_recovery() = true, читать можно, писать нельзя. При первом старте replica-entrypoint.sh делает pg_basebackup под ролью replicator (-Fp -Xs -R) и создаёт standby.signal с primary_conninfo. Мастер разрешает до пяти walsender'ов (max_wal_senders=5), строка pg_hba добавляется в 02-pg_hba.sh.

Репликация асинхронная: мастер не ждёт применения, поэтому 201 Created — это «запись в WAL мастера», а не «строка видна на реплике». Разрыв измеряется в LSN. api держит два пула (api/internal/store/store.go): запись идёт в Master, GetOrder и ListOrders — в Replica. Один и тот же заказ пишется и читается с разных узлов — источник read-your-writes-проблемы; глубина — pg-clickhouse m2.

📖 2.2. Лаг: WAL, replay, pg_wal_replay_pause и чем он опасен

Лаг — это две задержки: передача и применение (replay). pg_wal_replay_pause() останавливает вторую: реплика принимает WAL и складывает в pg_wal, но не применяет, и чтение замирает.

fail-2.sh ставит паузу, вставляет 5000 строк на мастере (заметная очередь WAL), создаёт заказ пользователя 7 и читает его с реплики — 404. Старые заказы видны: они применены до паузы.

Чем опасна пауза:

  1. Лаг растёт неограниченно, чтение врёт, а реплика жива: /readyz зелёный.
  2. Мастер пишет WAL; при долгой паузе нужные сегменты могут переработаться, и догнать без нового pg_basebackup не выйдет.
  3. После resume реплика применяет накопленное пачкой — всплеск CPU/IO и задержек.
  4. У паузы нет таймаута: это ручной режим отладки, а не средство управления.

fix-2.sh — одна строка SELECT pg_wal_replay_resume(); это чинит причину, но не защищает от повторения. Эксплуатация реплик — pg-clickhouse m8.

📖 2.3. Read-your-writes: три стратегии маршрутизации

  1. Читать с мастера только что записанное. После POST /orders или для своих сущностей чтение идёт на мастер: в стенде это /orders/{id}?direct=1 и /orders?user_id=7&master=1. Цена — нагрузка на мастер; чаще читают с мастера окно N секунд после записи.
  2. Ждать LSN. Запись возвращает позицию WAL (pg_current_wal_insert_lsn()), api ждёт pg_last_wal_replay_lsn() >= lsn с дедлайном 100–500 мс и fallback на мастер. Корректно, но добавляет хвостовую задержку.
  3. Липкость на сессии/клиенте. Клиент запоминает ответ записи (id и версию) и помечает следующие чтения как «мои»: мастер или реплика с ожиданием LSN. Цена — состояние в клиенте и риск перегрузить мастер.

?nocache=1 — не стратегия: он обходит Redis, но по-прежнему читает реплику. TTL кэша тоже не при чём: источник расхождения — replay. Кэш добавляет слой устаревания: заказ отстаёт на лаг плюс до 60 секунд TTL ключа order:{id}. Про dual-write и outbox — arch v2 m3.

📖 2.4. Диагностика лага: SQL, метрики, пороги

Диагностика — два SQL: на мастере кто подключён и сколько не доехало, на реплике где она и не на паузе ли.

sql
-- мастер
SELECT application_name, client_addr, state, sent_lsn, replay_lsn,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS lag_bytes
  FROM pg_stat_replication;
-- реплика
SELECT pg_is_in_recovery() AS in_recovery,
       pg_last_wal_replay_lsn() AS replay_lsn,
       now() - pg_last_xact_replay_timestamp() AS replay_delay,
       pg_get_wal_replay_pause_state() AS pause_state;
  • state = streaming, lag_bytes — единицы КБ и меньше норма; устойчиво десятки МБ — не успевает.
  • replay_delay растёт только при записи; на простое врёт — смотрите вместе с lag_bytes.
  • pause_state = paused при растущем лаге — сценарий 2, ручная остановка.
  • pg_is_in_recovery() = true — вы на реплике, а не на мастере по ошибке.

Готовых порогов и панелей по лагу в стенде нет. Prometheus скрапит только /metrics api, sink и worker; на polygon-overview есть p99, RPS, 5xx, кэш, партнёр и пул, но панели лага реплики нет. Поэтому диагностика — SQL и /readyz. /readyz пингует мастер, реплику и Redis (таймаут 2 с) и отдаёт 200, если все доступны. При паузе реплика доступна, значит /readyz зелёный: readiness не равно свежести. В проде ставят postgres_exporter и алерт на pg_stat_replication («> 10 МБ или > 5 с»); в стенде его нет, про SLO — arch v2 m6.

📖 2.5. Компромиссы

  • Мастер vs реплика. Мастер свежий, но один и держит запись; реплика масштабирует чтение, но отстаёт. Read-your-writes — точечное исключение.
  • Ожидание LSN vs хвостовая задержка. При остановленном replay каждый такой запрос ждёт дедлайн и уходит на мастер — под нагрузкой это деградация.
  • Липкость vs равномерность. Окно «читаем автора с мастера» просто, но концентрирует свежие заказы на мастере; его выбирают по p99 лага.
  • Пауза replay vs switchover. Пауза — инструмент отладки с ручным снятием, не средство управления и не плановая процедура.
  • Кэш vs свежесть. Кэш уменьшает нагрузку и увеличивает окно устаревания; для read-your-writes его инвалидируют по записи, а не ждут TTL.

💻 Практика

Все команды — из каталога polygon/. Время — ориентир для одного человека.

  1. Базовая линия (5 мин). make up && make seed: api готов, 200k заказов. В Grafana панели лага нет — это часть задачи.
  2. Сломать (5 мин).
bash
make fail-2

Ожидаемо: replay на реплике поставлен на паузу, создан заказ id=<N>, чтение с реплики (?nocache=1): HTTP 404. Запомните <N>.

  1. Красный чек (2 мин).
bash
make check-2

Через ~25 с: CHECK FAIL: заказ id=... не появился на реплике за 25с (replay стоит?). check-2 создаёт новый заказ, поэтому id другой — это нормально.

  1. SQL-диагностика (10 мин). Зайдите в оба узла:
bash
docker compose exec -T postgres-master psql -U app -d orderflow
docker compose exec -T postgres-replica psql -U app -d orderflow

На мастере — запрос к pg_stat_replication из 2.4: streaming, lag_bytes растёт. На реплике:

sql
SELECT pg_is_in_recovery(), pg_last_wal_replay_lsn(),
       pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn()) AS backlog,
       pg_get_wal_replay_pause_state();

Ожидаемо: t, paused, LSN не двигается, backlog растёт.

  1. Проверить /readyz и чтение с мастера (5 мин).
bash
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8088/readyz
curl -s "http://localhost:8088/orders/<N>?direct=1"
curl -s -o /dev/null -w '%{http_code}\n' "http://localhost:8088/orders/<N>?nocache=1"
curl -s "http://localhost:8088/orders?user_id=7&master=1"

Ожидаемо: 200, 200 + JSON заказа, 404, а с master=1 новый id есть в списке. Без master=1 его нет, хотя старые заказы пользователя 7 видны.

  1. Починить (2 мин).
bash
make fix-2
make check-2

replay возобновлён, затем CHECK OK: ... заказ виден с реплики (обычно за секунды). На реплике pause_state = not paused, backlog падает до нуля.

  1. Спроектировать защиту (10 мин). Опишите правило: какие чтения идут на мастер (свой заказ сразу после POST, окно N секунд), какие — на реплику, что делать при лаге выше порога. Алерта на лаг в alerts.yml нет, метрики PostgreSQL Prometheus не собирает.

🧩 Quiz "Проверь себя"

1. Почему /readyz возвращает 200 при остановленном replay?
2. Что делает ?nocache=1 при чтении /orders/{id}?
3. Что происходит при SELECT pg_wal_replay_pause() на реплике?
4. Какая защита от read-your-writes корректна?
5. Почему автор заказа видит 404, а другие пользователи — свои старые заказы?

✅ Итоги модуля

  • Объяснить разницу между sent_lsn, replay_lsn и pg_last_wal_replay_lsn().
  • Пройти цикл fail-2 → check-2 (red) → fix-2 → check-2 (green).
  • Показать по SQL, что replay стоит, и объяснить, почему /readyz при этом зелёный.
  • Сформулировать правило read-your-writes и назвать его цену.
  • Назвать, каких метрик не хватает в стенде для алерта на лаг.

🔗 Мостик к Модулю 3

Реплика догнала мастер, но это только первый из трёх лагов. В модуле 3 тот же заказ поедет дальше: слот репликации → Debezium → Kafka → sink → ClickHouse. Там появятся три независимых лага (слот, консьюмер, пайплайн), дубли и ReplacingMergeTree, а расхождение будет видно уже не в 404, а в цифрах дашборда. Навык прежний: измерить позицию и найти застрявшее звено. Теория — pg-clickhouse m6/m8.