5. Ordering и partitioning
Причинность, ключи и параллелизм в распределенном логе
«Время, часы и порядок событий в распределенной системе не абсолютны; они лишь артефакт каналов связи, которые мы сами решили построить».
— Лесли Лэмпорт, лауреат премии Тьюринга
Почему упорядоченность — вызов в распределённых системах
В монолитных системах и простых очередях сообщений (например, одиночной очереди в оперативной памяти процесса) строгий порядок первого пришедшего (FIFO — First-In, First-Out) даётся практически даром: один поток записывает события в циклический буфер, другой поток их вычитывает.
Но как только мы переходим к горизонтально масштабируемым распределенным системам, где терабайты данных ежесекундно обрабатываются сотнями серверов, глобальный порядок становится фундаментальным физическим препятствием:
- Теория относительности и задержки сигналов: В распределенной сети нет единого абсолютного «сейчас». Сетевые пакеты между дата-центрами испытывают джиттер, физические кварцевые часы серверов расходятся (clock drift), а маршрутизаторы меняют пути следования пакетов.
- Цена глобальной упорядоченности: Чтобы обеспечить абсолютно строгий глобальный порядок для всех событий в системе, вам потребуется единая точка сериализации — один процессор или один узел-координатор, через который обязаны пройти абсолютно все запросы. Это немедленно превращает систему в бутылочное горлышко: производительность всей вашей мировой инфраструктуры деградирует до скорости одного потока 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 для этой группы событий. Это фундамент, на котором строятся событийно-ориентированные микросервисы с сохранением причинно-следственных связей.
Партиционирование как рубильник порядка
Связь между ключом сообщения и партицией — вот где рождается или умирает порядок. Продюсер направляет сообщение в конкретную партицию по одному из трёх правил:
- Без ключа (
key == nil) — продюсер использует стратегиюStickyKeyPartitioner(или в старых версиях наивный round-robin). Сообщения пакетируются и распределяются по всем партициям топика. Любые два события, даже относящиеся к одному заказу, почти наверняка разойдутся по разным партициям и потеряют всякий гарантированный порядок. - С ключом (hash-based) — продюсер вычисляет 32-битный хеш ключа алгоритмом
murmur2и берёт остаток от деления на общее количество партиций:partition = hash(key) % num_partitions. Все сообщения с одинаковым ключом всегда направляются в одну и ту же партицию, гарантируя строгий порядок для этой сущности (например,order-12345илиorder-42). - Явное указание партиции — приложение-отправитель само задаёт номер партиции в метаданных записи, полностью контролируя логику распределения (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 (событие
Order Created). - Не дожидаясь ответа, продюсер сразу же отправляет батч 2 (событие
Order Paid). - Батч 1 испытывает временную потерю пакета в маршрутизаторе и завершается ошибкой таймаута на брокере.
- Батч 2 благополучно доходит до брокера и записывается под смещением 100 (
Order Paid). - Продюсер получает ошибку для батча 1, выполняет автоматический retry и повторно отсылает его.
- Батч 1 записывается под смещением 101 (
Order Created). - Итог: В логе заказ сначала оплачен, а затем создан!
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:
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 пишется линейно, без случайных перестановок, что идеально ложится на физическую природу носителя: минимум фрагментации, максимум загрузки каналов, эффективное использование страничного кеша.
Последовательная запись в конец файла:
[Сегмент 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.