[Kafka核心设计与实践原理]-消费者原理

1,055 阅读36分钟

代码示例

import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.PartitionInfo;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.serialization.StringDeserializer;

import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicBoolean;

@Slf4j
public class Consumer {

    public static final String brokerList = "ip:9092";

    public static final String topic = "topic-show";

    public static final String groupId = "group.demo";

    public static final AtomicBoolean isRunning = new AtomicBoolean(true);

    public static Properties initConfig() {
        Properties properties = new Properties();
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
        return properties;
    }

    public static void main(String[] args) {
        Properties properties = initConfig();

        KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(properties);
        // 订阅消息
        consumer.subscribe(Collections.singletonList(topic));

        try {
            while (isRunning.get()) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
                for (ConsumerRecord<String, String> record : records) {
                    System.out.println(String.format("topic:%s,offset:%d,消息:%s",
                            record.topic(), record.offset(), record.value()));
                }
            }
        } catch (Exception e) {
            log.error(e.getMessage());
        } finally {
            consumer.close();
        }
    }
}

消费者与消费组

消费者(Consumer):负责订阅Kafka中的主题(Topic),并且从订阅的主题上拉取消息。

消费者组(Consumer Group):每个消费者都有一个对应的消费组。当消息发布到主题后,只会被投递 给订阅它的每一个消费组中的一个消费者。

某个主题中共有4个分区(Partition):P0,P1,P2,P3。有两个消费者组A和B都订阅了这个主题,消费者A中有四个消费者(C0,C1,C2,C3),消费者组B中有两个消费者(C4,C5)。按照Kafka默认的规则,最后的分配结果是消费者组A中的每一个消费者分配到一个分区,消费者组B中每一个消费者分配到2个分区,两个消费者之间互不影响。每个消费者只能消费所分配到的分区中的消息。换言之,每一个分区只能被一个消费者组中的一个消费者所消费。

假如某消费者组内只有一个消费者C0,订阅了一个主题,这个主题包含了7个分区:P0,P1,P2,P3,P4,P5,P6。也就是说,这个消费者C0订阅了7个分区

此时消费者组内又加入了一个新的消费者C1,按照既定的逻辑,需要将原来消费者C0,的部分分区分配给消费者C1消费,消费者C0和C1各自负责消费所分配到的分区。

紧接着消费责组内又加入了一个新的消费者C2,消费者C0,C1,C2按照图3-4中的方式各自负责消费所分配到的分区。

消费者与消费组这种模型可以让整体的消费能力具备横向伸缩性,我们可以增加/减少消费者的个数来提高/降低整体的消费能力。对于分区数固定的情况,一味地增加消费者并不会让消费能力一直得到提升,如果消费者过多,出现了消费者的个数大于分区个数的情况,就会有消费者分配不到任何分区。

以上分配逻辑都是基于默认的分区分配策略进行分析的,可以通过消费者客户端参数partition.assignment.strategy来设置消费者与订阅主题之间的分区分配策略。

消息投递模式

点对点(P2P,Point-to-Point)模式

点对点模式是基于队列的,消息生产者发送消息到队列,消息消费者从队列中接收消息。

发布/订阅(Pub/Sub)模式

发布订阅模式定义了如何向一个内容节点发布和订阅消息,这个内容节点称为主题(Topic),主题可以认为是消息传递的中介,消息发布者将消息发布到某个主题,而消息订阅者从主题中订阅消息。主题使得消息的订阅者和发布者互相保持独立,不需要进行接触即可保证消息的传递。

  • 同消费组: 所有的消息都会被均衡地投递给每一个消费者,即每条消息只会被一个消费者 消费,这就是点对点模式的应用。

  • 不同消费组: 所有的消息都会被广播给所有的消费者,即每条消息都会被所有的消费者 消费,这就是发布/订阅模式的应用。

客户端开发

流程

  1. 配置消费者客户端参数及创建相应的消费者实例。
  2. 订阅主题
  3. 拉取消息并且消费
  4. 提交消费位移
  5. 关闭消费者实例

参数配置

必要的参数配置

bootstrap.servers: 该参数用来指定生产者客户端连接Kafka集群所需的broker地址清单,格式:host1:port1,host2:port2

key.deserializer&value.deserializer: 消费者从broker端获取消息格式都是字节数组(byte[])类型,所以需要反序列化操作才能还原成原有的对象格式。

group.id: 隶属消费组的名称,默认为空字符串,如果为空,则会报出异常:Exception in tread “main” org.apache.kafka.common.errors.InvalidGroupIdException:The configured groupId is invalid。一般而言,这个参数需要设置成具有一定业务意义的名称。

订阅主题与分区

订阅主题

# 前后两次订阅主题,以最后一次为准
consumer.subscribe(Collections.singletonList(topic1));
consumer.subscribe(Collections.singletonList(topic2));

# 正则表达式的方式订阅
consumer.subscribe(Pattern.compile(“topic-.*”));

ConsumerRebalanceListener: 设置相应的再均衡监听器

订阅分区

public void assign(Collection<TopicPartition> partitions) {
        this.acquireAndEnsureOpen();

        try {
            if (partitions == null) {
                throw new IllegalArgumentException("Topic partition collection to assign to cannot be null");
            }

            if (partitions.isEmpty()) {
                this.unsubscribe();
            } else {
                Set<String> topics = new HashSet();
                Iterator var3 = partitions.iterator();

                while(true) {
                    if (var3.hasNext()) {
                        TopicPartition tp = (TopicPartition)var3.next();
                        String topic = tp != null ? tp.topic() : null;
                        if (topic != null && !topic.trim().isEmpty()) {
                            topics.add(topic);
                            continue;
                        }

                        throw new IllegalArgumentException("Topic partitions to assign to cannot have null or empty topic");
                    }

                    this.coordinator.maybeAutoCommitOffsetsAsync(this.time.milliseconds());
                    this.log.debug("Subscribed to partition(s): {}", Utils.join(partitions, ", "));
                    this.subscriptions.assignFromUser(new HashSet(partitions));
                    this.metadata.setTopics(topics);
                    break;
                }
            }
        } finally {
            this.release();
        }

    }

这个方法只接受一个参数partitions,用来指定需要订阅的分区集合。

topic:分区所属的主题

partition:分区编号

consumer.assign(Arrays.asList(new TopicPartition("topic-demo", 0)));

如何知道主题中有多少个分区呢?

List<PartitionInfo> partitionInfos = consumer.partitionsFor(topic);

PartitionInfo

public class PartitionInfo {
    private final String topic;//主题
    private final int partition;//分区
    private final Node leader;//分区的leader副本所在的位置
    private final Node[] replicas;//分区的AR集合
    private final Node[] inSyncReplicas;//分区的ISR集合
    private final Node[] offlineReplicas;//分区的OSR集合
}

AR:分区中的所有副本统称为AR(Assigned Replicas)

ISR: 分区中与leader副本保持一定程度同步的副本成为ISR(In-Sync Replicas)

OSR: ISR集合是AR集合中的一个子集。消息会先发送到leader副本,然后follower副本才能从leader副本中拉取消息进行同步,同步期间内follower副本相对于leader副本而言会有一定程度的滞后。前面所说的“一定程度”是指可以忍受的滞后范围,这个范围可以通过参数进行配置。与leader副本同步滞后过多的副本(不包括leader)副本,组成OSR(Out-Sync Relipcas)

通过assign()方法来实现订阅主题(全部分区)

List<TopicPartition> partitions = new ArrayList<>();
List<PartitionInfo> partitionInfos = consumer.partitionsFor(topic);
if (partitionInfos != null) {
    for (PartitionInfo tpInfo : partitionInfos) {
    	partitions.add(new TopicPartition(tpInfo.topic(), tpInfo.partition()));
    }
}
consumer.assign(partitions);

取消订阅

consumer.unsubscribe();

如果将subscribe(Collection)或assign(Collection)中的集合参数设置为空集合,那么作用等同于unsubscribe()

consumer.unsubscribe();

consumer.subscribe(new ArrayList<String>());

consumer.assign(new ArrayList<TopicPartition>());

如果没有订阅任何主题或者分区的情况下,那么继续执行消费程序会报出IllegalStateException异常

集合订阅的方式、正则表达式订阅的方式和指定分区的订阅方式分别代表了三种不同的订阅状态:AUTO_TOPICS、AUTO_PATTERN、USER_ASSIGNED(如果没有订阅,订阅状态为NONE),然而三种状态是互斥的,一个消费者中只能使用一种,否则报出IllegalStateException异常

通过subscribe()方法订阅主题具有消费者自动再均衡的功能,在多个消费者的情况下可以根据分区 策略来自动分配各个消费者与分区的关系。当消费组内消费者增加或者减少时,分区分配关系自 动调整,以实现消费负载均衡及故障自动转移。而通过assign()方法订阅分区时,是不具备消费者 自动均衡的功能的

反序列化

Deserializer

public interface Deserializer<T> extends Closeable {
	//配置当前类。
    void configure(Map<String, ?> var1, boolean var2);

	//执行反序列化。如果data为null,那么处理的时候直接返回null而不是抛出一个异常。
    T deserialize(String var1, byte[] var2);
	
    //关闭当前反序列化器。
    void close();
}

StringDeserializer

public class StringDeserializer implements Deserializer<String> {
    private String encoding = "UTF8";

    public StringDeserializer() {
    }

    public void configure(Map<String, ?> configs, boolean isKey) {
        String propertyName = isKey ? "key.deserializer.encoding" : "value.deserializer.encoding";
        Object encodingValue = configs.get(propertyName);
        if (encodingValue == null) {
            encodingValue = configs.get("deserializer.encoding");
        }

        if (encodingValue instanceof String) {
            this.encoding = (String)encodingValue;
        }

    }

    public String deserialize(String topic, byte[] data) {
        try {
            return data == null ? null : new String(data, this.encoding);
        } catch (UnsupportedEncodingException var4) {
            throw new SerializationException("Error when deserializing byte[] to string due to unsupported encoding " + this.encoding);
        }
    }

    public void close() {
    }
}

自定义反序列化器

无特殊要求,不建议使用自定义的序列化器或反序列化器,因为这样会增加生产者和消费者之间的耦合度,在系统升级换代的时候很容易出错。

通用的序列化工具包

Avro,JSON,Thrift,ProtoBuf或者Protostuff等,也需要实现Serializer和Deserialiser接口

Protostuff

依赖

<dependency>
    <groupId>io.protostuff</groupId>
    <artifactId>protostuff-core</artifactId>
    <version>1.5.4</version>
</dependency>

<dependency>
    <groupId>io.protostuff</groupId>
    <artifactId>protostuff-runtime</artifactId>
    <version>1.5.4</version>
</dependency>

User

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@Setter
@Getter
@ToString
@NoArgsConstructor
public class User {
    private String userId;
    private String userName;
}

ProtostuffSerializer

import com.evan.kafka.bean.User;
import io.protostuff.LinkedBuffer;
import io.protostuff.ProtostuffIOUtil;
import io.protostuff.Schema;
import io.protostuff.runtime.RuntimeSchema;
import org.apache.kafka.common.serialization.Serializer;

import java.util.Map;

public class ProtostuffSerializer  implements Serializer<User> {

    @Override
    public void configure(Map<String, ?> map, boolean b) {}

    @Override
    public byte[] serialize(String s, User user) {
        if (user == null) {
            return null;
        }
        Schema schema = RuntimeSchema.getSchema(user.getClass());
        LinkedBuffer buffer = LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE);
        byte[] protostuff = null;
        try {
            protostuff = ProtostuffIOUtil.toByteArray(user, schema, buffer);
        } catch (Exception e) {
            throw new IllegalStateException(e.getMessage(), e);
        } finally {
            buffer.clear();
        }
        return protostuff;
    }

    @Override
    public void close() {}
}

ProtostuffDeserializer

import com.evan.kafka.bean.User;
import io.protostuff.ProtostuffIOUtil;
import io.protostuff.Schema;
import io.protostuff.runtime.RuntimeSchema;
import org.apache.kafka.common.serialization.Deserializer;

import java.util.Map;

public class ProtostuffDeserializer implements Deserializer<User> {

    @Override
    public void configure(Map<String, ?> map, boolean b) {}

    @Override
    public User deserialize(String s, byte[] bytes) {
        if (bytes == null) {
            return null;
        }
        Schema schema = RuntimeSchema.getSchema(User.class);
        User user = new User();
        ProtostuffIOUtil.mergeFrom(bytes, user, schema);
        return user;
    }

    @Override
    public void close() {}
}

使用通用的序列化工具实现自定义的序列化器和反序列化器的封装,尽可能实现了更加通用且前后兼容。

消息消费

Kafka中的消费是基于拉模式的。消息的消费一般有两种模式:推模式和拉模式。 推模式:服务端主动将消息推送给消费者。 拉模式:消费者主动向服务端发起请求来拉取消息。

从代码示例中可以看出,Kafka中的消息消费是一个不断轮询的过程,消费者所要做的就是重复地调用poll(方法,而poll()方法返回的是所有订阅的主题(分区)上的一组消息)

如果某些分区中没有可供消费的消息,那么此分区对应的消息拉取的结果为空; 如果订阅的所有分区中都没有可供消费的消息,那么poll()方法返回为空的消息集合。

public ConsumerRecords<K, V> poll(final Duration timeout)

timeout: Duration类型,用来控制poll()方法的阻塞时间,在消费者的缓冲区里没有可用数据时会发生阻塞。

ConsumerRecord

消费者消费到的每一条消息类型为ConsumerRecord(注意与ConsumerRecords的区别),与生产者发送的消息类型ProducerRecord相对应。

public class ConsumerRecord<K, V> {
    public static final long NO_TIMESTAMP = RecordBatch.NO_TIMESTAMP;
    public static final int NULL_SIZE = -1;
    public static final int NULL_CHECKSUM = -1;

    private final String topic;
    private final int partition;
    private final long offset;
    private final long timestamp;
    private final TimestampType timestampType;
    private final int serializedKeySize;
    private final int serializedValueSize;
    private final Headers headers;
    private final K key;
    private final V value;

    private volatile Long checksum;

topic: 所属主题

partition: 所在分区编号

offset: 所属分区的偏移量

timestamp: 时间戳

timestampType: 时间戳类型(CreateTime:消息的创建时间戳;LogAppendTime:消息追加到日志的时间戳)

headers: 消息的头部内容

key&value: 消息的键与值,一般业务要读取的就是value,比如StringSerializer序列化了一个字符串,然后将其存入Kafka,那么消费到的消息中value就是经过StringDeserializer反序列化后的字符串

serializedKeySize&serializedValueSize: 分别表示key和value经过序列化后的大小,如果key为空,serializedKeySize为-1。serializedValueSize同理。

checksum: CRC32的校验值。

方法的返回类型是ConsumerRecords

poll()方法的返回类型是ConsumerRecords,用来表示一次拉取操作所获取的消息集,内部包含了若干个ConsumerRecord,它提供了一个iterator()方法来循环遍历消息集内部的消息。

public Iterator<ConsumerRecord<K, V>> iterator()

获取消息集中指定分区的消息

public List<ConsumerRecord<K, V>> records(TopicPartition partition)

示例:

ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (TopicPartition tp : records.partitions()) {
    for (ConsumerRecord<String, String> record : records.records(tp)) {
        System.out.println(record.partition() + ":" +record.value());
    }
}

示例中records.partitions()获取消息集中所有分区。在ConsumerRecords类中提供了按照主题维度来进行消费的方法,这个方法是records(TopicPartition)的重载方法,具体定义如下:

public Iterable<ConsumerRecord<K, V>> records(String topic)

ConsumerRecords类中并没有提供与partitions()类似的topics()方法来查看拉取的消息集中所包含的主题列表,如果要按照主题维度来进行消费,那么只能根据消费者订阅主题时的列表来进行逻辑处理了。下面演示如何使用ConsumerRecords类中的records(String topic)方法:

List<String> topics = Arrays.asList(topic1, topic2);
consumer.subscribe(topics);

try {
    while (isRunning.get()) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
        for (String topic : topics) {
            for (ConsumerRecord<String, String> record : records.records(topic)) {
                System.out.println(record.topic() + ":" +record.value());
            }
        }
    }
} finally {
    consumer.close();
}

其他

在ConsumerRecords类中还提供了其他方法来方便开发人员对消息集进行处理

count(): 计算消息集中的消息个数,返回类型是int

isEmpty(): 用来判断消息集是否为空,返回类型是boolean;

empty(): 用来获取一个空的消息集,返回类型是ConsumerRecords<K, V>

总结

目前为止,简单认为poll()只是拉取一下消息,但就其内部逻辑涉及了消费位移、消费者协调器、组协调器、消费者的选择、分区分配的分发、再均衡的逻辑、心跳等内容。

位移提交

在旧消费者客户端,消费位移是存储在Zookeeper中。

新消费者客户端中,消费位移存储在Kafka的__consumer_offsets中。

把消费位移存储起来的动作称为"提交",消费者在消费完消息后需要执行消费位移的提交。

参考上图,x表示某一次拉取操作中此分区消息的最大偏移量,假设当前消费者已经消费了x位置的消息,那么我们就可以说消费者的消费位移为x,图中也用lastConsumerOffset这个单词来标识它。

当消费者需要提交的消费位移并不是x,而是x+1,对应图中的position,它表示下一条需要拉取的消息的位置。

//获取position

public long position(TopicPartition partition)

//获取committed offset

public OffsetAndMetadata committed(TopicPartition partition)

示例

TopicPartition tp = new TopicPartition(topic, 0);
consumer.assign(Arrays.asList(tp)) ;
loηg lastConsumedOffset = -1; //当前消费到的位移 
while (true) {
    ConsumerRecords<String, String> records = consumer.poll(l000);
    if (records.isEmpty()) {
    	break;
    }
    List<ConsumerRecord<String, String> = records.records(tp);
    lastConsumedOffset = partitionRecords.get(partitionRecords.size() -1).offset();
    consumer.commitSync();//同步提交消费位移
}
System.out.println(”comsumed offset is " + lastConsumedOffset) ; 
OffsetAndMetadata offsetAndMetadata = consumer.committed (tp );
System.out.println (”commited offset is" + offsetAndMetadata.offset());
long posititon = consumer.position(tp);
System.out.println (”the offset of the next record is " + posititon);

示例中通过assign()方法订阅了编号为0的分区,然后消费分区中的消息。示例中还通过调用ConsumerRecords.isEmpty()方法来判断是否已经消费完分区中的消息,以此来退出while循环,此示例仅作为参考演示,逻辑并不严谨。

position = committed offset = lastConsumedOffset + 1

消息丢失

假设一次poll()拉取的消息区间是[x+2, x+7],x+2就是上一次提交的offset,而将要提交的消费位移的committed offset是x+8,而当前正在处理的消息是x+5,如果还没消费完,我们就进行提交x+8,此时系统故障,系统恢复后,消费就会从x+8开始,那么x+6,x+7就没有进行消费。

重复消费

还是上面的图,依旧消费到x+5,此时系统出现异常,当系统恢复正常,那么读取到offset还是x+2,该次消费又会从x+2处开始消费,那么就会出现重复消费的情况。

实际情况分析

而实际情况还会有比这两种更加复杂的情形,比如第一次的位移提交的位置为x+8,而下一次的位移提交的位置为x+4,后面会做进一步的分析 。

在Kafka中默认的消费位移的提交方式是自动提交,这个由消费者客户端参数 enable.auto.commit 配置,默认值为 true。当然这个默认的自动提交不是每消费一条消息就提交 一次,而是定期提交,这个定期的周期时间由客户端参数auto.commit.interval.ms配置,默认值为 5 秒,此参数生效的前提是enable.auto.commit参数为 true。

在默认的方式下,消费者每隔5秒会将拉取到的每个分区中最大的消息位移进行提交。 自动位移提交的动作是在poll()方法的逻辑里完成的,在每次真正向服务端发起拉取请求之前会检查是否可以进行位移提交,如果可以,那么就会提交上一次轮询的位移。

在 Kafka 消费的编程逻辑中位移提交是一大难点,自动提交消费位移的方式非常简便,它免去了复杂的位移提交逻辑,让编码更简洁。但随之而来的是重复消费和消息丢失的问题。假设刚刚提交完一次消费位移,然后拉取一批消息进行消费,在下一次自动提交消费位移之前,消费者崩溃了,那么又得从上一次位移提交的地方重新开始消费,这样便发生了重复消费的现象(对于再均衡的情况同样适用)。我们可以通过减小位移提交的时间间隔来减小重复消息的窗口大小,但这样并不能避免重复消费的发送,而且也会使位移提交更加频繁。

按照一般思维逻辑而言,自动提交是延时提交,重复消费可以理解,那么消息丢失又是在什么情形下会发生的呢?我们来看一下下图中的情形。拉取线程A不断地拉取消息并存入本地缓存,比如在BlockingQueue中,另一个处理线程B从缓存中读取消息并进行相应的逻辑处理。假设目前进行到了第y+l次拉取,以及第m次位移提交的时候,也就是x+6之前的位移己经确认提交了,处理线程B却还正在消费x+3的消息。此时如果处理线程B发生了异常,待其恢复之后会从第m此位移提交处,也就是x+6的位置开始拉取消息,那么x+3至x+6之间的消息就没有得到相应的处理,这样便发生消息丢失的现象。

同步提交

public void commitSync ()
批量处理+批量提交
final int minBatchSize = 200; 
List<ConsumerRecord> buffer= new ArrayList<>();
while (isRunning.get()) {
    ConsumerRecords<String , String> records = consumer.poll(lOOO) ; 
    for (ConsumerRecord<String, String> record : records) {
        buffer.add(record);
        if (buffer.size() >= minBatchSize) {
            //do some logical processing with buffer .
            consumer.commitSync() ;
            buffer.clear() ;
        }
    }
}

上面的示例中将拉取到的消息存入缓存buffer,等到积累到足够多的时候,也就是示例中大于等于200个的时候,再做相应的批量处理,之后再做批量提交。这两个示例都有重复消费的问题,如果在业务逻辑处理完之后,并且在同步位移提交前,程序出现了崩渍,那么待恢复之后又只能从上一次位移提交的地方拉取消息,由此在两次位移提交的窗口中出现了重复消费的现象。

带参数的同步位移提交
while (isRunning. get()) {
    ConsumerRecords<String , String> records = consumer.poll(lOOO) ; 
    for (ConsumerRecord<String, String> record : records) {
        //do some logical processing. 
        long offset = record.offset() ; 
        TopicPartition partition = 
        	new TopicPartition(record.topic(), record.partition());
        consumer.commitSync(Collections
            .singletonMap(partition, new OffsetAndMetadata(offset + 1))) ;
    }
}
按分区粒度同步提交消费位移
try {
    while (isRunning.get()) {
        ConsumerRecords<String, String> records = consumer.poll(lOOO);
        for (TopicPartition partition : records.partitions()) {
            List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
            for (ConsumerRecord<String, String> record : partitionRecords) { 
                //do some logical processing.
                long lastConsumedOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
                consumer.commitSync(Collections.singletonMap(partition, new OffsetAndMetadata(lastConsumedOffset + 1)));
            }
        }
    }
} finally {
    consumer.close();
}

异步提交

public void commitAsync {}
public void commitAsync{OffsetCommitCallback callback}
public void commitAsync(final Map<TopicPartition, OffsetAndMetadata> offsets, OffsetCommitCallback callback)

第一个无参方法和第三个中offsets参照同步提交。关键是第二个方法中的callback,它提供了一个异步提交的回调方法,当位移提交完成后会回调OffsetCommitCallback中的onComplete()方法。这里采用第二个方法 来演示回调函数的用法。

示例

while (isRunning.get()) {
    ConsumerRecords<String, String> records = consumer.poll(1000);
    for (ConsumerRecord<String, String> record : records) {
        // do some logical processing.
    }
    consumer.commitAsync(new OffsetCommitCallback() {
        @Override
        public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception) {
            if (exception == null) {
                System.out.println(offsets);
            } else {
                log.error("fail to commit offsets {}", offsets, exception) ;
            }
        }
    });
}

commitAsync()提交的时候同样会有失败的情况发生,那么我们应该怎么处理呢?读者有可能想到的是重试,问题的关键也就在这里了。如果某一次异步提交的消费位移为x,但是提交失败了,然后下一次又异步提交了消费位移为x+y,这次成功了。如果这里引入了重试机制,前一次的异步提交的消费位移在重试的时候提交成功了,那么此时的消费位移又变为了x。如果此时发生异常(或者再均衡) ,那么恢复之后的消费者(或者新的消费者)就会从x处开始消费消息,这样就发生了重复消费的问题 。 为此我们可以设置一个递增的序号来维护异步提交的顺序,每次位移提交之后就增加序号相对应的值。在遇到位移提交失败需要重试的时候,可以检查所提交的位移和序号的值的大小,如果前者小于后者,则说明有更大的位移己经提交了,不需要再进行本次重试;如果两者相同,则说明可以进行重试提交。除非程序编码错误,否则不会出现前者大于后者的情况 。 如果位移提交失败的情况经常发生,那么说明系统肯定出现了故障,在一般情况下,位移提交失败的情况很少发生,不重试也没有关系,后面的提交也会有成功的。重试会增加代码逻辑的复杂度,不重试会增加重复消费的概率。如果消费者异常退出,那么这个重复消费的问题就很难避免,因为这种情况下无法及时提交消费位移;如果消费者正常退出或发生再均衡的情况,那么可以在退出或再均衡执行之前使用同步提交的方式做最后的把关。

控制或关闭消费

KafkaConsumer 提供了对消费速度进行控制的方法,在有些应用场景下我们可能需要暂停某些分区的消费而先消费其他分区,当达到一定条件时再恢复这些分区的消费。 KafkaConsumer中使用 pause()和 resume()方法来分别实现暂停某些分区在拉取操作时返回数据给客户端和恢复某些分区向客户端返回数据的操作。这两个方法的具体定义如下:

public void pause(Collection<TopicPartition> partitions)
public void resume(Collection<TopicPartition> partitions)

之前的展示中,while循环来包裹poll()方法及相应的消费逻辑,我们并没有以while(true)的形式做简单的包裹,而是使用 while(isRunning.get())的方式,这样可以在其他地方设定isRunning.set(false)来退出while循环。还有一种方式是调用KafkaConsumer的wakeup()方法 ,wakeup()方法是KafkaConsumer中唯一可以从其他线程里安全调用的方法(KafkaConsumer是非线程安全的〉,调用wakeup()方法后可以退出poll()的逻辑,并抛出WakeupException的异常,我们也不需要处理WakeupException的异常,它只是一种跳出循环的方式。

跳出循环以后一定要显式地执行关闭动作以释放运行过程中占用的各种系统资源,包括内存资源、 Socket连接等。KafkaConsumer提供了close()方法来实现关闭,close()方法有三种重载方法,分别如下:

public void close()

public void close(Duration timeout)

@Deprecated
public void close(long timeout , TimeUnit timeUnit)

第二种方法是通过 timeout 参数来设定关闭方法的最长执行时间, 有些内部的关闭逻辑会耗费一定的时间,比如设置了自动提交消费位移,这里还会做一次位移提交的动作; 而第一种方法没有timeout参数,这并不意味着会无限制地等待,它内部设定了最长等待时间(30秒);第三种方法己被标记为@Deprecated,可以不考虑。一个相对完整的消费程序的逻辑可以参考下面的伪代码 :

consumer.subscribe(Arrays .asList (topic)); 
try {
  while (running.get()) {
  	//consumer.poll(***) 
  	//process the record. 
  	//commit offset.
  }
} catch (WakeupException e) { 
  //ingore the error
} catch (Exception e) {
  //do some logic process.
} finally {
  //maybe commit offset. 
  consumer.close() ;
}

当关闭这个消费逻辑的时候,可以调用consumer.wakeup(),也可以调用isRunning.set(false)。

指定位移消费

在Kafka中,每当消费者查找不到所记录的消费位移时,就会根据消费者客户端参数auto . offset.reset的配置来决定从何处开始进行消费,这个参数的默认值为“latest”,表示从分区末尾开始消费消息。

按照默认的配置,消费者会从9开始进行消费(9是下一条要写入消息的位置〉,更加确切地说是从9开始拉取消息。如果将auto.offset.reset参数配置为“earliest”,那么消费者会从起始处,也就是0开始消费。

auto.offset.reset

根据消费者客户端参数auto.offset.reset的配置来决定从何处开始进行消费,这个参数的默认值为“latest”,表示从分区末尾开始消费消息。

还有一个可配置的值一一“none”,配置为此值就意味着出现查到不到消费位移的时候,既不从最新的消息位置处开始消费,也不从最早的消息位置处开始消费,此时会报出NoOffsetForPartitionException异常

org.apache.kafka.clients.consumer.NoOffsetForPartitionException:Undefined offsetwithnoresetpolicyforpartitions: [topic-demo-3, topic-demo-0, topic-demo-2, topic-demo-1].

如果都没配置,则会报出ConfigException异常

org.apache.kafka.common.config.ConfigException: Invalid value any for configuration auto.offset.reset: String must be one of: latest, earliest, none.

seek()

到目前为止,我们知道消息的拉取是根据poll()方法中的逻辑来处理的,这个poll()方法中的逻辑对于普通的开发人员而言是一个黑盒,无法精确地掌控其消费的起始位置。提供的auto.offset.reset 参数也只能在找不到消费位移或位移越界的情况下粗粒度地从开头或末尾开始消费。有些时候,我们需要一种更细粒度的掌控,可以让我们从特定的位移处开始拉取消息,而KafkaConsumer中的seek()方法正好提供了这个功能,让我们得以追前消费或回溯消费。

KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(properties);
// 订阅消息
consumer.subscribe(Collections.singletonList(topic));
//拉取消息
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
//获取消费者所分配到的分区信息
Set<TopicPartition> assignment = consumer.assignment();
for (TopicPartition tp : assignment) {
    //设置了每个分区的消费位置为10
    consumer.seek(tp, 10);
}

假如poll()方法参数设置为0,此方法立刻返回,那么内部的分区分配策略就还未实施。也就是说,消费者此时没有分配到任何分区,那么assignment便是一个空列表,seek()方法也不会执行。

consumer.poll(Duration.ofMillis(0));

timeout参数设置为多少合适呢?太短会使分配分区的动作失败,太长又有可能造成一些不必要的等待。

我们可以通过KafkaConsumer的 assignment()方法来判定是否分配到了相应的分区。

如果对未分配到的分区执行seek()方法,那么会报出IllegalStateException的异常。类似在调用 subscribe()方法之后直接调用seek()方法:

consumer.subscribe(Arrays.asList(topic)) ; 
consumer.seek(new TopicPartition(topic, 0) , 10);

异常:java.lang.IllegalStateException: No current assignment for partition topic-demo

使用seek()方法从分区末尾消费

KafkaConsumer<String , String> consumer = new KafkaConsumer<>(properties);
consumer.subscribe(Arrays.asList(topic));
Set<TopicPartition> assignment = new HashSet<>();
while(assignment.size() == 0) {
    consumer.poll(Duration.ofMillis(100));
    assignment = consumer.assignment();
}
Map<TopicPartition, Long> offsets = consumer.endOffsets(assignment);
for(TopicPartition tp : assignment) {
    consumer.seek(tp, offsets.get(tp));
}

endOffsets()方法用来获取指定分区的末尾的消息位置


/**
 * @see KafkaConsumer#endOffsets(Collection)
 */
Map<TopicPartition, Long> endOffsets(Collection<TopicPartition> partitions);

/**
 * @see KafkaConsumer#endOffsets(Collection, Duration)
 */
Map<TopicPartition, Long> endOffsets(Collection<TopicPartition> partitions, Duration timeout);

beginningOffsets()方法中的参数内容和含义都与endOffsets()方法中的一样,配合这两个方法我们就可以从分区的开头或末尾开始消费。

/**
 * @see KafkaConsumer#beginningOffsets(Collection)
 */
Map<TopicPartition, Long> beginningOffsets(Collection<TopicPartition> partitions);

/**
 * @see KafkaConsumer#beginningOffsets(Collection, Duration)
 */
Map<TopicPartition, Long> beginningOffsets(Collection<TopicPartition> partitions, Duration timeout);

KafakConsumer提供了seekToBeginning()和seekToEnd()来直接从开头或者结尾消费。

/**
 * @see KafkaConsumer#seekToBeginning(Collection)
 */
void seekToBeginning(Collection<TopicPartition> partitions);

/**
 * @see KafkaConsumer#seekToEnd(Collection)
 */
void seekToEnd(Collection<TopicPartition> partitions);

根据时间节点消费

KafkaConsumer同样考虑到了这种情况,它提供了一个offsetsForTimes()方法,通过timestamp来查询与此对应的分区位置。

/**
 * @see KafkaConsumer#offsetsForTimes(Map)
 */
Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch);

/**
 * @see KafkaConsumer#offsetsForTimes(Map, Duration)
 */
Map<TopicPartition, OffsetAndTimestamp> offsetsForTimes(Map<TopicPartition, Long> timestampsToSearch, Duration timeout);

timestampsToSearch是map类型,其中key是查询的分区,value是时间戳。

下面为使用示例

Map<TopicPartition, Long> timestampToSearch = new HashMap<>();
for (TopicPartition tp : assignment) {
    timestampToSearch.put(tp, System.currentTimeMillis() - 1 * 24 * 3600 * 1000);
}
Map<TopicPartition, OffsetAndTimestamp> offsets = consumer.offsetsForTimes(timestampToSearch);

for (TopicPartition tp : assignment) {
    OffsetAndTimestamp offsetAndTimestamp = offsets.get(tp);
    if (offsetAndTimestamp != null) {
        consumer.seek(tp, offsetAndTimestamp.offset());
    }
}

数据存储消费位移

KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties);
Set<TopicPartition> assignment = consumer.assignment();

consumer.subscribe(Arrays.asList(topic));
for (TopicPartition tp : assignment) {
    //从DB中读取消费位移
    long offset = getOffsetFromDB(tp);
    consumer.seek(tp, offset);
}
while (true) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
    for (TopicPartition partition : records.partitions()) {
        List<ConsumerRecord<String, String>> partitionRecords = records.records(partition);
        for (ConsumerRecord<String, String> record : partitionRecords) { //process the record .
            long lastConsumedOffset = partitionRecords.get(partitionRecords.size() - 1).offset();
            //将消费位移存储在 DB 中
            storeOffsetToDB(partition, lastConsumedOffset + 1);
        }
    }
}

再均衡

再均衡是指分区的所属权从一个消费者转移到另一消费者的行为,它为消费组具备高可用性和伸缩性提供保障,使我们可以既方便 又安全地删除消费组内的消费者或往消费组内添加消费者。不过在再均衡发生期间,消费组内的消费者是无法读取消息的。 也就是说,在再均衡发生期间的这一小段时间内,消费组会变得不可用。另外,当一个分区被重新分配给另一个消费者时,消费者当前的状态也会丢失。比如消费者消费完某个分区中的一部分消息时还没有来得及提交消费位移就发生了再均衡操作,之后这个分区又被分配给了消费组内的另一个消费者,原来被消费完的那部分消息又被重新消费一遍,也就是发生了重复消费。一般情况下,应尽量避免不必要的再均衡的发生。

之前描述的订阅主题的时候有介绍,subscribe()方法提及了再均衡监昕器ConsumerRebalanceListener。再均衡监昕器用来设定发生再均衡动作前后的一些准备或收尾的动作。

ConsumerRebalanceListener

//这个方法会在再均衡开始之前和消费者停止读取消息之后被调用。可以通过这个回调方法来处理消费位移的提交,以此来避免一些不必要的重复消费现象的发生。参数partitions表示再均衡前所分配到的分区。
void onPartitionsRevoked(Collection<TopicPartition> partitions);

//这个方法会在重新分配分区之后和消费者开始读取消费之前被调用。参数partitions表示再均衡后所分配到的分区 。
void onPartitionsAssigned(Collection<TopicPartition> partitions);

使用示例

KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties);

Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
consumer.subscribe(Arrays.asList(topic), new ConsumerRebalanceListener() {
    //在发生再均衡动作之前可以通过再均衡监听器的onPartitionsRevoked()回调执行commitSync()方法同步提交消费位移,以尽量避免一些不必要的重复消费。
    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
    	//将消费位移暂存到一个局部变量currentOffsets中,
        consumer.commitSync(currentOffsets);
        currentOffsets.clear();
    }

    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        //do nothing.
    }
});
try {
    while (isRunning.get()) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
        for (ConsumerRecord<String, String> record : records) {
            //process the record.
            currentOffsets.put(
                    new TopicPartition(record.topic(), record.partition()),
                    new OffsetAndMetadata(record.offset() + 1));
        }
        //在正常消费的时候可以通过 commitAsync()方法来异步提交消费位
        consumer.commitAsync(currentOffsets, null);
    }
} finally {
    consumer.close();
}

配合外部存储使用

KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties);

consumer.subscribe(Arrays.asList(topic), new ConsumerRebalanceListener() {
    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        //store offset in DB (storeOffsetToDB)
    }

    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        for (TopicPartition tp : partitions) {
            consumer.seek(tp, getOffsetFromDB(tp));
            //从DB中读取消费位移
        }
    }
});

消费者拦截器

消费者拦截器主要在消费到消息或在提交消费位移时进行一些定制化的操作。

与生产者拦截器对应的,消费者拦截器需要自定义实现org.apache.kafka.clients.consumer. Consumerlnterceptor接口。

public ConsumerRecords<K, V> onConsume(ConsumerRecords<K, V> records);

public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets);

public void close();

KafkaConsumer会在poll()方法返回之前调用拦截器的onConsume()方法来对消息进行相应的定制化操作。如果onConsume()方法中抛出异常,那么会被捕获并记录到日志中,但是异常不会再向上传递。

KafkaConsumer会在提交完消费位移之后调用拦截器的onCommit()方法,可以使用这个方法来记录跟踪所提交的位移信息。

close()方法和ConsumerInterceptor的父接口中的configure()方法与生产者的ProducerInterceptor接口中的用途一样。

自定义一个消费者拦截器

import org.apache.kafka.clients.consumer.ConsumerInterceptor;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.common.TopicPartition;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class ConsumerInterceptorTTL implements ConsumerInterceptor<String, String> {
    private static final long EXPIRE_INTERVAL = 10 * 1000;

    @Override
    public ConsumerRecords<String, String> onConsume(ConsumerRecords<String, String> records) {
        long now = System.currentTimeMillis();
        Map<TopicPartition, List<ConsumerRecord<String, String>>> newRecords = new HashMap<>();
        for (TopicPartition tp : records.partitions()) {
            List<ConsumerRecord<String, String>> tpRecords = records.records(tp);
            List<ConsumerRecord<String, String>> newTpRecords = new ArrayList<>();
            for (ConsumerRecord<String, String> record : tpRecords) {
                if (now - record.timestamp() < EXPIRE_INTERVAL) {
                    newTpRecords.add(record);
                }
            }
            if (!newTpRecords.isEmpty()) {
                newRecords.put(tp, newTpRecords);
            }
        }
        return new ConsumerRecords<>(newRecords);
    }

    @Override
    public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets) {
        offsets.forEach((tp, offset) ->
                System.out.println(tp + ":" + offset.offset()));
    }

    @Override
    public void close() {}

    @Override
    public void configure(Map<String, ?> configs) {}
}

使用示例

properties.put(ConsumerConfig.INTERCEPTOR_CLASSES_CONFIG, ConsumerInterceptorTTL.class);

多线程实现

KatkaProducer是线程安全的,然而KafkaConsumer却是非线程安全的。KafkaConsumer中定义了 一个acquire()方法,用来检测当前是否只有一个线程在操作,若有其他线程正在操作则会抛出ConcurrentModifcationException异常:

java.util.ConcurrentModificationException : KafkaConsumer is not safe for multi-threaded access.

KafkaConsumer中的每个公用方法在执行所要执行的动作之前都会调用这个acquire()方法,只有wakeup()方法是个例外。

//KafkaConsumer中的成员变量
private final AtomicLong currentThread = new AtomicLong(NO_CURRENT_THREAD); 

private void acquire() {
    long threadId = Thread.currentThread().getId();
    if (threadId != currentThread.get() && 
    		!currentThread.compareAndSet(NO_CURRENT_THREAD, threadId) )
      	throw new ConcurrentModificationException
        		("KafkaConsumer is not safe for multi-threaded access");
    refcount.incrementAndGet();
}

acquire()方法和我们通常所说的锁(synchronized、Lock等)不同,它不会造成阻塞等待,我们可以将其看作一个轻量级锁,它仅通过线程操作计数标记的方式来检测线程是否发生了并发操作,以此保证只有一个线程在操作。acquire()方法和release()方法成对出现,表示相应的加锁和解锁操作。release()方法也很简单,

private void release() {
  if (refcount.decrementAndGet() == 0)
  	currentThread.set(NO_CURRENT_THREAD);
}

acquire()方法和release()方法都是私有方法,因此在实际应用中不需要我们显式地调用。

KafkaConsumer非线程安全并不意味着我们在消费消息的时候只能以单线程的方式执行。如果生产者发送消息的速度大于消费者处理消息的速度,那么就会有越来越多的消息得不到及时的消费,造成了一定的延迟。除此之外,由于Kafka中消息保留机制的作用,有些消息有可能在被消费之前就被清理了,从而造成消息的丢失。我们可以通过多线程的方式来实现消息消费,多线程的目的就是为了提高整体的消费能力。

多线程实现方式一:线程封闭

一个线程对应一个KafkaConsumer实例,我们可以称之为消费线程。一个消费线程可以消费一个或多个分区中的消息,所有的消费线程都隶属于同一个消费组。这种实现方式的并发度受限于分区的实际个数,根据消费者与分区数的关系,当消费线程的个数大于分区数时,就有部分消费线程一直处于空闲的状态。

public class FirstMultiConsumerThreadDemo {
    public static final String brokerList = "localhost:9092";
    public static final String topic = "topic-demo";
    public static final String groupid = "group.demo";

    public static Properties initConfig() {
        Properties props = new Properties();
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
                StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
                StringDeserializer.class.getName());
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
        props.put(ConsumerConfig.GROUP_ID_CONFIG, groupid);
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
        return props;
    }

    public static void main(String[] args) {
        Properties props = initConfig();
        int consumerThreadNum = 4;
        for (int i = 0; i < consumerThreadNum; i++) {
            new KafkaConsumerThread(props, topic).start();
        }
    }

    public static class KafkaConsumerThread extends Thread {
        private KafkaConsumer<String, String> kafkaConsumer;

        public KafkaConsumerThread(Properties props, String topic) {
            this.kafkaConsumer = new KafkaConsumer<>(props);
            this.kafkaConsumer.subscribe(Arrays.asList(topic));
        }


        @Override
        public void run() {
            try {
                while (true) {
                    ConsumerRecords<String, String> records = kafkaConsumer.poll(Duration.ofMillis(100));
                    for (ConsumerRecord<String, String> record : records) {
                        //处理消息模块
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                kafkaConsumer.close();
            }
        }
    }
}

内部类KafkaConsumerThread代表消费线程,其内部包裹着一个独立的KafkaConsumer实例。通过外部类的main()方法来启动多个消费线程,消费线程的数量由consumerThreadNum变量指定。一般一个主题的分区数事先可以知晓,可以将consumerThreadNum设置成不大于分区数的值,如果不知道主题的分区数,那么也可以通过KafkaConsumer类的partitionsFor()方法来间接获取,进而再设置合理的consumerThreadNum值。

上面这种多线程的实现方式和开启多个消费进程的方式没有本质上的区别,它的优点是每个线程可以按顺序消费各个分区中的消息。缺点也很明显,每个消费线程都要维护一个独立的TCP连接,如果分区数和consumerThreadNum的值都很大,那么会造成不小的系统开销。

参考代码清单处理消息模块,如果这里对消息的处理非常迅速,那么poll()拉取的频次也会更高,进而整体消费的性能也会提升;相反,如果在这里对消息的处理缓慢,比如进行一个事务性操作,或者等待一个RPC的同步响应,那么poll()拉取的频次也会随之下降,进而造成整体消费性能的下降。

多线程实现方式二:多个消费线程同时消费同一个分区

与此对应的第二种方式是多个消费线程同时消费同一个分区,这个通过assign()、seek()等方法实现,这样可以打破原有的消费线程的个数不能超过分区数的限制,进一步提高了消费的能力。不过这种实现方式对于位移提交和顺序控制的处理就会变得非常复杂,实际应用中使用得极少,笔者也并不推荐。一般而言,分区是消费线程的最小划分单位。

多线程实现方式三:将处理消息模块改成多线程

一般而言,poll()拉取消息的速度是相当快的,而整体消费的瓶颈也正是在处理消息这一块,如果我们通过一定的方式来改进这一部分,那么我们就能带动整体消费性能的提升。

public class ThirdMultiConsumerThreadDemo {
    public static final String brokerList = "localhost:9092";
    public static final String topic = "topic-demo";
    public static final String groupid = "group.demo";

    public static Properties initConfig() {
        Properties props = new Properties();
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
                StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
                StringDeserializer.class.getName());
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
        props.put(ConsumerConfig.GROUP_ID_CONFIG, groupid);
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
        return props;
    }

    public static void main(String[] args) {
        Properties props = initConfig();
        KafkaConsumerThread consumerThread = new KafkaConsumerThread(props, topic, Runtime.getRuntime().availableProcessors());
        consumerThread.start();
    }

    public static class KafkaConsumerThread extends Thread {
        private KafkaConsumer<String, String> kafkaConsumer;
        private ExecutorService executorService;
        private int threadNumber;

        public KafkaConsumerThread(Properties props, String topic, int threadNumber) {
            kafkaConsumer = new KafkaConsumer<>(props);
            kafkaConsumer.subscribe(Collections.singletonList(topic));
            this.threadNumber = threadNumber;
            executorService = new ThreadPoolExecutor(threadNumber, threadNumber,
                    0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(1000),
                    new ThreadPoolExecutor.CallerRunsPolicy());
        }

        @Override
        public void run() {
            try {
                while (true) {
                    ConsumerRecords<String, String> records =
                            kafkaConsumer.poll(Duration.ofMillis(100));
                    if (!records.isEmpty()) {
                        executorService.submit(new RecordsHandler(records));
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                kafkaConsumer.close();
            }
        }
    }

    public static class RecordsHandler extends Thread {

        public final ConsumerRecords<String, String> records;

        public RecordsHandler(ConsumerRecords<String, String> records) {
            this.records = records;
        }

        @Override
        public void run() {
            //处理records.
        }
    }
}

RecordHandler类是用来处理消息的,而KafraConsumerThread类对应的是一个消费线程,里面通过线程池的方式来调用RecordHandler处理一批批的消息。注意KafraConsumerThread类中 ThreadPoolExecutor里的最后一个参数设置的是CallerRunsPolicy(), 这样可以防止线程池的总体消费能力跟不上poll()拉取的能力,从而导致异常现象的发生。第三种实现方式还可以横向扩展,通过开启多个KafraConsumerThread实例来进一步提升整体的消费能力。

多线程实现方式三-2:带有具体位移提交的实现方式

每一个处理消息的RecordHandler类在处理完消息之后都将对应的消费位移保存到共享变量offsets中,KafraConsumerThread在每一次poll()方法之后都读取offsets中的内容并对其进行位移提交。注意在实现的过程中对offsets读写需要加锁处理,防止出现并发问题。井且在写入offsets的时候需要注意位移覆盖的问题,针对这个问题,可以将RecordHandler类中的run()方法实现改为如下内容:

public class ThirdMultiConsumerThreadDemo {
    public static final String brokerList = "localhost:9092";
    public static final String topic = "topic-demo";
    public static final String groupid = "group.demo";

    public static Properties initConfig() {
        Properties props = new Properties();
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
                StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
                StringDeserializer.class.getName());
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList);
        props.put(ConsumerConfig.GROUP_ID_CONFIG, groupid);
        props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
        return props;
    }

    public static void main(String[] args) {
        Properties props = initConfig();
        KafkaConsumerThread consumerThread = new KafkaConsumerThread(props, topic, Runtime.getRuntime().availableProcessors());
        consumerThread.start();
    }

    public static class KafkaConsumerThread extends Thread {
        private KafkaConsumer<String, String> kafkaConsumer;
        private ExecutorService executorService;
        private int threadNumber;

        public KafkaConsumerThread(Properties props, String topic, int threadNumber) {
            kafkaConsumer = new KafkaConsumer<>(props);
            kafkaConsumer.subscribe(Collections.singletonList(topic));
            this.threadNumber = threadNumber;
            executorService = new ThreadPoolExecutor(threadNumber, threadNumber,
                    0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(1000),
                    new ThreadPoolExecutor.CallerRunsPolicy());
        }

        @Override
        public void run() {
            try {
                while (true) {
                    ConsumerRecords<String, String> records =
                            kafkaConsumer.poll(Duration.ofMillis(100));
                    if (!records.isEmpty()) {
                        executorService.submit(new RecordsHandler(records));
                    }

                    synchronized (offsets) {
                        if(!offsets.isEmpty()) {
                            kafkaConsumer.commitSync(offsets);
                            offsets.clear();
                        }
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                kafkaConsumer.close();
            }
        }
    }

    public static class RecordsHandler extends Thread {

        public final ConsumerRecords<String, String> records;

        public RecordsHandler(ConsumerRecords<String, String> records) {
            this.records = records;
        }

        @Override
        public void run() {
            for (TopicPartition tp : records.partitions()) {
                List<ConsumerRecord<String, String>> tpRecords = records.records(tp); //处理 tpRecords .
                long lastConsumedOffset = tpRecords.get(tpRecords.size() - 1).offset();
                synchronized (offsets) {
                    if (!offsets.containKey(tp)) {
                        offsets.put(tp, new OffsetAndMetadata(lastConsumedOffset + 1));
                    } else {
                        long position = offsets.get(tp).offset();
                        if (position < lastConsumedOffset + 1) {
                            offsets.put(tp, new OffsetAndMetadata(lastConsumedOffset + 1));
                        }
                    }
                }
            }
        }
    }
}

其实这种位移提交的方式会有数据丢失的风险。对于同一个分区中的消息,假设一个处理线程RecordHandlerl正在处理offset为 099 的消息,而另一个处理线程RecordHandler2己经处理完了offset为 100199 的消息并进行了位移提交,此时如果RecordHandler1发生异常,则之后的消费只能从200开始而无法再次消费 0~99 的消息,从而造成了消息丢失的现象。这里虽然针对位移覆盖做了一定的处理,但还没有解决异常情况下的位移覆盖问题。对此就要引入更加复杂的处理机制,这里再提供一种解决思路,参考下图,总体结构上是基于滑动窗口实现的。对于第三种实现方式而言,它所呈现的结构是通过消费者拉取分批次的消息,然后提交给多线程进行处理,而这里的滑动窗口式的实现方式是将拉取到的消息暂存起来,多个消费线程可以拉取暂存的消息,这个用于暂存消息的缓存大小即为滑动窗口的大小,总体上而言没有太多的变化,不同的是对于消费位移的把控。

如图,每一个方格代表一个批次的消息,一个滑动窗口包含若干方格,startOffset标注的是当前滑动窗口的起始位置,endOffset标注的是末尾位置。每当startOffset指向的方格中的消息被消费完成,就可以提交这部分的位移,与此同时,窗口向前滑动一格,删除原来startOffset所指方格中对应的消息,并且拉取新的消息进入窗口。滑动窗口的大小固定,所对应的用来暂存消息的缓存大小也就固定了,这部分内存开销可控。方格大小和滑动窗口的大小同时决定了消费线程的并发数 ;一个方格对应一个消费线程,对于窗口大小固定的情况,方格越小并行度越高;对于方格大小固定的情况,窗口越大并行度越高。不过,若窗口设置得过大,不仅会增大内存的开销,而且在发生异常(比如 Crash)的情况下也会引起大量的重复消费,同时还考虑线程切换的开销,建议根据实际情况设置一个合理的值,不管是对于方格还是窗口而言,过大或过小都不合适。

如果一个方格内的消息无法被标记为消费完成,那么就会造成startOffset的悬停。为了使窗口能够继续向前滑动,那么就需要设定一个阈值,当startOffset悬停一定的时间后就对这部分消息进行本地重试消费,如果重试失败就转入重试队列,如果还不奏效就转入死信队列。真实应用中无法消费的情况极少,一般是由业务代码的处理逻辑引起的,比如消息中的内容格式与业务处理的内容格式不符,无法对这条消息进行决断,这种情况可以通过优化代码逻辑或采取丢弃策略来避免。如果需要消息高度可靠,也可以将无法进行业务逻辑的消息(这类消息可以称为死信)存入磁盘、数据库或Kafka,然后继续消费下一条消息以保证整体消费进度合理推进,之后可以通过一个额外的处理任务来分析死信进而找出异常的原因。