前言
我们在使用 kafka 过程中,经常遇到 重复消费、业务处理异常导致的消息丢失 ...
在 Kafka 中,offset 记录了我们目前消费的位置,重复消费、消息丢失等场景出现,本质与 offset 处理方式有关。
本文将深入讲解 spring 整合 kafka 时,如何提交 offset ,及其应用场景。
原理
提交方式
大方向上分为两种,自动提交和手动提交
自动提交
消费者在消费消息后,自动将消息的偏移量 offset 提交给 Kafka。这种方式的优点是简单易用,但缺点是可能会导致消息丢失或重复消费。
配置自动提交参数:
ini
spring.kafka.consumer.enable-auto-commit=true
spring.kafka.consumer.auto-commit-interval=1000 # 自动提交的时间间隔,单位为毫秒
手动提交
消费者在消费消息后,手动将消息的偏移量 offset 提交给 Kafka。这种方式的优点是可以更精细地控制消息的提交,避免消息丢失或重复消费,但缺点是需要更多的代码来管理提交逻辑。
配置手动提交参数:
ini
spring.kafka.consumer.enable-auto-commit=false
当然,我们在使用 spring-kafka 的时候,不一定完全需要手动提交,spring 已经将手动提交做了更细分的划分,我们在使用时简单配置即可,在一定程度上减少开发。
Spring ACK 策略:
- RECORD:每处理一条消息就提交一次偏移量。适用于对消息处理要求较高的场景。
- BATCH:处理一批消息后提交一次偏移量。适用于批量处理的场景,可以提高性能。
- TIME:每隔一定时间提交一次偏移量。适用于需要定期提交的场景。
- COUNT:每处理一定数量的消息后提交一次偏移量。适用于需要按消息数量提交的场景。
- COUNT_TIME:结合时间和数量的策略,满足任一条件即提交偏移量。适用于需要更灵活的提交策略的场景。
- MANUAL:手动提交偏移量,但不会立即提交。适用于需要手动控制提交时机的场景。
- MANUAL_IMMEDIATE:手动提交偏移量,并且立即提交。适用于需要立即提交偏移量的场景。
spring ack 策略何时使用?
- 当 enable-auto-commit=true 时,Kafka 会自动提交偏移量,Spring Kafka 的 ACK 策略将不起作用。
- 当 enable-auto-commit=false 时,Kafka 不会自动提交偏移量,Spring Kafka 的 ACK 策略将生效。
源码实现
源码参考版本:spring-kafka-2.2.9-RELEASE
消息拉取&消费
Spring kafka 消息处理核心类文件:KafkaMessageListenerContainer,spring 为每一个消费端创建一个线程,每一个消费者的核心处理逻辑从 run 方法开始:
java
public void run() {
this.consumerThread = Thread.currentThread();
if (this.genericListener instanceof ConsumerSeekAware) {
((ConsumerSeekAware) this.genericListener).registerSeekCallback(this);
}
if (this.transactionManager != null) {
ProducerFactoryUtils.setConsumerGroupId(this.consumerGroupId);
}
this.count = 0;
this.last = System.currentTimeMillis();
initAsignedPartitions();
while (isRunning()) {
try {
pollAndInvoke();
}
catch (@SuppressWarnings(UNUSED) WakeupException e) {
// Ignore, we're stopping or applying immediate foreign acks
}
catch (NoOffsetForPartitionException nofpe) {
this.fatalError = true;
ListenerConsumer.this.logger.error("No offset and no reset policy", nofpe);
break;
}
catch (Exception e) {
handleConsumerException(e);
}
catch (Error e) { // NOSONAR - rethrown
Runnable runnable = KafkaMessageListenerContainer.this.emergencyStop;
if (runnable != null) {
runnable.run();
}
this.logger.error("Stopping container due to an Error", e);
wrapUp();
throw e;
}
}
wrapUp();
}
真正的处理逻辑在于 pollAndInvoke:
java
protected void pollAndInvoke() {
// 非自动提交,也不是 RECROD 提交
if (!this.autoCommit && !this.isRecordAck) {
// 从这里依照策略进行提交
processCommits();
}
processSeeks();
checkPaused();
this.polling.set(true);
ConsumerRecords<K, V> records = this.consumer.poll(this.pollTimeout);
if (!this.polling.compareAndSet(true, false)) {
if (records.count() > 0 && this.logger.isDebugEnabled()) {
this.logger.debug("Discarding polled records, container stopped: " + records.count());
}
return;
}
this.lastPoll = System.currentTimeMillis();
checkResumed();
debugRecords(records);
if (records != null && records.count() > 0) {
if (this.containerProperties.getIdleEventInterval() != null) {
this.lastReceive = System.currentTimeMillis();
}
// 业务处理入口
invokeListener(records);
}
else {
checkIdle();
}
}
我们展开 offset 位移提交逻辑分析:
java
private void processCommits() {
this.count += this.acks.size();
handleAcks();
AckMode ackMode = this.containerProperties.getAckMode();
if (!this.isManualImmediateAck) {
if (!this.isManualAck) {
updatePendingOffsets();
}
boolean countExceeded = this.isCountAck && this.count >= this.containerProperties.getAckCount();
if ((!this.isTimeOnlyAck && !this.isCountAck) || countExceeded) {
if (this.logger.isDebugEnabled() && isCountAck) {
this.logger.debug("Committing in " + ackMode.name() + " because count "
+ this.count
+ " exceeds configured limit of " + this.containerProperties.getAckCount());
}
commitIfNecessary();
this.count = 0;
}
else {
timedAcks(ackMode);
}
}
}
private void handleAcks() {
ConsumerRecord<K, V> record = this.acks.poll();
while (record != null) {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Ack: " + record);
}
processAck(record);
record = this.acks.poll();
}
}
可以看到,这里将处理除 MANUAL_IMMEDIATE 之外的几种 ACK 方式,因为手动并立即提交模式,会在处理消息之后立即提交 offset,处理逻辑在 invokeListener 中。
processCommits() 逻辑会处理 批量
、数量
、时间
相关的几种模式提交 offset逻辑。
需要注意的是,该方法是在 pollAndInvoke
中调用,而 pollAndInvoke 则每一次拉取一批消息,也就是说这几种 ACK 处理模式是每一批次消息都处理完之后,下一批次开始之前,进行尝试提交 offset。
遇到异常时
有时候业务逻辑会抛出异常,此时消息 offset 提交是怎样的?
展开 invokeListener 业务处理层:
java
private RuntimeException doInvokeRecordListener(final ConsumerRecord<K, V> record,
@SuppressWarnings(RAW_TYPES) Producer producer,
Iterator<ConsumerRecord<K, V>> iterator) {
try {
invokeOnMessage(record, producer);
}
catch (RuntimeException e) {
// 判断遇到异常时,是否将此消息进行 ack
if (this.containerProperties.isAckOnError() && !this.autoCommit && producer == null) {
ackCurrent(record);
}
if (this.errorHandler == null) {
throw e;
}
try {
invokeErrorHandler(record, producer, iterator, e);
}
catch (RuntimeException ee) {
this.logger.error("Error handler threw an exception", ee);
return ee;
}
catch (Error er) { //NOSONAR
this.logger.error("Error handler threw an error", er);
throw er;
}
}
return null;
}
public boolean isAckOnError() {
return this.ackOnError &&
!(AckMode.MANUAL_IMMEDIATE.equals(this.ackMode) || AckMode.MANUAL.equals(this.ackMode));
}
由此可见:
- MANUAL、MANUAL_IMMEDIATE 模式,遇到异常是不会提交 ack。消息可以再次消费。
- 其他几种模式,还需要根据配置 ackOnError 决定,当前源码版本中,ackOnError 默认 true,也就是会自动提交 ack。换句话说当你业务抛出异常时,消息没有被正常处理,而 offset 已经被提交了,该消息不会再次进行消费。
值得注意的是,MANUAL、MANUAL_IMMEDIATE 等方式,你需要小心 ack 逻辑,避免异常消息无法被 ack,导致不断重试,成为死信队列。
另外,MANUAL、MANUAL_IMMEDIATE 也是你在业务逻辑中,需要手动调用 ack 的模式,其他模式则不用。