Раздел 4 · Базы данных — глава 4.1

Базы: репликация, отставание и файловер

Репликация — тема, где легче всего утонуть в терминах и ничего не сказать по делу. Но вся она держится на одной конструкции — журнале предзаписи, — и если понять его, остальное выводится. Разбираем на примере PostgreSQL, потому что он у тебя в проде; в конце — чем отличается MySQL, потому что спрашивают и про него.

§1Сначала — зачем

Прежде чем «как», стоит уверенно ответить «зачем», потому что от цели зависит вся конфигурация. Причин ровно три, и они требуют разных настроек.

Отказоустойчивость. Умер сервер — поднимаем реплику мастером, теряем минуты вместо часов. Здесь важна синхронность и автоматика переключения.

Масштабирование чтения. Аналитика, отчёты, тяжёлые выборки уезжают на реплику, мастер занят только записью. Здесь важно, чтобы отставание было терпимым, и чтобы приложение умело различать запросы.

Географическая близость. Реплика рядом с потребителем, чтобы не гонять запросы через полстраны.

И сразу то, что нужно проговорить вслух на собеседовании, потому что это отличает инженера от человека, прочитавшего документацию: репликация — это не бэкап. Она защищает от отказа железа и ни от чего больше. Кто-то выполнил DELETE FROM users без WHERE — этот запрос доедет до всех реплик за миллисекунды и удалит данные везде. От логических ошибок спасает только резервная копия с возможностью восстановления на момент времени: pg_basebackup или pgBackRest/WAL-G плюс архив журналов. Наличие реплик не отменяет ни одного бэкапа.

§2WAL: журнал, из которого растёт всё остальное

PostgreSQL никогда не пишет изменения сразу в файлы данных. Сначала он записывает в журнал предзаписи (WAL) описание того, что именно изменилось: в таком-то блоке такой-то страницы было вот это, стало вот то. И только после того, как запись журнала физически легла на диск, транзакция считается зафиксированной. Сами страницы данных запишутся позже, при контрольной точке.

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

COMMIT │ ├─→ запись в WAL ──→ fsync на диск ──→ клиенту говорим "готово" │ ↑ │ вот здесь настоящая точка невозврата │ └─→ грязные страницы в shared_buffers полежат в памяти и уедут на диск при checkpoint — когда-нибудь потом

А теперь ключевая мысль всей главы. Если в этом журнале лежит полное описание всех изменений базы, то реплику можно построить тривиально: взять копию файлов данных на какой-то момент и потом просто проигрывать на ней тот же самый журнал. Никакой отдельной машинерии не нужно — репликация оказывается побочным продуктом механизма восстановления после сбоя. Именно поэтому она в PostgreSQL называется физической: передаются не запросы, а буквальные изменения байтов на страницах.

§3Как течёт поток репликации

Механика простая и состоит из двух процессов.

На мастере для каждой реплики запускается walsender — он читает WAL и отправляет его по сети. На реплике работает walreceiver — принимает поток, пишет его в свой журнал, а отдельный процесс восстановления применяет записи к файлам данных. Реплика при этом находится в постоянном режиме восстановления, который никогда не заканчивается.

МАСТЕР РЕПЛИКА клиент → COMMIT │ WAL ──→ walsender ══════ TCP ═════════→ walreceiver │ │ │ WAL реплики файлы данных │ процесс восстановления │ файлы данных реплики │ можно читать (hot standby)

Развернуть реплику руками — три шага, и стоит уметь их назвать: снять базовую копию с мастера (pg_basebackup -R -h master -D /var/lib/pgsql/data — флаг -R сам создаст файл standby.signal и пропишет параметры подключения), запустить, проверить.

Дальше вся диагностика — два представления. На мастере:

=# SELECT client_addr, state, sync_state,
          -- отставание в байтах по каждой стадии:
          pg_wal_lsn_diff(sent_lsn,   write_lsn)  AS write_lag,
          pg_wal_lsn_diff(sent_lsn,   flush_lsn)  AS flush_lag,
          pg_wal_lsn_diff(sent_lsn,   replay_lsn) AS replay_lag
   FROM pg_stat_replication;

 client_addr | state     | sync_state | write_lag | flush_lag | replay_lag
-------------+-----------+------------+-----------+-----------+-----------
 10.0.1.21   | streaming | sync       |         0 |         0 |          0
 10.0.1.22   | streaming | async      |      8192 |      8192 |   41943040
                                                                ↑ 40 МБ не применено

Три разных «отставания» здесь — не занудство, а разные проблемы. write/flush растут, когда узкое место — сеть или диск реплики: данные не успевают доехать и записаться. replay растёт при нормальных первых двух, когда узкое место — применение: обычно долгий читающий запрос на реплике блокирует накат изменений (об этом в §6). Диагноз разный, лечение разное.

На реплике смотрят в секундах, что понятнее для алертов:

=# SELECT now() - pg_last_xact_replay_timestamp() AS lag;
      lag
----------------
 00:00:02.31

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

§4Слоты репликации и как ими положить мастер

Проблема, которую решают слоты: мастер не может хранить журналы вечно, он удаляет их при контрольных точках. Если реплика полежала полчаса, нужные ей сегменты WAL могут быть уже удалены — тогда она не сможет догнать поток и потребует полного пересоздания с нуля. Для терабайтной базы это часы.

Слот репликации — это запись на мастере, которая говорит: «вот до этой позиции реплика точно всё получила, всё что новее — не удалять». Мастер физически придерживает журналы, пока реплика их не заберёт. Отсутствие пересозданий после любого простоя реплики.

И ровно здесь лежит одна из самых известных ловушек PostgreSQL. Если реплика со слотом умерла навсегда, а слот остался, мастер будет держать WAL бесконечно. Раздел с журналами растёт, растёт — и когда он заполняется, PostgreSQL останавливается. Не деградирует, а именно перестаёт обслуживать запросы, потому что не может записать журнал. Продовая база ложится из-за давно забытой реплики.

=# SELECT slot_name, active,
          pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn))
            AS wal_retained
   FROM pg_replication_slots;

 slot_name   | active | wal_retained
-------------+--------+--------------
 replica_1   | t      | 16 MB
 old_replica | f      | 142 GB       ← мёртвый слот держит 142 гигабайта

=# SELECT pg_drop_replication_slot('old_replica');

Отсюда два обязательных пункта в мониторинге любой базы с репликацией: алерт на неактивные слоты и алерт на объём удерживаемого WAL. В свежих версиях есть страховка — параметр max_slot_wal_keep_size, который ограничивает удержание сверху: при превышении слот инвалидируется, реплика ломается, но мастер выживает. Это правильный размен, и его стоит выставлять осознанно.

Спросят

«Мастер встал, на диске кончилось место в pg_wal — что случилось?» — Три типовые причины, называй все: мёртвый слот репликации удерживает журналы (проверить pg_replication_slots на active = f); сломался или отстаёт archive_command, и PostgreSQL не может отдать сегменты в архив, поэтому не удаляет их (смотреть pg_stat_archiver); либо просто гигантская транзакция сгенерировала WAL больше свободного места. Первые две встречаются на порядок чаще.

§5Синхронно или асинхронно: чем платим

Здесь единственный по-настоящему архитектурный выбор во всей теме, и на собесе он ценнее любых команд.

Асинхронная репликация (по умолчанию): мастер записал WAL себе на диск, ответил клиенту «зафиксировано» и потом отправил данные реплике. Быстро — коммит стоит ровно один локальный fsync. Но если мастер сгорит в промежутке между этими событиями, последние транзакции существуют только на нём. Потеря данных при аварии возможна, обычно это доли секунды транзакций, но при отставании реплики — сколько угодно.

Синхронная репликация: мастер не отвечает клиенту, пока реплика не подтвердит получение. Данные гарантированно есть в двух местах. Цена — каждая фиксация теперь включает круговой путь по сети, то есть время коммита вырастает на удвоенную задержку до реплики. В одном дата-центре это доли миллисекунды, между городами — десятки миллисекунд на каждую транзакцию.

Управляется всё параметром synchronous_commit, у которого не два значения, а пять — и знание этой шкалы производит хорошее впечатление:

ЗначениеМастер отвечает клиенту, когда…Что можно потерять
off…сразу, не дожидаясь даже своего дискапоследние транзакции даже при своём перезапуске (но не целостность базы)
local…WAL записан на диск мастеравсё, что не успело уехать на реплику
remote_write…реплика приняла и отдала ОСтолько при одновременном падении мастера и ОС реплики
on…реплика записала WAL на свой дискничего при отказе одного узла. Значение по умолчанию
remote_apply…реплика применила изменения и они видны в запросахничего, плюс чтение с реплики гарантированно свежее

Оговорка про on: сам по себе он ничего не делает, пока не задан synchronous_standby_names — список реплик, от которых ждать подтверждения. Без него режим фактически эквивалентен local.

Тонкость, о которой почти никто не помнит: настройку можно менять на уровне отдельной транзакции. Списание денег — SET LOCAL synchronous_commit = on, запись в лог действий пользователя — SET LOCAL synchronous_commit = off. Не обязательно платить за надёжность там, где она не нужна.

Грабли

Синхронная репликация с одной репликой — это ловушка: если реплика упадёт, мастер начнёт ждать подтверждения, которого не будет, и все записи встанут. Ты хотел повысить надёжность, а понизил доступность: теперь падение любого из двух узлов останавливает запись. Правильная конфигурация — минимум две синхронные реплики с кворумом: synchronous_standby_names = 'ANY 1 (replica1, replica2)' означает «ждать подтверждения от любой одной из двух», и падение одной ничего не ломает.

§6Чтение с реплики: за что придётся заплатить

Реплика в режиме hot standby обслуживает SELECT, и это отличный способ снять с мастера нагрузку от отчётов. Но у этого есть две неочевидные цены.

Данные могут быть устаревшими

Отставание существует всегда — вопрос только в его величине. Классический баг: приложение записало строку в мастер, тут же прочитало с реплики и не нашло. Пользователь нажал «сохранить», страница перезагрузилась — изменений нет. Правило простое и его надо уметь сформулировать: читать с реплики можно всё, кроме того, что ты только что записал. Для «прочитай своё же» либо ходи в мастер, либо используй remote_apply, либо сравнивай LSN — приложение запоминает позицию журнала после записи и ждёт, пока реплика её догонит.

Конфликт восстановления с длинными запросами

Механика такая. На реплике идёт отчёт на десять минут. В это время на мастере отработал VACUUM и удалил версии строк, которые этот отчёт как раз читает. Изменение приезжает на реплику — и процесс восстановления обязан применить его немедленно, иначе отставание будет расти. Но применить — значит выдернуть данные из-под работающего запроса.

PostgreSQL разрешает этот конфликт так: ждёт max_standby_streaming_delay (по умолчанию 30 секунд) и потом убивает запрос с ошибкой «canceling statement due to conflict with recovery». Знакомая по логам строчка, за которой стоит именно эта история.

Два способа с этим жить, и оба с оговорками:

  • max_standby_streaming_delay побольше или -1 — реплика будет ждать завершения запроса. Но всё это время она отстаёт, и если это ещё и резервный узел для файловера — ты незаметно ухудшил свой RPO.
  • hot_standby_feedback = on — реплика сообщает мастеру, какие версии строк ей ещё нужны, и мастер их не вычищает. Отставания нет, но на мастере накапливается раздувание таблиц: VACUUM не может убрать мусор, пока на реплике крутится длинный запрос. Забытый отчёт на несколько часов способен ощутимо раздуть продовые таблицы.

Честный вывод, который стоит озвучить: если аналитическая нагрузка тяжёлая, лучше держать отдельную реплику под неё — с большим допустимым отставанием и включённой обратной связью — и не смешивать её с той, что стоит в очереди на файловер.

§7Логическая репликация: когда физической мало

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

Логическая репликация работает иначе: тот же WAL декодируется обратно в логические операции («в таблицу orders вставлена строка с такими значениями») и передаётся подписчику, который применяет их как обычные команды. Настраивается парой публикация/подписка:

-- на источнике (нужен wal_level = logical)
CREATE PUBLICATION orders_pub FOR TABLE orders, order_items;

-- на приёмнике
CREATE SUBSCRIPTION orders_sub
  CONNECTION 'host=master dbname=shop user=repl'
  PUBLICATION orders_pub;

Что это даёт: выборочные таблицы, разные мажорные версии по обе стороны, запись в базу-приёмник (там могут быть свои таблицы и индексы), передачу изменений во внешние системы через плагины декодирования — так работают все CDC-конвейеры вроде Debezium.

Чего не даёт и о чём обязательно спросят: DDL не реплицируется — добавил колонку на источнике, руками добавь и на приёмнике, иначе подписка сломается. Таблицам нужен первичный ключ или явно заданная REPLICA IDENTITY, иначе UPDATE и DELETE применить не получится. Последовательности не синхронизируются — при переключении их надо перематывать вручную. И накладные расходы выше, чем у физической репликации.

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

§8Файловер: почему это сложнее, чем «повысить реплику»

Само переключение тривиально — одна команда pg_ctl promote или вызов pg_promote(). Реплика выходит из режима восстановления, открывается на запись и начинает новую линию времени (timeline). Сложность вся вокруг.

Старый мастер нельзя просто вернуть в строй

Пока реплику повышали, на старом мастере могли остаться зафиксированные транзакции, которые не успели уехать. У нового мастера их нет, и он ушёл по своей линии времени вперёд. Две истории разошлись — это буквально расхождение веток.

Вернуть старый узел как реплику можно двумя способами: залить заново с нуля (надёжно, долго — часы на большой базе) или использовать pg_rewind, который находит точку расхождения и откатывает старый мастер до неё, докачивая нужное с нового. Быстро, но требует, чтобы заранее были включены wal_log_hints = on или контрольные суммы страниц. Это как раз тот параметр, который надо включить до аварии, а не после — и хороший ответ на вопрос «что ты настраиваешь на новом кластере сразу».

Split-brain

Главная опасность автоматики. Реплика перестала видеть мастер — но почему? Мастер действительно умер или просто порвалась сеть между ними? Изнутри реплики эти ситуации неотличимы. Если она решит повыситься, а мастер на самом деле жив и продолжает принимать запись от части клиентов, ты получишь две базы, независимо принимающие записи. Расхождение данных, которое потом сводить вручную по логам.

Защита строится на двух принципах:

  • Кворум. Решение о повышении принимает не сама реплика, а внешнее хранилище состояния (etcd, Consul, ZooKeeper) с нечётным числом узлов. Узел, оказавшийся в меньшинстве при разрыве сети, знает, что он в меньшинстве, и мастером не становится.
  • Изоляция старого мастера (fencing). Прежде чем повысить нового, надо гарантировать, что старый больше не пишет: отключить его от сети, погасить, отобрать VIP. В классической HA-терминологии это STONITH — «shoot the other node in the head».

§9Patroni и вопрос «а куда пойдут клиенты»

Patroni — это то, что все эти рассуждения превращает в работающую систему. Он ставится рядом с каждым PostgreSQL и делает три вещи.

Во-первых, узлы через распределённое хранилище договариваются о лидере: победитель кладёт в etcd ключ «я лидер» с коротким сроком жизни и постоянно его продлевает. Перестал продлевать — ключ исчезает — остальные проводят выборы. Никакой узел не может стать мастером, не получив ключ, а ключ в один момент времени только один — отсюда невозможность split-brain при работающем кворуме.

Во-вторых, Patroni сам управляет PostgreSQL: разворачивает реплики, повышает, делает pg_rewind при возврате старого мастера, применяет конфигурацию единообразно на всех узлах.

В-третьих — и это то, о чём часто забывают, — он отдаёт HTTP API со своей ролью. Обращение к /master возвращает 200 только на лидере, к /replica — только на репликах. И вот на этом строится ответ на главный практический вопрос: а как клиенты узнают, куда подключаться после переключения?

приложение │ HAProxy ── health check GET /master → 200 только у лидера │ └─→ отдельный порт с GET /replica для чтения ├──────────────┬───────────────┐ pg-0 leader pg-1 replica pg-2 replica └── etcd: ключ "leader = pg-0", TTL 30 c, продлевается каждые 10 c

HAProxy опрашивает эти эндпоинты и всегда отправляет пишущий трафик туда, где сейчас лидер. Переключение произошло — проверка на старом узле начала отдавать не-200, на новом начала отдавать 200, и трафик переехал сам, без изменения конфигурации приложения. Альтернативы: плавающий VIP (см. gratuitous ARP в главе 1.2) или, в Kubernetes, обычный Service, у которого оператор просто переставляет метку role: master на нужный под.

В Kubernetes всё это чаще всего берут готовым: операторы Zalando (внутри как раз Patroni) или CloudNativePG. Они делают то же самое, только через API кластера. И там сама собой всплывает первая глава: узлы базы — это StatefulSet, потому что нужны стабильные имена, персональные диски и предсказуемый порядок обновления, при котором лидер трогается последним.

В бою

Между приложением и базой почти всегда должен стоять пулер соединений — PgBouncer. PostgreSQL создаёт отдельный процесс на каждое соединение, и полторы тысячи коннектов от подов означают полторы тысячи процессов и съеденную память. PgBouncer в режиме transaction pooling держит небольшой пул реальных соединений и раздаёт их на время транзакции. Побочная польза: при файловере достаточно переподключить пулер, а не каждое приложение. Ограничение — в transaction-режиме не работают подготовленные выражения на уровне сессии, SET и advisory-блокировки; это надо знать заранее.

Коротко про MySQL, раз уж спросят

Идея та же, реализация другая. Вместо WAL — binlog, отдельный логический журнал, который пишется поверх redo-лога InnoDB. Реплика скачивает его через IO-поток и применяет через SQL-поток. Формат бывает statement-based (передаётся сам SQL — компактно, но недетерминированные функции вроде NOW() дадут расхождение), row-based (передаются изменения строк — надёжно, объёмнее; сейчас это дефолт) и смешанный.

Ключевое отличие в эксплуатации — GTID: глобальные идентификаторы транзакций, благодаря которым реплика точно знает, что уже применила, и её можно переподключить к любому мастеру без ручного вычисления позиции в файле бинлога. Без GTID переключение требует возни с координатами, и это была вечная боль старых инсталляций. По умолчанию репликация асинхронная, есть плагин полусинхронной (мастер ждёт, пока хотя бы одна реплика подтвердит получение, но не применение), а для настоящего кворума — InnoDB Cluster на базе Group Replication.

§10Как это звучит в ответе

«PostgreSQL пишет все изменения сначала в журнал предзаписи и только потом в файлы данных — это нужно для восстановления после сбоя. Физическая репликация из этого следует напрямую: реплика получает тот же журнал и постоянно его применяет, находясь в бесконечном режиме восстановления. На мастере за отправку отвечает walsender, на реплике за приём — walreceiver, состояние смотрится в pg_stat_replication.

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

Дальше главный выбор — синхронность. Асинхронно: коммит быстрый, но при аварии теряются последние транзакции. Синхронно: потерь нет, но каждая фиксация оплачивается круговым путём до реплики. И важная деталь — синхронная репликация с одной репликой опасна: упадёт реплика, встанет запись на мастере. Нужен кворум через ANY 1 (...).

Чтение с реплики даёт разгрузку мастера, но приносит две проблемы: устаревшие данные (нельзя читать оттуда то, что только что записал) и конфликты восстановления, когда длинный запрос на реплике мешает накатывать изменения, — лечится либо задержкой применения, либо hot_standby_feedback, но каждое со своей ценой.

Файловер — это не только promote. Старый мастер после переключения нельзя вернуть просто так, у него разошлась линия времени: либо заливать заново, либо pg_rewind, для которого заранее нужен wal_log_hints. И главная опасность автоматики — split-brain, потому что реплика не может отличить смерть мастера от разрыва сети. Поэтому решение принимает кворум во внешнем хранилище: этим и занимается Patroni с etcd, а трафик переключает HAProxy по его health-check эндпоинтам. И отдельно: репликация не заменяет бэкап — ошибочный DELETE приедет на все реплики за миллисекунды.»