5. Raft. Safety и гарантии

В предыдущих статьях мы шаг за шагом собрали сложнейший пазл распределенного консенсуса: разобрались, как узлы кластера выбирают Лидера (3. Raft. Leader election), и как этот Лидер заставляет Фолловеров реплицировать записи в свой локальный журнал (4. Raft. Log replication).

Казалось бы, система построена и готова к бою: выбрали вожака, отправили команду в сеть, дождались подтверждения от большинства (Кворума) — и можно радостно отвечать клиенту HTTP 200 OK.

Но в распределенных системах дьявол всегда кроется в пограничных сценариях (corner cases). Что, если Лидер успел реплицировать запись на большинство серверов, но упал за наносекунду до того, как успел пометить эту запись зафиксированной (Committed) и отправить ответ клиенту?

Новый Лидер увидит эту запись в журнале, но как ему понять: имеет ли он право считать её зафиксированной, или ее нужно стереть и заменить новыми данными?

Без строгих математических гарантий безопасности (Safety) любая распределенная база данных рано или поздно перезапишет уже подтвержденную пользователю финансовую транзакцию.


Фундаментальная гарантия: State Machine Safety

Главная и абсолютная цель всего алгоритма Raft формулируется в одном фундаментальном инварианте:

«Свойство безопасности конечного автомата (State Machine Safety Property)».
Если какой-либо сервер применил запись лога с определенным индексом к своей стейт-машине (базе данных), никакой другой сервер кластера никогда не сможет применить другую запись для этого же самого индекса.

Если этот инвариант нарушится хотя бы один раз, система перестанет быть линейно консистентной (Linearizable) и превратится в генератор хаоса. Чтобы железно соблюсти это свойство, Raft задействует два взаимосвязанных механизма, связывающих фазу Выборов и фазу Репликации.


Механизм 1: Математика пересечения Кворумов

В статье про выборы мы сформулировали правило Election Restriction (Ограничение на избрание): Фолловер категорически отказывает Кандидату в голосе, если лог Кандидата старше (меньший Терм последней записи) или короче (при равных Термах меньший Индекс), чем собственный лог Фолловера.

Почему это правило математически гарантирует невозможность потери данных?

Вспомним дискретную математику и принцип Дирихле (Pigeonhole Principle):

  1. Пусть в кластере работает $N = 5$ узлов.
  2. Лидер зафиксировал запись X. Это означает, что запись X была гарантированно записана на диск большинства узлов — минимум 3 серверов из 5. Обозначим это множество узлов как Кворум А (например, узлы $\{1, 2, 3\}$).
  3. Внезапно Лидер падает. Начинаются новые выборы. Чтобы победить, новому Кандидату тоже необходимо собрать строгое большинство голосов — минимум 3 узла из 5. Обозначим это множество как Кворум Б.
flowchart LR
    subgraph QA["Кворум А (Записали X)"]
        U1["Узел 1 (Лидер - Упал)"]
        U2["Узел 2 (Есть X)"]
        U3["Узел 3 (Есть X)"]
    end

    subgraph QB["Кворум Б (Голосуют на выборах)"]
        U3
        U4["Узел 4 (Нет X)"]
        U5["Узел 5 (Нет X)"]
    end

    style U3 fill:#10b981,stroke:#059669,stroke-width:3px,color:#ffffff
  

Теорема о пересечении кворумов:
В любой группе из $N$ элементов любые два подмножества размером $\lfloor N/2 \rfloor + 1$ гарантированно пересекаются хотя бы по одному общему элементу:

$$|QA \cap QB| \ge 1$$

Это означает, что в избирательном Кворуме Б гарантированно присутствует хотя бы один узел (в нашем примере — Узел 3), который физически сохранил запись X.

Когда отставший Кандидат (например, Узел 4, у которого нет записи X) обратится к Узлу 3 за голосом, Узел 3 сверит логи, увидит, что лог претендента устарел, и ответит отказом! Отставший узел никогда не сможет собрать кворум и стать вожаком. Лидером станет только тот, кто уже обладает всеми подтвержденными записями.


Механизм 2: Проблема прошлых эпох (Самое сложное в Raft)

Существует один тончайший сценарий, который ломает человеческую интуицию и заставил создателей Raft переписать раздел безопасности в своей работе. Этот сценарий подробно описан как знаменитая Figure 8 в оригинальной публикации Диего Онгаро.

Представьте следующую драматическую цепочку событий:

  1. Эпоха 2 (Term 2): Лидер $S_1$ принимает команду и успевает реплицировать ее на узел $S_2$. Затем $S_1$ внезапно падает, не успев зафиксировать запись.
  2. Эпоха 3 (Term 3): Узел $S_5$ избирается лидером благодаря голосам $S_3$, $S_4$ и $S_5$ (кворум 3 из 5). $S_5$ принимает новую команду, записывает ее только себе в лог и тоже падает.
  3. Эпоха 4 (Term 4): Узел $S_1$ оживает, избирается лидером и продолжает работу. Он находит в своем логе незафиксированную запись из старой эпохи 2 и реплицирует ее на узел $S_3$.
  4. Теперь запись из Терма 2 физически лежит на трех узлах из пяти ($S_1, S_2, S_3$). То есть на большинстве!
TEXT
Индекс:      1       2
S1 (Term 4): [1]    [2] -> реплицировал [2] на S3 (теперь на S1, S2, S3!)
S2:          [1]    [2]
S3:          [1]    [2]
S4:          [1]
S5 (Term 3): [1]    [3] (был лидером в Term 3, упал)

Железное правило безопасности Raft:

Лидер имеет право фиксировать (commit) записи методом подсчета реплик на большинстве узлов ТОЛЬКО И ИСКЛЮЧИТЕЛЬНО для записей СВОЕЙ ТЕКУЩЕЙ ЭПОХИ.

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

Как же тогда старые записи вообще фиксируются?
Лидер просто продолжает штатную работу: принимает новые команды в своей текущей эпохе (Term 4). Как только хотя бы одна запись текущей эпохи успешно реплицируется на кворум узлов, все предшествующие записи автоматически считаются зафиксированными благодаря фундаментальному свойству Log Matching Property!


Под капотом: Raft в Go и канал Ready

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

Создатели etcd/raft изолировали алгоритм в виде чистого автомата без ввода-вывода. Любое действие ноды сериализуется через канал Ready():

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    RN["Движок etcd/raft<br/>(Чистый конечный автомат)"]:::accent -->|"Выдает структуру Ready"| RD["Канал Ready()"]

    subgraph AppLoop["Пользовательский событийный цикл (Go Engine)"]
        RD --> S1["1. Сброс WAL на диск (fsync)<br/>HardState + Новые Entries"]
        S1 --> S2["2. Сетевая рассылка (Fan-Out)<br/>Отправка Messages пирам"]
        S2 --> S3["3. Применение к Бизнес-БД<br/>Итерация по CommittedEntries"]
        S3 --> S4["4. Вызов Advance()<br/>Переход автомата к следующему шагу"]
    end
  

Взглянем на канонический select-loop, управляющий узлом:

Go
package main

import (
    "context"
    "log"

    "go.etcd.io/raft/v3"
    "go.etcd.io/raft/v3/raftpb"
)

type ConsensusEngine struct {
    node         raft.Node
    storage      *raft.MemoryStorage
    walWriter    *WALWriter
    network      *NetworkTransport
    stateMachine *KVStore
}

func (ce *ConsensusEngine) Run(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return

        case rd := <-ce.node.Ready():
            // ШАГ 1: Механическое сочувствие — НАДЕЖНОСТЬ ДИСКА
            // Сохраняем HardState (текущий Term и Vote) и незакоммиченные записи в WAL.
            // fsync() обязан произойти ДО того, как мы отправим пакеты в сеть!
            if !raft.IsEmptyHardState(rd.HardState) || len(rd.Entries) > 0 {
                if err := ce.walWriter.Save(rd.HardState, rd.Entries); err != nil {
                    log.Fatalf("Критическая ошибка WAL: %v", err)
                }
                ce.storage.Append(rd.Entries)
            }

            // ШАГ 2: СЕТЕВОЕ ВЗАИМОДЕЙСТВИЕ
            // Рассылаем подготовленные сообщения пирам (RPC AppendEntries и RequestVote)
            ce.network.Send(rd.Messages)

            // ШАГ 3: БЕЗОПАСНОСТЬ БИЗНЕС-ДАННЫХ
            // Применяем к боевой базе данных ТОЛЬКО гарантированно зафиксированные записи!
            for _, entry := range rd.CommittedEntries {
                if entry.Type == raftpb.EntryNormal && len(entry.Data) > 0 {
                    ce.applyToStateMachine(entry)
                }
            }

            // ШАГ 4: СИГНАЛ АВТОМАТУ
            // Подтверждаем, что батч обработан, можно готовить следующую пачку
            ce.node.Advance()
        }
    }
}

func (ce *ConsensusEngine) applyToStateMachine(entry raftpb.Entry) {
    // Бизнес-логика обязана быть идемпотентной!
    ce.stateMachine.Execute(entry.Index, entry.Data)
}

Проблема "Грязного чтения" (Stale Reads)

Если Лидер — это абсолютный и единственный центр принятия решений, возникает соблазнительная мысль: «Зачем для операций чтения (HTTP GET) гонять консенсус и писать что-то в лог? Пусть Лидер просто отдаст данные из своей оперативной памяти!».

Чтобы обеспечить честную линейную консистентность (Linearizable Reads), Raft предлагает два механизма:

1. Механизм ReadIndex

Перед тем как вернуть данные из памяти, Лидер обязан подтвердить свою легитимность:

sequenceDiagram
    autonumber
    actor Client as Клиент
    participant L as Лидер
    participant F as Фолловеры (Кворум)

    Client->>L: HTTP GET /balance
    Note over L: Фиксирует локальный CommitIndex (напр. 100)

    L->>F: Пустой RPC Heartbeat (Проверка власти)
    F-->>L: Success (Большинство подтвердило: Ты Лидер!)

    Note over L: Ждет, пока State Machine применит записи до индекса 100
    L->>L: Читает баланс из локальной БД
    L-->>Client: HTTP 200 OK (Гарантированно свежие данные)
  
  1. Лидер сохраняет текущий CommitIndex.
  2. Лидер рассылает быстрый раунд Heartbeat-сообщений Фолловерам.
  3. Дождавшись подтверждения от кворума, Лидер на 100% уверен, что в кластере нет другого более свежего Лидера.
  4. Лидер ждет, пока его стейт-машина применит все записи до сохраненного CommitIndex, и возвращает ответ клиенту.

Это гарантирует идеальную строгость ценой одного сетевого RTT (Round-Trip Time) без дискового I/O.

2. Механизм Lease Read (Чтение по аренде)

Для экстремального снижения задержек Лидер может использовать аренду (Lease). Получив кворум на очередном пульсе, Лидер считает себя легитимным правителем на фиксированный интервал времени (меньший, чем минимальный Election Timeout). В течение этого окна Лидер отдает чтения мгновенно без сетевых подтверждений.

Однако, как мы помним из статьи 5. Time и clock drift, аренда опирается на физические часы узла. Если операционная система зависнет на 500 мс в паузе Stop-the-World сборщика мусора (GC) или гипервизор заморозит виртуальную машину, таймер аренды может истечь незаметно для процессора, создав окно для чтения фантомных данных.


Итог всего Raft

Мы завершили фундаментальное исследование Raft — самого надежного и понятного протокола консенсуса в современной индустрии.

  1. Три столпа сложности: Raft превратил запутанную проблему консенсуса в прозрачную цепочку этапов: Выборы лидера $\to$ Репликация лога $\to$ Гарантии безопасности.
  2. Пересечение кворумов: Ни один отстающий узел не сможет захватить власть, так как кворум голосования неизбежно пересекается с кворумом репликации предыдущей записи.
  3. Защита прошлых эпох (Figure 8): Лидер коммитит подсчетом реплик только записи своего собственного Терма, страхуя кластер от затирания истории.
  4. Событийный конвейер Go: В эталонных библиотеках (etcd/raft) разделение чистого конечного автомата и инфраструктуры (канал Ready()) обеспечивает максимальную скорость дискового I/O и безупречную надежность.
  5. ReadIndex: Чтение данных требует проверки актуальности власти Лидера, исключая отдачу клиенту устаревших данных из изолированных сегментов сети.

Raft завоевал мир благодаря своей кристальной понятности. Однако до его триумфа пионеры распределенных систем шли сквозь математические дебри алгоритма Paxos. Чтобы стать по-настоящему эрудированным инженером и понимать истоки архитектур Google Chubby и Spanner, в следующей статье мы заглянем в гости к классике: 6. Paxos обзорно.