Перейти к содержанию

Алгоритм discovery

В данном разделе описаны подробности работы алгоритма discovery, который отвечает за обнаружение инстансами друг друга при инициализации кластера.

Входные данные

Алгоритм использует для работы следующие входные данные:

  • N узлов, пока не связанных друг с другом по сети (связать их предстоит алгоритму)
  • Информация о соседях каждого узла (массив initial_peers), содержащая сетевые адреса узлов (не менее одного)

Алгоритм налагает некоторые ограничения на входные данные, в противном случае результат работы алгоритма может оказаться некорректным. Чтобы обеспечить выполнение результата, у любой пары узлов должен существовать как минимум один общий элемент initial_peers.

Требования к связности сети

Алгоритм discovery изначально спроектирован таким образом, чтобы его легко было адаптировать под конкретный транспортный протокол (TCP, UDP etc).

Также в расчет приняты возможные спонтанные нарушения связности сети. Алгоритм учитывает, что сообщение может быть доставлено получателю:

  • спустя неопределенное время
  • возможно бесконечно малое время
  • никогда

Так как алгоритм должен быть, прежде всего, полезен на практике, он изначально допускает и умеет обрабатывать типовые ситуации сбоев, аварий и длительных периодов недоступности серверов и датацентров.

В тоже время алгоритм совершенно не подготовлен к решению задачи о византийских генералах. Акцент в первую очередь делается на отказоустойчивости к ошибкам пользователя, а не злонамеренности "генералов". Это призвано упростить процесс инициализации raft-группы (который в худшем случае можно и перезапустить на этапе подготовки к эксплуатации, в отличие от "генералов"). "Византийская" отказоустойчивость, если это необходимо, должна обеспечиваться другим протоколом.

Результат работы алгоритма

Результатом работы распределенного алгоритма discovery является единственное булево значение i_am_bootstrap_leader. Мы ожидаем (и заверяем пользователей), что, пока входные параметры не нарушают наложенных ограничений, не более одного узла будущего кластера присвоят себе эту статус. Идеальной ситуации, когда "не меньше" тоже равно одному, к сожалению, не бывает: в условиях полной сетевой изоляции ответ будет "ноль".

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

Этапы работы алгоритма

Шаг 0.1

Как уже упоминалось раньше, каждый узел i инициализирует массив известных адресов known_peers[i] = initial_peers[i] (со значениями, предоставленными пользователем).

Шаг 0.2

Каждый узел i генерирует случайный идентификатор (guid[i]), вероятность коллизии которого с другими идентификаторами, как предполагается, равной нулю. Это требование совсем не сложно выполнить на практике.

Шаг 1

Каждый узел i проводит раунд запросов номер r — отправляет по всем известным адресам known_peers[i][m] сообщение ("discovery_req", m, known_peers[i]). Параметр m представляет собой всего лишь индекс адресата в массиве known_peers[i]. Его единственная роль в алгоритме — быть возвращенным в ответе, чтобы можно было сопоставить ответ с конкретным адресом. Впрочем, его можно вообще опустить, если эту функциональность предоставляет используемый транспортный протокол.

Меж двух шагов

Получив сообщение ("discovery_req", m, known_peers[i]), узел (j) проверяет свое состояние.

Если на данный момент лидер уже выбран и известен, то узел отправляет в ответ сообщение ("discovery_finished").

В противном случае узел обновляет свой массив known_peers[j], добавляя туда значения из known_peers[i], о которых еще не было известно. Ответ содержит ("discovery_resp", m, known_peers[j], guid[j]).

Здесь делается еще одно допущение о том, что используемый транспортный протокол обладает функцией "отправки ответа". И TCP, и UDP такой функциональностью обладают.

Шаг 2 (Возвращаясь к узлу i - отправителю запроса)

Получив ответ ("discovery_finished"), алгоритм завершает свою работу — задача выполнена. i_am_bootstrap_leader[i] = false.

Получив ответ ("discovery_resp", m, known_peers[j], guid[j]), узел (i), как и в случае обработки discovery_req, обновляет свой массив known_peers[j], добавляя туда значения из known_peers[i], о которых еще не было известно. Далее полученный ответ сопоставляется с адресатом known_peers[i][m]. Если в массиве known_peers[i] остались еще не ответившие адресаты, алгоритм останавливает работу до получения следующего сообщения.

Если ответ содержит ошибку протокола транспортного уровня, запрос повторяется с некоторым интервалом до тех пор, пока не будет получен внятный ответ.

Если к концу этого шага были обнаружены новые адресаты, алгоритм начинает новый раунд запросов r+1 и возвращается к шагу 1.

Шаг 3

Если на этом шаге лидер все еще не известен, то к этому моменту каждому узлу i становится известна полная карта known_peers[i][m] -> guid[m].

Если справедливо равенство guid[i] == min(guid), то узел понимает, что i_am_bootstrap_leader[i] = true, и с этих пор перестает изменять внутреннее состояние, и отвечает на все запросы ("discovery_finished").

Если же, напротив, guid[i] != min(guid), то делается вывод i_am_bootstrap_leader[i] = false.

Доказательство

Корректность алгоритма проще всего доказать от противного. Предположим, что в какой-то момент узел i решил, что i_am_bootstrap_leader[i] == true, в то время как уже существовал i_am_bootstrap_leader[j] == true (и при этом i != j).

Так как на третьем шаге оба узла вычисляют min(guid) идентичным образом, то отличаться должны были сами таблицы guid.

При этом массив known_peers[j] заведомо не содержал адрес i в момент принятия решения, но содержал свой адрес j, иначе бы не выполнилось условие guid[j] == min(guid). Аналогично, known_peers[i] точно содержал i.

В то же время, known_peers[i] не мог содержать j, иначе i не смог бы получить от j ответ ("discovery_resp") и не “выдать” себя.

Таким образом i и j не должны были в момент принятия решения ничего знать друг о друге.

Однако, узлы i и j существуют внутри одного кластера, поэтому у них должен быть как минимум один общий "сосед", назовем его z. Он также должен был дать ответы ("discovery_resp") для i и j. Поскольку обработка запроса выполняется атомарно, узлу z пришлось бы одному из двух узлов в ответе ("discovery_resp") сообщить о существовании второго. Таким образом, утверждение "known_peers[j] не содержал адрес i , а known_peers[i] не содержал адрес j в момент принятия решения", неверно.

Возможные оптимизации

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

Начинать новый раунд на втором шаге не возбраняется сразу после обнаружения нового адресата. Не обязательно для этого дожидаться всех ожидаемых ответов.

После завершения работы алгоритма (сообщение ("discovery_finished")) можно рассылать информацию об адресе лидера, о known_peers, или другие данные, которые хотелось бы синхронизировать. Так как уникальность лидера доказана, остальные узлы могут ему верить, если конечно никто больше не возьмется ее модифицировать, а "византийские" сценарии мы не рассматриваем.

См. также: