Pulsar Consumer 与 线程池搭配使用不当发生的现象问题

2,227 阅读4分钟

场景

近日,使用pulsar做削峰,一波3-40条消息,每条消息的平均处理完毕速度在10秒左右,发现吞吐量很慢,根据日志观察,消费者每次在同一秒处理10条消息,等待一分钟后继续处理第2波的十条消息,以此类推,很有规律,每次都是相隔一分钟处理后面的10条信息。

解决过程

1. 首先观察每条消息所在的线程名称

找到消息处理的线程名称,发现比较特殊化,带有页面名称,应该是项目自定义的线程池没错了,随后找到这个线程池的配置,

发现核心、最大线程数都是10,这应该符合我们看到上面的每次10条的现象之一了, 同时线程池使用的是拒绝策略:

ThreadPoolExecutor pulsarConsumerExecutor = new ThreadPoolExecutor(10, 10, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue(), (new ThreadFactoryBuilder()).setNameFormat("pulsar-consumer-thread-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy());

2. 观察在何处使用该线程池处理消息

处理消息的线程池找到,下一步找到在何处使用这个线程池,根据直觉,肯定是pulsar consumer接收消息时,将消息放到线程池中处理了,

consumerBuilder
.consumerName(name + "-consumer")
.subscriptionName(subscriptionName)
.topic(pulsarConsumerProperties.getSupportTopicEnv() ? topic.concat(topicSuffix) : topic)
.ackTimeout(pulsarConsumerProperties.getAckTimeOut(), TimeUnit.SECONDS)
.subscriptionType(getSubscriptionType(pulsarConsumerProperties.getSubscriptionType()))//订阅主题时要使用的订阅类型
.maxTotalReceiverQueueSizeAcrossPartitions(
        pulsarConsumerProperties.getMaxTotalReceiverQueueSizeAcrossPartitions())//跨分区的最大接收器队列总大小
.subscriptionInitialPosition(SubscriptionInitialPosition.Earliest)
.receiverQueueSize(pulsarConsumerProperties.getReceiverQueueSize())//消费者本地接受队列的大小
.intercept(consumerInterceptor)
.messageListener((consumer, msg) -> {
    // todo  伪代码
    将consumer和msg提交到线程池中处理
}).subscribe();

项目中只创建了一个consumer,也就是说一个consumer同时只处理一个消息,但是为了提高一个consumer的消费能力,在 messageListener将 msg 给到了线程池中处理,也就是说一个消费者可以不停的接收消息往线程池中丢,让线程池处理逻辑,自己只负责接收消息。

这么做固然可以提高吞吐量,但是当线程池爆满后,因为执行了拒绝策略,导致了拿到了消息,没有处理,直接抛出异常了,

看到这里应该比较明确了,一般消息队列中间件都会有重试消息的机制,它把消息给到了消费端,但是消费端没有给ack回复,

那么消息队列为了保证消息不丢失,就会尝试在一段时间内重新发送这个消息,那么这个超时时间是多久?

在上面的代码中,是ackTimeout这个属性,我们的项目当中使用的是60秒,到这里就真相大白了。

总结

因为消费者不停的接收消息,同时往线程池中丢,但是线程池处理能力有限,一次只能处理10个,每次还都需要10秒钟,

但是接收消息的速度是很快的,基本1秒都能接收很多个,这时候还往线程池中丢,线程池无奈只能拒绝,那这个消息在我们客户端就不了了之了,但是pulsar 60秒后没有收到ack,就重试了一波,将消息继续发送过来,此时,我们的线程池有空闲了,则可以继续处理了,这个就是开篇所说的根本原因了。

解决方案

  1. 开启多个消费者,每个消费者不用线程池处理消息,自己处理,处理完一条再接收一条,之所以要开多个是为了提高吞吐能力,可以根据自己的情况决定要不要开多个,这里的开 就是 创建 ~

  2. 使用线程池处理消息,将线程池的拒绝策略改为调用者执行策略,也就是说线程池处理不过来了,那么就在当前线程中处理,就是consumer自己的线程中,这样consumer也在帮忙干活,这么做不好的地方就是只有consumer活干完了,才能继续接收消息,可能10秒后干完,他才能继续接收消息,然后这时线程池有空闲了,则提交到线程池中,如此循环。

还有其他的方式,可以根据情况设置,比如

在知道最大的消息数与服务的消费能力下,设置线程池的线程数,等待队列数,保证可以容纳下具体的消息数量,

或者让consumer线程每次接收消息的时候和线程池配合一下,判断线程池中是否有空闲,有就提交,没有则等待,当线程池有空闲时间,通过线程协同方式,通知consumer继续提交到线程池中处理,具体根据自己的情况来使用。