最近自己在写一个小项目,其中有一个批量爬取公募基金净值的功能如下:
- 客户端向服务器端发送请求,包含拉取数据的具体日期
- 服务端接收请求并获取数据
- 从tushare.pro数据源拉取当前日期的所有公募基金净值
- 存储到自己的PostgreSQL数据库中
这看起来非常简单,就是不能把所有业务逻辑简单地写在一个Service里然后让Controller去调用,否则的话Request要猴年马月才能返回。自然就想到了用消息队列,但是正好刷到Kafka比较高级(现在想起来真的是好随便的理由……),就想着用Kafka来实现上面的拉取功能。具体来说:
- 配置一个生产者,Controller接收到创建批量拉取任务的请求后,让生产者发送消息,消息内包含任务类型(当然现在只实现了批量爬取公募基金净值,但不排除以后要实现更多批量爬取功能)和需要爬取的日期
- 配置一个消费者,监听批量爬取任务的消息。接收到生产者发送的消息之后,执行前面提到的获取数据的流程
一切看起来都是很简单的,但是实际运行过程中发现,如果一下子发给服务器太多的任务请求,即生产者短时间生产了大量的消息,那么消费者消费完一轮新消息之后,会发现自己已经不在Kafka服务器中的一个活跃的消费者组中。换句话说,消费者消费完新消息之后,就和Kafka服务器断开了连接。具体来说,在Spring Boot上的输出是这样的:
Failing OffsetCommit request since the consumer is not part of an active group
Giving away all assigned partitions as lost since generation/memberID has been reset,indicating that consumer is in old state or no longer part of the group
Lost previously assigned partitions <partition-name>
(Re-)joining group
...(中间省略)
Setting offset for partition <partition-name> to the committed offset FetchPosition{offset=537,...(之后省略)
这里537的offset就是生产者发送的第一条批量拉取数据消息的offset。
为什么会这样呢?先去C站上找找,看到一个解释:
- Kafka消费者的消费方式是,从broker里取出一个batch的数据进行处理
- 当消费者消费能力相对于生产者的生产能力很低时,处理完一个batch就已经超过了session.timeout.ms。Kafka Broker会首先报告:
..., client reason: consumer poll timeout has expired. ...
- 于是,当消费者处理完batch想提交offset时就会失败,呈现前述Spring Boot的输出。
- 消费者发现自己已经被标为失效,于是换身份重新连接Kafka Broker,再从老的offset开始读消息,这样周而复始。
那么问题可能在于“取出一个batch的数据并处理”的时间太长,我们可不可以把batch的大小调小一点呢?
当然可以!实际上,Consumer一次拿的batch里有多少个元素,是由spring.kafka.consumer.max.poll.records决定的。发现这个设置项的默认值是500。如果不涉及网络请求、数据库操作之类的话倒还好,但如果涉及(就像本文一开始提的功能那样),就很容易超过spring.kafka.consumer.max.poll.interval.ms所规定的间隔。这个间隔指的是消费者每两次向broker取一个batch的时间间隔;如果某个消费者在取了一个batch之后,超过该间隔后仍未发出下一个取batch的请求,那么broker就会判定该消费者已失效。
在我自己的项目里,我调整了前述records条目为20,interval.ms条目为100000,这样能保证消费者能在规定最大时间间隔内消费完消息。当然也要注意session.timeout.ms的设置,其需要大于interval.ms。
重新启动,一次性发送大量爬取请求,此时可以看到爬取完规定范围内最后一天的基金净值数据后,不再出现之前的报错。
然后之前在查资料的时候还看到有说将enable.auto.commit设置为false,然后自己在Listener里手动commit的,现在我的项目里还保留着这个修改,但貌似没什么用。
最后感叹Kafka真的是好复杂啊,排查问题的时候尝试看了源码,只大概看懂了刚才说的“消费者一次拿一个batch进行消费”是有阻塞队列参与实现的,剩下的调用关系好复杂都没看懂orz自己还是需要多多提升