不知道从什么时候开始,业界刮起了一股“万物皆可 Exactly-Once”的风气。前阵子听信了某架构大厂的宣传,说我们的分布式记账系统必须无脑切成 Kafka 事务性生产,以达到端到端的精确一次性。结果赶鸭子上架折腾了两周,刚上线就发生了大面积的重复记账和脏数据。仔细排查后我们彻底无语了:原来消费端代码的 isolation.level 居然保持着默认的 read_uncommitted!这等于脱裤子放屁,事务性生产者发送的未提交/已终止事务数据,全部被消费端直接脏读了。
要实现 Kafka 端到端的一次性语义(EOS,Exactly-Once Semantics),必须满足三个核心条件,缺一不可:
- 幂等性生产者(Idempotent Producer):通过
enable.idempotence = true避免网络抖动重试引起的重复消息。 - 事务性生存周期(Transactional API):通过
transactional.id将一组发往不同 Partition 的消息打包在一个事务中。 - 读已提交隔离(Read Committed):这是最容易遗漏的,消费者必须配置
isolation.level = read_committed,才能对事务外的“悬挂事务”以及“中止事务”消息进行过滤。
如果没有配置最后一步,Kafka 的事务不仅没有任何数据防重效果,反而因为引入了控制消息(Control Batch)增加了 broker 的协调负担。
为了让大家在落地 EOS 时少踩坑,我们整理了一套在 Go 语言下使用 confluent-kafka-go 的完整生产配置,包含了如何配置事务性 Producer 以及支持 read_committed 的 Consumer。
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/confluentinc/confluent-kafka-go/v2/kafka"
)
// RunTransactionalProducer 启动事务型消息生产者
func RunTransactionalProducer() {
// 1. 初始化生产者,开启幂等和事务 ID
p, err := kafka.NewProducer(&kafka.ConfigMap{
"bootstrap.servers": "127.0.0.1:9092",
"enable.idempotence": true, // 开启幂等性
"transactional.id": "accounting_tx_prod_001", // 必须指定唯一的 Transactional ID
})
if err != nil {
log.Fatalf("创建生产者失败: %s", err)
}
defer p.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 2. 初始化事务
err = p.InitTransactions(ctx)
if err != nil {
log.Fatalf("初始化 Kafka 事务失败: %s", err)
}
// 3. 开启事务
err = p.BeginTransaction()
if err != nil {
log.Fatalf("开启 Kafka 事务失败: %s", err)
}
topic := "user-wallet-events"
msg := &kafka.Message{
TopicPartition: kafka.TopicPartition{Topic: &topic, Partition: kafka.PartitionAny},
Value: []byte(`{"txn_id":"TXN_20260509_9982","amount":150.00,"action":"CREDIT"}`),
}
// 4. 发送消息
err = p.Produce(msg, nil)
if err != nil {
log.Printf("发送消息失败,准备回滚事务: %s", err)
_ = p.AbortTransaction(context.Background())
return
}
// 5. 提交事务
err = p.CommitTransaction(context.Background())
if err != nil {
log.Printf("提交事务失败,进行回滚: %s", err)
_ = p.AbortTransaction(context.Background())
return
}
fmt.Println("Kafka 事务消息生产成功,并且已成功 Commit!")
}
// RunCommittedConsumer 启动只读已提交的消费者
func RunCommittedConsumer() {
// 必须显式配置 isolation.level 为 read_committed 过滤未提交的数据
c, err := kafka.NewConsumer(&kafka.ConfigMap{
"bootstrap.servers": "127.0.0.1:9092",
"group.id": "accounting_consumer_group",
"auto.offset.reset": "earliest",
"isolation.level": "read_committed", // 关键:只消费已 Commit 的事务消息
"enable.auto.commit": false, // 禁用自动提交,转为手动提交 offset
})
if err != nil {
log.Fatalf("创建消费者失败: %s", err)
}
defer c.Close()
err = c.SubscribeTopics([]string{"user-wallet-events"}, nil)
if err != nil {
log.Fatalf("订阅主题失败: %s", err)
}
for {
msg, err := c.ReadMessage(3 * time.Second)
if err != nil {
// 读取超时或发生网络抖动,继续循环
continue
}
fmt.Printf("消费到合法且已提交的事务消息: %s\n", string(msg.Value))
// 手动提交 offset
_, err = c.CommitMessage(msg)
if err != nil {
log.Printf("[WARNING] 提交 offset 失败: %v", err)
}
}
}
func main() {
// 启动事务生产者示例
RunTransactionalProducer()
// 启动只读已提交消费者示例
go RunCommittedConsumer()
// 挂起主线程
select {}
}
端到端一次性语义不仅带来了复杂度的上升,其背后的双阶段提交(Two-Phase Commit)还带来了吞吐量的显著下降。在实际业务中,对于订单记账,我们到底是用 Kafka 事务强行续命,还是应该选择在消费端设计一个极轻量级的 Redis/MySQL 幂等表(去重表)来去重处理?各位在自己的分布式架构中是如何取舍的?