Раздел 4 · Базы данных — глава 4.1
Базы: репликация, отставание и файловер
Репликация — тема, где легче всего утонуть в терминах и ничего не сказать по делу. Но вся она держится на одной конструкции — журнале предзаписи, — и если понять его, остальное выводится. Разбираем на примере PostgreSQL, потому что он у тебя в проде; в конце — чем отличается MySQL, потому что спрашивают и про него.
§1Сначала — зачем
Прежде чем «как», стоит уверенно ответить «зачем», потому что от цели зависит вся конфигурация. Причин ровно три, и они требуют разных настроек.
Отказоустойчивость. Умер сервер — поднимаем реплику мастером, теряем минуты вместо часов. Здесь важна синхронность и автоматика переключения.
Масштабирование чтения. Аналитика, отчёты, тяжёлые выборки уезжают на реплику, мастер занят только записью. Здесь важно, чтобы отставание было терпимым, и чтобы приложение умело различать запросы.
Географическая близость. Реплика рядом с потребителем, чтобы не гонять запросы через полстраны.
И сразу то, что нужно проговорить вслух на собеседовании, потому что это отличает инженера от человека, прочитавшего документацию: репликация — это не бэкап. Она защищает от отказа железа и ни от чего больше. Кто-то выполнил DELETE FROM users без WHERE — этот запрос доедет до всех реплик за миллисекунды и удалит данные везде. От логических ошибок спасает только резервная копия с возможностью восстановления на момент времени: pg_basebackup или pgBackRest/WAL-G плюс архив журналов. Наличие реплик не отменяет ни одного бэкапа.
§2WAL: журнал, из которого растёт всё остальное
PostgreSQL никогда не пишет изменения сразу в файлы данных. Сначала он записывает в журнал предзаписи (WAL) описание того, что именно изменилось: в таком-то блоке такой-то страницы было вот это, стало вот то. И только после того, как запись журнала физически легла на диск, транзакция считается зафиксированной. Сами страницы данных запишутся позже, при контрольной точке.
Причина такого порядка — производительность и надёжность одновременно. Журнал пишется последовательно, что на порядки быстрее случайных записей в разбросанные по диску страницы. А при аварийном выключении база при старте читает журнал с последней контрольной точки и повторяет все записанные, но не применённые изменения — и приходит в консистентное состояние без потери зафиксированных транзакций.
А теперь ключевая мысль всей главы. Если в этом журнале лежит полное описание всех изменений базы, то реплику можно построить тривиально: взять копию файлов данных на какой-то момент и потом просто проигрывать на ней тот же самый журнал. Никакой отдельной машинерии не нужно — репликация оказывается побочным продуктом механизма восстановления после сбоя. Именно поэтому она в PostgreSQL называется физической: передаются не запросы, а буквальные изменения байтов на страницах.
§3Как течёт поток репликации
Механика простая и состоит из двух процессов.
На мастере для каждой реплики запускается walsender — он читает WAL и отправляет его по сети. На реплике работает walreceiver — принимает поток, пишет его в свой журнал, а отдельный процесс восстановления применяет записи к файлам данных. Реплика при этом находится в постоянном режиме восстановления, который никогда не заканчивается.
Развернуть реплику руками — три шага, и стоит уметь их назвать: снять базовую копию с мастера (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 опрашивает эти эндпоинты и всегда отправляет пишущий трафик туда, где сейчас лидер. Переключение произошло — проверка на старом узле начала отдавать не-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 приедет на все реплики за миллисекунды.»