目录
提交消费位移方法commitFinishedOffset()
重平衡监听处理方法onPartitionsRevoked()
设计总览
在多线程模式中,将并行消费的职责分解到两个 Java 类
主线程类:Mainthread
主线程类负责整体的消费流程控制和协调,具体职责包括:
-
订阅 Topic 和拉取消息: 主线程通过 KafkaConsumer 订阅指定的 Topic,并从中拉取数据。
-
创建子线程并分配任务: 使用 Java 线程池机制,主线程创建多个具体的子线程(ConsumerSubTask),并将不同的 Partition 的数据分发给这些子线程处理。
-
管理子任务的执行状态: 主线程负责监控和管理每个 ConsumerSubThread 的执行状态,确保它们按照预期进行消费。
-
提交消费位移: 主线程在适当的时机,根据消费情况提交消费位移,以确保消息被正确地标记为已消费。
子线程类:subThread
子线程类负责具体的消息消费任务,具体职责包括:
-
消费指定 Partition 的数据: 每个 ConsumerSubThread 被分配了一个或多个 Partition,负责从这些 Partition 消费数据。
-
维护子任务的执行状态: 子线程需要维护自身消费任务的执行状态,包括消费进度、处理错误、重试机制等。
-
处理消费逻辑: 根据业务需求,子线程实现具体的消费逻辑,可能涉及数据处理、持久化等操作。
子线程的设计实例
由于每个分区只能交给一个消费子线程处理,因此确保每个分区的消息按顺序处理。
为了避免低效利用CPU资源或者过多的线程开销
-
线程池管理: 使用线程池来管理消费子线程,这样可以有效地重用线程资源,提高CPU利用率。线程池可以动态地调整线程数量,根据当前负载情况和分区数量来分配适当的线程资源。
-
分区合并: 对于分区数量较少的情况,可以考虑将多个分区的消费任务合并到一个消费子线程中处理。这样可以减少线程数量,提高每个线程的处理效率。
-
动态分配: 根据系统负载和实时分区的数量,动态调整线程池的大小和分配策略,以达到最佳的性能和资源利用率。
代码设计实例
/**
* 这个类表示消费特定分区的Kafka记录的任务。
* 每个ConsumerSubThread实例顺序处理一组ConsumerRecords,并维护最后处理消息的偏移量。
*/
public class ConsumerSubThread implements Runnable {
private final List<ConsumerRecord<String, String>> records;
private final AtomicLong currentOffset = new AtomicLong(-1); // 初始为-1,表示尚未处理任何记录
private final CompletableFuture<Long> taskCompletion;
private static final Lock startStopLock = new ReentrantLock();
private static volatile boolean isTaskStarted = false;
private static volatile boolean isTaskStopped = false;
private static volatile boolean isTaskFinished = false;
/**
* 构造一个ConsumerSubThread,使用要处理的ConsumerRecords列表。
*
* @param records 要处理的ConsumerRecords列表。
*/
public ConsumerSubThread(List<ConsumerRecord<String, String>> records) {
this.records = records;
this.taskCompletion = new CompletableFuture<>();
}
/**
* 运行消费者子线程以顺序处理列表中的每个记录。
* 更新当前消费的偏移量并标记任务完成。
*/
@Override
public void run() {
startStopLock.lock();
try {
if (isTaskStopped) {
return;
}
isTaskStarted = true;
} finally {
startStopLock.unlock();
}
// 处理每条记录并更新当前偏移量
for (ConsumerRecord<String, String> record : records) {
if (isTaskStopped) {
break;
}
// 更新偏移量以处理下一条消息
currentOffset.set(record.offset() + 1);
}
// 标记任务已完成,并使用最终偏移量完成taskCompletion future
isTaskFinished = true;
taskCompletion.complete(currentOffset.get());
}
/**
* 获取当前消费者子线程的当前消费偏移量。
*
* @return 当前消费偏移量。
*/
public long getCurrentConsumedOffset() {
return currentOffset.get();
}
}
主线程的设计实例
主执行循环 (run()
方法):
public void run() {
try {
// 订阅 Kafka 主题
consumer.subscribe(Collections.singletonList("msjx-topic"), this);
while (!taskStopped.get()) {
// ① 拉取记录
ConsumerRecords<String, String> fetchedRecords = consumer.poll(Duration.of(500, ChronoUnit.MILLIS));
// ② 分发拉取到的记录给相应的 ConsumerSubTask 线程进行处理
dispatchFetchedRecords(fetchedRecords);
// ③ 检查任务进度:控制消费速率
checkTaskProcess();
// ④ 提交已完成记录的位移,按照分区提交
commitFinishedOffset();
}
} catch (Exception e) {
log.error("run() 方法执行异常", e);
} finally {
consumer.close();
}
}
-
① 拉取记录:主线程调用 Kafka 消费者的
poll()
方法,从订阅的主题中拉取记录,超时设定为 500 毫秒。这是一个非阻塞操作。 -
② 分发记录:拉取到的记录(
ConsumerRecords
)会被分发给ConsumerSubTask
线程进行处理。每个ConsumerSubTask
处理特定分区的数据,确保每个分区的数据由一个线程按顺序处理,以保持顺序性。 -
③ 检查任务进度:这涉及监控每个
ConsumerSubTask
的执行进度。主线程确保每个分区在上一轮分配的数据被其对应的ConsumerSubTask
完全处理后,再进行下一次poll()
操作。这种控制有助于调节消费速率,并确保没有未处理的记录。 -
④ 提交位移:一旦记录由
ConsumerSubTask
处理并确认,主线程会提交位移(指示每个分区的消费位置),将其存储到 Kafka 中。这一步确保在发生故障时,不会从头重新消费记录,维护至少一次的传递语义。
给子线程分发任务dispatchRecords()方法
private void dispatchRecords(ConsumerRecords<String, String> records) {
if (records.count() > 0) {
List<TopicPartition> partitionsToPause = new ArrayList<>();
/**
* ① 按照 partition 划分给 ConsumerSubThread 子线程处理
* 每个 ConsumerSubThread 子线程可以处理多个 partition,但每个 partition 只能被一个 ConsumerSubThread 子线程处理
*/
records.partitions().forEach(partition -> {
List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
log.debug("{} 拉取到的记录数 {}", partition, partitionRecords.size());
ConsumerSubThread subThread = new ConsumerSubThread(partitionRecords);
partitionsToPause.add(partition);
/**
* ② 使用 ExecutorService.submit() 方法提交子线程到线程池执行,
* 每个子线程可以处理一个或多个分区的数据
*/
executor.submit(subThread);
// 将每个分区与其对应的子线程映射关系存储到 activeTasks 中
activeTasks.put(partition, subThread);
});
/**
* ③ 在调用 KafkaConsumer.poll() 方法时,暂停主线程拉取 partitionsToPause 中分区的数据,
* 以确保在子线程处理完之前不会再次拉取这些分区的新数据
*/
consumer.pause(partitionsToPause);
}
}
-
① 将拉取到的记录按照分区划分,分配给不同的
ConsumerSubTask
子线程处理:records.partitions().forEach(partition -> { ... })
迭代拉取到的每个分区。List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
获取当前分区的所有记录。ConsumerSubTask subTask = new ConsumerSubTask(partitionRecords);
创建一个ConsumerSubTask
实例,用于处理当前分区的记录。partitionsToPause.add(partition);
将当前分区加入到暂停列表,以便在后续暂停主线程对该分区的数据拉取。
-
② 使用
ExecutorService.submit()
方法提交子任务到线程池中执行:executor.submit(subTask);
将subTask
提交给线程池executor
执行,实现并发处理。activeTasks.put(partition, subTask);
将当前分区与对应的subTask
映射关系存储到activeTasks
中,用于后续管理和跟踪。
-
③ 在调用
KafkaConsumer.poll()
方法时,暂停主线程拉取暂停列表中的分区数据:consumer.pause(partitionsToPause);
调用 Kafka 消费者的pause()
方法,暂停主线程对partitionsToPause
列表中分区的数据拉取。- 这确保了在主线程将分区分配给子任务后,主线程在子任务完成处理前不会再次拉取这些分区的新数据,从而保证了消息的顺序性和避免重复消费的问题。
检查子线程消费状态checkSubThread()方法
private void checkSubThread() {
List<TopicPartition> finishedTasksPartitions = new ArrayList<>();
// ① 检查每个子线程是否完成了分配的分区数据消费
activeTasks.forEach((topicPartition, consumerSubThread) -> {
if (consumerSubThread.isTaskFinished()) {
finishedTasksPartitions.add(topicPartition);
}
// ② 收集每个分区已经消费的最大偏移量
long offset = consumerSubThread.getCurrentConsumedOffset();
if (offset > 0) {
offsetsToCommit.put(topicPartition, new OffsetAndMetadata(offset));
}
});
// ③ 从活跃任务中移除已完成的分区->子线程映射关系
finishedTasksPartitions.forEach(topicPartition -> activeTasks.remove(topicPartition));
/**
* ④ 恢复已完成任务的分区数据拉取:
* 在调用 KafkaConsumer.pause() 方法暂停拉取某个 partition 的数据后,
* 主线程的 poll() 方法将不再返回该分区的记录。
* 调用 KafkaConsumer.resume() 方法恢复该分区后,
* 主线程的 poll() 方法才会继续拉取该分区的数据。
*/
consumer.resume(finishedTasksPartitions);
}
-
① 判断子线程是否已经消费完分配的分区数据:
consumerSubThread.isTaskFinished()
方法用于判断当前的ConsumerSubThread
是否已经消费完分配的所有分区数据。如果是,则将对应的topicPartition
添加到finishedTasksPartitions
列表中。- 由于一个
ConsumerSubThread
可能处理多个分区,必须确保所有分配的分区数据都被消费完毕,才能认为任务完成。
-
② 返回每个分区已经消费完的最大 offset:
consumerSubThread.getCurrentConsumedOffset()
方法返回当前ConsumerSubThread
在每个分区上已经消费的最大 offset。- 如果
offset > 0
,则将该 offset 与其对应的topicPartition
放入offsetsToCommit
映射中,用于后续提交消费位移。
-
③ 移除 activeTasks 映射中已经执行完的 partition->consumerSubThread 映射关系:
- 遍历
finishedTasksPartitions
列表,从activeTasks
映射中移除已经处理完成的topicPartition -> consumerSubThread
映射关系。 - 这确保了不再跟踪已经完成的任务,从而释放相关资源。
- 遍历
-
④ 恢复 poll 已经执行完的 partition 数据:
consumer.resume(finishedTasksPartitions)
方法调用KafkaConsumer
的resume()
方法,恢复之前暂停的partitionsToPause
中的分区。- 这样,主线程在调用
poll()
方法时会重新开始拉取这些分区的新数据,从而实现持续的消息处理流程。
提交消费位移方法commitFinishedOffset()
public class ConsumerMainThread implements Runnable, ConsumerRebalanceListener {
// 禁用自动提交 offset
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
/**
* 提交已处理的消费位移
*/
private void commitFinishedOffset() {
try {
long currentTimeMillis = System.currentTimeMillis();
// 每隔 8 秒提交一次消费位移
if (currentTimeMillis - lastCommitTime > 8000) {
if (!offsetsToCommit.isEmpty()) {
// 同步提交消费位移
consumer.commitSync(offsetsToCommit);
// 清空待提交的位移信息
offsetsToCommit.clear();
}
// 更新最后提交时间
lastCommitTime = currentTimeMillis;
}
} catch (Exception e) {
log.error("提交消费位移失败!", e);
}
}
}
-
禁用自动提交 offset:
- 在消费者配置中设置
ENABLE_AUTO_COMMIT_CONFIG
为false
,以禁用消费者的自动偏移量提交功能。这样可以避免在发生 Rebalance(重新分配分区)后,部分数据被重新消费的问题。
- 在消费者配置中设置
-
提交已处理的消费位移:
commitFinishedOffset()
方法负责定期提交消费者已经处理完的消费位移。- 每隔 8 秒钟会检查一次是否需要提交位移信息。
- 如果
offsetsToCommit
集合非空,则调用consumer.commitSync(offsetsToCommit)
同步提交消费位移,并在提交后清空offsetsToCommit
集合。 lastCommitTime
变量记录了上一次提交位移的时间,确保提交操作的频率控制在合理范围内,避免过多的提交请求。
重平衡监听处理方法onPartitionsRevoked()
public class ConsumerMainThread implements Runnable, ConsumerRebalanceListener {
/**
* 当分区被撤销时调用,停止处理已撤销分区的所有子任务,并提交其消费位移。
*
* @param partitions 撤销的分区集合
*/
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
// ① 停止处理已撤销分区记录的所有子任务
Map<TopicPartition, ConsumerSubTask> stoppedTask = new HashMap<>();
for (TopicPartition partition : partitions) {
// 从活跃任务中移除该分区对应的子任务,并停止任务
ConsumerSubTask removeTask = activeTasks.remove(partition);
if (removeTask != null) {
removeTask.stopTask(); // 停止任务的方法
stoppedTask.put(partition, removeTask); // 记录已停止的任务
}
}
// ② 等待停止的任务完成对当前记录的处理,并提交消费位移
stoppedTask.forEach((topicPartition, consumerSubTask) -> {
// 等待任务完成并获取最后处理的位移
long offset = consumerSubTask.waitForCompletion();
// 如果成功获取有效的位移,准备提交消费位移
if (offset > 0) {
offsetsToCommit.put(topicPartition, new OffsetAndMetadata(offset));
}
});
// ③ 收集已撤销分区的消费位移
Map<TopicPartition, OffsetAndMetadata> revokedPartitionOffsets = new HashMap<>();
partitions.forEach(topicPartition -> {
// 从待提交位移中移除撤销的分区对应的位移
OffsetAndMetadata offset = offsetsToCommit.remove(topicPartition);
if (offset != null) {
revokedPartitionOffsets.put(topicPartition, offset); // 收集需要提交的消费位移
}
});
// ④ 提交撤销分区的消费位移,可以把消费位移提交到 DB 或者默认的 __consumer_offsets 中
try {
consumer.commitSync(revokedPartitionOffsets); // 同步提交消费位移
} catch (Exception e) {
log.error("提交撤销分区的消费位移失败", e); // 提交失败时记录错误日志
}
}
}
-
处理 Rebalance 时撤销分区的逻辑:
onPartitionsRevoked()
方法是实现了ConsumerRebalanceListener
接口的回调方法,用于在发生 Rebalance 前处理撤销分区的逻辑。其目的是在停止消费之前确保提交已消费的位移信息。
-
① 停止处理已撤销分区记录的所有子线程:
- 遍历
partitions
集合,停止和移除处理被撤销分区的所有ConsumerSubThread
实例。停止的子线程通过调用stopThread()
方法停止处理该分区的数据,并将其添加到stoppedThreads
集合中备用。
- 遍历
-
② 等待停止的子线程完成对当前记录的处理:
- 对于每个停止的子线程,通过调用
waitForCompletion()
方法等待其处理完当前记录。如果子线程确实处理了记录并返回有效的偏移量,则将该偏移量添加到offsetsToCommit
中,以便稍后提交。
- 对于每个停止的子线程,通过调用
-
③ 收集已撤销分区的消费位移信息:
- 遍历
partitions
集合,从offsetsToCommit
中移除撤销分区的位移信息,并将其存储在revokedPartitionOffsets
中。
- 遍历
-
④ 提交撤销分区的消费位移:
- 尝试使用
consumer.commitSync(revokedPartitionOffsets)
同步提交revokedPartitionOffsets
中的消费位移信息。如果提交过程中出现异常,则记录错误日志。
- 尝试使用