Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 5 из 25 ~20 мин +20 XP

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

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

Содержание урока · 10 разделов

Инцидент, с которого начинается половина споров про 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, и порядок по аккаунтам исчезнет.

Порядок сообщений 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.

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

Число партиций — решение, которое принимается до первого сообщения и потом стоит денег. Сверху партиции ограничивают параллелизм чтения: больше потребителей в группе, чем партиций, — лишние простаивают, и масштабирование упирается в потолок. Снизу — тоже не бесплатные: каждая партиция — открытые файлы, индексы и участие в репликации, а сотни партиций на топик усложняют ребалансировку и выборы лидеров. Практический ориентир для сервисного топика — единицы-десятки партиций: три-шесть для событий с умеренным потоком, десяток-другой для горячих каналов; топики под сотни тысяч событий в секунду проектируют от измеренной скорости одного консьюмера. План на рост честнее всего такой: заложить запас на год, считать ключи и порядок контрактом, а увеличение партиций проводить как миграцию — с остановкой записи или перечитыванием, потому что индексы сдвигаются. Управление топиками из кода — 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))

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

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

writer := kafka.NewWriter(kafka.WriterConfig{ Brokers: []string{"localhost:9092"}, Topic: "payments", : &kafka.{}, }) msg := kafka.Message{ Key: []("acc-42"), Value: []byte("charge-100"), }

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

Ключ сообщения

нажмите, чтобы перевернуть
Определение

Срез байтов в kafka.Message: хэш от него выбирает партицию. Одинаковый ключ — одна партиция — порядок по сущности.

1 / 6

Цепочка собрана: ключ — хэш — партиция — порядок. Продюсер теперь отправляет события так, чтобы консьюмер видел заказы и платежи каждой сущности в правильной последовательности. Дальше по курсу: в уроке про чтение напишем первого консьюмера на 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 · Ключи и порядок сообщений)

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

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

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

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

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

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

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Ebitengine25 уроков~12 чпрактика25 уроков уже доступны

Ebitengine: 2D-игры на Go

Курс Ebitengine для Go: игровой цикл Update/Draw, спрайты и анимации, ввод, коллизии, тайлмапы, камера, звук, сцены и сборка на платформы. 25 уроков.

  • Понимать игровой цикл Update/Draw и Layout
  • Рисовать спрайты, атласы и примитивы
  • Обрабатывать ввод: клавиатура, мышь, тач
  • Строить движение, коллизии и анимации