KAFKA 实践:【二十】如何保证消息顺序?消息不丢失?消息不重复?
大家好,这是一个为了梦想而保持学习的博客。这个专题会记录我对于 KAFKA 的学习和实战经验,希望对大家有所帮助,目录形式依旧为问答的方式,相当于是模拟面试。
前言
我们在前面几个文章,知道了 kafka 的生产者 / 消费者的基本原理,这里就让我们来思考一些常见的生产问题,例如标题中的那些。
在讨论这些问题之前,我需要强调一下:消息交付是一个端到端的问题,所以我们需要进行全链路的分析和思考才能得到问题的答案。
如何保证消息顺序?
首先我们需要知道:kafka 只保证分区内的消息是顺序的,并不保证 Topic 维度的消息顺序。
其实我们联系存储架构来思考,就很容易理解。我们的分区文件是追加写入的,那么对于一个分区而言,它保证消息顺序的特性是天然自带的。但是 Topic 是一个逻辑概念,是由多个分区文件组成的,因此想要做到 Topic 维度的数据顺序代价是非常大的,所以 kafka 并不保证 Topic 维度的消息顺序。
有了上面的那个前提,我们就知道了如果想要实现顺序消费,那么对于生产 / 服务 / 消费三端就需要以下动作:
- 生产端,保证将消息写入一个分区内。
- 服务端,创建只有一个分区的 Topic。
- 消费端,保证一个线程消费一个分区。
对于生产端而言,如何保证消息写入到一个分区内呢?这得分情况讨论。
1、如果 Topic 有多个分区,那么我们可以通过设置 key 的方式让一些特定的消息通过 key.hash 写入到特定的分区中,例如我们想让某个订单的消息都写到一个分区中,可以设置 orderId 为 key 即可实现。
2、如果 Topic 有多个分区,我们还可以手动指定分区,在创建「ProducerRecord」时。
3、如果 Topic 只有一个分区,那么显然,我们使用默认的生产方式即可达到目的。
除此之外呢,还需要关闭重试机制,也就是把重试次数设置为 0,否则当出现可重试异常的时候,客户端会自动重试会导致乱序。
很多同学可能还对 max.in.flight.requests.per.connection
有疑问,不知道是否需要设置为 1(默认值是 5)。首先我们了解下这个参数的含义是控制发出给某个 Node 的请求还有未收到响应的个数,不超过 5 个。内部的数据结构类似于是 Map<Node, List<Request>>
,这个参数就是控制这个 List.size() <= 5
。
这么分析下来,显然是不需要设置为 1 的,因为只要你关闭了重试,你哪怕是前面一个请求失败了,后面一个请求成功了,只要是前一个请求不重试,那么也是符合顺序的要求的。
那如果说业务连这种场景都无法忍受的话,那么可以考虑设置为 1,就相当于一条一条的发。其实真有这么严格的要求的话,干脆同步发送,一条条的发,如果失败了就报错终止发送。
对于服务端而言,保证消息顺序最简单的就是创建一个只有一个分区的 Topic 即可。但是我们知道分区是用于横向扩展的,单分区的话可能整体的吞吐量比较一般。如果是多分区的话呢?那么就需要配合生产端进行了,要么指定分区,要么设置 key,也可以达到同样的效果,不过这种方式又可能存在数据 / 流量倾斜的情况,也就是造成某个分区的消息积压非常多,或者某个分区的流量特别大,导致整体的负载不均衡。这种情况的话,就需要考虑好业务场景以及搭配好对应的监控。
对于消费端而言,也需要分情况讨论。
1、如果是单线程消费,那么所消费到的所有消息都是顺序的,不需要做什么额外的处理,但是这种消费方式往往消费速率跟不上,导致消息积压。
2、如果是多线程消费,比如经典的拿到消息之后丢入线程池,这种方式呢显然就无法保证消息的有序性了。那么我们就需要思考一下如何让一个分区的消息只被一个线程消费呢?一种简单的实现方式就是使用内存队列 —— 将消费出来的消息,根据一些策略丢入对应的内存队列,队列的下游再用单线程的方式从队列中拉取数据进行消费。最基本的策略有:
- 基于
record.partition()
拿到对应的分区 ID,然后丢入对应的内存队列。 - 基于
record.key()
拿到上游设置的 key,进行 hash 后丢入对应的内存队列。
这两种策略的选择呢,也是根据业务场景去进行选择的,还需要配合上游生产者的生产策略进行选择。
除此之外呢,对应的消息提交还会变得比较麻烦,需要进一步的设计,这里只是提供一个简单的思路。
总结一下
生产端:设置 Key 或者指定分区,关闭重试。
服务端:单分区、多分区 Topic 都可以,取决于上游生产者的生产策略。
消费端:单线程消费都是顺序的,如果想要多线程消费可以考虑使用内存队列构造一个内部的生产 / 消费模型。
如何实现消息不丢失
我们首先来思考一下哪些场景下会丢失消息?
- 生产端,
acks
参数设置为 0(默认是 1),发完就不管了,可能压根消息没发送到服务端从而导致消息丢失。 - 生产端,
retries
参数设置为 0(默认是 0),当收到异常之后不重试,直接丢弃发送的消息从而导致消息丢失。 - 服务端,如果 Topic 的副本数为 1,那么对应的机器如果损坏就会直接导致消息丢失。
- 服务端,如果没有设置数据强制刷盘,那么可能机器重启导致写入 OS 的那部分消息丢失。
- 服务端,如果 ISR 中的副本数只有 1 的情况下如果还允许生产消息,那么当这个副本所在的机器出问题后对应的消息就会丢失。
- 服务端,如果允许非 ISR 中的副本选举为 Leader,那么也可能导致消息丢失。
- 消费端,如果在业务处理完这条消息之前,该消息被提交位移了,那么当业务处理出现问题后就可能丢失消息。
以上就我们想到的端到端可能存在消息丢失的所有场景,那么我们一个个来回答应该怎么做。
- 生产端,
acks
参数设置为 all,强制要求写入所有 ISR 中的副本成功后才认为是成功。 - 生产端,
retries
参数设置为Integer.MAX_VALUE
,在出现一条消息发送失败之后,就一直重试直到成功为止。 - 服务端,设置 Topic 的副本数至少大于等于 2,通常情况下是默认为 3。
- 服务端,设置
log.flush.interval.messages
参数为 1,也就是每写入一条消息就强制刷盘。默认情况下 kafka 是不控制刷盘的,交给 OS 去控制。 - 服务端,设置
min.insync.replicas
参数大于等于 2,也就是要求 ISR 中的副本数不得小于 2,否则不再提供生产服务,拒绝生产请求。 - 服务端,设置
unclean.leader.election.enable
参数为 false,也就是当不会选择 ISR 之外的副本成为 Leader。 - 消费端,关闭自动提交,即设置
enable.auto.commit
为 false,同时使用同步提交及在代码中使用commitOffsetsSync
函数按照 offset 的维度进行消息提交。
通过以上的一系列参数设置,是可以保证全链路的消息不丢失的,但是同时吞吐量会下降到一个令人发指的程度。
最后,补充一个链接,大家可以在官网上查到某个版本的 kafka 所有参数详情。
https://kafka.apache.org/documentation/#brokerconfigs
如何实现消息不重复?
上面的两个问题呢,其实很少真的有业务有这种严格的需求,出现的最多可能就是面试了。
但是这个消息不重复却是很多服务都需要保证的,它还有个名字,叫做消息幂等。
这里需要说明的一点是,我们通常说的消息幂等,默认是指消费端如何保证不重复消费消息。
因此,这个问题我们就不需要进行全链路端到端的进行分析了。
其实有两种常见的策略:
- 基于 DB 的唯一键,我们可以通过消息的内容拼成一个唯一的 key。然后创建一个幂等表,其中可以就两列 <id, key>,其中设置 key 列为唯一键。每次进行消息的业务处理前,进行幂等判断,也就是朝表中插入一个 key,如果报了对应的违反唯一性的异常,那么就跳过该消息的处理。
- 基于缓存,实现原理跟用 DB 基本一致,不过可以修改为判断 key 是否存在于缓存中,如果存在则跳过否则存入后再进行业务处理。
了解了策略之后,我们再思考下,哪些情况下会出现消息重复呢?
首先是生产端,如果遇到了网络抖动,服务端已经写入消息成功了,但是客户端却以为超时了,因此进行重试,此时就会出现重复消息。
然后服务端,服务端是不管那么多的,只要你上游的生产请求到了我这里我就会往对应的 Log 里面写数据。而服务端本身呢,是不会产生重复数据的。
接着是消费端,如果我一次性拉取了 500 条消息,我业务处理了其中 200 条,然后消费端 Crash 了,由于上次消费位移没有提交,因此消费者重新拉取原先的 500 条进行消费,那么原来处理过的 200 条消息就被重复消费了。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构