Kafka 消息
pkg/core/kafka 把 franz-go 关在里面:一个默认 acks=all 的
生产者、一个 at-least-once 的消费循环,以及可选死信队列。业务代码不 import franz-go。
配了才建
kafka:
brokers: ${KAFKA_BROKERS:localhost:9092} # 逗号分隔串或 YAML 列表
client_id: matex
dlq_topic: my.events.dlq # 留空则不启用 DLQ
brokers 留空即禁用:env.Kafka() 返回 ok=false,
env.RequireKafka() 则让启动失败。verticle.Run 按这段配置建
一个生产者给所有业务复用,取到后用 env.KafkaCfg(同一份 brokers /
client id)去建消费者。
生产者:同步还是异步
p, err := kafka.NewProducer(kafka.Config{Brokers: []string{"localhost:9092"}, ClientID: "svc"})
err = p.SendSync(ctx, topic, key, value) // 等 broker 确认;不能丢就用它
p.Send(ctx, topic, key, value) // fire-and-forget;失败只记日志
err = p.SendJSON(ctx, topic, key, obj) // 同步 + JSON 序列化
| 方法 | 语义 | 失败怎么表现 | 什么时候用 |
|---|---|---|---|
SendSync |
阻塞到 broker 确认 | 返回包装过的 error,调用者决定怎么办 | 关键事件、事件即业务事实 |
Send |
立刻返回 | 只记一条 obs.Error,绝不冒泡给调用者 |
可丢的通知、日志、埋点 |
SendJSON |
序列化后走 SendSync |
序列化失败或发送失败都返回 error | 结构化事件 |
生产者默认 RequiredAcks(kgo.AllISRAcks()),不额外配置就是持久语义。
Close 会 flush 缓冲记录,由 verticle 在关停时调用。
消费循环
消费者注册在 Setup 里,用 env.Block 让它随应用启动、随 ctx 取消停止:
func (s *kafkaService) Setup(ctx context.Context, env *verticle.Env) error {
if err := env.DecodeService(&s.cfg); err != nil {
return err
}
p, ok := env.Kafka()
if !ok {
return errors.New("kafkademo: the `kafka:` section is required")
}
s.p = p
if s.cfg.Consume {
cfg, topic, group := env.KafkaCfg, s.cfg.Topic, s.cfg.Group
env.Block("consumer", func(ctx context.Context) error {
return kafka.RunConsumer(ctx, cfg, group, []string{topic}, s.handle)
})
}
return nil
}
handler 签名是 func(ctx context.Context, m *kafka.Message) error,
Message 已经归一化成 topic / partition / offset / key / value / time。
循环的语义有四点:
- at-least-once:关闭自动提交,每批 poll(最多 100 条)处理完后同步提交。
- handler 返回 error 只记日志,本批照样提交:一条毒消息不能把整个 partition 卡死。
- 起始位点
AtStart:换组名会从头消费整个 topic。 - 关停时仍提交:用 10 秒超时的 detached context,否则最后一批会被重投给下一个成员。
recover 的包装里运行,panic 会连栈一起转成普通 error。
一条消息里的 bug 不会带走整个消费者——也就不会连带带走同组的每个成员。
at-least-once 意味着可能重复。幂等要自己做:用 key 去重,或写库时靠唯一约束 (见 数据库 的约束冲突映射)。重试与退避属于 handler 内部的事。
死信队列
需要给毒消息留证据、能重放时,把 RunConsumer 换成 RunConsumerDLQ:
env.Block("consumer", func(ctx context.Context) error {
dlq, _ := env.RequireKafka() // 业务不必再建一个生产者
return kafka.RunConsumerDLQ(ctx, env.KafkaCfg, group, []string{topic}, s.handle, dlq)
})
handler 失败时,消息以 kafka.DLQRecord 的 JSON 形态写入 dlq_topic:
type DLQRecord struct {
SourceTopic string `json:"source_topic"`
SourcePartition int32 `json:"source_partition"`
SourceOffset int64 `json:"source_offset"`
SourceTime time.Time `json:"source_time"`
Key []byte `json:"key,omitempty"`
Value []byte `json:"value"`
Error string `json:"error"`
}
原 key 保留,所以按 key 分区的 DLQ 消费者看到的顺序与源 topic 一致;
value 是未经改动的原始字节,加上源坐标就能重放这一条。
flowchart TD
POLL["PollRecords
一批最多 100 条"] --> H{"handle 单条
(panic 已 recover)"}
H -->|"nil"| OK["继续下一条"]
H -->|"error"| LG["obs.Error: handle failed
带 topic/partition/offset"]
LG --> Q{"dlq_topic 非空
且 dlq 非 nil"}
Q -->|否| CMT["降级:与 RunConsumer 相同
log-and-continue"]
Q -->|是| PUB["SendSync 到 DLQ
DLQRecord JSON"]
PUB -->|成功| WARN["obs.Warn: moved to the dead letter queue"]
PUB -->|失败| DROP["obs.Error: publish failed;
record is dropped"]
OK --> CMT
WARN --> CMT
DROP --> CMT
CMT["CommitRecords
整批同步提交"] --> NEXT["下一轮 poll"]
classDef bad fill:#fbecec,stroke:#b23b3b,color:#2b2722
class LG,DROP bad
三条行为约定,都是刻意的:
- 先发 DLQ 再提交:如果先提交再发 DLQ,进程在两者之间挂掉就会同时丢掉这条消息 和它的证据。
- DLQ 发布失败不能卡住 partition:这时代价是丢掉一条已经记过日志的记录, 收益是 partition 不会永久停摆。error 级日志是最后的证据。
-
dlq_topic为空(或dlq为 nil)即降级:行为与RunConsumer完全一致。两个条件都不满足时 DLQ 路径才生效,所以传入的dlq可以是 nil,不必为此多建一个生产者。
DLQ 自身也是一个 RunConsumer / RunConsumerDLQ:在它的 handler
里告警或重放。
什么时候不用 Kafka
只需要进程内解耦的话,一个带缓冲的 channel 就够了。引入 Kafka 的门槛是:需要 跨实例、可回放、单 partition 内有序的事件流。
相关示例
-
examples/kafka:
/events同步发、/events/async异步发、/stats看生产/消费计数。 - 服务与生命周期:
env.Block与优雅退出。 - 离线测试:用
kfake在进程内起一个 broker。 - 包参考:
pkg/core/kafka的导出面。