本文首发于公众号:托尼学长,立个写 1024 篇原创技术面试文章的flag,欢迎过来视察监督~
消息队列应该是所有中间件中最为复杂的,从底层原理的角度上讲,它是由两次 RPC + 一次持久化组成的。
因此,如何保证一条消息从 Producer 投递到 Broker,再从 Broker 投递到 Consumer 完成最终消费,就成了工程师务必要保证的基本面,也是面试官在消息队列方向最喜欢问的问题。
本文中我们来讲后半段,如何解决消息从 Kafka 到消费者的重复消费、漏消费问题。
推拉模式
我们都知道,消息从 Broker 投递到 Consumer 有推模式和拉模式两种。
推模式,消息被 Broker 主动发送给 Consumer,而 Consumer 只负责被动接收并进行消费。
推模式的优点在于消息的实时性高,但当消息消费速率赶不上消息推送速率时,Consumer 会出现消息积压并最终导致宕机。
拉模式,Consumer 变被动为主动去 Broker 拉取消息,并进行消费。
相比较于推模式,拉模式中 Consumer 以定时轮询的方式从 Broker 获取消息,会存在一定的消息延迟,但可以根据自身消费情况来控制消息获取速率,为系统增加了柔性。
Kafka正是采用拉模式来实现的。
重复消费、漏消费本质
来看Kafka 的两个消费者端的参数,enable.auto.commit 和 auto.commit.interval.ms。
(1)enable.auto.commit
顾名思义,该参数用来设定消费者是否自动提交偏移量,默认值为 true。
(2)auto.commit.interval.ms
当参数(1)设置为 true 时,可以通过该参数设置提交偏移量的频率,默认值为 5 秒。结合于这两个参数,我们说下 Kafka 产生重复消费和漏消费的原因。
如果将 enable.auto.commit 参数设置为 true,无论 auto.commit.interval.ms 参数设置为几秒钟,本质上就是一种延迟提交偏移量的操作。
当 consumer 将业务逻辑处理完了,但自动提交偏移量的操作还没做的时候发生宕机,那 consumer 重启后继续进行消息处理,会发生重复消费的情况。
如果将 enable.auto.commit 参数设置为 false,选择手动提交偏移量,同样会存在问题。
1、选择先处理 Consumer 中的业务逻辑,处理完成但尚未提交偏移量就发生宕机的话,那 Consumer 重启后继续进行消息处理,会发生重复消费的情况。
2、选择先进行提交偏移量操作,但尚未处理 Consumer 中的业务逻辑就发生宕机的话,那 Consumer 重启后继续进行消息处理,会发生漏消费的情况。
btw:在 Kafka 0.9 版本之前,消费者的偏移量默认存储在 Zookeeper 中,在 0.9 及以后的版本,Consumer 将 offset 保存在 Kafka 一个内置的 topic __consumer_offsets 中。
之所以这样演进设计,主要是考虑在消费者频繁与 Zookeeper 进行通信的场景中,Zookeeper 可能会存在性能问题。这样来看,重复消费和漏消费的问题,难道真的无解了吗?
当然不是。
仅依靠 Kafka 本身解决不了的问题,我们可以在系统层面配合 Kafka 一起解决。
解决方案
在上文中,我们已经重复消费、漏消费的问题做了详细分析,接下来我们看如何进行解决。
整体方案为,我们先通过调整 Kafka 的参数配置,杜绝出现漏消费的现象,再通过消费者的代码来规避重复消费的现象。
1、杜绝漏消费问题
如上文所述,如果将 enable.auto.commit 参数设置为 false,即手动提交偏移量。
接下来在消费者代码中,先进行偏移量提交操作,尚未处理 Consumer 中的业务逻辑就发生宕机的话,那 Consumer 重启后继续进行消息处理,会发生漏消费的情况。
我们只需要规避上述情况即可,无论是将 enable.auto.commit 参数默认为 true,还是将该参数设置为 false,在程序代码中先处理业务逻辑,后进行提交偏移量的操作,这两种方式都是可以的。
2、解决重复消费问题
归根结底,解决重复消费问题就是幂等性的问题。
幂等性的概念是,对某接口(某段代码)执行一次或多次相同操作,其最终的业务结果是相同的,并不会产生额外影响。
基于此,有两种相对主流的解决方案,都是以全局唯一 ID 作为底层原理来实现的。
我们来看第一种方案, 基于 MySQL 表中的唯一索引进行判重。
如上图所示,当执行 insert 语句的时候,若 order_id 在当前表中不存在则写入成功,否则会被唯一索引挡住而导致写入失败。
这种技术解决方案的优点非常明显,开发成本低,只需要增加一个唯一索引,并处理好索引冲突返回的错误信息就可以。
其缺点在于,由于唯一索引不能使用 Change Buffer(写缓冲),这会使 MySQL InnoDB 的磁盘随机 IO 增加,对数据库的性能有所影响。
btw:Change Buffer** ,如果一个非唯一索引并不存在于 Buffer Pool 的 Index Page 中,若对其执行写操作(insert、delete、update),会产生成本较高的磁盘随机IO。
此时,可以将写操作缓存在 Change Buffer 中,然后再以该 Index Page 被访问、 Master Thread 定期执行、数据库关闭作为触发点,将多个写操作合并为一个,并一次性写入到磁盘中,以减少磁盘 IO 次数的方式来提升写入性能。
如下图所示:
**
接下来看第二种方案,利用 Redis key 的唯一特性 + 过期时间进行判重。
解释一下上图中提到的第三点,由于全局唯一 ID 中都会加入时间序列属性,且大多数都是在毫秒级别的,一定不会出现间隔很久的唯一 ID 重复的情况。
所以,我们可以放心大胆地设置过期时间将其淘汰,以释放 Redis 宝贵的内存空间。
这种技术解决方案的优点在于,为数据库承担了判重工作并减轻了压力,且开发成本也比较低。
缺点在于,如果出现 Redis 挂掉的情况,那 Plan B 是什么?到底是容忍出现消息重复的情况,还是接着通过数据库来判重?
如果还是通过数据库的唯一索引来判重的话,那前置的 Redis key 的唯一特性 + 过期时间进行判重岂不就没有意义了?
如果通过select ... for update 加锁判重,这种操作明显更重了。
这可能需要更加深入结合系统的业务特性,才能制定出更加合理的 Plan B。