После этой статьи вы будете знать, как BiHA ведёт себя при отказе лидера: сколько длится простой записи, какие отказы автоматика ловит плохо, чем это поведение управляется и что нужно поправить на стороне приложения, чтобы двадцать секунд кластера не превратились в минуты простоя сервиса.
Зачем этот текст
Про BiHA, встроенную отказоустойчивость Postgres Pro написано немало, и почти всё написано вендором. Есть обзор архитектуры в блоге на Хабре, есть «High Availability в Postgres Pro без головной боли», есть страница продукта и подробная документация. Материалы честные и полезные, но все статьи написаны в одном продуктово-обзорном жанре: как устроено, из чего состоит, что умеет. Практической и эксплуатационной боли в них нет.
Инженерно-технических текстов, чтобы с замерами, аварийными сценариями и цифрами, production-опытом, я не нашёл вообще.
Пробел заметный, потому что BiHA позиционируется как HA-решение: встроенная в СУБД замена связке из внешних агентов и DCS. А от HA-решения ждут поведения в аварии и оценкой последствий, а не общего описания решения. Сколько система тратит на то, чтобы понять, что узел мёртв. Что при этом происходит с приложением. Какие отказы она ловит, а какие нет и что из этого можно настроить, а что зашито.
Этот текст закрывает пробел с той стороны, которая мне ближе и которую я люблю: я развернул кластер и стал его ломать.
Что такое BiHA в двух абзацах
Если коротко: в Postgres Pro отказоустойчивость встроена в саму СУБД, а не собирается из внешних компонентов (как например в Patroni). Кластер состоит из узла-лидера, открытого на чтение и запись, и узлов-последователей, открытых только на чтение; между ними идёт физическая потоковая репликация. Когда лидер пропадает, оставшиеся узлы проводят выборы и повышают одного из своих.
Управляется всё расширением biha и утилитой bihactl. Внешнего DCS нет, отдельного агента на узлах нет и координацией занимаются фоновые процессы внутри самого PostgreSQL. Для нашей темы это важно: сторож живёт внутри того, что он сторожит.
Кому это
Читателю, который выбирает HA-решение для своих кластеров и сам же разбирает аварии, когда они случаются. То есть человеку, которому нужны и цифры для решения, и понимание, куда смотреть, когда всё уже сломалось.
Пара слов о себе
Я работаю в Postgres Professional, но к разработке BiHA отношения не имею, это другой отдел, и относительно BiHA я такой же пользователь, как читатель. Интерес у меня здесь чисто практический и инженерный: понять, как инструмент ведёт себя в аварии, и померить это числами.
Отсюда и позиция, в которой написан текст. Я не знаю внутреннего устройства BiHA (с точки зрения исходников) и не претендую: вижу процессы в операционной системе, представления и функции в SQL, строки в логе и секундомер со стороны приложения. Поэтому все утверждения ниже про наблюдаемое: «при таких условиях наблюдается то-то», а не «система устроена так-то». Где наблюдать нечего, об этом также говорю как есть.
TL;DR
Резюме для тех, кому нужен ответ, вместо длинной дороги к нему (всё перечисленное дальше разбирается подробно, с методикой и оговорками).
У кластера своё время реакции, и оно почти не зависит от варианта отказа. Штатная остановка СУБД, kill -9, перезагрузка (reset) узла, обрыв сети, зависший процесс — во всех случаях узел признаётся потерянным через восемь–одиннадцать секунд, выборы начинаются на тринадцатой–пятнадцатой, повышение нового лидера запускается на двадцатой.
У приложения время своё, и разброс там в разы. От двадцати секунд до «вообще не восстановилось за время опыта». Причём зависит это от настроек клиента (а не кластера): при одних отказах ядро сообщает клиенту об обрыве немедленно, при других вообще не сообщает.
Исправляется на стороне клиента, двумя механизмами сразу. Сетевой таймаут закрывает класс «пакеты пропадают», прикладной дедлайн на запрос закрывает класс «сервер жив, но не отвечает». Заменить один другим нельзя и с обоими любой из сценариев сходится к собственному времени кластера, около двадцати одной секунды.
Настройками простой уменьшается, но не до нуля. Обнаружение отказа считается ровно как heartbeat_send_period × heartbeat_max_lost, и у обоих сомножителей есть жёсткий минимум: полторы секунды на обнаружение и это предел настройки. Плюс выборы с повышением, которые на моём стенде стоили ещё семь с половиной секунд.
Что появляется в системе вместе с BiHA
Итак, начнём с того, что видно из операционной системы, с этого начинается и практическое знакомство с BiHA, и разбор аварии.
После включения расширения на каждом узле появляются два служебных процесса СУБД:
postgres: BiHA worker: node 1postgres: BiHA pgc worker: node 1
Что делает каждый, снаружи не видно; судя по имени, второй как-то связан с управлением самим PostgreSQL; в логе строки с префиксом [BiHA PGC] относятся к запуску, понижению и повышению инстанса. Роль узла при этом читается по другим процессам: у лидера на каждого последователя висит walsender, у последователя walreceiver и startup recovering.
Здесь стоит сразу отметить, как это может подвести. Наличие процесса еще не гарантия, что он работает. Дальше в статье будет сценарий, где все процессы на месте, кластер считает узел живым, а обслуживания нет.
Что пишется в лог
Всё, что кластер делает при отказе, он фиксирует в логе PostgreSQL. Строки узнаваемые, и по ним удобно размечать фазы, снимать и фиксировать тайминги:
|
Фаза |
Строка в логе |
|---|---|
|
замечен обрыв соединения |
|
|
понижение лидера |
|
|
смена состояния узла |
|
|
promote начат |
|
|
promote завершён |
|
Из этого набора присмотритесь к строке про понижение. Понижение лидера делается через немедленный перезапуск инстанса: в логе за ней идут received immediate restart request и all server processes terminated; reinitializing, после чего сервер поднимается в режиме standby. Все клиентские соединения при этом рвутся.
Деталь для тех, кто мониторит перезапуски: pg_postmaster_start_time() после такого понижения не меняется. Снаружи сервер выглядит непрерывно работающим, хотя все сессии на нём только что были принудительно завершены.
Что видно из SQL
В кластере появляется служебная база biha_db со схемой biha и разными служебными объектами:
|
Объект |
Что даёт |
|---|---|
|
|
|
|
|
адреса узлов и состояние соединений между ними |
|
|
кворум, параметры heartbeat, приоритеты, флаги голосования |
|
|
состояние узла и состояние PostgreSQL на нём |
|
|
детали ошибки узла |
Здоровый кластер из трёх узлов выглядит так:
id | leader_id | term | online | state | last_known_state | since_last_hb----+-----------+------+--------+-----------+------------------+----------------- 1 | 1 | 1 | t | LEADER_RW | LEADER_RW | 00:00:01.374121 2 | 1 | 1 | t | FOLLOWER | FOLLOWER | 00:00:01.374121 3 | 1 | 1 | t | FOLLOWER | FOLLOWER | 00:00:01.374121
Обратите внимание на since_last_hb, это время с последнего heartbeat. Именно по нему удобно следить за узлом, который начал отваливаться: величина растёт, пока остальные ещё считают его живым.
Две ручки, которые решают всё
В pg_settings есть параметры с префиксом biha., и для отказоустойчивости важны ровно два:
biha.heartbeat_send_period = 1000 # мс, период отправки heartbeatbiha.heartbeat_max_lost = 10 # сколько потерять, чтобы счесть узел мёртвым
Перемножим: тысяча миллисекунд на десять, получится десять секунд только на то, чтобы заметить проблему. И это ещё до всяких выборов. Это значения по умолчанию, и уже половина ответа на вопрос статьи.
Оба параметра меняются без рестарта, и у расширения есть функции для их изменения на лету: biha.set_heartbeat_send_period() и biha.set_heartbeat_max_lost(). В разделе про настройку мы посмотрим, что происходит, если их покрутить.
Здесь же полезно отметить, чего в pg_settings нет. Размер кворума nquorum не обычный параметр конфигурации, он живёт в конфигурации кластера и меняется функцией biha.set_nquorum(). Мелочь, но это может сэкономить время на попытках найти его в postgresql.conf.
В целом, этого набора хватает, чтобы разбирать аварии. Про всё остальное, рефери, кворум, геораспределённые схемы, надстройки… для нашего вопроса сейчас не нужно (и возможно, это темы следующих статей).
Стенд и методика
Три узла Postgres Pro Enterprise 18.6 с расширением biha 1.8, Debian 12, по два ядра и гигабайту памяти на узел. Плюс четвёртый узел только под нагрузчик, в кластер он не входит. Репликация асинхронная, кворум nquorum = 2, параметры heartbeat дефолтные.
Стенд по ресурсам слабый, и для моей задачи этого достаточно, так как замеряться будет время реакции (а не пропускную способность).
Также одну настройку я сменил намеренно: biha.autorewind = on. По умолчанию autorewind выключен, и в таком случае убитый лидер после возвращения встаёт в NODE_ERROR с расхождением WAL и требует ручной пересборки (на этом, к слову, закончился мой первый прогон). Само время переключения от параметра не зависит, он исключительно про возвращение выбывшего узла, и без autorewind тестировать и запускать прогоны накладно. Если захочется повторить, то потребуется включить заранее, задним числом (после аварии) включение не помогает. Это обычный GUC уровня sighup, функции-сеттера у него нет:
alter system set biha.autorewind = on;select pg_reload_conf();
И, в отличие от heartbeat-параметров, по кластеру он сам не расползается, выполнять надо на каждом узле. Включив его только на лидере, можно остаться без защиты ровно там, где она понадобится: на узле, который станет лидером после аварии.
Линейка на стороне клиента
Ключевое методическое решение: простой считается по приложению. Но чтобы объяснить полученные числа, будет три шкалы, и дальше по тексту я постоянно между ними переключаюсь:
-
Клиент — главная шкала. Нагрузчик пишет в таблицу в цикле, порядка двухсот вставок в секунду (по прогонам от ста сорока до трёхсот), и фиксирует три момента: последнюю успешную запись до отказа, первую ошибку и первую успешную запись после. Разрешение по времени около пяти миллисекунд, по ней и считается простой.
-
Состояние кластера — вторая шкала. Отдельный сборщик опрашивает
biha.status_vна каждом узле раз в четверть секунды. Недоступность узла тоже фиксируем, это тоже полезные данные. По этой шкале видно, что кластер думает о себе сам. -
Логи узлов — третья шкала. Размечены по маркерам из раздела про лог. По ней раскладываются внутренние фазы: обнаружение, выборы, повышение.
Первая шкала отвечает на вопрос статьи, вторая и третья объясняют полученный ответ. Именно расхождение первой со второй и оказалось главным результатом.
Подключение выполняется, как подключался бы обычный клиент без прокси перед кластером: через libpq со списком всех узлов и target_session_attrs=read-write. То есть драйвер сам выбирает узел, готовый принимать запись. В строке подключения всегда стоит connect_timeout=2 , иначе попытка подключиться к изолированному узлу будет упираться в таймаут установки соединения на стороне ядра ОС (получится, что мерить будем уже его, а нам это не надо). Это относится и к колонке «клиент по умолчанию» в таблицах ниже: «по умолчанию» там означает «без таймаутов, о которых пойдёт речь дальше», а не «вообще без настроек».
Пять способов убить лидера
Отказы подобраны так, чтобы задеть разные механизмы обнаружения:
|
Тип отказа |
Как воспроизводится |
Что проверяет |
|---|---|---|
|
штатная остановка |
|
нижняя граница |
|
аварийное завершение |
|
обрыв соединений без предупреждения |
|
пропадание узла |
|
перезагрузка без сброса на диск |
|
сетевая изоляция |
|
пакеты теряются молча |
|
зависание |
|
процесс жив, но не обслуживает клиента |
Последние два, самые интересные. Там узел не исчезает и с точки зрения сети и ядра всё в порядке, просто ответов нет.
Каждый сценарий прогонялся по три раза. Метка времени снимается часами самого узла: часы разных машин расходятся, и на масштабе десятков миллисекунд это ломает сопоставление шкал.
Как ведёт себя кластер
Начнём со второй шкалы, с того, что кластер говорит о себе.
|
Фаза |
Разброс по всем пятнадцати прогонам |
|---|---|
|
узел объявлен потерянным ( |
8.2–10.8 с |
|
начались выборы ( |
12.8–15.1 с |
|
|
19.5–21.1 с |
Это полный разброс: крайние значения по всем пяти типам отказа и всем трём повторам каждого. Диапазоны узкие и в основном перекрываются между типами: по времени обнаружения и по моменту повышения сказать, каким способом убивали лидера, нельзя вовсе. На фазе выборов разделение слабое, но есть и у сетевой изоляции она начинается чуть раньше, чем у перезагрузки и заморозки.
И вот первое, что стоит запомнить: обрыв соединения виден мгновенно, но ничего не ускоряет. Разложим по секундам один прогон с kill -9 по лидеру:
|
Время |
Событие |
|---|---|
|
+0.02 |
последователи видят |
|
+0.03 |
клиент получает первую ошибку |
|
+9.38 |
узел объявлен потерянным |
|
+13.49 |
|
|
+20.26 |
|
|
+20.33 |
первая успешная запись клиента |
Между «увидели обрыв» и «признали потерянным» проходит девять с половиной секунд и это ровно бюджет heartbeat. То есть оборванный сокет ничего не сокращает: время до признания узла потерянным получается таким же, как если бы обрыва никто не заметил.
Дальше ещё около четырёх секунд до начала выборов и около семи на сами выборы с повышением, итого в сумме двадцать.
Отдельно любопытна асимметрия. В обратном случае, когда лидер теряет кворум, а не последователи теряют лидера, наблюдается совсем другое время: если соединения с последователями рвутся явно, лидер понижает себя примерно за секунду. Двадцать против одной, то есть разница на порядок, и она устойчиво воспроизводится.
Что при этом видит приложение
Теперь главная шкала. Медиана простоя записи по трём повторам:
|
Отказ лидера |
простой записи |
|---|---|
|
штатная остановка |
20.96 с |
|
|
20.33 с |
|
перезагрузка узла через sysrq |
27.21 с |
|
сетевая изоляция |
≥ 110 с, клиент не восстановился |
|
зависание процессов ( |
≥ 61 с, граница опыта |
Две нижние строки это уже не измеренные величины, и это важно. В ячейках стоит «не менее», потому что числа там равны длительности эксперимента, а не длительности простоя. При заморозке (SIGSTOP) клиент ожил только после того, как я разморозил (SIGCONT) узел. При сетевой изоляции он не ожил вовсе: первую ошибку получил на сто десятой секунде, через пятьдесят секунд после того, как я снял изоляцию. Сколько это длилось бы на самом деле, в первом случае до вмешательства человека, во втором до срабатывания таймаутов TCP, то есть минуты или десятки минут.
Разброс между повторами в трёх верхних строках от 0.4 до 6.3 процента, числа устойчивые.
Кластер в это время работал штатно: во всех пяти сценариях новый лидер был готов принимать запись примерно на двадцатой секунде. Разница целиком на стороне клиента, и объясняется она тем, как именно умирающий узел прекращает общаться по TCP.
Штатная остановка и kill -9. Процессы завершаются, ядро закрывает сокеты и отправляет FIN или RST. Клиент получает ошибку сразу, на тридцатой миллисекунде и дальше просто ждёт, пока кластер выберет нового лидера. Простой равен времени кластера и это в лучшем случае.
Перезагрузка узла. Узел исчезает вместе со своим сетевым стеком: никто не шлёт RST, потому что некому, и клиент висит на открытом сокете, пока узел не загрузится обратно и не ответит на его повторную передачу отказом. Отсюда 27 секунд, и это время загрузки виртуальной машины (а не кластера). На реальном железе с долгим POST это были бы минуты.
Сетевая изоляция. Пакеты отбрасываются молча, RST не приходит никогда, клиент бесконечно переспрашивает, libpq по умолчанию ждёт столько, сколько скажет ядро. Кластер давно выбрал нового лидера, а приложение об этом не знает.
Зависание процессов. Самый неприятный случай. Сигнал SIGSTOP замораживает postgres, но узел остаётся полноценным участником сети: ядро на нём работает, сокет открыт. Запрос уходит, доставляется по сетевому стеку, но остаётся без ответа, потому что отвечать на него некому: ядро просто больше не даёт остановленному процессу выполняться. Кластер при этом отработал штатно и быстро: heartbeat от замороженного узла перестали приходить, его пометили потерянным, и на двадцатой секунде новый лидер уже принимал запись. Клиент узнал об этом только после разморозки узла, хотя всё это время рядом стоял живой лидер, готовый принимать запись, а приложение сидело на соединении с замороженным. Но после разморозки, получив ошибку, клиент переподключился с первой попытки и продолжил запись.
Что показывает сокет
Последний случай стоит того, чтобы посмотреть на него по прямым признакам. Косвенный такой: за всю шестидесятисекундную заморозку клиент получил одну ошибку, и та пришла уже после того, как я разморозил узел. Но это скорее следствие, чем объяснение, поэтому я снял ss -ti со стороны клиента во время заморозки:
ESTAB ... cubic rto:212 ... bytes_acked:49162
Соединение установлено, rto держится на 212 миллисекундах и не растёт, полей unacked и retrans нет (ядро их не печатает, если их нет). bytes_acked не растёт, потому что клиент больше ничего не шлёт: он ждёт ответа на запрос, который уже доставлен и подтверждён ядром замороженного узла.
Вот и вся механика: приложение висит потому что на том конце тишина. Сеть при этом в полном порядке и выглядит это ровно так же, как работа медленного запроса.
Из этих четырёх разборов следует главный вывод раздела: кластер здоров, новый лидер принимает запись, а приложение висит на соединении с зомби. И чтобы это исправить, кластер трогать бесполезно.
Что влияет и что можно настроить
Ручек три, все они на разных уровнях. Две на стороне клиента, это сетевой таймаут и прикладной дедлайн на запрос; они закрывают разные классы отказов и не заменяют друг друга. Одна на стороне кластера, бюджет обнаружения отказа. Начну с клиентских: они дают больше и обходятся дешевле.
Уровень первый: сетевой таймаут
tcp_user_timeout ограничивает время, которое ядро будет вслепую переспрашивать отправленные, но не подтверждённые данные. Прошло больше и соединение объявляется мёртвым.
Добавляем в строку подключения tcp_user_timeout=5000 и повторяем ту же серию:
|
Отказ лидера |
клиент по умолчанию |
с |
|---|---|---|
|
|
20.33 с |
21.40 с |
|
перезагрузка узла |
27.21 с |
21.68 с |
|
сетевая изоляция |
≥ 110 с, не восстановился |
21.89 с |
|
зависание процессов |
≥ 61 с, граница опыта |
≥ 61 с, граница опыта |
Три строки из четырёх сошлись к одному значению, примерно двадцать одна секунда, то есть к собственному времени кластера. Неограниченный простой при изоляции превратился в нормальный failover одним параметром строки подключения.
А четвёртая строка не сдвинулась вообще, и мы уже видели почему. Таймаут tcp_user_timeout не срабатывает, так как неподтверждённых данных на замороженном соединении нет. Для сравнения, при сетевой изоляции тот же таймаут срабатывал исправно, клиент получал ошибку на 5.67 секунде, ровно как задано, потому что там его запрос как раз оставался неподтверждённым.
Важный нюанс, tcp_user_timeout умеет срабатывать не только на неподтверждённых данных, но и когда окно приёма схлопнулось в ноль. Замороженный сервер к этому в конце концов и придёт: ядро складывает пакеты в буфер сокета, приложение их не читает, буфер заполняется. В моих замерах до этого не дошло, так как нагрузчик пишет короткие вставки и за минуту буфер не забил. То есть спастись сетевым таймаутом теоретически можно, но время срабатывания будет зависеть от размера буфера и объёма записи. Так что полагаться на это нельзя.
Уровень второй: прикладной дедлайн
Дедлайн на запрос обычная вещь в пулах соединений: если запрос не вернулся за N секунд, соединение считается непригодным и берётся новое.
Ставим такой дедлайн в нагрузчике, пять секунд на запрос, поверх сетевого таймаута и повторяем заморозку:
|
Заморозка лидера |
простой записи |
|---|---|
|
клиент по умолчанию |
≥ 61 с (граница опыта) |
|
|
≥ 61 с (граница опыта) |
|
+ дедлайн запроса 5 с |
21.41 с |
Последний класс закрыт: остававшийся сценарий сошёлся к тому же времени кластера, что и остальные четыре.
Важная деталь: серверный statement_timeout здесь не помог бы, так как его отсчитывает сам сервер, а сервер заморожен. Отсюда правило: против зависшего сервера бесполезно всё, что работает через «попросить его прекратить». Дедлайн обязан отсчитываться локально у клиента.
И отсюда же критерий, который стоит приложить к своему стеку разработки (можно закинуть разработчикам прикладного ПО). Механизмы «таймаут запроса» бывают двух типов, выглядят они одинаково, но реализация отличается:
-
локальный дедлайн при котором клиент сам перестаёт ждать и бросает соединение. Против зависшей СУБД работает;
-
отмена, когда клиент просит прекратить выполнение. Против зависшей СУБД не работает: отмена уходит отдельным соединением, но адресована она всё равно тому же процессу, который и так не отвечает.
Так что вопрос к своему драйверу СУБД простой: когда срабатывает его таймаут, он перестаёт ждать сам или шлёт что-то серверу? В Go, например, для этого есть context.WithTimeout, но тип поведения задаёт драйвер: pgx по истечении контекста прерывает запрос локально, а lib/pq шлёт серверу CancelRequest. В Python встроенного дедлайна на запрос нет ни в psycopg2, ни в psycopg3 и тут нужен либо асинхронный режим с ожиданием на сокете по таймауту, либо обёртка вокруг вызова. Про эту обёртку как раз дальше, потому что сделать её неправильно проще, чем кажется.
Грабли (на которые я наступил сам)
Первая версия нагрузчика делала всё правильно, и всё равно не восстанавливалась до конца заморозки вместо положенных двадцати одной секунды.
Реализация была такая: Python, psycopg2, запрос уходит в отдельный поток, основной ждёт его с дедлайном. Логика после срабатывания дедлайна казалась очевидной: соединение негодное, закрываем его и открываем новое. Проблема оказалась в том, что зависший поток всё ещё сидит внутри драйвера и держит это соединение. Вызов close() из основного потока встал в очередь за ним и заблокировал уже основной поток.
В логе нагрузчика это выглядело так: дедлайн честно сработал на пятой секунде, дальше одна попытка переподключения и тишина до конца заморозки.
Правильное поведение оказалось таким, что брошенное по дедлайну соединение закрывать нельзя. Надо потерять ссылку на него и открыть новое, а зависший поток пусть завершается сам, когда сервер оживёт. Но опять же, сварщик я не настоящий, Python-разработчики со стажем over 10 лет могут тут снисходительно улыбнуться наивности подхода. Но тем не менее, после этой правки те же прогоны дали 21.4 секунды.
Почему подход наивный и за что приходится платить: на каждое срабатывание дедлайна утекают поток и соединение до тех пор, пока сервер не отвиснет. В пуле это означает лимит на число брошенных соединений и осознанное решение, что делать при его исчерпании, но лучше платить этим, чем блокировать поток (и работу всего приложения), который должен переподключаться.
Мораль тут очень широкая: таймаут, который сработал, но был обработан неправильно, ничем не лучше отсутствующего.
Уровень третий: бюджет обнаружения отказа
Со стороны клиента мы выжали всё: теперь любой сценарий сходится к времени кластера. Осталось понять, из чего это время складывается и можно ли его уменьшить.
Вернёмся к heartbeat_send_period и heartbeat_max_lost из раздела про SQL. Меняются они на лету, парой вызовов на любом узле кластера, рестарт не нужен, значения разъезжаются по остальным узлам сами:
select biha.set_heartbeat_send_period(500); -- период, мсselect biha.set_heartbeat_max_lost(3); -- сколько потерять
Перебираю пять комбинаций и на каждой повторяю kill -9 по лидеру, два прогона на точку:
|
Бюджет детекта, с |
Параметры, мс × шт |
Детект, с |
Простой записи, с |
Остаток, с |
|---|---|---|---|---|
|
20.0 |
2000 × 10 |
19.89 |
35.16 |
15.27 |
|
10.0 |
1000 × 10 (дефолт) |
9.88 |
20.94 |
11.06 |
|
5.0 |
1000 × 5 |
5.11 |
16.17 |
11.06 |
|
2.5 |
500 × 5 |
2.46 |
10.85 |
8.39 |
|
1.5 |
500 × 3 |
1.35 |
9.12 |
7.77 |
«Остаток» это простой минус детект, то есть всё, что уходит на выборы и повышение нового лидера после того, как старый признан потерянным.
Первое наблюдение: время обнаружения совпадает с произведением heartbeat_send_period × heartbeat_max_lost. На всех пяти точках расхождение с номиналом не превышает 0.15 секунды, при том что два повтора одной точки расходятся между собой сильнее (5.38 и 4.84 при бюджете в пять секунд). Тут сложно объяснить это расхождение, к тому же оно меньше, чем разрешение сборщика, которым снималось состояние кластера. Короче, смысл простой, скрытых поправок нет и формула совпадает с тем, что в документации: тайм-аут контроля состояния как произведение biha.heartbeat_max_lost на biha.heartbeat_send_period; на стенде он таким и оказался, на всех пяти комбинациях.
Ещё важный момент к данным таблицы: развёртка снималась клиентом с настроенным tcp_user_timeout. Поэтому в строке дефолта стоит 20.94 против 20.33 из предыдущего раздела. Разница хоть и небольшая, но это просто разные условия замера.
Второе наблюдение интереснее. Остаток зависит от периода (не от числа пропущенных сигналов): полсекунды дают около восьми секунд остатка, секунда — около одиннадцати, две секунды — около пятнадцати. Похоже, выборы отмеряются тем же тактом.
Отсюда практический вывод (которого нет в документации): уменьшение периода помогает дважды, а уменьшение max_lost только один раз. Если хочется быстрее, крутить надо в первую очередь период.
Грубая формула, которой я пользовался дальше:
простой ≈
send_period×max_lost+ 5 ×send_period+ 5 спервое слагаемое:
send_period×max_lost— обнаружение,
второе: 5 ×send_period— выборы (около пяти периодов),
третье: 5 c — повышение и подъём инстанса.send_periodв секундах.
По сути это подгонка под пять точек по два прогона, снятых одним типом отказа, одним клиентом и в одной локальной сети. На собственных данных она занижает до полутора секунд (но не завышает). Свободные пять секунд это повышение и подъём инстанса на моём стенде, слабом и с почти пустой базой; на боевой базе с большим shared_buffers и непрочитанным WAL это слагаемое будет другим (и это важно).
Если захотите приложить формулу к своему проду, слагаемые переносятся по-разному. Первое, обнаружение, это документированная арифметика таймера, переносится как есть. Второе, выборы, оно пропорционально периоду и, судя по трём проверенным значениям, ведёт себя так же. А константу надо померить у себя: сделать плановое переключение лидера и засечь клиентом, сколько не проходила запись. Полученное число подставляется вместо моих пяти секунд. Оно выйдет чуть больше самой константы, там есть ещё понижение старого лидера, и это как раз в безопасную сторону. Мерить надо именно клиентом: переподключение в формулу не входит, и как раз поэтому она и занижает.
Важный момент про «плановое переключение». Переключение лидера по сути это тоже настоящий простой записи, разница лишь в том, что момент выбираете вы сами. Если у команды эксплуатации сервиса есть SLA, этот замер (и простой записи) спишется с бюджета ошибок и просадит SLI ровно так же, как настоящая авария. Поэтому мерить стоит либо в окне обслуживания, либо на стенде, похожем на прод по объёму базы и железу: константа складывается из повышения и подъёма инстанса, а они зависят как раз от этого.
Зато у параметров есть пол, и он жёсткий:
select biha.set_heartbeat_send_period(300);ERROR: [BiHA] biha.set_heartbeat_send_period() failed: heartbeat_send_period must be at least 500 ms, but passed value is 300 msselect biha.set_heartbeat_max_lost(2);ERROR: [BiHA] biha.set_heartbeat_max_lost() failed: heartbeat_max_lost must be at least 3, but passed value is 2
Пятьсот миллисекунд и три пропущенных сигнала, это минимумы обоих параметров. То есть строка 500 × 3 в таблице выше это самая агрессивная настройка, которую вообще можно выставить. Полученные на ней девять секунд складываются из полутора секунд обнаружения, которые упираются в пол параметров, и семи с половиной на выборы с повышением, которые я померил на своём стенде. Настройками ниже полутора секунд не опуститься; что будет с остальным на другом железе уже вопрос замера.
Ложных срабатываний я не поймал ни на одной точке, включая самую агрессивную. Важный нюанс окружения: все узлы стенда находятся в одной локальной сети с задержкой много меньше миллисекунды. На инфраструктуре, где узлы разнесены между стойками или вообще датацентрами, полуторасекундный бюджет поведёт себя иначе, и подбирать его надо по своей сети.
Выводы и лучшие практики
Резюме результатов было в начале, теперь подведу итог, что со всем этим делать, по убыванию профита:
Настройте клиентские таймауты — это даёт больше всего. Два механизма защиты: tcp_user_timeout закрывает потерю пакетов, прикладной дедлайн на запрос, зависший сервер; заменить один другим нельзя. Плюс connect_timeout как гигиена, чтобы попытка подключиться к исчезнувшему узлу не упиралась в таймаут ядра.
Проверьте, что дедлайн обработан правильно. Если запрос остался висеть внутри драйвера, закрывать его соединение опасно: close() встанет в очередь за зависшим вызовом и заблокирует поток, который должен был переподключаться. Надёжнее потерять ссылку и взять новое соединение, заложив в пул лимит на такие брошенные соединения.
Посчитайте свой бюджет детекта. По умолчанию это десять секунд, и общий простой выходит около двадцати. Обнаружение считается ровно как send_period × max_lost, а вот сколько сверху уйдёт на выборы и повышение тут нужно мерить у себя: моя прикидка снята на слабом стенде с почти пустой базой. Стоит помнить про пол в пятьсот миллисекунд и три пропущенных сигнала, и про то, что агрессивные значения надо проверять на своей сети, а не на локальной.
Не полагайтесь на то, что кластер сообщает о себе. Здоровый status_v и online = t не означают, что приложение может писать. Мониторинг отказоустойчивости должен включать проверку со стороны клиента и пробную запись через ту же строку подключения, которой пользуется приложение.
Проверьте, укладываетесь ли вы в нижнюю границу. На моём стенде самая агрессивная настройка дала около девяти секунд простоя, и полторы из них это неустранимый пол, или дно 🙂 обнаружения. Если сервису нужно заметно меньше, стоит померить у себя, прежде чем закладываться на встроенное переключение: время уходит на то, чтобы сначала заметить отказ, а потом договориться о новом лидере, и быстрее этого не бывает. Как с этим у других решений, вопрос отдельных замеров, которых я не делал.
И последнее, время, это не единственная цена отказа. Во всех прогонах этой статьи репликация была асинхронной, и я ни разу не отметил, что стало с транзакциями, которые лидер успел подтвердить перед смертью. Ответ оказался неприятным, и о нём в следующей статье.
Скрипты стенда, сырые данные всех прогонов и полная методику лежит в репозитории со статьями, в каталоге 2026-08-biha. Материалы следующих частей серии планирую выкладывать туда же вместе с ними.
ссылка на оригинал статьи https://habr.com/ru/articles/1077004/