Go 客户端 franz-go
2026 年 Go 生态操作 Kafka 的事实标准:github.com/twmb/franz-go。协议覆盖 Kafka 0.8.0 ~ 4.x 全家桶,幂等生产默认开启,事务、协作式重平衡、KIP-848(服务端重平衡)、Share Groups 全部跟进。本章先讲清选型依据,再按"建客户端 → 生产 → 消费 → 位点 → 管理 → 错误 → 安全"过一遍用法;kafka-go 作为存量项目参考保留在末尾。
先定选型:2026 年的 Go 生态
| 库 | 定位 | 优势 | 短板 |
|---|---|---|---|
| twmb/franz-go | 纯 Go、活跃维护 | 协议覆盖 0.8.0~4.x 最全;幂等默认开、事务/KIP-848/Share Groups 都有实现;kadm 一包管运维 | API 面大而全,学习曲线略陡 |
| segmentio/kafka-go | 纯 Go、低频维护 | 接口最"Go 味",存量代码多 | 无幂等/事务;默认值有坑(04 章);新协议长期缺席 |
| Shopify/sarama | 老牌、用户多 | 生态成熟(大量框架内置集成) | API 偏老(Gather/Shutdown 模型);配置项命名绕 |
| confluent-kafka-go | librdkafka CGO 绑定 | C 库的性能与特性同步 | CGO 依赖,交叉编译/容器镜像体积麻烦 |
主线为什么从 kafka-go 换成 franz-go。kafka-go 没有被归档、也没有官宣停止维护,但实际状态是"低频 bugfix":
- 发版节奏:v0.4.47(2023-12)到 v0.4.48(2025-05)一年半没有版本;之后 v0.4.49~v0.4.51(当前最新,2026-04)也只是零星修补;
- README 自述的测试范围停留在 Kafka 0.10.1.0 ~ 2.7.1,并明说"Kafka API 的新特性可能尚未实现"——幂等生产、事务在 Writer 里连字段都没有(04 章实测),KIP-848、Share Groups 更无从谈起;
- 对照 franz-go:2026 年内提交近千、几乎每天都有新提交,README 声明 “Feature complete client (Kafka >= 0.8.0 through v4.4+)",且每实现一项协议能力都附 KIP 对照表。
结论:新项目直接用 franz-go;存量 kafka-go 代码可以继续跑,但别指望它补新特性。日志采集、简单事件投递这类老场景 kafka-go 仍然够用——本手册 04/05/06 的对照表也继续保留 kafka-go 内容,方便两头查。
go get github.com/twmb/franz-go/pkg/kgo # 核心客户端(生产/消费/事务)
go get github.com/twmb/franz-go/pkg/kadm # 管理与监控:建 Topic、看组、算 Lag一个 module 按用途拆成几个子包,按需 import:
| 包 | 用途 |
|---|---|
pkg/kgo |
客户端本体:Produce/Poll/Commit、事务、Share Groups |
pkg/kadm |
管理面:Topic/组/位点/配置的增删查改,Lag 统计 |
pkg/kerr |
Kafka 协议错误码(*kerr.Error,含 Retriable 标记) |
pkg/kmsg |
原始协议请求/响应结构(kadm 不够用时的逃生舱) |
pkg/sasl/* |
plain / scram / oauthbearer / aws_msk 认证机制 |
创建客户端
cl, err := kgo.NewClient(
kgo.SeedBrokers("localhost:9092", "localhost:9093", "localhost:9094"),
kgo.ClientID("order-svc"), // 对应 Java 的 client.id,会出现在 Broker 日志/配额里
)
if err != nil {
log.Fatal(err)
}
defer cl.Close() // 会 flush 在途批次——生产端退出前必须调
if err := cl.Ping(ctx); err != nil {
log.Fatal("集群不可达: ", err) // 启动自检好去处
}- 客户端按需建连:构造时不连接,
SeedBrokers只是拿元数据的入口,之后自动发现全部 Broker。 - 一个
*kgo.Client生产消费都能干;但消费者组请单独建一个带 group 选项的实例(见下文),职责分清楚。 kgo.AllowAutoTopicCreation()才允许自动建 Topic——默认关,比 kafka-go 时代"静默建出一堆垃圾 Topic"干净得多(13 章陷阱 #23 在 franz 这侧不存在)。
TLS 与认证都是选项:
import "github.com/twmb/franz-go/pkg/sasl/scram"
cl, _ := kgo.NewClient(
kgo.SeedBrokers("kafka.internal:9093"),
kgo.DialTLSConfig(&tls.Config{ServerName: "kafka.internal"}), // 用系统 CA 可简写 kgo.DialTLS()
kgo.SASL(scram.Auth{User: "svc-order", Pass: "s3cret"}.AsSha512Mechanism()),
)plain.Auth{User, Pass}.AsMechanism()、oauth 等同理,换 import 即可。
生产消息
消息抽象叫 Record(对应 kafka-go 的 kafka.Message):
rec := &kgo.Record{
Topic: "order-events",
Key: []byte("SO20261013001"), // 路由键;nil 即"无键"
Value: payload, // 序列化自理,库不提供 Serializer 抽象(同 kafka-go)
Headers: []kgo.RecordHeader{{Key: "x-msg-id", Value: []byte(msgID)}},
}同步发送(等结果,单条或小批量用它):
res := cl.ProduceSync(ctx, rec)
if err := res.FirstErr(); err != nil {
// 分类处理见"错误分类"小节;超时前 franz 内部已按可重试性自动重试
}
pr := res[0] // ProduceResults 与入参逐条对应
log.Printf("写入 topic=%s partition=%d offset=%d",
pr.Record.Topic, pr.Record.Partition, pr.Record.Offset)异步发送(回调,高吞吐主力):
cl.Produce(ctx, rec, func(r *kgo.Record, err error) {
if err != nil {
// 唯一的结果回收口:告警/落兜底表;回调里不要 panic
}
})
// 在途记录入队后立即返回;Close() 会等在途批次与回调全部完成默认值已经是生产级姿势——这是 franz-go 与 kafka-go 最大的哲学差异,也是"新代码别再抄 kafka-go 五件套"的原因:
| 配置 | franz-go 默认 | kafka-go 默认 | Java 客户端默认 |
|---|---|---|---|
| acks | AllISRAcks()(-1,等全 ISR) |
零值 RequireNone(0,发了不管) |
1(3.0+ 建议 all) |
| 幂等生产 | 开启(PID+序号去重,KIP-360) | 不支持 | 3.0+ 开启 |
| 重试 | 请求级重试 + 记录级无限次,RetryTimeout 封顶 |
MaxAttempts: 10 |
retries=MAX + delivery.timeout.ms 2min |
| 攒批 | linger 10ms,按 ProducerBatchMaxBytes 成批 |
BatchSize 100 / BatchTimeout 1s |
batch.size 16KB / linger.ms 0 |
| 压缩 | [Snappy, 不压] 按 Broker 能力协商 |
不压缩 | 不压缩 |
两处要注意的默认:
- 幂等需要权限:Kafka 3.0 之前的集群,幂等生产要求账号有 CLUSTER 级
IDEMPOTENT_WRITEACL。权限收紧的集群上 produce 会报授权错误——要么找运维加 ACL,要么显式kgo.DisableIdempotentWrite()退回老行为(代价:每分区 in-flight 降为 1、重试可能产生重复消息)。该选项与TransactionalID互斥。 - 记录级重试无限:
Produce的回调可能很久不来,因为 franz 默认对可重试错误无限重试(预算由RetryTimeout与RecordRetries控制)。落库兜底场景记得设kgo.RetryTimeout(10*time.Second)之类,别让一条发不出去的消息永远悬着。
常调的生产选项:
cl, _ := kgo.NewClient(
kgo.SeedBrokers("localhost:9092"),
kgo.DefaultProduceTopic("order-events"), // 只给"没写 Topic"的 Record 兜底 Topic
kgo.ProducerBatchCompression(kgo.ZstdCompression(), kgo.SnappyCompression()), // 按偏好传多个,与 Broker 协商
kgo.ProducerLinger(50*time.Millisecond), // 多攒一点换吞吐/压缩率,延迟敏感就调小
kgo.MaxBufferedRecords(100_000), // 内存里待发的记录数上限(默认 5 万)
kgo.RetryTimeout(10 * time.Second),
)DefaultProduceTopic 与显式 Record.Topic 可以共存—— franz 只在 Record 没带 Topic 时填充它,所以一个客户端发多 Topic 不需要像 kafka-go 那样专门建"不设 Topic 的 Writer”(10 章重试通道会用到这一点)。
分区路由(对应 Java 的 Partitioner):默认是 UniformBytesPartitioner——KIP-794 思路(Java 3.3+ 同款粘性分区器):有 Key 的记录按 murmur2 哈希,结果与 Java 客户端默认分区器逐分区一致,混部/迁移首选;无 Key 的记录按字节累积自适应轮转,天然摊平且自动避开高积压分区。换策略用 kgo.RecordPartitioner(...),可选 kgo.StickyKeyPartitioner(nil)、kgo.RoundRobinPartitioner()、kgo.ManualPartitioner()(自设 Record.Partition 精确投递,09 章事故二用)。KIP-1123 机架感知生产也有:kgo.Rack(...) + kgo.RackAwarePartitioning(),无键流量优先打同机架 Leader。
消费:消费者组
消费组 = 带 group 选项的另一个客户端:
group, err := kgo.NewClient(
kgo.SeedBrokers("localhost:9092", "localhost:9093", "localhost:9094"),
kgo.ConsumerGroup("order-processors"),
kgo.ConsumeTopics("order-events"),
)
if err != nil {
log.Fatal(err)
}
defer group.Close()
for {
fs := group.PollFetches(ctx)
fs.EachError(func(topic string, partition int32, err error) {
log.Printf("fetch 错误 %s-%d: %v", topic, partition, err) // 分区级错误不中断整体消费
})
fs.EachRecord(func(r *kgo.Record) {
handle(r)
})
}与 kafka-go ReadMessage 逐条模型的三个差异:
- 一批一批地拉:
PollFetches返回本轮所有分区的全部记录(Fetches类型);要限条数用PollRecords(ctx, 500)(对应 Javamax.poll.records)。 - 错误藏在 Fetches 里:重平衡、位点越界等都表现为分区级 fetch error,用
EachError/Errors()处理而不是盯着返回值;判断本轮是不是"ctx 取消/客户端关闭"提前返回,用fs.Err0()配errors.Is(err, context.Canceled)/errors.Is(err, kgo.ErrClientClosed)(老教程里的fs.IsContextCancelled(ctx)在 v1.8 起已删除)。 - 重平衡全自动:成员加入/退出、分区接管在 Poll 背后完成;需要"拿到新分区先预热本地状态"的场景用
kgo.OnPartitionsAssigned(fn)钩子。
心跳由后台 goroutine 维持,处理慢不会被踢出组(与 kafka-go 同、与 Java max.poll.interval.ms 异)——代价是"卡死自动转移分区"的保护没了,看门狗要自己配(05 章)。
提交:三档模型
| 档位 | 配置 | 语义 |
|---|---|---|
| 自动(默认) | AutoCommitInterval 默认 5s |
周期提交"已 Poll 到"的最大位点——可能提交还没处理完的消息,至多一次风险 |
| 标记式 | kgo.AutoCommitMarks() + group.MarkCommitRecords(rs...) |
周期只提交"标记过处理完成"的记录——介于自动与全手动之间 |
| 全手动 | kgo.DisableAutoCommit() + group.CommitRecords(ctx, rs...) |
处理成功才提交——至少一次,业务标准姿势 |
// 全手动提交的标准循环(08 章的幂等消费接着这个骨架讲)
group, _ := kgo.NewClient(
kgo.SeedBrokers("localhost:9092"),
kgo.ConsumerGroup("order-processors"),
kgo.ConsumeTopics("order-events"),
kgo.DisableAutoCommit(),
)
for {
fs := group.PollRecords(ctx, 500)
if err := fs.Err0(); errors.Is(err, context.Canceled) || errors.Is(err, kgo.ErrClientClosed) {
return // 退出路径:ctx 取消或客户端已 Close,先停 Poll 再收尾
}
var done []*kgo.Record
fs.EachRecord(func(r *kgo.Record) {
if err := handle(r); err != nil {
return // 不标记不提交:这条留给后续重放(重试阶梯见 10 章)
}
done = append(done, r)
})
if len(done) > 0 {
if err := group.CommitRecords(ctx, done...); err != nil {
log.Printf("提交失败: %v", err) // 没提上去=下次重放,靠幂等兜底(08 章)
}
}
}CommitRecords 的语义与 kafka-go/Java 一致:每个分区提交这批记录里的最大 Offset,更小的位点被累积覆盖——乱序完成时的坑见 09 章模型 B。需要精确提交"哪个分区到哪个位点"时用 CommitOffsets(ctx, map[string]map[int32]kgo.EpochOffset{...})(异步带回调)或 CommitOffsetsSync;查本地未提交进度用 MarkedOffsets()/CommittedOffsets()。
起点与重置:franz 的默认很"franz"
kgo.ConsumeStartOffset(kgo.NewOffset().AtEnd()), // 组首次上线、无已提交位点时的起点;默认 AtStart(earliest!)
kgo.ConsumeResetOffset(kgo.RewindOffset(time.Minute)), // 位点越界/数据丢失时的恢复策略;默认"回退 1 分钟"对齐 Java auto.offset.reset 的心智:
| Java | franz-go |
|---|---|
earliest |
NewOffset().AtStart() |
latest |
NewOffset().AtEnd() |
none |
NewOffset().AtCommitted() 或 NoResetOffset() |
| (无对应) | LookbackOffset(d) 只保留最近 d 时长;RewindOffset(d) 回退重放 |
注意:franz 的默认起点是 earliest——和 kafka-go 的
StartOffset零值同一个坑(05 章的"新组上线全量重放"事故),换客户端不换教训:新组上线必须显式写ConsumeStartOffset(kgo.NewOffset().AtEnd()),做初始化重建时才用 AtStart。
默认的 RewindOffset(1m) 重置策略是为"不丢数据"设计的:Leader 切换导致已提交位点之前的数据被截断时,从上次消费位置回退 1 分钟补读;丢得更多时上报 ErrDataLoss 而不是静默跳过。代价是每次故障恢复多放大约 1 分钟的重复——下游幂等(08 章)是它的前提。
重平衡相关选项
group, _ := kgo.NewClient(
kgo.SeedBrokers("localhost:9092"),
kgo.ConsumerGroup("order-processors"),
kgo.ConsumeTopics("order-events"),
kgo.Balancers(kgo.CooperativeStickyBalancer()), // 默认就这一个;可选 RoundRobin/Range/Sticky
kgo.InstanceID("order-processors-2"), // 静态成员:取 StatefulSet Pod 名,重启不触发重平衡
kgo.SessionTimeout(30 * time.Second),
kgo.AutoCommitInterval(5 * time.Second),
)- 协作式重平衡是默认:kafka-go 只有 eager(全员先交再领),franz 默认 cooperative-sticky,滚动发布不再整组 STW——机制与对比见 05 章。
- 静态成员:
InstanceID每实例唯一且稳定才有意义(全员同名会互相顶掉)。适合"发布频繁、故障罕见"的稳态组。 - KIP-848(新一代组协议,重平衡搬到 Broker 端):Kafka 4.0+ 加
kgo.ServerSideBalancer()并把分配器设为RangeBalancer()或StickyBalancer()即启用。建议 4.3+ 集群再开——此前版本重平衡期间提交可能撞上STALE_MEMBER_EPOCH(franz 文档原话)。 - Share Groups(Kafka 4.2+ 的逐条 ACK 模型):
kgo.ShareGroup("g")替代ConsumerGroup,处理后group.MarkAcks(kgo.AckAccept, rs...)+group.FlushAcks(ctx);AckAccept/AckRelease/AckReject/AckRenew四态,慢处理可 RENEW 续锁。语义对照与选型评估见 10/12 章。
非组消费:指定分区 / 按时间回放
脱离消费组直接消费某几个分区(重建本地缓存、数据订正回放):
scan, _ := kgo.NewClient(
kgo.SeedBrokers("localhost:9092"),
kgo.ConsumePartitions(map[string]map[int32]kgo.Offset{
"user-profile": {0: kgo.NewOffset().AtStart()}, // 11 章 compact Topic 全量扫就靠它
"order-events": {
3: kgo.NewOffset().AfterMilli(time.Now().Add(-time.Hour).UnixMilli()), // 按时间:分区 3 一小时前起
5: kgo.NewOffset().AtEnd().Relative(-1000), // 按位点:分区 5 倒数 1000 条起
},
}),
)
// PollFetches/PollRecords 与组模式相同,只是没有提交概念——进度在你自己手里kafka-get-offsets --time(02 章)的编程等价物就是 AfterMilli,客户端内部走 ListOffsets 时间查询。
管理与监控:kadm
kadm.NewClient 包一个普通 *kgo.Client 就得到管理面——相当于把 kafka-topics / kafka-consumer-groups / kafka-configs 搬进代码:
adm := kadm.NewClient(group)
// 组 Lag(等价 --describe --group;巡检/告警直接取总量)
lags, err := adm.Lag(ctx, "order-processors")
for _, dl := range lags.Sorted() {
log.Printf("group=%s 总Lag=%d", dl.Group, dl.Total())
for _, l := range dl.Lag.Sorted() { // 分区级明细
log.Printf(" %s-%d lag=%d member=%v", l.Topic, l.Partition, l.Lag, l.Member)
}
}
// 建 Topic:6 分区、3 副本、带 Topic 级配置(ptr 是自己写的一行取指针小工具)
resp, err := adm.CreateTopics(ctx, 6, 3,
map[string]*string{"retention.ms": ptr("259200000")}, "order-events")
resp.EachError(func(name string, err error) { log.Printf("%s 创建失败: %v", name, err) })
// 查组的已提交位点(等价 --describe 的 CURRENT-OFFSET 列)
offs, _ := adm.FetchOffsets(ctx, "order-processors")常用方法一览:ListTopics/DeleteTopics、CreatePartitions/UpdatePartitions(扩分区——先看 09 章事故一)、AlterTopicConfigs/AlterBrokerConfigs(动态配置,等价 kafka-configs)、ListGroups/DescribeGroups/DeleteGroups/DeleteOffsets、DeleteRecords(按位点删数据,合规场景)。kadm 没覆盖的协议端点(DescribeMetrics、动态客户配额等)用 pkg/kmsg 构造原始请求直接发送,官方 franz-go-examples 里有成套写法可抄。
错误分类:kerr
协议层错误统一是 *kerr.Error,带 Kafka 官方错误码与可重试标记:
var ke *kerr.Error
switch {
case errors.As(err, &ke) && ke.Code == kerr.NotEnoughReplicas.Code:
// ISR 不足(min.insync.replicas 挡写):等副本恢复,兜底落库
case errors.As(err, &ke) && ke.Code == kerr.MessageTooLarge.Code:
// 超限:重试一万次也不会成功(kafka-go 里叫 MessageSizeTooLarge,注意名字差异)
case errors.As(err, &ke) && ke.Retriable:
// 可重试类(LeaderNotAvailable 等):走到这里说明 franz 内部重试预算已用尽
default:
// 非协议错误:网络/超时/序列化,或本地检查失败
}kerr.IsRetriable(err) 可以在不想展开 switch 时一把梭。常打交道的常量:LeaderNotAvailable、NotLeaderForPartition、NotEnoughReplicas、NotEnoughReplicasAfterAppend、OffsetOutOfRange、UnknownTopicOrPartition——与 08 章的错误处理分层一一对应。
kafka-go 存量速查
存量项目用的还是 github.com/segmentio/kafka-go(当前 v0.4.51)的话,这一节是你的地图;新代码请走上文。它与 franz 的默认值差异(acks 零值=RequireNone、StartOffset 零值=FirstOffset)是本手册重复度最高的警告,展开见 04/05 章。
两个层级与低级别 Conn
// 低级别:手动挡,直连目标分区 Leader
conn, _ := kafka.DialLeader(ctx, "tcp", "localhost:9092", "order-events", 0)
defer conn.Close()
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
_, err = conn.WriteMessages(kafka.Message{Key: []byte("K"), Value: []byte("V")})
// 手动建 Topic:连到 Controller 再 CreateTopics(生产建议显式建,04/13 章)
c, _ := kafka.Dial("tcp", "localhost:9092")
ctrl, _ := c.Controller()
cc, _ := kafka.Dial("tcp", net.JoinHostPort(ctrl.Host, strconv.Itoa(ctrl.Port)))
err = cc.CreateTopics(kafka.TopicConfig{Topic: "order-events", NumPartitions: 6, ReplicationFactor: 3})Reader 与限制表
r := kafka.NewReader(kafka.ReaderConfig{
Brokers: []string{"localhost:9092"},
Topic: "order-events",
GroupID: "order-processors",
MaxBytes: 10e6, // 默认只有 1MB,大消息体务必调大(05 章)
})
defer r.Close() // 组模式下必须优雅断开,否则新消费者接入有延迟
m, err := r.FetchMessage(ctx) // 手动提交模式;ReadMessage 则读一条提一条
if err == nil {
_ = r.CommitMessages(ctx, m)
}设置 GroupID 后存在以下限制:
| 限制 | 说明 |
|---|---|
(*Reader).SetOffset 返回错误 |
Offset 由 Broker 统一管理 |
(*Reader).Offset 恒为 -1、Lag 恒为 -1、ReadLag 报错 |
消费进度/延迟信息不可用(监控走 Broker 侧,05 章) |
(*Reader).Stats 的分区数为 -1 |
统计受限 |
分配器只有 eager 协议(RangeGroupBalancer/RoundRobinGroupBalancer),无协作式、无静态成员、无 client.id——这几项在 franz 都有现成对应。
Writer 与两条互斥规则
w := &kafka.Writer{
Addr: kafka.TCP("localhost:9092"),
Topic: "order-events", // 设了 Writer.Topic 就不能再在 Message 里设 Topic
RequiredAcks: kafka.RequireAll, // 零值是 RequireNone!
Balancer: &kafka.Murmur2Balancer{},
}
err := w.WriteMessages(ctx, kafka.Message{Key: []byte("K"), Value: v})不设 Writer.Topic 则每条消息必须自带 Message.Topic,混用直接报错。多 Topic 写入用后者,与 franz 的"Record 各带 Topic"思路相同。Balancer 与跨语言兼容性对照表在 09 章;全部字段与实测默认值在 04 章。
实战建议
- 客户端实例复用:一个进程一个
cl(生产)+ 一个group(消费组);连接、元数据缓存、批次都在 Client 内维护,频繁 New 等于放弃批量与幂等序号。 - 退出顺序:消费端先让 Poll 循环随 ctx 退出、
Close()收尾(组模式会主动离组,减少别人等会话超时的时间);生产端Close()负责 flush 在途批次。 - 排查"消息没进去"先看
Produce回调/ProduceSync错误是不是可重试类——默认无限重试会把错误"藏"在迟迟不来的回调里,上线前把RetryTimeout定死。 - franz-go 版本策略:它按 Kafka 协议版本全量兼容(0.8.0~4.x),日常升级 franz 小版本即可拿到新 KIP;反过来,集群升到 4.x 后想开 KIP-848/Share Groups,Go 侧只有 franz 跟得上。