Выбор лидера (Leader Election)
Зачем нужен лидер
Во многих распределённых системах один узел должен координировать действия остальных: обрабатывать записи, распределять задачи, управлять состоянием.
| Роль лидера | Примеры |
|---|---|
| Primary для записи | PostgreSQL primary, MongoDB primary |
| Координатор транзакций | 2PC coordinator |
| Планировщик задач | Cron-подобные задачи, batch processing |
| Менеджер партиций | Kafka partition leader |
| Распределение работы | Worker manager |
Требования к leader election
- Safety (безопасность): в каждый момент не более одного лидера
- Liveness (живучесть): если лидер упал, новый будет выбран
- Leader detection: узлы должны узнать, кто лидер
Bully Algorithm
Алгоритм
Каждый узел имеет уникальный ID. При обнаружении отсутствия лидера:
- Узел P отправляет ELECTION всем узлам с ID > P
- Если никто не ответил, P становится лидером и рассылает COORDINATOR
- Если кто-то ответил OK, P ждёт -- этот узел продолжит выборы
- Узел с наибольшим 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
Узлы организованы в логическое кольцо. При обнаружении отсутствия лидера:
- Узел P отправляет ELECTION со своим ID следующему по кольцу
- Каждый узел добавляет свой ID и передаёт дальше
- Когда сообщение обходит полный круг, побеждает наибольший 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";
},
);
Проблема: устаревший лидер
Лидер 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 всегда используйте проверенные координационные сервисы