---
title: "Ключи и порядок сообщений"
description: "Ключи и партиции в Kafka: как хэш ключа выбирает партицию, почему порядок сообщений гарантирован только внутри партиции, Hash против RoundRobin и цена их смены."
course: "Kafka в Go: продюсеры и консьюмеры на kafka-go"
url: "https://golangmaster.ru/courses/kafka-go/lessons/05-klyuchi-particionirovanie/"
published: "2026-09-19"
updated: "2026-09-29"
minutes: 20
---

# Ключи и порядок сообщений

*Курс «Kafka в Go: продюсеры и консьюмеры на kafka-go», урок 5 из 25. ~20 минут. Обновлено 29.09.2026. Источник: https://golangmaster.ru/courses/kafka-go/lessons/05-klyuchi-particionirovanie/*

Ключ сообщения в Kafka — это не поле для поиска, а маршрутизатор: по его хэшу продюсер выбирает партицию, и именно поэтому порядок сообщений kafka существует внутри партиции и исчезает за её пределами. В этом уроке связываем ключ, хэш и партицию в одну цепочку: kafka.Hash и RoundRobin, сообщения без ключа, горячие партиции и цена изменения числа партиций на живом топике.

Инцидент, с которого начинается половина споров про Kafka: события одного заказа отправлены подряд, а обработались в обратном порядке — оплата раньше создания, отмена раньше оплаты. Код корректный, брокер здоровый, а порядок всё равно сломался, потому что сообщения разошлись по разным партициям, а брокер не даёт порядка между ними. Лечится это на стороне продюсера — ключом сообщения, который собирает события одной сущности в одну партицию. Но ключ — инструмент с правилами: работает хэш, ломается при изменении числа партиций, падает в RoundRobin без ключа и создаёт горячие партиции при кривом выборе. Разберём каждый узел этой цепочки так, чтобы к концу урока выбор ключа стал осознанным решением, а не полем, скопированным из примера. Пара минут теории здесь экономит дни отладки позже: ключ — самый дешёвый инструмент в Kafka и самый дорогой, если выбрать его неправильно.

### Ключ сообщения: зачем он и как связан с партицией

В kafka.Message поле Key — срез байтов рядом с Value, и разница между ними принципиальная: Value — полезная нагрузка, которую читает консьюмер, Key — служебный маршрут, по которому решается партиция. Kafka ключ не хранит для поиска и не индексирует: он нужен в момент записи, чтобы продюсер выбрал партицию, и консьюмеру в конце концов виден как метаданные. Связь «kafka ключ партиция» проста: одинаковый ключ всегда попадает в одну и ту же партицию топика, сколько бы сообщений с ним ни отправили. А раз события лежат в одном журнале подряд, порядок их обработки гарантирован: партиция — append-only лог, читатель идёт по оффсетам и видит записи ровно в том порядке, в каком их дописали. Отсюда правило выбора ключа уровня архитектуры: ключом становится идентификатор сущности, внутри которой важна последовательность — заказ, аккаунт, устройство, сессия. Не пользователь в целом, если порядок нужен по его заказам, и не случайное число — иначе вы получите перемешанный поток, ради которого Kafka и не начинали. Вопрос «а если ключей миллионы» отводится просто: партиций конечное число, хэш оседлит ключи по ним, и ограничений на количество уникальных ключей нет.

### Хэш ключа: как kafka-go выбирает партицию

Балансировщик kafka.Hash в kafka-go делает ровно одну вещь: считает от байтов ключа хэш FNV-1a и берёт остаток от деления на число партиций — полученный индекс и есть адрес записи. Всё детерминированно: тот же ключ, тот же набор партиций — тот же индекс, без обращений к брокеру и без состояния между вызовами. Хэш FNV-1a выбран не случайно: он быстрый, редко даёт коллизии на коротких ключах вроде order-42 и в среднем распределяет произвольные ключи по партициям равномерно. Слово «в среднем» — важная оговорка: хэш равномерен по множеству ключей, а не по вашему трафику. Пять ключей из тысячи могут принадлежать одному мегаклиенту, и его партиция получит непропорциональную нагрузку — классическая горячая партиция, симптомы которой ловят мониторингом записи по партициям. Из механики хэша следуют и два практических правила: число партиций входит в формулу, поэтому его изменение сдвигает все индексы разом — об этом коллаут ниже; а предсказать партицию по ключу «на глаз» невозможно и не нужно — считайте, что распределение честное, пока мониторинг не показал обратное. Проверить перекос на живом топике легко: печать m.Partition по сотне сообщений или счётчики продюсера по партициям покажут его раньше, чем он станет инцидентом.

### Продюсер с Hash-балансировщиком

```go
writer := kafka.NewWriter(kafka.WriterConfig{
        Brokers:  []string{"localhost:9092"},
        Topic:    "payments",
        Balancer: &kafka.Hash{},
})
defer writer.Close()

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

for _, p := range payments {
        err := writer.WriteMessages(ctx, kafka.Message{
                Key:   []byte(p.AccountID),
                Value: p.Payload(),
        })
        if err != nil {
                log.Printf("payment %s: %v", p.ID, err)
        }
}
```

Единственное отличие от продюсера прошлого урока — Balancer: &kafka.Hash{} в конфиге. Дальше всё решают ключи: события аккаунта acc-42 уйдут в одну партицию независимо от того, с какой горутины и в какой момент их отправили. Ключ берётся из домена — идентификатор аккаунта, а Value несёт данные платежа. Если опустить Balancer, Writer возьмёт RoundRobin, и порядок по аккаунтам исчезнет.

> **Увеличили партиции — сдвинули все индексы.** Живое место из практики: топик на трёх партициях вырос, команда увеличила их до шести, и всё «поплыло». Формула хэша — остаток от деления на число партиций, поэтому у половины ключей изменился остаток: события аккаунта, что жили в партиции 1, поехали в партицию 4. Пока старые сообщения доехали до консьюмеров, биллинг видел списания раньше пополнений — порядок сломался на стыке миграции. Вывод: число партиций — часть контракта порядка, меняется с планом миграции и перечитыванием, а не одной командой. Мораль шире конкретного случая: инфраструктурные изменения в Kafka — не «настройка», а операция, влияющая на контракты данных; планируйте их как релиз.

### Порядок сообщений kafka: гарантия только внутри партиции

Соберём гарантию точно, без маркетинга. Продюсер дописывает сообщения в конец партиции, брокер выдаёт им монотонные оффсеты, консьюмер читает партицию последовательно: сумма этих трёх шагов и называется порядком сообщений kafka — внутри одной партиции он абсолютный. Всё, что вне партиции, порядок не образует: две партиции читаются параллельно двумя консьюмерами, и ни брокер, ни клиент не скажут, какое событие «раньше». Поэтому порядок в Kafka всегда проектируют, а не получают: определить сущность, внутри которой нужна последовательность; выбрать её идентификатор ключом; проверить, что все продюсеры этих событий используют Hash-балансировщик с тем же ключом. И принять цену: единица порядка становится единицей параллелизма — все события аккаунта упираются в одну партицию и обрабатываются одним консьюмером группы, сколько бы инстансов ни было развёрнуто. Если сущность с постоянным потоком событий одна и та же — узкое место уже не в партициях, а в модели: возможно, события стоит дробить по под-сущностям или отдавать порядок на согласование самой базе. Это не поражение, а нормальная эволюция: многие команды начинают с одного ключа и позже выделяют внутри сущности подпотоки — например, по типу события, — когда параллелизм становится важнее общего порядка.

### Что делает ключ одним списком

- Маршрутизирует: хэш ключа выбирает партицию, и выбор детерминирован.
- Собирает порядок: события одной сущности лежат в одной партиции подряд.
- Экономит консьюмерам работу: состояние сущности обрабатывается одним читателем без блокировок.
- Ставит потолок параллелизма: сущность с ключом — один читатель в группе.
- Создаёт риск горячей партиции: ключи с перекосом нагрузки перекашивают партиции.
- Умирает при смене числа партиций: индексы пересчитываются, порядок на стыке не гарантирован.

### RoundRobin против Hash: сообщения без ключа

RoundRobin — балансировщик по умолчанию и второй полюс выбора: он крутит сообщения по партициям по кругу, не глядя на содержимое. Нагрузка получается максимально ровной, но одинаковые ключи разъезжаются по разным партициям — и никакого порядка по сущностям больше нет. У kafka.Hash есть важный запасной ход: если у сообщения нет ключа, балансировщик отдаёт его в RoundRobin-ветку. То есть с Balancer: &kafka.Hash{} вы получаете порядок для сообщений с ключами и ровность для сообщений без них — удобная смесь для топиков, где события с телеметрией соседствуют с событиями домена. Что класть без ключа? Всё, где порядок не имеет смысла: метрики, пинги, разовые нотификации. А вот события, которые консьюмер сравнивает или складывает в состояние, обязаны идти с ключом — иначе два экземпляра одной сущности обработаются параллельно, и гонка вернётся уже на стороне читателя. Выбор между RoundRobin и Hash — не вкусовщина: это выбор между ровной нагрузкой и порядком, и смешивать их в одном ключе-поле хуже, чем держать по балансировщику на топик. Отдельный бонус RoundRobin: он не требует держать в голове домен — быстро, ровно и предсказуемо, поэтому для служебных потоков остаётся разумным умолчанием.

### Меню: два балансировщика рядом

```go
// ровный круг: без порядка по сущностям
roundWriter := kafka.NewWriter(kafka.WriterConfig{
        Brokers:  []string{"localhost:9092"},
        Topic:    "metrics",
        Balancer: &kafka.RoundRobin{},
})

// порядок по ключу, ровность для пустых
eventWriter := kafka.NewWriter(kafka.WriterConfig{
        Brokers:  []string{"localhost:9092"},
        Topic:    "payments",
        Balancer: &kafka.Hash{},
})

// без ключа: под Hash уйдёт по кругу
eventWriter.WriteMessages(ctx, kafka.Message{
        Value: []byte("health-check"),
})
```

Два Writer'а — два контракта. Метрики едут под RoundRobin: равномерный круг, порядок не нужен. Платежи — под Hash: ключ аккаунта собирает их в одну партицию, а сообщение без ключа, как health-check, в Hash-ветке падает в тот же RoundRobin-круг. Третьего пути в конфиге нет: балансировщик задаётся на весь Writer.

> **Ключ — домен, а не случайное число.** Самый дешёвый способ испортить и порядок, и нагрузку — выбрать ключ неправильно: случайный UUID на каждое событие превращает Hash в RoundRobin с лишним хэшем, а общий ключ вроде tenant-id для всего потока складывает всех в одну партицию. Хороший ключ — узкое место домена: order-ID для событий заказа, account-ID для платежа. А «подсолить» ключ для выравнивания нагрузки можно, но тогда солите с мыслью: каждая соль — отдельный порядок, и консьюмеру придётся собирать сущность обратно. Ревью ключа — дешёвая страховка: одна строка в диффе экономит миграцию на живом топике.

### Сколько партиций планировать заранее

Число партиций — решение, которое принимается до первого сообщения и потом стоит денег. Сверху партиции ограничивают параллелизм чтения: больше потребителей в группе, чем партиций, — лишние простаивают, и масштабирование упирается в потолок. Снизу — тоже не бесплатные: каждая партиция — открытые файлы, индексы и участие в репликации, а сотни партиций на топик усложняют ребалансировку и выборы лидеров. Практический ориентир для сервисного топика — единицы-десятки партиций: три-шесть для событий с умеренным потоком, десяток-другой для горячих каналов; топики под сотни тысяч событий в секунду проектируют от измеренной скорости одного консьюмера. План на рост честнее всего такой: заложить запас на год, считать ключи и порядок контрактом, а увеличение партиций проводить как миграцию — с остановкой записи или перечитыванием, потому что индексы сдвигаются. Управление топиками из кода — CreateTopics и Admin API — вернётся в шестнадцатом уроке, там же про то, как менять конфигурацию партиций осознанно. И запишите решение о числе партиций в ADR: через год никто не вспомнит, почему их было шесть, а спорить с числом без контекста — любимое развлечение архитектурных ревью.

### Балансировщики kafka-go одним столом

| Balancer | Как распределяет | Когда брать |
| --- | --- | --- |
| kafka.RoundRobin{} | По кругу, без учёта содержимого | Метрики, пинги, поток без требований к порядку |
| kafka.Hash{} | FNV-1a от ключа, остаток от деления на партиции; без ключа — RoundRobin | События домена, где нужен порядок по сущности |
| kafka.LeastBytes{} | В партицию с наименьшим накопленным объёмом | Крупные сообщения, когда важнее ровная нагрузка, чем порядок |

### Проверка: судьба одинаковых ключей

**Вопрос:** Writer с Balancer: &kafka.Hash{}, в топике три партиции. Два сообщения с ключом acc-42 уйдут…

- ✅ В одну и ту же партицию — хэш ключа детерминирован
- В разные партиции — балансировщик выравнивает нагрузку
- В одну партицию только при подряд идущей отправке
- В нулевую партицию — ключи без цифрового суффикса туда и идут

Hash считает FNV-1a от байтов ключа и берёт остаток от деления на число партиций: одинаковый ключ даёт одинаковый индекс всегда, независимо от момента и порядка отправки. Так и строится порядок по сущности — без этого свойства ключ не имел бы смысла.

### Проверка: сообщение без ключа под Hash

**Вопрос:** У Writer задан Balancer: &kafka.Hash{}, а сообщение отправлено без Key. Куда оно уйдёт?

- В партицию ноль по умолчанию
- Writer вернёт ошибку: ключ обязателен
- ✅ По кругу — Hash отдаёт безключевые сообщения в RoundRobin-ветку
- В отдельный служебный топик

У kafka.Hash есть запасной путь: без ключа сообщение распределяется по кругу, как под RoundRobin. Поэтому Hash безопасен для смешанных потоков — события с ключами держат порядок, телеметрия без ключей не перекашивает партиции.

### Что увидит консьюмер?

```go
writer := kafka.NewWriter(kafka.WriterConfig{
        Brokers:  []string{"localhost:9092"},
        Topic:    "payments",
        Balancer: &kafka.Hash{},
})

err := writer.WriteMessages(ctx,
        kafka.Message{Key: []byte("acc-1"), Value: []byte("charge-100")},
        kafka.Message{Key: []byte("acc-1"), Value: []byte("charge-200")},
        kafka.Message{Key: []byte("acc-2"), Value: []byte("charge-300")},
)

// консьюмер той же группы печатает:
// fmt.Println(m.Partition, string(m.Value))
```

**Что выведет программа?**

- ✅ charge-100 и charge-200 из одной партиции, в порядке отправки; charge-300 — возможно, из другой
- Все три сообщения из одной партиции по порядку
- charge-100 и charge-200 в разных партициях, порядок не гарантирован
- Ошибка: два сообщения с одинаковым ключом в одной пачке

Ключ acc-1 отправляет оба первых сообщения в одну партицию, и внутри неё они лежат в порядке отправки — charge-100 раньше charge-200. Ключ acc-2 считает свой хэш и попадает куда придётся: возможно, в ту же партицию, возможно, в соседнюю — брокер не обещает ничего. Дубликаты ключа ошибкой не являются.

### Соберите партиционирование по ключу

Допишите балансировщик и тип ключа.

```go
writer := kafka.NewWriter(kafka.WriterConfig{
        Brokers:  []string{"localhost:9092"},
        Topic:    "payments",
        ___:      &kafka.___{},
})

msg := kafka.Message{
        Key:   []___("acc-42"),
        Value: []byte("charge-100"),
}
```

Подсказка: Поле конфига называется Balancer, балансировщик порядка — kafka.Hash, а ключ — срез байтов.

Правильные ответы в пропусках: `Balancer`, `Hash`, `byte`

### Соберите продюсера с порядком по аккаунту

Расставьте строки так, чтобы события аккаунта уезжали в одну партицию: конфиг с Hash, закрытие, контекст, цикл, сообщение с ключом, отправка, лог ошибки.

Правильный порядок строк:

1. `writer := kafka.NewWriter(kafka.WriterConfig{`
2. `        Brokers:  []string{"localhost:9092"},`
3. `        Topic:    "payments",`
4. `        Balancer: &kafka.Hash{},`
5. `})`
6. `defer writer.Close()`
7. `err := writer.WriteMessages(ctx, kafka.Message{Key: []byte("acc-42"), Value: []byte("charge-100")})`
8. `if err != nil { log.Printf("acc-42: %v", err) }`

### Карточки: ключи и порядок

- **Ключ сообщения** — Срез байтов в kafka.Message: хэш от него выбирает партицию. Одинаковый ключ — одна партиция — порядок по сущности.
- **kafka.Hash{}** — Балансировщик с хэшем FNV-1a от ключа и остатком от деления на число партиций. Без ключа уходит в RoundRobin-ветку.
- **kafka.RoundRobin{}** — Балансировщик по умолчанию: раскидывает сообщения по партициям по кругу. Нагрузка ровная, порядка по сущностям нет.
- **Порядок в Kafka** — Гарантируется только внутри партиции: append-only лог плюс последовательное чтение по оффсетам. Между партициями порядка нет.
- **Горячая партиция** — Партиция с непропорциональной нагрузкой из-за перекоса ключей. Симптом — одна партиция растёт, остальные простаивают.
- **Смена числа партиций** — Меняет остаток в формуле хэша: ключи переезжают, порядок на стыке не гарантирован. Проводится как миграция, а не одной командой.

Цепочка собрана: ключ — хэш — партиция — порядок. Продюсер теперь отправляет события так, чтобы консьюмер видел заказы и платежи каждой сущности в правильной последовательности. Дальше по курсу: в [уроке про чтение](/courses/kafka-go/lessons/06-pervyj-consumer/) напишем первого консьюмера на Reader и проверим порядок своими глазами; в [уроке про группы](/courses/kafka-go/lessons/07-gruppy-consjumerov/) посмотрим, как партиции распределяются между членами группы; а в [уроке про Admin API](/courses/kafka-go/lessons/16-admin-topiki/) вернёмся к числу партиций уже с инструментами. Ещё раз про формат: песочница сайта сеть не поднимает, поэтому продюсер запускается против локального брокера из третьего урока, а интерактивы ниже тренируют механику хэша и выбора ключа на программах в чистом Go. Домашнее задание рассчитано на брокера из третьего урока: десять минут на него — и распределение ключей станет для вас вещью, а не абстракцией.

## Коротко: что запомнить

- Key — маршрут, Value — нагрузка: одинаковый ключ всегда попадает в одну партицию топика.
- kafka.Hash{} считает FNV-1a от ключа и берёт остаток от деления на число партиций.
- Порядок сообщений гарантирован только внутри партиции: между партициями его нет и не будет.
- Без ключа Hash отдаёт сообщение в RoundRobin-ветку: смешанные потоки живут в одном топике без перекосов.
- Ключ выбирается из домена: идентификатор сущности, внутри которой важна последовательность.
- Число партиций входит в формулу хэша: увеличение партиций — миграция, а не настройка на ходу.
- Код урока запускается локально против брокера в Docker; песочница показывает тематические программы на чистом Go.

## Домашнее задание

Возьмите топик payments с тремя партициями и продюсера из урока. Отправьте по пять платежей для аккаунтов acc-1 и acc-2 с Balancer: &kafka.Hash{}, затем прочитайте топик консьюмером и напечатайте m.Partition рядом с m.Value: оба аккаунта должны оказаться в фиксированных партициях, а события каждого аккаунта — в порядке отправки. Затем создайте рядом второй топик payments-6 на шесть партиций и отправьте туда те же ключи — убедитесь, что партиции для них изменились, и запишите, какой мониторинг вы бы поставили, чтобы поймать такое на живой системе.

## Вопросы и ответы по уроку

### Что делать, если порядок нужен между разными сущностями?

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

### Консьюмер упал посреди обработки — порядок сохранится?

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

### Можно ли послать сообщение в конкретную партицию напрямую?

Writer всегда раздаёт сообщения балансировщиком, а поле Partition в kafka.Message — это адрес из ответа брокера, а не входной параметр. Если безусловная привязка нужна, пишут свой Balancer с одним методом Balance — например, для отдельных ключей возвращают фиксированный индекс, — или спускаются на уровень ниже, к соединению DialLeader с нужной партицией. Это точечный инструмент исправлений и миграций, а не штатный путь: вы берёте на себя и порядок, и ровность нагрузки, а Hash-балансировщик существует именно для того, чтобы не думать об индексах вручную.
