kafka — 生产者、消费者与死信队列
pkg/core/kafka 包住 franz-go:NewProducer 默认
acks=all(durable),RunConsumer 是一个 at-least-once
的消费循环。本示例把生产者做成两个 HTTP 端点,把消费者注册成
env.Block,随应用一起启动、随 ctx 取消一起停止。
运行
需要真实的 Kafka,先 make dev(起一个单节点 KRaft broker)。
HTTP 默认监听 :8080,配置文件是目录里的 config.yaml,
-conf 默认指向它。
make dev # 起 Kafka(KRaft 单节点)
go run ./examples/kafka # 等价于 make example NAME=kafka
verticle.Run 按 kafka: 段创建一个生产者并复用
(p, ok := env.Kafka();一定要有就用 env.RequireKafka()),
不需要自己 NewProducer,也不需要自己 Close。
它演示了什么
- 同步生产:
SendSync等 broker 确认,写请求不能丢时用它。 - 异步生产:
Send立即返回,失败只记日志(fire-and-forget)。 - 消费循环挂在生命周期上:
env.Block("consumer", …)让它随应用启动、随 ctx 取消停止,不用自己管 goroutine。 - at-least-once 与它的代价:handler 返回 error 只记日志,本批照样提交, 所以可能重复——幂等要自己做。
- 可开关的消费:配置项
kafka.consume(示例里是kafkademo.consume),false时只产不消,适合网关/写入侧。 - 生产/消费计数:
/api/v1/stats暴露produced与consumed。
消费者注册
来自 examples/kafka/main.go 的 Setup。
注意它复用生产者用过的同一份 broker 配置(env.KafkaCfg),
组名与 topic 来自服务段。
if s.cfg.Consume {
// The same brokers/client id the producer used.
cfg, topic, group := env.KafkaCfg, s.cfg.Topic, s.cfg.Group
env.Block("consumer", func(ctx context.Context) error {
obs.Info(ctx, "consumer started", "topic", topic, "group", group)
return kafka.RunConsumer(ctx, cfg, group, []string{topic}, s.handle)
})
}
同步生产
来自 publishSync。生产者的错误已经被包过一层,直接返回即可;
成功才增加计数。
if err := s.p.SendSync(ctx, s.cfg.Topic, []byte(e.Key), []byte(e.Value)); err != nil {
return nil, err // kafka wrapper already wrapped it
}
s.produced.Add(1)
return map[string]any{"topic": s.cfg.Topic, "key": e.Key, "mode": "sync"}, nil
死信队列
本示例注册的是 kafka.RunConsumer,它遇到 handler 错误是
"记日志后照样提交"。pkg/core/kafka 还提供
RunConsumerDLQ:handler 出错时先把消息以
kafka.DLQRecord(源 topic / partition / offset、key、value、error)
写进 cfg.DLQTopic,写完再提交本批。
flowchart TD
P["PollRecords(maxPoll=100)"] --> H{"handle(msg)"}
H -- "err == nil" --> C["提交本批 offset"]
H -- "err != nil" --> D{"RunConsumerDLQ
DLQTopic 非空?"}
D -- "是" --> S["dlq.SendSync(DLQRecord)
保留原 key 与源坐标"]
S --> C
D -- "否" --> C
C --> P
classDef drop fill:#fdf6f0,stroke:#c8641f,color:#2b2722
classDef ok fill:#eef7f0,stroke:#2f7d4f,color:#22372a
class C,S ok
class D drop
RunConsumerDLQ 且 DLQTopic 非空时才
写 DLQ;若连写 DLQ 也失败,消息记日志后仍提交——把 partition 永久堵在一个坏掉的
DLQ 上,比丢掉一条已经记过日志的记录更糟。
验证
curl -i -X POST localhost:8080/api/v1/events -d '{"key":"u1","value":"hello"}'
curl -i -X POST localhost:8080/api/v1/events/async -d '{"key":"u2","value":"fire-and-forget"}'
curl -i localhost:8080/api/v1/stats
两个生产端点都返回 {"topic":"matex.example.events","key":"u1","mode":"sync"}
(异步端 mode 为 async);空 value 回
400 {"code":40002,"msg":"value is required"}。
/api/v1/stats 返回
{"topic":…,"group":"kafkademo-consumers","produced":N,"consumed":M}——
消费由后台 env.Block 完成,稍等片刻 consumed 就会涨上来。
测试
main_test.go 用 kfake(进程内 broker,kfake.NewCluster)
起一个单 broker 集群并 seed 一个 topic,然后用真实的
kafka.NewProducer + kafka.RunConsumer 跑一遍
生产-消费闭环。它钉住"发出去的消息能被同组消费者收到且 value 一致",
不需要 Docker、不需要网络。kfake 是唯一允许示例测试 import 的
franz-go 子包(见 .golangci.yml 的 examples-no-drivers),
因为它只是测试替身。
go test ./examples/kafka
运行时记得:起始位点是 AtStart,新组会把 topic 里的历史消息全读一遍;
换组名也会从头消费。
相关
- Kafka 消息:生产/消费与交付语义。
- 可观测性:消费日志里的 trace id。
- examples/observability:结构化日志与指标。
- Skill:
skills/matex-setup-kafka/SKILL.md。 - 示例索引。