Kafka - 从消息堆积谈谈应用服务的消费者模型

本文探讨了Kafka消费者在处理大量数据时遇到的问题,如消息堆积、IO过高和心跳不稳。通过分析,提出了三种消费者模型:一对一模型、消息处理线程池模型和优化后的模型,分别解决并发、IO和消息顺序问题。优化后的模型通过统一提交offset和多级队列确保了消息的有序和无丢失。
摘要由CSDN通过智能技术生成

0 背景

kafka是常用的后端组件,往往用来做服务间的解耦。相信大家在用的过程中或多或少都出现过一些问题,比如:

  1. 如何设置partition和consumer的数量
  2. 项目中需要用到大量partition和consumer,容易导致高CPU,IO,如何优化
  3. 出现consumer在commit offset失败等导致应用不稳定的情况如何应对

这些问题都和应用的消费者相关,对于问题1,常见的消费模型是partition-consumer-thread是一对一的关系,所以partition和consumer数量理论上最好保持一致。大部分的业务其实不需要过度设计partition的数量多少,我之前的项目组中,默认给所有的topic设置4个partition,绝大多数情况都满足要求。
对于少数业务,数据量很大,所以要增加partition和消费者线程数把并发度提上去,如问题2,随之而来的问题就是,应用服务会产生大量对kafka broker的IO请求,而且当数据峰值降下来的时候线程并不会闲置,而是不断轮询从kafka获取数据,导致CPU浪费。
从原理上看,consumer需要和broker保持心跳,如果心跳断开了,就会出现commit offset失败等不稳定的情况时有发生。单纯用kafka的client API调参是很难同时解决这些问题的,下文将从应用程序自定义的的消费者模型出发,聚焦于解决上面提到的问题。

Tips:
kafka comsumer 在每次poll()函数拉取数据的时候,都会发送一次心跳请求,具体代码在ConsumerCoordinator类的poll()函数中,会有一次pollHeartbeat()的函数调用。
如果 consumer 长时间不poll()数据就会被broker踢出去,那么consumer就无法commit offset了。

1 消费者模型实现与逐步优化

模型 1

一种简单的模型是一个线程中运行一个consumer,应用中consumer的数量和partition的数量保持一致,相信很多开发者都是这么使用的,消费模型如下:
在这里插入图片描述

优势:

  • 实现简单,直观
  • 可简单应对消费者并发数增加的情况
  • 消费者消费到的数据在partition维度上是有序的

缺点:

  • partition数量大容易出现问题2中的情况,例如应用集群有4给节点,kakfa集群有3个节点,topic有20个partition,那么每个应用节点会有5个consumer,每次就会发送5个拉取数据的请求,实际上这样肯定没必要,因为kafka总共才3个节点,发3个拉去数据的请求就够了,这样易造成IO过高的问题。
  • 当消息处理时间过长的时候,可能会出现consumer没有发送心跳的情况,这时候我们可以调整max.poll.size参数减少每次拉取消息的数量,但是在消息量大的时候这种做法并不太好,容易产生消息积压。
  • 不管partition有没有数据,线程都需要不断轮询拉取消息,这样就容易出现CPU使用居高不下。

模型2

如果想解决模型1的缺点,可以从partition-consumer-thread是一对一这个特点着手,consumer数量和partition数量其实可以没有关联的,而且consumer拉取消息和消息处理两个步骤可以解耦,在不同线程中做。
基于上面的思路,可以得到一个新模型,新模型中包含consumer线程和消息处理线程池,consumer线程不断轮询拉取消息,并向消息处理线程池中提交任务。模型如下:
在这里插入图片描述
上述模型中,消费线程的数量可以设置的比partition数量少,并且由于consumer异步提交任务,所以能够持续保持和broker的心跳。假设应用的topic有20个partition,应用服务部署在4个物理机,每个物理机上只有一个consumer,但是每个应用服务上都有5个线程处理消息,就相当于有20个线程处理消息。

优势:

  • 处理消息的线程和partition数量无关,线程池大小可以动态调整,即使增加了partition也不需要调整应用,自适应能力强
  • consumer数量少,减少了应用服务的IO请求量
  • consumer的心跳稳定,不会出现长时间处理任务而没发送心跳包的情况

缺点:

  • 异步提交offset的时候,如果同一个partition中的消息,如果序列号大的消息比序列号小的消息先commit,那就有消息丢失的风险
  • 同一个partition中的消息被处理的时候不是顺序的

模型3

模型2解决了模型1中的不足,反而丢掉了模型1中的一些优势,写代码就是这样,往往不可兼得。而且引入的问题在模型2中的处理这些问题的难度明显比模型1要变大了,但是也不是没有办法,下面一个个问题处理。

缺陷1:异步commit offset 导致消息丢失

对于异步commit offset的问题的处理方式,就是不要让线程池中的线程各自去commit,而是由一个线程统一commit,我们把这个线程称为commit线程。那么问题就变成了commit线程在什么时机进行commit操作,很明显,当所有消息都被处理完的时候就提交一次offset,得到的模型如下:
在这里插入图片描述
这个模型中,序列号大的消息被线程池中的线程处理完之后写入到一个队列中,只有当序列号0,1,2…n的消息都被处理完的时候,才将序列号为n的消息的offset进行提交操作,保证了At Most Once消费。

缺陷2:同一个partition中的消息被处理的时候无序

消费者从某个partition消费到多条消息的时候,直接提交给了线程池处理,所以无法保证消息处理的顺序,我们最先想到的方案就是:

  • 加锁
    如果每个partiton都分配一个锁对象,partition 0的消息被处理的时候,先去尝试获取partition 0的锁,如果获取不到,就进入等待状态。但是这样有明显的缺点,那就是可能导致大量的闲置线程等待锁对象,极端的时候甚至导致可能整个线程池中只有一个线程在运行,这种方式不够优雅。
  • 多级队列
    另一种思路是从线程池本身着手,线程池中的线程是从一个Queue中获取任务,如果能改造这个Queue变成一个新的类MultiQueue,它实现Queue的接口,里面实际上是一个多级队列,如下图:
    在这里插入图片描述
    每次线程取任务会轮询所有queue,例如查看queue 0的标志位,当标志位为1,表示queue 0队列已经有任务在线程池中运行,跳过继续查看queue 1,发现符合要求,那就从queue 1中取出任务执行,并将其标志位设置位1,然后继续上述过程。
    这样实际上是将partition队列中的数据映射到内存的队列中了,并且可以保证了partition中的任务处理是顺序的。

最终形成的消费者模型如下:
在这里插入图片描述

处理完上述的问题之后,我们最后得到的消费者模型是个高性能,有序,无丢失,稳定,自适应能力强的消费者模型。
在以前的工作中我整理了一份代码可以直接使用,到时候再附上链接。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值