小Cの已经记不起来的博客

用 Apache Kafka 处理重复消息时幂等写入的设计

Kafka 说它保证“至少一次”,很多人第一眼看到这句话就默认消息不会丢,然后顺手把“重复”这件事交给下游数据库的唯一索引去扛。真到线上跑一阵子才发现,唯一索引只能挡住完全一样的主键,挡不住“业务上已经处理过两次”的情况。尤其是消费端先查后写、或者写库成功但提交 offset 失败的时候,重复消息一定会来,不是概率问题,是时间问题。

先说结论:Kafka 的幂等生产者和消费端的幂等写入是两件事,谁也替代不了谁。生产者幂等主要解决重试导致的 broker 端重复写入,消费端该做的去重还是得做。下面是我自己在项目里用的一套设计,谈不上多优雅,但比较实在。

重复消息到底从哪儿来

最常见的情况就三种:生产者开了重试,网络抖一下,broker 其实写成功了但 ack 没回来;消费者处理完业务逻辑,写库也成功了,结果提交 offset 前进程被 kill;再就是 rebalance,分区换了个消费者,上一任没来得及提交的位移被新消费者重新拉一遍。前两种靠 Kafka 自己很难完全消掉,第三种更是消费组机制自带的副作用。

所以不要幻想“配置一个参数就万事大吉”。我一般会把重复当成正常流量来设计,幂等写入是兜底,不是优化。

生产者幂等:先把源头按住

Kafka 0.11 之后支持幂等生产者,配置很简单:

enable.idempotence=true
acks=all
retries=Integer.MAX_VALUE
max.in.flight.requests.per.connection=5

开了 enable.idempotence 之后,生产者会带 PID 和 sequence number,broker 端对同一分区内的重复批次做去重。注意它只保证单生产者会话、单分区内的幂等,生产者重启后 PID 会变,跨会话它不管。所以这层只能减少重复,不能作为业务幂等的依据。

我一般会把它当成“别让重试把 broker 写爆”的基础配置,业务侧该有的唯一键一个不少。

消费端幂等:别只靠唯一索引

消费端幂等写入,核心就一句话:把“判断是否处理过”和“写入业务数据”放在同一个原子操作里。听起来像废话,但很多人写出来是这样的:

if (!orderRepository.existsByOrderNo(orderNo)) {
    orderRepository.save(order);
    // 更新其他表
}

这个写法在并发重复消费下会直接翻车,两个线程同时查到不存在,然后一起 insert,唯一索引能挡住一条,但另一条会抛异常,如果你没处理好,offset 就卡住了。更麻烦的是“写订单表 + 扣库存”这种多表操作,唯一索引只挡得住订单表,库存可能已经扣了两次。

我的做法是建一张幂等表,或者叫消费记录表,至少包含 message_key、consumer_group、status、created_at,message_key 和 consumer_group 做联合唯一索引。消费逻辑大概是这样:

INSERT INTO consume_record(message_key, consumer_group, status)
VALUES (?, ?, 'PROCESSING');

如果插入成功,说明当前消费者抢到了处理权,继续执行业务逻辑,最后把状态改成 DONE。如果插入冲突,说明已经有人处理过或者正在处理,直接跳过。这里的关键是插入幂等记录和业务写入要在同一个数据库事务里,或者至少保证业务写入成功后才把状态改成 DONE。如果业务失败,把记录删掉或者标记失败,让重试有机会进来。

有人会问,那用 Redis 的 SETNX 行不行?行,但你要接受 Redis 和数据库不一致的风险。比如 SETNX 成功了,业务库写失败了,key 还在,后面重试全被挡住。所以我一般只把 Redis 当第一层快速过滤,最终还是落库做唯一约束。

去重键怎么选

message_key 不要直接用 Kafka 的 offset,offset 在 rebalance 或者 topic 重建后可能变,而且换消费者组就没意义了。最好用业务唯一键,比如订单号、支付流水号、设备事件 ID。如果消息体里没有天然唯一键,就在生产端生成一个 UUID 放 header 里,消费端拿这个 UUID 做去重。别用消息内容的 hash,内容一样不代表业务上重复,内容不一样也不代表不是同一条消息。

事务和位移提交的顺序

Kafka 事务消息能保证“消费-处理-生产”的原子性,但它不帮你做数据库幂等。如果你用的是 read_committed 消费者,事务能减少一部分重复,但消费端写数据库失败后重试,还是可能重复。我的习惯是:业务库事务提交成功之后,再提交 offset。如果提交 offset 失败,消息会重投,幂等表兜住;如果业务库事务失败,不提交 offset,让消息重试。不要为了“ exactly once”硬上 Kafka 事务,除非你的下游也在事务范围里,否则收益不大。

几个容易踩的坑

第一,幂等表的唯一索引字段不要太长,message_key 用 varchar(128) 足够了,太长会影响插入性能。第二,幂等记录要定期清理,不然表会膨胀,我一般按时间分区,保留 7 到 30 天,具体看业务重试窗口。第三,消费逻辑里如果有外部调用,比如发短信、调支付,幂等表只能保证数据库不重复写,保证不了外部副作用,这种要么让外部接口支持幂等键,要么在本地做状态机,别指望 Kafka 帮你解决。

最后说句实话,幂等写入没有银弹,它就是拿一点存储和一次唯一索引冲突换一份安心。设计的时候别追求“绝对不重复”,追求“重复了也不会出错”更现实。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放