5. Ordering и partitioning

Причинность, ключи и параллелизм в распределенном логе

«Время, часы и порядок событий в распределенной системе не абсолютны; они лишь артефакт каналов связи, которые мы сами решили построить».
— Лесли Лэмпорт, лауреат премии Тьюринга


Почему упорядоченность — вызов в распределённых системах

В монолитных системах и простых очередях сообщений (например, одиночной очереди в оперативной памяти процесса) строгий порядок первого пришедшего (FIFO — First-In, First-Out) даётся практически даром: один поток записывает события в циклический буфер, другой поток их вычитывает.

Но как только мы переходим к горизонтально масштабируемым распределенным системам, где терабайты данных ежесекундно обрабатываются сотнями серверов, глобальный порядок становится фундаментальным физическим препятствием:

  1. Теория относительности и задержки сигналов: В распределенной сети нет единого абсолютного «сейчас». Сетевые пакеты между дата-центрами испытывают джиттер, физические кварцевые часы серверов расходятся (clock drift), а маршрутизаторы меняют пути следования пакетов.
  2. Цена глобальной упорядоченности: Чтобы обеспечить абсолютно строгий глобальный порядок для всех событий в системе, вам потребуется единая точка сериализации — один процессор или один узел-координатор, через который обязаны пройти абсолютно все запросы. Это немедленно превращает систему в бутылочное горлышко: производительность всей вашей мировой инфраструктуры деградирует до скорости одного потока CPU и сетевой задержки до одного сервера.

Apache Kafka предлагает зрелое инженерное решение этой дилеммы: отказ от тотального глобального порядка ради строгого локального порядка и горизонтального параллелизма.

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


Партиция как гарант порядка

Физически партиция — это неизменяемый append-only лог, хранящийся на диске в виде последовательных файлов-сегментов. Каждое новое сообщение дописывается строго в конец активного сегмента и получает монотонно возрастающий целочисленный идентификатор — offset.

flowchart LR
    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
    subgraph Partition["Партиция 0 (Append-Only Log)"]
        direction LR
        O0["Offset 0<br>Order #42 Created"]:::process --> O1["Offset 1<br>Order #42 Paid"]
        O1 --> O2["Offset 2<br>Order #42 Shipped"]
        O2 --> O3["Offset 3<br>Order #42 Delivered"]
    end

    C["Консьюмер (Single Thread)"] -->|"Читает монотонно 0 -> 1 -> 2 -> 3"| Partition
  

Эта архитектура даёт железные гарантии:

  • Детерминизм чтения: Консьюмер, последовательно запрашивающий данные из партиции, физически не может получить сообщение с offset 2 раньше, чем сообщение с offset 1.
  • Инвариантность репликации: Все реплики в ISR-наборе (In-Sync Replicas) — как лидер, так и фолловеры — реплицируют байты строго в порядке их фиксации лидером. Даже при аварийном переключении лидера новый лидер содержит ту же самую последовательность смещений.
  • Причинно-следственная связь (Causal Ordering): Если событие B вызвано событием A (например, «Платёж подтверждён» после «Заказ создан»), и оба они записаны в одну партицию, ни один консьюмер в мире не увидит подтверждение платежа раньше создания заказа.

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


Партиционирование как рубильник порядка

Связь между ключом сообщения и партицией — вот где рождается или умирает порядок. Продюсер направляет сообщение в конкретную партицию по одному из трёх правил:

  1. Без ключа (key == nil) — продюсер использует стратегию StickyKeyPartitioner (или в старых версиях наивный round-robin). Сообщения пакетируются и распределяются по всем партициям топика. Любые два события, даже относящиеся к одному заказу, почти наверняка разойдутся по разным партициям и потеряют всякий гарантированный порядок.
  2. С ключом (hash-based) — продюсер вычисляет 32-битный хеш ключа алгоритмом murmur2 и берёт остаток от деления на общее количество партиций: partition = hash(key) % num_partitions. Все сообщения с одинаковым ключом всегда направляются в одну и ту же партицию, гарантируя строгий порядок для этой сущности (например, order-12345 или order-42).
  3. Явное указание партиции — приложение-отправитель само задаёт номер партиции в метаданных записи, полностью контролируя логику распределения (Custom Partitioner).
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
    subgraph ProducerApp["Продюсер: отправка бизнес-событий"]
        E1["Key: 'order-42' | Event: Created"]:::accent
        E2["Key: 'order-42' | Event: Paid"]:::accent
        E3["Key: 'order-99' | Event: Created"]:::accent
        E4["Key: 'order-42' | Event: Shipped"]:::accent
    end

    subgraph Hashing["Алгоритм murmur2(key) % 3"]
        H1["murmur2('order-42') % 3 = 1"]
        H2["murmur2('order-99') % 3 = 2"]
    end

    subgraph TopicPartitions["Топик 'orders' (3 партиции)"]
        P0["Партиция 0<br>(Другие ключи)"]
        P1["Партиция 1<br><b>order-42:</b> Created → Paid → Shipped"]:::process
        P2["Партиция 2<br><b>order-99:</b> Created"]:::process
    end

    E1 --> H1
    E2 --> H1
    E4 --> H1
    E3 --> H2

    H1 --> P1
    H2 --> P2
  

В этой схеме события по заказу order-42 гарантированно упорядочены, а события заказов order-42 и order-99 обрабатываются параллельно на разных ядрах CPU или разных серверах, не блокируя друг друга. Именно так достигается баланс между порядком и пропускной способностью.


Подводный камень: переупорядочивание на стороне Producer

Мало кто задумывается, что порядок может быть сломан ещё до того, как байты коснутся диска брокера, — внутри самого сетевого конвейера продюсера!

Представьте следующую настройку продюсера:

  • max.in.flight.requests.per.connection = 5 (продюсер может отправить до 5 сетевых TCP-пакетов без ожидания ответа, чтобы насытить сетевой канал).
  • retries = 3 (автоматический повтор при ошибках сети).
  • enable.idempotence = false (идемпотентность отключена).

Сценарий катастрофы упорядоченности:

  1. Продюсер отправляет батч 1 (событие Order Created).
  2. Не дожидаясь ответа, продюсер сразу же отправляет батч 2 (событие Order Paid).
  3. Батч 1 испытывает временную потерю пакета в маршрутизаторе и завершается ошибкой таймаута на брокере.
  4. Батч 2 благополучно доходит до брокера и записывается под смещением 100 (Order Paid).
  5. Продюсер получает ошибку для батча 1, выполняет автоматический retry и повторно отсылает его.
  6. Батч 1 записывается под смещением 101 (Order Created).
  7. Итог: В логе заказ сначала оплачен, а затем создан!
sequenceDiagram
    autonumber
    participant P as Продюсер (max.in.flight=5)
    participant B as Брокер (Партиция 1)

    P->>B: Батч 1: Order Created
    P->>B: Батч 2: Order Paid
    Note over B: Батч 1 поврежден/потерян в сети!
    B->>B: Запись Батча 2 (offset 100: Paid)
    B-->>P: ACK (offset 100: Paid)
    B--xP: Error/Timeout для Батча 1
    Note over P: Ретрай Батча 1!
    P->>B: Батч 1 (Retry): Order Created
    B->>B: Запись Батча 1 (offset 101: Created)
    B-->>P: ACK (offset 101: Created)
    Note over B: КАТАСТРОФА: Заказ оплачен ДО создания!
  

Ловушка изменения числа партиций

Добавление новых партиций в топик — операция, которая ломает гарантии порядка по ключу. Формула partition = hash(key) % num_partitions после увеличения $N$ начинает давать совершенно другие результаты, и сообщения с одним и тем же ключом могут «переехать» в другую партицию.

Если динамическое масштабирование неизбежно, применяют техники:

  • Двухфазная миграция: Запись старых ключей продолжается в старые партиции через специальную карту маппинга или кастомный партиционер, пока активные процессы по ним не завершатся.
  • Временные топики: Новый топик создается с нужным числом партиций, старый топик дочитывается до конца (drain), и лишь затем продюсеры переключаются на новый.

Упорядоченная обработка в Consumer Group

Consumer Group назначает каждую партицию строго одному консьюмеру-члену. Следовательно, порядок обработки сообщений из одной партиции автоматически сохраняется: их читает один поток выполнения, последовательно.

Однако здесь кроется главная дилемма разработчика на Go:

  • Если обрабатывать сообщения из партиции строго последовательно в одной горутине — скорость обработки ограничена скоростью выполнения бизнес-логики (например, если запись в БД занимает 10 мс, один поток выдаст максимум 100 сообщений в секунду на партицию).
  • Если наивно запускать горутину на каждое сообщение: go process(msg), то горутины будут выполняться планировщиком Go в случайном порядке, и порядок обработки событий внутри заказа немедленно разрушится!

Паттерн: Шардированный Worker Pool внутри Go-консьюмера

Чтобы получить высокий параллелизм и при этом на 100% сохранить порядок обработки событий по ключам, внутри консьюмера реализуют детерминированный пул воркеров по хэшу ключа:

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
    subgraph Fetcher["Горутина опроса (Poll loop)"]
        F["Получен батч из партиции 0<br>[Msg1: order-42, Msg2: order-99, Msg3: order-42]"]
    end

    subgraph Router["Внутренний роутер: Hash(Key) % NumWorkers"]
        R{"Роутер по ключу"}:::process
    end

    subgraph Workers["Фиксированный пул воркеров"]
        direction TB
        W0["Worker 0 (Канал 0)"]
        W1["Worker 1 (Канал 1)<br>Получает: Msg1 (order-42) → Msg3 (order-42)"]
        W2["Worker 2 (Канал 2)<br>Получает: Msg2 (order-99)"]
    end

    F --> R
    R -->|"order-99"| W2
    R -->|"order-42"| W1
  

Пример реализации на Go:

Go
package main

import (
    "context"
    "fmt"
    "hash/fnv"
    "sync"

    "github.com/twmb/franz-go/pkg/kgo"
)

type KeyedWorkerPool struct {
    workers int
    queues  []chan *kgo.Record
    wg      sync.WaitGroup
}

func NewKeyedWorkerPool(numWorkers int, bufferSize int) *KeyedWorkerPool {
    p := &KeyedWorkerPool{
        workers: numWorkers,
        queues:  make([]chan *kgo.Record, numWorkers),
    }
    for i := 0; i < numWorkers; i++ {
        p.queues[i] = make(chan *kgo.Record, bufferSize)
        p.wg.Add(1)
        go p.workerLoop(i, p.queues[i])
    }
    return p
}

func (p *KeyedWorkerPool) workerLoop(id int, queue <-chan *kgo.Record) {
    defer p.wg.Done()
    for record := range queue {
        // Обработка строго последовательна ДЛЯ ДАННОГО КАНАЛА!
        // Все события с одинаковым ключом попадут в один и тот же канал.
        processBusinessEvent(record)
    }
}

func (p *KeyedWorkerPool) Route(record *kgo.Record) {
    h := fnv.New32a()
    h.Write(record.Key)
    workerIdx := int(h.Sum32()) % p.workers
    if workerIdx < 0 {
        workerIdx = -workerIdx
    }
    p.queues[workerIdx] <- record
}

func (p *KeyedWorkerPool) Close() {
    for _, q := range p.queues {
        close(q)
    }
    p.wg.Wait()
}

func processBusinessEvent(r *kgo.Record) {
    fmt.Printf("[Worker Process] Key: %s, Offset: %d, Value: %s\n",
        string(r.Key), r.Offset, string(r.Value))
}

При ребалансировке партиция может переехать к другому консьюмеру, но и там её сообщения будут обрабатываться последовательно. Однако если во время ребалансировки старый консьюмер ещё не завершил обработку, а новый уже начал, возможно временное дублирование сообщений (если оффсет не был закоммичен). Именно для таких случаев важны идемпотентные обработчики (4. Idempotent handlers) и транзакционная доставка (6. Exactly once в Kafka).


Mechanical Sympathy: порядок и дисковый ввод-вывод

Почему партиция как строгий лог настолько эффективна? Современные накопители (SSD/NVMe и даже классические вращающиеся HDD) показывают пиковую производительность именно на последовательном доступе.

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

TEXT
Последовательная запись в конец файла:
[Сегмент 0000000000] -> [Запись 1] -> [Запись 2] -> [Запись 3] ===> Головка/Контроллер не ищет секторы!

Более того, консьюмер, читающий партицию последовательно, движется по файлу сегментам вперёд, позволяя операционной системе предвыбирать следующие блоки данных в кеш (Linux kernel read-ahead). Это даёт устойчивый throughput без провалов, даже при тысячах параллельных потребителей, читающих разные партиции.


Сравнение с RabbitMQ и типичные собеседовательные вопросы

Различие в моделях упорядоченности между традиционными брокерами и Kafka фундаментально:

В RabbitMQ упорядоченность сообщений в очереди гарантируется, пока нет конкурирующих потребителей. Как только появляются competing consumers (несколько воркеров слушают одну очередь), порядок разрушается: каждый получает сообщение и обрабатывает его параллельно; быстрое сообщение обгоняет медленное.

Kafka переворачивает эту модель: порядок привязан к партиции, а конкуренция (Competing Consumers) идёт на уровне партиций, а не отдельных сообщений.

Критерий RabbitMQ (Classic Queue) Apache Kafka
Единица порядка Вся очередь целиком (при 1 консьюмере) Строго внутри отдельной партиции
Поведение при конкурирующих воркерах Порядок мгновенно нарушается ($A$ и $B$ обгоняют друг друга) Порядок по ключу сохраняется на 100%
Масштабирование чтения Добавление воркеров ломает FIFO Добавление консьюмеров не нарушает FIFO по ключам
Причина выбора партиции Роутинг через Exchange/Routing Key Хэширование ключа murmur2(key)


Заключение и следующие шаги

Упорядоченность в Kafka — это управляемый архитектурный компромисс. Вы получаете строгий порядок внутри партиции, но теряете глобальный порядок в топике. Ключ сообщения становится инструментом, который «закрепляет» причинность за сущностью, а количество партиций — рычагом, регулирующим параллелизм. Неизменяемый лог и последовательный ввод-вывод делают эту модель исключительно производительной на современном железе.

Теперь мы готовы рассмотреть самый высокий уровень гарантий доставки — exactly-once семантику, объединяющую идемпотентных продюсеров, транзакции и изолированные операции чтения-записи: 6. Exactly once в Kafka.