消费端:消费者客户端 SDK 有哪些设计?
前言
上一篇内容讲了生产端,这次继续来聊聊有关消费端的内容。从技术上看,消费端 SDK 和生产端 SDK 一样,主要包括客户端基础功能和消费相关功能两部分。客户端基础功能之前已经讲过,这里也就不做过多的赘述。
从实现上看,消费相关功能包括消费模型、分区消费模型、消费分组(订阅)、消费确认和消费失败处理五个部分。我们一个一个来看。
消费模型的选择
为了满足不同场景的业务需求,从实现机制上来看,主流消息队列一般支持 Pull、Push 和 Pop 三种消费模型。
Pull 模型
Pull 模型是指客户端通过不断轮询的方式想服务端拉取数据。它是消息队列中使用最广泛和最基本的模型,主流的消息队列一般也都支持这个模型。
它的好处是客户端根据自身的处理速度去拉取数据,不会对客户端和服务端造成额外的风险和负载压力。缺点是可能会出现大量无效返回的 Pull 调用,另外消费及时性不够,无法满足一些需要全链路低耗时的场景。
为了提供消费性能,Pull 模型都会支持批量读,即在客户端指定需要拉取多少条数据或者拉取多大的数据,然后传递给服务端。客户端拉取到数据并处理完成后,再重复拉取数据处理。如前面讲的,这种拉取模式的缺点是可能会出现长时间轮询到空数据的情况,从而浪费通信资源,提高服务端的负载。
比如下面这个场景,当 Topic1 数据已经被消费完,此时如果消费者频繁来拉取数据并立即返回结果,客户端就会不停地重复请求服务端。当空数据请求特别多的时候,就会造成资源损耗,不利于提高吞吐,也有可能导致负载问题。
为了解决这个问题,正常的思路是在客户端根据一定策略进行等待和回避。这样做的话,就会出现如何设置等待时间的问题,客户端等待时间设置不合理就会出现消费不及时的情况。
为了解决空请求带来的问题,一般服务端会协助处理,有如下两种思路:
1. 服务端 hold 住请求
当客户端根据策略拉取数据时,如果没有足够的数据,就先在服务端等一段时间,等有数据后一起返回给客户端。这种方案的好处是,可以尽量提高吞吐能力,不会有太多的空交互请求。缺点则是如果长时间不给客户端回包,会导致客户端请求超时,另外当数据不够时,hold 住请求的时间太长就会提高消费延时。
2. 服务端有数据的时候通知客户端
当服务端不 hold 住请求,立刻返回空数据,客户端收到空数据时则不再发起请求,会等待服务端的通知。当服务端有数据的时候,再主动通知客户端来拉取。这种方案的好处是可以及时通知客户端来拉取数据,从而降低消费延时。缺点是因为客户端和服务端一般是半双工的通信,此时服务端是不能主动向客户端发送消息的。
所以在 Pull 模型中,比较合适的方案是客户端告诉服务端:最多需要多少数据、最少需要多少数据、未达到最小数据时可以等多久三个信息。然后服务端首先判断是否有足够的数据,有的话就立即返回,否则就根据客户端设置的等待时长 hold 住请求,如果超时,无论是否有数据,都会直接给客户端返回当前的结果。
这种策略可以解决频繁不可控的空轮询请求。即使全是空轮询,对单个消费者来说,其 TPS 也是可以预估的,即总时间 / 等待时长 = 总轮询次数。而如果需要降低消费延时,可以通过降低最小获取的数据大小和最大等待时长来提高获取的频率,从而尽量降低延时。通过这种方案,我们可以把理想的消费延迟时间降低到两次 Pull 请求之间的时间间隔。
在一些业务消息的场景中,因为应对的场景规模有限,可以将最大等待时长设置为 0,此时消费模型就变成了“请求—返回”的模式,当没有数据的时候就会立即返回数据,其余逻辑交给客户端自己处理。
Push 模型
Push 模型是为了解决消费及时性而提出来的。这个模型的本意是指当服务端有数据时会主动推给客户端,让数据的消费更加及时。理想中的思路如下图所示,即当服务端由数据以后,会主动推给各个消费者。在实际的 Push 模型的实现上,一般有 Broker 内置 Push 功能、Broker 外独立实现 Push 功能的组件、在客户端实现伪 Push 功能三种思路。
1. Broker 内置 Push 功能
第一种,Broker 内置 Push 功能是指在 Broker 中内置标准的 Push 的能力,由服务端向客户端主动推送数据。
这种方案的好处是 Broker 自带 Push 能力,无需重复开发和部署。Broker 内部可以感知到数据堆积情况,可以保证消息被及时消费。缺点是当消费者很多时,内核需要主动维护很多与第三方的长连接,并且需要处理各种客户端异常,比如客户端卡住、接收慢、处理慢等情况。这些推送数据、异常处理、连接维护等工作需要消耗很多的系统资源,在性能上容易对 Broker 形成反压,导致 Broker 本身的性能和稳定性出现问题。所以这种方案在主流消息队列中用得较少,为了保证消息投递的高效及时(比如全链路的毫秒级耗时),才会采用这种方案。
2. Broker 外独立实现 Push 功能的组件
第二种,Broker 外独立实现 Push 功能的组件是指独立于 Broker 提供一个专门实现 Push 模型的组件。通过先 Pull 数据,再将数据 Push 给客户端,从而简化客户端的使用,提高消费数据的及时性。
这种方案的好处是将 Push 组件单独部署,解决了 Broker 的性能和稳定性问题,也能实现 Push 的效果。缺点是虽然实现了 Push 的模型,但其本质还是先 Pull 再 Push,从全链路来看,还是会存在延时较高的问题,并且需要单独开发独立的 Push 组件,开发和运维成本比较高。
从实际业务上来讲,这种模型的使用场景较为有限,主要用在回调、事件触发的场景,在实际的流消费场景用的不是很多。主要是因为通过第三方组件的 Push 灵活性不够,性能会比 Pull 第。
3. 客户端实现伪 Push 功能
**第三种,在客户端实现伪 Push 功能是指在客户端内部维护内部队列,SDK 底层通过 Pull 模型从服务端拉取数据存储到客户端的内存队列中。**然后通过回调的方式,触发用户设置的回调函数,将数据推送给应用程序,在使用体验上看就是 Push 的效果。
这种方案的好处在于通过客户端底层的封装,从用户体验看是 Push 模型的效果,解决用户代码层面的不断轮询问题,降低了用户的使用复杂度。缺点是底层依旧是 Pull 模型,还是得通过不断轮询的方式去服务端拉取数据,就会遇到 Pull 模型遇到的问题。
在客户端实现伪 Push,是目前消息队列在实现 Push 模型常用的实现方案,因为它解决了客户体验上的主动回调触发消费问题。虽然底层会有不断轮询和消费延时的缺点,但是可以通过合理的编码设计来降低这两个问题的影响。
因为 Push 模型需要先分配区和消费者的关系,客户端就需要感知分区分配、分区均衡等操作,从而在客户端就需要实现比较重的逻辑。并且当客户端和订阅的分区数较多时,容易出现需要长时间的重平衡时间的情况。此时为了解决这个问题,于是就有了 Pop 模型。
Pop 模型
Pop 模型想解决的是客户端实现较重,重平衡会暂停消费并且可能时间较长,从而出现消费倾斜的问题。
它的思路是客户端不需要感知到分区,直接通过 Pop 模型提供的 get 接口去获取到数据,消费成功后 ACK 数据。这就像发起 HTTP 请求去服务端拉取数据一样,不用感知服务端的数据分布情况,只需要拉到数据。这种方案的好处是简化了消费模型,同时服务端可以感知到消费的堆积情况,可以根据堆积情况返回哪些分区的数据给客户端,这样也就简化了消息数据的分配策略。
从实现上来看,它将分区分配的工作移到了服务端,在服务端完成了消费者的分区分配、进度管理,然后暴露出新的 Pop 和 ACK 接口。客户端调用 Pop 接口去拿去数据,消费成功后调用 ACK 去确认数据。可以类比 HTTP 中的 Request 和 Response 使用模型。
分区消费模式
我们知道,消息队列的数据是在 Partition/Queue 维度承载的,所以消费过程中一个重要的工作就是消费者和分区的消费模式问题,即分区的数据能不能被多个消费者并发消费,一条数据能不能被所有消费者消费到,分区的数据能不能被顺序消费等等。
从技术上看,在数据的消费模式上主要有独占消费、共享消费、广播消费和灾备消费四种思路
独占消费
**独占消费是指一个分区同一个时间只能被一个消费者消费。**在消费者启动时,会分配消费者和分区之间的消费关系。当消费者数据和分区数量都没有变化的情况下,两者之间的分配关系不会变动。当分配关系变动时,一个分组也只能被一个消费者消费,这个消费者可能是当前的,也可能是新的。如果消费者数量大于分区数量,则会有消费者被空置;反之,如果分区数量大于消费者数量,一个消费者则可以同时消费多个分区。
独占消费的好处是可以保证分区维度的消费是有序的。缺点是当数据出现倾斜、单个消费者出现性能问题或 hang 住时,会导致有些分区堆积严重。Kafka 默认支持的就是独占消费的类型。
共享消费
**共享消费是指单个分区的数据可以同时被多个消费者消费。**即分区的数据会依次投递给不同的消费者,一条数据只会投递给一个消费者。
这种方式的好处是,可以避免单个消费者的性能和稳定性问题导致分区的数据堆积。缺点是无法保证数据的顺序消费。这种模式一般用在对数据的有序性无要求的场景,比如日志。
广播消费
**广播消费是指一条数据要能够被多个消费者消费到。**即分区中的一条数据可以投递给所有的消费者,这种方式是需要广播消费的场景。
实现广播消费一般有内核实现广播消费的模型、使用不同的消费分组消费和指定分区消费三种技术思路。
- 内核实现广播消费的模型,指在 Broker 内核中的消息投递流程实现广播消费模式,即 Broker 投递消息时,可以将一条消息吐给不同的消费者,从而实现广播消费。
- 使用不同的消费分组对数据进行消费,指通过创建不同的消费者组消费同一个 Topic 或分区,不同的消费分组管理自己的消费进度,消费到同一条消息,从而实现广播消费的效果。
- 指定分区消费,是指每个消费者指定分区进行消费,在本地记录消费位点,从而实现不同消费者消费同一条数据,达到广播消费的效果。
三种方案的优劣对比:
广播消费类型 | 优点 | 缺点 |
---|---|---|
内核实现 | 客户端成本低,无多余工作 | 服务端开发工作量大 |
消费分组实现 | 统一消费模型,无需服务端开发 | 需要创建很多消费分组 |
指定分区消费 | 统一消费模型,避免创建很多消费分组 | 客户端编码工作较重,使用相对复杂 |
灾备消费
灾备消费是独占消费的升级版,在保持独占消费可以支持顺序消费的基础上,同时加入灾备的消费者。 当消费者出现问题的时候,灾备消费者加入工作,继续保持独占顺序消费。
好处是既能保持独占顺序消费,又能保证容灾能力。缺点是无法解决消费倾斜的性能问题,而且还需要准备一个消费者来做灾备,使用成本比较高。
消费分组
消费分组是用来组织消费者、分区、消费进度关系的逻辑概念。为什么需要消费分组呢?
在没有消费分组直接消费 Topic 的场景下,如果希望不重复消费 Topic 中的数据,那么就需要有一个标识来标识当前的消费情况,比如记录进度。这个唯一标识就是消费分组。
在一个集群中可以有很多消费分组,消费分组间通过名称来区分。消费分组自身的数据是集群元数据的一部分,会存储在 Broker 的元数据存储服务中。消费分组主要有管理消费者和分区的对应关系、保存消费者的消费进度、实现消息可重复被消费三类功能。
消费分组和 Topic 是强相关的,它需要包含 Topic 才有意义,一个空的消费分组是没有意义的。消费分组内有很多个消费者,一个消费分组也可以订阅和消费多个 Topic,一个 Topic 也可以被多个消费分组订阅和消费。
因为 Topic 不存储真实数据,分区才存储消息数据,所以就需要解决消费者和分区的分配关系,即哪个分区被哪个消费者消费,这个分配的过程就是消费重平衡。
从流程上来看,当新建一个消费分组的时候,就需要开始分配消费者和分区的消费关系了。分配完成后,就可以正常消费。如果消费者和分区出现变动,比如消费者挂掉、新增消费者、订阅的 Topic 的分区数发生变化等等,就会重新开始分配消费关系,否则就会存在某些分区不能被订阅和消费的情况。
协调者
从实现上来看,如果要对消费者和分区进行分配,肯定需要有一个模块拥有消费分组、所有的消费者、分区信息三部分信息,这个模块我们一般命名为协调者。协调者主要的工作就是执行消费重平衡,并记录消费分组的消费进度。
在消费分组创建、消费者变化、分区变化的时候就会触发重新分配。分区分配的操作可以在协调者内部或消费者上完成。
- 在协调者完成,即协调者首先获取消费者和分区的信息,然后再协调者内部完成分区分配,最后再把分配关系同步给所有消费者。
- 在消费者完成,即负责分配的消费者获取所有消费者和分区的信息,然后该消费者完成分区分配操作,最后再把分配关系同步给其他消费者。
从技术上来看,这两种形式的优劣区别并不大,取决于代码的实现。一般在创建消费分组和消费者 / Topic 分区变化的时候,会触发协调者执行消费重平衡。
从实现的角度来看,协调者一般是 Broker 内核的一个模块,就是一段代码或者一个类,专门用来完成上述的工作。当有多台 Broker 时,协调者的实现有多种方式,比如 Kafka 集群每台 Broker 都有协调者存在。通过消费分组的名称计算出来一个 hash 值和 _consumer_offset 的分区数,取余计算得出一个分区号。最后这个分区号对应的 Leader 所在的 Broker 节点就是协调者所在的节点。客户端就和计算出来的这台 Broker 节点进行交互,来执行消费重平衡的相关操作。
当有了协调者后,就需要确认哪个分区给哪个消费者了,此时就需要一个分配策略来执行,这就是消费分区分配策略。
消费分区分配策略
再具体实现上,一般内核会默认提供几种分配策略,也可以通过定义接口来支持用户自定义实现分区分配策略。分区分配策略的制定一般遵循以下三个原则:
- 各分区的数据能均匀地分配给每个消费者,保证所有消费者的负载最大概率是均衡的,该原则最为常用;
- 在每次重新分配的时候,尽量减少陪去和消费者之间的关系变动,这样有助于加快重新分配的速度,并且保持数据处理的连续性,降低处理切换成本;
- 可以运行灵活地根据业务特性指定分配关系,比如根据机房就近访问最近的分区、某个 Topic 的奇数分区分配给第一个消费者等等。
所有消息队列的默认策略都是相对通用的,一般都会包含有轮询、粘性、自定义三种策略。
轮询
轮询就是指用轮询的方式将分区分配给各个消费者,保证每个消费者的分区数量是尽量相同的,从而保证消费者的负载最大概率上是均衡的。思路是拿到所有主题的所有分区和所有消费者,根据拿到的顺序(实际实现中可能会先全部打乱,以确保随机性)将分区逐个分配给消费者。分配到最后的效果是,每个消费者所分到的分区数是一样的,最多相差 1 个分区。比如 tp0 有 3 分区,tp1 有 2 分区,tp2 有 3 分区,分配后效果如下。
消费者 1:tp0-0、tp2-1、tp1-1
消费者 2:tp2-2、tp0-1、tp2-0
消费者 3:tp1-0、tp0-2
因为 Topic 一般会有多个分区,默认情况下写入大部分是均匀的。这个方案的优点是,从随机性的原理来看,打乱分区后再分配给每个消费者,消费者的负载大概率是均匀的。但是也有可能出现不均衡,比如当消费组同时订阅多个分区时,有可能会将同一个 Topic 的多个分区都分配给一个消费者,从而出现消费者的负载倾斜。
在轮询的基础上,为了解决随机轮询的情况,某些流量搞的 Topic 可能会分配给同一个消费者。为了解决这种情况,就可以调整一下轮询的策略,比如在随机的基础上,将 Topic 的不同分区尽量打散到不同的消费者,从而保证整体消费者之间的分区是均衡的,如下所示:
消费者1:tp0-0、tp2-1、tp1-1
消费者 2:tp0-1、tp2-0、tp1-0
消费者 3:tp0-2、tp2-2
主要的核心思路都是为了消费者更加均衡,避免消费倾斜。
粘性
粘性是指尽量减少分区分配关系的变动,进而减少重平衡所耗费的时间和资源损耗。即当已经分配好消费者和分区的消费关系后,当消费者或者分区出现变动,就会触发重平衡。从底层来看,可能就是一个消费者掉了或者新增分区。此时需要重新进行分配的消费者和分区其实是有限的,大部分的分配关系可以不动。而此时,如果使用轮询算法,则要全部打算重来,耗时就会非常长,并且浪费资源,即把原先不需要重新分配的关系都重新分配一遍。
粘性的效果如下,比如当上面的消费者 3 挂了后,只需要将 tp1-0、tp0-2 平均分给消费者 1 和 2 即可,消费者 1 和 2 原先分配的分区不用动。
消费者1:tp0-0、tp2-1、tp1-1、tp1-0
消费者 2:tp0-1、tp2-0、tp1-0、tp0-2
在实际的实现中,为了减少重新分配关系,有一个非常常用的算法是一致性哈希。一致性哈希的算法经常用在负载均衡中。用一致性哈希实现粘性分配策略的优点是,当节点或者分区变动时,只需要执行少量的分区再分配即可。
自定义策略
在一些消息队列中,也会提供一些与自己相关的特色的分区分配策略。比如 Kafka 就提供了轮询策略改进版的 RoundRobinAssignor
分配策略。这些策略的核心出发点,都是为了解决消费者和分区之间的分配均衡、重平衡耗时、业务场景需要等诉求。
自定义分区分配算法,和生产端数据的分区分配策略是一样的,内核会提供接口,用户可以根据自身需求自定义算法,然后指定配置生效即可。比如 Kafka 提供了 org.apache.kafka.clients.consumer.internals.PartitionAssignor
接口来提供自定义分区分配策略。
消费确认
那么当数据被消费成功后,就必须进行消费确认操作了,告诉服务端已经成功消费了这个数据。消费确认就是我们在消息队列中常说的 ACK。一般情况下,消费确认分为确认后删除数据和确认后保存数据两种形式。
确认后删除数据是指集群的每条消息只能被消费一次,只要数据被消费成功,就会回调服务端的 ACK 接口,服务端就会执行数据删除操作。在实际开发过程中,一般都会支持单条 ACK 和 批量ACK 两种操作。这种方式不利于回溯消费,所以用得比较少。
消费成功保存消费进度是指当消费数据成功后,调用服务端的消费进度接口来保持消费进度。这种方式一般都是基于配合消费分组一起用的,服务端从消费分组维度来保存进度数据。
为了保证消息的回溯消费和多次消费,消息队列大多数用的是第二种方案。数据的删除交由数据过期策略去执行。
保存消费进度一般为服务端保存和客户端自定义保存两种机制实现。
服务端保存是指当消费端消费完成后,客户端需要调用一个接口提交信息,这个接口是由服务端提供的”提交消费进度“接口,然后服务端会持久保存进度。当客户端断开重新消费时,可以从服务端读取这个进度进行消费。服务端一般会通过内置的 Topic 或者文件来持久保存该数据。这种方式的优点是客户端会封装好这些逻辑,使用简单,无序管理进度相关的信息,缺点就是不够灵活。服务端保存一般是默认的方案。
在提交位点信息的时候,底层一般支持自动提交和手动提交两种实现。
- 自动提交一般是根据时间批次或数据消费到客户端后就自动提交,提交过程客户端无感知;
- 手动提交:是指业务根据自己的处理情况,手动提交进度信息,以避免业务处理异常导致的数据丢失。
提交方式 | 优点 | 缺点 |
---|---|---|
自动提交 | 实现简单,业务无感知,使用成本低 | 可能会漏消费数据,导致数据丢失 |
手动提交 | 安全可控 | 需要代码提交,使用成本较高 |
如果想避免数据丢失的情况下,优先考虑手动提交的方式。
客户端自定义保存是指当消费完成后,客户端自己管理保存消费进度。此时就不需要向服务端接口提交进度信息了,自定义保存进度信息即可,比如保存在客户端的缓存、文件、自定义的服务中,当需要修改和回滚的时候就比较方便。这种方案的的优点是灵活,缺点是会带来额外的工作量。
消费失败处理
我们知道,一个完整的消费流程包括消费数据、本地业务处理、消费进度提交三部分。那么从消费失败的角度来看,就应该分为从服务端拉取数据失败、本地业务数据处理失败、提交位点信息失败三种情况。下面我们逐一来看。
从服务端拉取数据失败,和客户端的错误逻辑处理是一致的,根据可重试错误和不可重试错误的分类,进行重复消费或者向上抛错。
本地业务数据处理失败,处理起来就变囧复杂了。如果是偶尔失败,那么在业务层做好重试处理逻辑,配合手动提交消费进度的操作即可解决。如果是一直失败,即使重试多次也无法被解决,比如这条数据内容有异常,导致无法被处理。此时如果一直重试,就会出现消费卡住的情况,这就需要配合死信队列等功能,将无法被处理的数据投递到死信队列中,从而保存异常数据,并保证消费进度不阻塞。
提交位点信息失败,其处理方法通常是一直重试,重复提交,如果持续失败就向上抛错。因为如果提交进度失败,即使再从服务端拉取数据,还是会拉到同一批数据,出现重复消费的问题。
总结
在消费端,为了提高消费速度和消息投递的及时性,需要选择合适的消费模型,目前主流有 Pull、Push 和 Pop 三种模型。
这三种模型的应用场景都不一样。目前业界主流消息队列使用的都是 Pull 模型。但为了满足业务需求,很多消息队列也会支持 Push 模型和 Pop 模型。其中,Push 模型的及时性更高,实现较为复杂,限制也比较多。Pop 模型本质上是 Pull 模型的一种,只是在实现和功能层面上,与 Push 的实现思路和使用场景不一样。所以在模型的选择上来看,因为场景复杂,三种模型都是需要的。
常用的消费模式一般有独占消费、共享消费、广播消费和灾备消费四种。为了避免堆积,保证消息消费顺序,一般需要选择分区独占的消费模式。从单分区的维度,共享消费的性能是最高的。广播消费主要是通过创建多个消费分组、指定分区消费来实现的。灾备消费的场景用的则相对较少。
从设计上看,消费端要解决的问题依次分为三步:
- 满足基本的消费需求,能消费到数据,确认数据;
- 满足稳定性和性能的需求,能快速稳定地消费到数据;
- 支持功能方面的需求,比如回溯消费、消费删除、广播消费等等。
为了能满足基本的消费需求,服务端会提供消费和确认接口,同时在客户端封装消费和确认操作中,底层通过网络层和服务端建立、维护 TCP 连接,然后通过协议完成基本的消费操作。
如果要回溯消费,则需要单独记录消费进度。这样就能抽象出消费分组的概念,用来管理消费者、分区和消费进度的关系。通过消费分组来记录消费进度,从而实现数据的多次分发。另外,消费分组机制也可以用在广播消费的场景。
在消费确认的过程中,一般需要客户端回调服务端提供的确认接口。确认接口分为确认删除和确认记录消费进度两种模式。主流方式是在确认的时候记录消费进度。
异常处理主要是为了保证数据能被正常消费,重点关注不丢数据、不重复消费、不阻塞住消费三个问题,我们需要针对不同的问题做不一样的处理。