Модуль 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. Старые заказы видны: они применены до паузы.
Чем опасна пауза:
- Лаг растёт неограниченно, чтение врёт, а реплика жива:
/readyzзелёный. - Мастер пишет WAL; при долгой паузе нужные сегменты могут переработаться, и догнать без
нового
pg_basebackupне выйдет. - После resume реплика применяет накопленное пачкой — всплеск CPU/IO и задержек.
- У паузы нет таймаута: это ручной режим отладки, а не средство управления.
fix-2.sh — одна строка SELECT pg_wal_replay_resume(); это чинит причину, но не защищает
от повторения. Эксплуатация реплик — pg-clickhouse m8.
📖 2.3. Read-your-writes: три стратегии маршрутизации
- Читать с мастера только что записанное. После
POST /ordersили для своих сущностей чтение идёт на мастер: в стенде это/orders/{id}?direct=1и/orders?user_id=7&master=1. Цена — нагрузка на мастер; чаще читают с мастера окно N секунд после записи. - Ждать LSN. Запись возвращает позицию WAL (
pg_current_wal_insert_lsn()), api ждётpg_last_wal_replay_lsn() >= lsnс дедлайном 100–500 мс и fallback на мастер. Корректно, но добавляет хвостовую задержку. - Липкость на сессии/клиенте. Клиент запоминает ответ записи (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/. Время — ориентир для одного человека.
- Базовая линия (5 мин).
make up && make seed: api готов, 200k заказов. В Grafana панели лага нет — это часть задачи. - Сломать (5 мин).
bashmake fail-2
Ожидаемо: replay на реплике поставлен на паузу, создан заказ id=<N>,
чтение с реплики (?nocache=1): HTTP 404. Запомните <N>.
- Красный чек (2 мин).
bashmake check-2
Через ~25 с: CHECK FAIL: заказ id=... не появился на реплике за 25с (replay стоит?). check-2 создаёт новый заказ, поэтому id другой — это нормально.
- SQL-диагностика (10 мин). Зайдите в оба узла:
bashdocker 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
растёт. На реплике:
sqlSELECT 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 растёт.
- Проверить
/readyzи чтение с мастера (5 мин).
bashcurl -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 видны.
- Починить (2 мин).
bashmake fix-2 make check-2
replay возобновлён, затем CHECK OK: ... заказ виден с реплики (обычно за секунды).
На реплике pause_state = not paused, backlog падает до нуля.
- Спроектировать защиту (10 мин). Опишите правило: какие чтения идут на мастер (свой
заказ сразу после
POST, окно N секунд), какие — на реплику, что делать при лаге выше порога. Алерта на лаг вalerts.ymlнет, метрики PostgreSQL Prometheus не собирает.
🧩 Quiz "Проверь себя"
/readyz возвращает 200 при остановленном replay??nocache=1 при чтении /orders/{id}?SELECT pg_wal_replay_pause() на реплике?✅ Итоги модуля
- Объяснить разницу между
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.