Неделя 5: Kafka, сага, Idempotency-Key, AnalyticsService на ClickHouse
С первых четырёх недель сервисы общаются синхронно через gRPC. Для CRUD этого хватает: вызвал, дождался ответа, пошёл дальше. Но долгий процесс (сборка корабля), рассылка уведомлений нескольким получателям и аналитика на потоке событий просят асинхронную модель: отправил событие и не ждёшь. На этой неделе семь задач сразу: добавляем Kafka, AssemblyService, доводим оплату до production-надёжности через Idempotency-Key, замыкаем сагу через OrderCancelled, прокидываем correlation_id через всю цепочку и поднимаем седьмой сервис, AnalyticsService на ClickHouse.
Цели недели
- Поднять Kafka в KRaft-режиме (без ZooKeeper) одним брокером.
- Создать AssemblyService с теми же слоями что у предыдущих.
- Описать контракты событий OrderPaid, OrderCancelled, ShipAssembled с обязательным
correlation_id. - Реализовать Producer/Consumer в OrderService и AssemblyService через transactional outbox.
- Обернуть Kafka в platform-библиотеку с middleware для logger, метрик и
correlation_id. - Перевести PaymentService на Postgres, добавить Idempotency-Key.
- Замкнуть сагу через
OrderCancelled(компенсация при отказе оплаты). - Создать AnalyticsService: Kafka in -> ClickHouse, gRPC read API.
Что меняется в системе
Счастливый путь:
OrderService.Pay --(OrderPaid)--> Kafka --+--> AssemblyService.OnOrderPaid
| |
| v
| sleep 10s, "сборка"
| |
| v
| --(ShipAssembled)--> Kafka --+
| |
+--> AnalyticsService +--> OrderService.OnShipAssembled
+--> NotificationService | status -> COMPLETED
+--> AnalyticsService
+--> NotificationService
Компенсация при отказе оплаты:
OrderService.Pay --(payment failed)-->
OrderService publishes OrderCancelled --> Kafka --+--> InventoryService освобождает резерв
+--> NotificationService шлёт "Заказ отменён"
+--> AnalyticsService пишет факт отмены
Это Saga по choreography, где каждый сервис реагирует на событие и публикует следующее, без центрального оркестратора. AnalyticsService подписан на все события и не публикует ничего сам, это терминальный read-model.
Kafka в KRaft-режиме
KRaft, это режим Kafka без ZooKeeper. К версии 3.5 он стал production-ready, а в 4.0 остался единственным: ZooKeeper выпилили совсем. Один процесс совмещает роли controller и broker.
docker-compose
# в deploy/compose/core/docker-compose.yml
services:
kafka:
image: apache/kafka:3.7.0
container_name: kafka
ports:
- "${KAFKA_PORT:-9092}:9092"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://kafka:29092,CONTROLLER://kafka:29093,EXTERNAL://0.0.0.0:9092
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,EXTERNAL://localhost:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:29093
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_DEFAULT_REPLICATION_FACTOR: 1
KAFKA_MIN_INSYNC_REPLICAS: 1
CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
networks:
- microservices_net
kafka-ui:
image: provectuslabs/kafka-ui:latest
container_name: kafka-ui
ports:
- "${KAFKA_UI_PORT:-8090}:8080"
environment:
KAFKA_CLUSTERS_0_NAME: local
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
networks:
- microservices_net
depends_on:
- kafka
Для dev хватает одного брокера. В продакшене минимум три, плюс replication.factor=3, min.insync.replicas=2.
Создание топиков
docker exec kafka kafka-topics.sh \
--bootstrap-server localhost:9092 \
--create --topic order-paid \
--partitions 3 \
--replication-factor 1
Партиций больше единицы даже на dev, чтобы сразу привыкнуть: Kafka держит порядок только внутри одной партиции, не глобально.
Контракты событий
Хранить контракты надо так же серьёзно, как HTTP/gRPC API. Два варианта:
- JSON Schema, проще, читаемо, удобно для дебага.
- Protobuf, типизированно, компактно, schema registry для строгой эволюции.
В этом курсе берём JSON: его видно глазами и удобно смотреть в kafka-ui. В реальном продакшене берут Protobuf через Confluent Schema Registry или Apicurio.
contracts/order_service_contracts.md
event: OrderPaid
topic: order-paid
key: order_uuid # для shard-by-order гарантии порядка
schema:
type: object
required: [event_id, occurred_at, order_uuid, user_uuid, amount_cents, payment_method, correlation_id]
properties:
event_id: { type: string, format: uuid }
occurred_at: { type: string, format: date-time }
order_uuid: { type: string, format: uuid }
user_uuid: { type: string, format: uuid }
amount_cents: { type: integer, minimum: 0 }
payment_method: { type: string, enum: [CARD, SBP, CRYPTO] }
correlation_id: { type: string, format: uuid, description: "UUID цепочки запросов от edge до consumer-а" }
event: OrderCancelled
topic: order-cancelled
key: order_uuid
schema:
type: object
required: [event_id, occurred_at, order_uuid, user_uuid, reason, correlation_id]
properties:
event_id: { type: string, format: uuid }
occurred_at: { type: string, format: date-time }
order_uuid: { type: string, format: uuid }
user_uuid: { type: string, format: uuid }
reason: { type: string, enum: [PAYMENT_FAILED, INVENTORY_UNAVAILABLE, USER_REQUESTED] }
correlation_id: { type: string, format: uuid }
Поля которые обязательно есть во всех событиях:
event_id, для идемпотентности на consumer-стороне,occurred_at, бизнес-время события,correlation_id, единый ID цепочки запросов между сервисами,event_type, имя в формате<aggregate>.<verb>(опционально, если уже есть в topic).
correlation_id, как пробрасывается
Поток выглядит так:
- Envoy на входе ставит заголовок
X-Correlation-Id(если уже есть, переиспользует). - OrderService читает заголовок, кладёт в OTel
baggageи в request-context. - При записи в
outboxмы кладёмcorrelation_idпрямо вpayload. - Outbox-воркер шлёт сообщение, не теряя поле.
- Consumer-ы (Assembly, Notification, Analytics) берут
correlation_idиз payload в свой ctx, и если публикуют дальше, переносят в исходящие события. - В W8 OpenTelemetry:
correlation_idшлёт вtrace.SetAttributes("correlation_id", id), что позволяет искать trace и логи по одному ID.
В platform/kafka это делается одним middleware: producer берёт correlation_id из ctx и кладёт в payload, consumer обратное.
Producer и Consumer на segmentio/kafka-go
Из двух популярных Go-клиентов (segmentio/kafka-go и IBM/sarama) у kafka-go меньше кода на простой Producer и Consumer, поэтому в курсе берём его. У Sarama больше настроек и режимов, но и порог входа выше:
go get github.com/segmentio/kafka-go
platform/kafka/producer
type Producer struct {
writer *kafka.Writer
log logger.Logger
}
func NewProducer(brokers []string, topic string, log logger.Logger) *Producer {
w := &kafka.Writer{
Addr: kafka.TCP(brokers...),
Topic: topic,
Balancer: &kafka.Hash{}, // shard-by-key
RequiredAcks: kafka.RequireAll,
AllowAutoTopicCreation: false,
Async: false,
}
return &Producer{writer: w, log: log}
}
func (p *Producer) Send(ctx context.Context, key string, value any) error {
payload, err := json.Marshal(value)
if err != nil { return err }
return p.writer.WriteMessages(ctx, kafka.Message{
Key: []byte(key),
Value: payload,
Time: time.Now(),
})
}
RequiredAcks=RequireAll плюс min.insync.replicas на брокере, базовая защита от потери сообщений.
platform/kafka/consumer
type Consumer struct {
reader *kafka.Reader
log logger.Logger
}
type Handler func(ctx context.Context, msg kafka.Message) error
func NewConsumer(brokers []string, groupID, topic string, log logger.Logger) *Consumer {
r := kafka.NewReader(kafka.ReaderConfig{
Brokers: brokers,
GroupID: groupID,
Topic: topic,
MinBytes: 1,
MaxBytes: 10e6,
StartOffset: kafka.LastOffset,
CommitInterval: 0, // ручной commit после успешной обработки
})
return &Consumer{reader: r, log: log}
}
func (c *Consumer) Run(ctx context.Context, h Handler) error {
for {
msg, err := c.reader.FetchMessage(ctx)
if err != nil {
if errors.Is(err, context.Canceled) { return nil }
return err
}
if err := h(ctx, msg); err != nil {
c.log.Error("kafka handler", logger.Err(err))
continue // в дев-варианте, в проде, retry или DLQ
}
if err := c.reader.CommitMessages(ctx, msg); err != nil {
c.log.Error("kafka commit", logger.Err(err))
}
}
}
CommitInterval=0 плюс ручной CommitMessages дают at-least-once гарантию: после успешной обработки коммитим offset.
Идемпотентность на consumer-стороне
Поскольку у нас at-least-once, одно сообщение может прийти дважды. Consumer должен быть идемпотентным:
- по
event_idхранить таблицуprocessed_events (event_id PK, processed_at), - перед обработкой проверять не было ли,
- бизнес-операции писать в одной транзакции с записью event_id.
Это must-have для production-Kafka, особенно при оплатах и денежных операциях.
Размер сообщения
Kafka спроектирована под поток множества мелких записей, а не под крупные блобы. Поэтому «из коробки» один продюсер не отправит сообщение больше примерно 1 МБ, пока ты явно не поднимешь согласованно несколько лимитов:
message.max.bytesна брокере иmax.message.bytesна топике (исторически около 1 МБ, в свежих версиях1048588байт),max.request.sizeна продюсере (дефолт1048576, ровно 1 МБ),replica.fetch.max.bytesна брокере иfetch.max.bytesна консьюмере, иначе крупное сообщение застрянет на репликации или чтении.
На практике сообщения держат меньше 1 МБ, и не из-за самого лимита, а ради производительности:
- Память. Брокер и консьюмер буферизуют сообщение целиком, крупные записи раздувают heap и провоцируют паузы GC.
- Батчинг. Пропускную способность даёт сборка мелких записей в батч и сжатие пачкой, одно жирное сообщение ломает эту экономику.
- Репликация и хвост задержек. Большой батч дольше летит по сети и дольше реплицируется, растёт latency для всех в партиции.
Если полезная нагрузка большая (картинки, отчёты, дампы), берут паттерн claim-check: кладём данные в объектное хранилище (S3 и подобное), а в Kafka шлём только ссылку и метаданные.
Не путай это с MaxBytes: 10e6 в нашем консьюмере выше: там 10 МБ, это размер буфера на один fetch-запрос (сколько байт сообщений притянуть за раз), а не лимит на одно сообщение.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.