Содержание урока · 10 разделов
- Ключ сообщения: зачем он и как связан с партицией
- Хэш ключа: как kafka-go выбирает партицию
- Порядок сообщений kafka: гарантия только внутри партиции
- Что делает ключ одним списком
- RoundRobin против Hash: сообщения без ключа
- Сколько партиций планировать заранее
- Балансировщики kafka-go одним столом
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Инцидент, с которого начинается половина споров про 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-балансировщиком
GOwriter := 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
У Writer задан Balancer: &kafka.Hash{}, а сообщение отправлено без Key. Куда оно уйдёт?
Что увидит консьюмер?
Прочитайте код и выберите, что он выведет:
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))Соберите партиционирование по ключу
Допишите балансировщик и тип ключа.
Соберите продюсера с порядком по аккаунту
Расставьте строки так, чтобы события аккаунта уезжали в одну партицию: конфиг с Hash, закрытие, контекст, цикл, сообщение с ключом, отправка, лог ошибки.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: ключи и порядок
Цепочка собрана: ключ — хэш — партиция — порядок. Продюсер теперь отправляет события так, чтобы консьюмер видел заказы и платежи каждой сущности в правильной последовательности. Дальше по курсу: в уроке про чтение напишем первого консьюмера на Reader и проверим порядок своими глазами; в уроке про группы посмотрим, как партиции распределяются между членами группы; а в уроке про Admin API вернёмся к числу партиций уже с инструментами. Ещё раз про формат: песочница сайта сеть не поднимает, поэтому продюсер запускается против локального брокера из третьего урока, а интерактивы ниже тренируют механику хэша и выбора ключа на программах в чистом Go. Домашнее задание рассчитано на брокера из третьего урока: десять минут на него — и распределение ключей станет для вас вещью, а не абстракцией.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- 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-балансировщик существует именно для того, чтобы не думать об индексах вручную.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Ebitengine: 2D-игры на Go
Курс Ebitengine для Go: игровой цикл Update/Draw, спрайты и анимации, ввод, коллизии, тайлмапы, камера, звук, сцены и сборка на платформы. 25 уроков.
- Понимать игровой цикл Update/Draw и Layout
- Рисовать спрайты, атласы и примитивы
- Обрабатывать ввод: клавиатура, мышь, тач
- Строить движение, коллизии и анимации