Kafka.05 - 生产者与消费者介绍
Producer
Producer 将消息发布到它指定的 topic 中,并负责决定发布到哪个分区。通常简单的由负载均衡机制随机选择分区,但也可以通过特定的分区函数选择分区。使用的更多的是第二种。
producer 直接将数据发送到 broker 的 leader(主节点),不需要在多个节点进行分发。为了帮助 producer 做到这点,所有的 Kafka 节点都可以及时的告知:哪些节点是活动的,目标 topic 目标分区的 leader 在哪。这样 producer 就可以直接将消息发送到目的地了。
客户端控制消息将被分发到哪个分区。可以通过负载均衡随机的选择,或者使用分区函数。Kafka 允许用户实现分区函数,指定分区的 key,将消息 hash 到不同的分区上(当然有需要的话,也可以覆盖这个分区函数自己实现逻辑).比如如果你指定的 key 是 user id,那么同一个用户发送的消息都被发送到同一个分区上。经过分区之后,consumer 就可以有目的的消费某个分区的消息。
异步发送
批量发送可以很有效的提高发送效率。Kafka producer 的异步发送模式允许进行批量发送,先将消息缓存在内存中,然后一次请求批量发送出去。这个策略可以配置的,比如可以指定缓存的消息达到某个量的时候就发出去,或者缓存了固定的时间后就发送出去(比如 100 条消息就发送,或者每 5 秒发送一次)。这种策略将大大减少服务端的 I/O 次数。
既然缓存是在 producer 端进行的,那么当 producer 崩溃时,这些消息就会丢失。Kafka0.8.1 的异步发送模式还不支持回调,就不能在发送出错时进行处理。Kafka 0.9 可能会增加这样的回调函数。见 Proposed Producer API.
Consumer
发布消息通常有两种模式:队列模式(queuing)和发布-订阅模式(publish-subscribe)。
队列模式中,consumers 可以同时从服务端读取消息,每个消息只被其中一个 consumer 读到;发布-订阅模式中消息被广播到所有的 consumer 中。
Consumers 可以加入一个 consumer 组,共同竞争一个 topic,topic 中的消息将被分发到组中的一个成员中。同一组中的 consumer 可以在不同的程序中,也可以在不同的机器上。如果所有的 consumer 都在一个组中,这就成为了传统的队列模式,在各 consumer 中实现负载均衡。如果所有的 consumer 都不在不同的组中,这就成为了发布-订阅模式,所有的消息都被分发到所有的 consumer 中。更常见的是,每个 topic 都有若干数量的 consumer 组,每个组都是一个逻辑上的“订阅者”,为了容错和更好的稳定性,每个组由若干 consumer 组成。这其实就是一个发布-订阅模式,只不过订阅者是个组而不是单个 consumer。
由两个机器组成的集群拥有 4 个分区 (P0-P3) 2 个 consumer 组. A 组有两个 consumerB 组有 4 个。
Kafa consumer 消费消息时,向 broker 发出"fetch"请求去消费特定分区的消息。consumer 指定消息在日志中的偏移量(offset),就可以消费从这个位置开始的消息。customer 拥有了 offset 的控制权,可以向后回滚去重新消费之前的消息,这是很有意义的。
推还是拉?
Kafka 最初考虑的问题是,customer 应该从 brokes 拉取消息还是 brokers 将消息推送到 consumer,也就是 pull 还 push。在这方面,Kafka 遵循了一种大部分消息系统共同的传统的设计:producer 将消息推送到 broker,consumer 从 broker 拉取消息。
一些消息系统比如 Scribe 和 Apache Flume 采用了 push 模式,将消息推送到下游的 consumer。这样做有好处也有坏处:由 broker 决定消息推送的速率,对于不同消费速率的 consumer 就不太好处理了。消息系统都致力于让 consumer 以最大的速率最快速的消费消息,但不幸的是,push 模式下,当 broker 推送的速率远大于 consumer 消费的速率时,consumer 恐怕就要崩溃了。最终 Kafka 还是选取了传统的 pull 模式。
Pull 模式的另外一个好处是 consumer 可以自主决定是否批量的从 broker 拉取数据。Push 模式必须在不知道下游 consumer 消费能力和消费策略的情况下决定是立即推送每条消息还是缓存之后批量推送。如果为了避免 consumer 崩溃而采用较低的推送速率,将可能导致一次只推送较少的消息而造成浪费。Pull 模式下,consumer 就可以根据自己的消费能力去决定这些策略。
Pull 有个缺点是,如果 broker 没有可供消费的消息,将导致 consumer 不断在循环中轮询,直到新消息到达。为了避免这点,Kafka 有个参数可以让 consumer 阻塞直到新消息到达(当然也可以阻塞直到消息的数量达到某个特定的量这样就可以批量发送)。
消费状态跟踪
对消费消息状态的记录也是很重要的。
大部分消息系统在 broker 端的维护消息被消费的记录:一个消息被分发到 consumer 后 broker 就马上进行标记或者等待 customer 的通知后进行标记。这样也可以在消息在消费后立马就删除以减少空间占用。
但是这样会不会有什么问题呢?如果一条消息发送出去之后就立即被标记为消费过的,一旦 consumer 处理消息时失败了(比如程序崩溃)消息就丢失了。为了解决这个问题,很多消息系统提供了另外一个个功能:当消息被发送出去之后仅仅被标记为已发送状态,当接到 consumer 已经消费成功的通知后才标记为已被消费的状态。这虽然解决了消息丢失的问题,但产生了新问题,首先如果 consumer 处理消息成功了但是向 broker 发送响应时失败了,这条消息将被消费两次。第二个问题时,broker 必须维护每条消息的状态,并且每次都要先锁住消息然后更改状态然后释放锁。这样麻烦又来了,且不说要维护大量的状态数据,比如如果消息发送出去但没有收到消费成功的通知,这条消息将一直处于被锁定的状态,
Kafka 采用了不同的策略。Topic 被分成了若干分区,每个分区在同一时间只被一个 consumer 消费。这意味着每个分区被消费的消息在日志中的位置仅仅是一个简单的整数:offset。这样就很容易标记每个分区消费状态就很容易了,仅仅需要一个整数而已。这样消费状态的跟踪就很简单了。
这带来了另外一个好处:consumer 可以把 offset 调成一个较老的值,去重新消费老的消息。这对传统的消息系统来说看起来有些不可思议,但确实是非常有用的,谁规定了一条消息只能被消费一次呢?consumer 发现解析数据的程序有 bug,在修改 bug 后再来解析一次消息,看起来是很合理的呀!
离线处理消息
高级的数据持久化允许 consumer 每个隔一段时间批量的将数据加载到线下系统中比如 Hadoop 或者数据仓库。这种情况下,Hadoop 可以将加载任务分拆,拆成每个 broker 或每个 topic 或每个分区一个加载任务。Hadoop 具有任务管理功能,当一个任务失败了就可以重启而不用担心数据被重新加载,只要从上次加载的位置继续加载消息就可以了。
Broker 如何处理请求
Broker 采用了 Reactor 模式来处理请求。简单来说,Reactor 模式是事件驱动架构的一种实现方式,特别适合应用于处理多个客户端并发向服务器端发送请求的场景。
谈到 Reactor 模式,大神 Doug Lea 的“Scalable IO in Java”应该算是最好的入门教材了。即使你没听说过 Doug Lea,那你应该也用过 ConcurrentHashMap 吧?这个类就是这位大神写的。其实,整个 java.util.concurrent 包都是他的杰作!
Reactor 模式的架构如下图所示:
从这张图中,我们可以发现,多个客户端会发送请求给到 Reactor。Reactor 有个请求分发线程 Dispatcher,也就是图中的 Acceptor,它会将不同的请求下发到多个工作线程中处理。在这个架构中,Acceptor 线程只是用于请求分发,不涉及具体的逻辑处理,非常得轻量级,因此有很高的吞吐量表现。而这些工作线程可以根据实际业务处理需要任意增减,从而动态调节系统负载能力。
如果我们来为 Kafka 画一张类似的图的话,那它应该是这个样子的:
显然,这两张图长得差不多。Kafka 的 Broker 端有个 SocketServer 组件,类似于 Reactor 模式中的 Dispatcher,它也有对应的 Acceptor 线程和一个工作线程池,只不过在 Kafka 中,这个工作线程池有个专属的名字,叫网络线程池。Kafka 提供了 Broker 端参数 num.network.threads,用于调整该网络线程池的线程数。其默认值是 3,表示每台 Broker 启动时会创建 3 个网络线程,专门处理客户端发送的请求。
Acceptor 线程采用轮询的方式将入站请求公平地发到所有网络线程中,因此,在实际使用过程中,这些线程通常都有相同的几率被分配到待处理请求。这种轮询策略编写简单,同时也避免了请求处理的倾斜,有利于实现较为公平的请求处理调度。
当网络线程拿到请求后,它不是自己处理,而是将请求放入到一个共享请求队列中。Broker 端还有个 IO 线程池,负责从该队列中取出请求,执行真正的处理。如果是 PRODUCE 生产请求,则将消息写入到底层的磁盘日志中;如果是 FETCH 请求,则从磁盘或页缓存中读取消息。
IO 线程池处中的线程才是执行请求逻辑的线程。Broker 端参数 num.io.threads 控制了这个线程池中的线程数。目前该参数默认值是 8,表示每台 Broker 启动后自动创建 8 个 IO 线程处理请求。你可以根据实际硬件条件设置此线程池的个数。比如,如果你的机器上 CPU 资源非常充裕,你完全可以调大该参数,允许更多的并发请求被同时处理。当 IO 线程处理完请求后,会将生成的响应发送到网络线程池的响应队列中,然后由对应的网络线程负责将 Response 返还给客户端。
细心的你一定发现了请求队列和响应队列的差别:请求队列是所有网络线程共享的,而响应队列则是每个网络线程专属的。这么设计的原因就在于,Dispatcher 只是用于请求分发而不负责响应回传,因此只能让每个网络线程自己发送 Response 给客户端,所以这些 Response 也就没必要放在一个公共的地方。
我们再来看看刚刚的那张图,图中有一个叫 Purgatory 的组件,这是 Kafka 中著名的“炼狱”组件。它是用来缓存延时请求(Delayed Request)的。所谓延时请求,就是那些一时未满足条件不能立刻处理的请求。比如设置了 acks=all 的 PRODUCE 请求,一旦设置了 acks=all,那么该请求就必须等待 ISR 中所有副本都接收了消息后才能返回,此时处理该请求的 IO 线程就必须等待其他 Broker 的写入结果。当请求不能立刻处理时,它就会暂存在 Purgatory 中。稍后一旦满足了完成条件,IO 线程会继续处理该请求,并将 Response 放入对应网络线程的响应队列中。
讲到这里,Kafka 请求流程解析的故事其实已经讲完了,我相信你应该已经了解了 Kafka Broker 是如何从头到尾处理请求的。
评论