HardТеория9 min

Выбор лидера

Bully algorithm, Ring algorithm, ZooKeeper и etcd leader election в распределённых системах

Выбор лидера (Leader Election)

Зачем нужен лидер

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

Роль лидера Примеры
Primary для записи PostgreSQL primary, MongoDB primary
Координатор транзакций 2PC coordinator
Планировщик задач Cron-подобные задачи, batch processing
Менеджер партиций Kafka partition leader
Распределение работы Worker manager

Требования к leader election

  1. Safety (безопасность): в каждый момент не более одного лидера
  2. Liveness (живучесть): если лидер упал, новый будет выбран
  3. Leader detection: узлы должны узнать, кто лидер

Bully Algorithm

Алгоритм

Каждый узел имеет уникальный ID. При обнаружении отсутствия лидера:

  1. Узел P отправляет ELECTION всем узлам с ID > P
  2. Если никто не ответил, P становится лидером и рассылает COORDINATOR
  3. Если кто-то ответил OK, P ждёт -- этот узел продолжит выборы
  4. Узел с наибольшим ID всегда побеждает (bully = задира)
Узлы: [1] [2] [3] [4] [5-лидер]

Узел 5 падает. Узел 2 обнаруживает это.

Узел 2: ELECTION -> 3, 4, 5
Узел 3: OK -> 2
Узел 4: OK -> 2
Узел 3: ELECTION -> 4, 5
Узел 4: OK -> 3
Узел 4: ELECTION -> 5  (нет ответа)
Узел 4: COORDINATOR -> всем

Новый лидер: Узел 4

Плюсы и минусы

Плюсы Минусы
Простой Много сообщений O(n^2)
Детерминированный результат Зависит от failure detection
Быстрый при малом N Split-brain при network partition

Ring Algorithm

Узлы организованы в логическое кольцо. При обнаружении отсутствия лидера:

  1. Узел P отправляет ELECTION со своим ID следующему по кольцу
  2. Каждый узел добавляет свой ID и передаёт дальше
  3. Когда сообщение обходит полный круг, побеждает наибольший ID
Кольцо: [1] -> [2] -> [3] -> [4] -> [1]

Узел 2 начинает выборы:
  [2] -> ELECTION{2} -> [3] -> ELECTION{2,3} -> [4] -> ELECTION{2,3,4} -> [1] -> ELECTION{2,3,4,1} -> [2]

Узел 2 получил полный список: max(2,3,4,1) = 4
Узел 4 -- новый лидер.

Leader Election через ZooKeeper/etcd

В production leader election реализуется через координационные сервисы.

ZooKeeper (Ephemeral Nodes)

ZooKeeper предоставляет ephemeral nodes -- узлы, которые автоматически удаляются при потере сессии.

Алгоритм:
1. Каждый кандидат создаёт ephemeral sequential node: /election/node-000001
2. Узел с наименьшим номером -- лидер
3. Остальные watch'ат узел перед собой
4. Если лидер падает, его ephemeral node удаляется
5. Следующий узел становится лидером

etcd (Lease + Campaign)

etcd предоставляет lease-based leader election через Raft consensus.

<?php

declare(strict_types=1);

/**
 * Simplified leader election using Redis (practical PHP approach).
 * Production systems use etcd/ZooKeeper, but Redis works for simpler cases.
 */
final class RedisLeaderElection
{
    private readonly string $lockKey;

    public function __construct(
        private readonly \Redis $redis,
        private readonly string $nodeId,
        private readonly string $electionName,
        private readonly int $ttlSeconds = 30,
        private readonly int $renewIntervalSec = 10,
    ) {
        $this->lockKey = "leader:{$this->electionName}";
    }

    /**
     * Try to become the leader.
     * Uses SET NX (set if not exists) + TTL for automatic expiration.
     */
    public function tryBecomeLeader(): bool
    {
        // SET key value NX EX ttl
        // NX = only set if key doesn't exist
        // EX = expire after ttl seconds
        $result = $this->redis->set(
            $this->lockKey,
            $this->nodeId,
            ['NX', 'EX' => $this->ttlSeconds],
        );

        return $result === true;
    }

    /**
     * Renew leadership (extend TTL).
     * Only succeeds if this node is still the leader.
     */
    public function renewLeadership(): bool
    {
        // Lua script for atomic check-and-extend
        $script = <<<'LUA'
            if redis.call("GET", KEYS[1]) == ARGV[1] then
                return redis.call("EXPIRE", KEYS[1], ARGV[2])
            end
            return 0
        LUA;

        $result = $this->redis->eval(
            $script,
            [$this->lockKey, $this->nodeId, $this->ttlSeconds],
            1,
        );

        return $result === 1;
    }

    /**
     * Get current leader node ID.
     */
    public function getCurrentLeader(): ?string
    {
        $leader = $this->redis->get($this->lockKey);
        return $leader !== false ? $leader : null;
    }

    /**
     * Am I the current leader?
     */
    public function isLeader(): bool
    {
        return $this->getCurrentLeader() === $this->nodeId;
    }

    /**
     * Step down from leadership.
     * Only releases if this node is the leader (atomic).
     */
    public function stepDown(): bool
    {
        $script = <<<'LUA'
            if redis.call("GET", KEYS[1]) == ARGV[1] then
                return redis.call("DEL", KEYS[1])
            end
            return 0
        LUA;

        return $this->redis->eval($script, [$this->lockKey, $this->nodeId], 1) === 1;
    }

    /**
     * Run leader election loop.
     * Continuously tries to become leader and renews leadership.
     */
    public function run(callable $onBecomeLeader, callable $onLoseLeadership): void
    {
        $wasLeader = false;

        while (true) {
            if ($this->isLeader()) {
                if (!$wasLeader) {
                    $onBecomeLeader($this->nodeId);
                    $wasLeader = true;
                }
                $this->renewLeadership();
            } else {
                if ($wasLeader) {
                    $onLoseLeadership($this->nodeId);
                    $wasLeader = false;
                }
                $this->tryBecomeLeader();
            }

            sleep($this->renewIntervalSec);
        }
    }
}

// Usage: scheduled task coordinator
$election = new RedisLeaderElection(
    redis: new \Redis(),
    nodeId: gethostname() . ':' . getmypid(),
    electionName: 'cron-scheduler',
    ttlSeconds: 30,
    renewIntervalSec: 10,
);

$election->run(
    onBecomeLeader: function (string $nodeId) {
        echo "Node {$nodeId} is now the leader. Starting scheduler.\n";
    },
    onLoseLeadership: function (string $nodeId) {
        echo "Node {$nodeId} lost leadership. Stopping scheduler.\n";
    },
);
## Fencing Tokens

Проблема: устаревший лидер

Лидер A получает lock в момент T1
Лидер A делает GC pause (или network partition) на 30 секунд
Lock истекает, Лидер B получает lock
Лидер A "просыпается" и думает, что он всё ещё лидер
Оба пишут в БД -- конфликт!

Решение: Fencing Token

При каждом получении лидерства выдаётся монотонно растущий токен. Ресурсы (БД) проверяют, что токен >= последнего принятого.

Лидер A: token=33, пишет в БД с token=33
Лидер B: token=34, пишет в БД с token=34
Лидер A (устаревший): пишет с token=33 -> БД отвергает (33 < 34)

Для System Design: Fencing tokens -- критический механизм безопасности для leader election. Без них возможен split-brain. Redis Redlock, ZooKeeper, etcd поддерживают fencing через version/revision.

Выводы

  • Leader election необходим для координации в распределённых системах
  • Bully и Ring -- академические алгоритмы, в production используются ZooKeeper/etcd
  • Redis может использоваться для simple leader election с SET NX + TTL
  • Fencing tokens предотвращают split-brain от устаревших лидеров
  • TTL и lease renewal обеспечивают автоматическое переизбрание при отказе
  • В production всегда используйте проверенные координационные сервисы