概念深入
集群和中央控制器
一个独立的kafka服务器被称为broker。broker用来接收来自生产者的消息,为消息设置偏移量,并把消息保存到磁盘。多个kafka实例组成kafka集群,每个实例被称为broker。
broker中央控制器:kafka集群中多个broker,在kafka启动时只有一个会被选举为broker中央控制器(controller leader)。
中央控制器的主要工作:
- 管理整个集群中的分区
- 监控副本的状态
例如:
- leader副本故障,由中央控制器负责为该parttion重新选举新的leader副本
- 当检测到同步列表发生变化,由中央控制器通知集群中所有broker更新其元数据缓存信息
- 当增加某个topic分区的时候也会由中央控制器管理分区的重新分配工作
broker中央控制器的选举过程:
当broker启动的时候,都会创建kafkaController对象,但是集群中只能有一个leader对外提供服务,每个节点上的kafkaController会在指定的zookpeer路径下创建临时节点,只有第一个成功创建节点的kafkaController才可以成为leader,其余的都是follower。当leader故障后,所有的follower都会收到通知,再次竞争在该路径下创建节点从而选举出新的leader。
主题
主题就是指数据主题,是数据记录发布的地方,可以用来区分业务系统。kafka中的Topic是多订阅者模式,一个Topic可以拥有一个或多个消费者来订阅它的数据。
分区
对于每个Topic,kafka集群都会有一个或者多个分区。分区角色分为Leader分区和Follower分区。每个分区都是有序且顺序不可变的记录集,并且不断的追加到log文件中。
为什么设计分区?
- 为了并发读写,加快读写速度;通过这种优化,可以极大的提高并发响应。
- 运用多分区的储存,利于数据的均衡。
- 加速数据的康复速率。如:一旦某台服务器宕机,全部集群只需要康复一部分数据,可加快故障康复的时间。
深入API编程
producer
Producer直接将数据发送到Leader分区的Broker,而无需任何中间路由层。为了帮助生产者做到这一点,所有的kafka节点都可以在任何时间响应关于Metadata的请求,这些数据关于那些服务器处于活动状态以及主题分区的Leader在哪,以允许生产者适当的定向其请求。
生产者(Producer)发送模式:
- 同步方式(Synchornized):调用send方法发送消息,它会返回一个Future对象,调用Future对象就知道消息是否正常发送成功。(Kafka默认为同步,即producer.type=sync)。
- 异步方式(Asynchornized):调用send方法并指定一个回调函数,服务器在返回响应时调用该函数。
- 发送后不管(OneWay):把消息发送给服务器,但并不关心是否正常到达。多数情况下,消息会正常到达,但有时也会丢失一些消息。
代码实例
public class ProduceMessage {
private static Properties props;
private static KafkaProducer<String, String> producer;
private static final String TOPIC_NAME = "topic-02";
public static void main(String[] args) {
// 定义Kafka服务信连接信息
props = operties();
// Kafka broker连接地址
props.put("bootstrap.servers", "192.168.160.130:9092,192.168.160.131:9092,192.168.160.132:9092");
// 序列化类
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
sendMessage();
synchSendMessage();
asynchSendMessage();
}
// 默认方式发送消息(发送后不管结果)
public static void sendMessage() {
props.put("acks", "0");
props.put("retries", 0);
props.put("batch.size", 16384);
props.put("linger.ms", 1);
props.put("buffer.memory", 33554432);
producer = new KafkaProducer<>(props);
ProducerRecord<String, String> record = new ProducerRecord<>(TOPIC_NAME,
"key01", "无确认发送方式");
try {
System.out.println("默认方式发送");
producer.send(record);
} catch (Exception e) {
e.printStackTrace();
}
}
// 同步发送
public static void synchSendMessage() {
producer = new KafkaProducer<>(props);
ProducerRecord<String, String> record = new ProducerRecord<>(TOPIC_NAME, 2,
"key02", "同步确认方式");
try {
// Future.get()等待结果
RecordMetadata result = producer.send(record).get();
System.out.printf("同步发送:%s,分区:%d,offset:%d\n", result.topic(),
result.partition(), result.offset());
} catch (Exception e) {
e.printStackTrace();
}
}
// 异步发送
public static void asynchSendMessage() {
producer = new KafkaProducer<>(props);
ProducerRecord<String, String> record = new ProducerRecord<>(TOPIC_NAME, 2, "key03", "异步确认方式");
producer.send(record, new Callback() {
public void onCompletion(RecordMetadata metadata, Exception e) {
if (e != null) {
e.printStackTrace();
} else {
System.out.println("异步发送消息");
}
}
});
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
序列化/反序列化
生产者需要用序列化把对象转成字节数据再发送给Kafka。而消费者也需要使用反序列化把从Kafka接收到的
字节数组转化成Java对象。Kafka默认提供ByteArraySerializer、StringSerializer和IntegerSerializer等。
序列化接口
// 把对象转换成字节数组
public interface Serializer<T> extends Closeable {
// 序列化方法
byte[] serialize(String topic, T data);
// 序列化方法
default byte[] serialize(String topic, Headers headers, T data) {
return serialize(topic, data);
}
}
反序列化接口
public interface Deserializer<T> extends Closeable {
// 反序列化方法
T deserialize(String topic, byte[] data);
// 反序列化方法
default T deserialize(String topic, Headers headers, byte[] data) {
return deserialize(topic, data);
}
}
代码示例
// 自定义序列化实现
public class UserSerializer implements Serializer<User> {
@Override
public void configure(Map<String, ?> map, boolean b) {
}
@Override
public byte[] serialize(String topic, User user) {
int size = 0;
byte[] serializedName = new byte[0];
if (user == null)
return null;
if (user.getName() != null) {
try {
serializedName = user.getName().getBytes("UTF-8");
size = serializedName.length;
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
ByteBuffer buffer = ByteBuffer.allocate(4 + 4 + size);
buffer.putInt(user.getId());
buffer.putInt(size);
buffer.put(serializedName);
return buffer.array();
}
@Override
public void close() {
}
}
// 自定义反序列化实现
public class UserDeserializer implements Deserializer<User> {
@Override
public void configure(Map<String, ?> configs, boolean isKey) {
// nothing to do
}
@Override
public User deserialize(String topic, byte[] data) {
if (data == null)
return null;
if (data.length < 8)
throw new SerializationException("数据格式不正确,无法序列化!");
ByteBuffer buffer = ByteBuffer.wrap(data);
int id = buffer.getInt();
byte[] nameBytes = new byte[buffer.getInt()];
buffer.get(nameBytes);
try {
String name = new String(nameBytes, "UTF-8");
return new User(id, name);
} catch (UnsupportedEncodingException e) {
throw new SerializationException("反序列化时出错 name to byte[] " + e);
}
}
@Override
public void close() {
// nothing to do
}
}
不建议使用自定义序列化和反序列化。它们会把生产者和消费者紧紧地耦合在一起,脆弱且容易出错。
建议使用标准的消息格式,比如:JSON、Apache Thrift、Google Protobuf或者Apache Avro。
分区器
客户端可以控制将消息发送到哪个分区。可以使用随机策略以实现随机负载平衡,也可以根据用户指定的键值散列后发送到某个分区,Kafka还允许用户自定义策略来决定消息应该发送到哪个分区。
Kafka默认的分区机制:
- 如果记录中制定了分区则直接使用
- 如果未指定分区但指定了key值,则根据key的hash值选择一个分区
- 如果没有指定分区也没有指定key值,则以黏性分区策略(2.4版本之前使用轮询策略)选择一个分区
轮询策略
如果键值为null,并且使用了默认的分区器,Kafka会根据轮询(Random Robin)策略将消息均匀地分布到各个分区上。
散列策略
如果键值不为null,并且使用了默认的分区器,Kafka会对键进行散列,然后根据散列值把消息映射到对应的分区上。
黏性分区器
很多时候消息是没有指定Key的。而Kafka 2.4之前的策略是轮询策略,这种策略在使用中性能比较低。所以2.4中版本加入了黏性分区策略(Sticky Partitioning Strategy)。
黏性分区器(Sticky Partitioner)主要思路是选择单个分区发送所有无Key的消息。一旦这个分区的batch已满或处于“已完成”状态,黏性分区器会随机地选择另一个分区并会尽可能地坚持使用该分区——像黏住这个分区一样。
自定义策略
默认分区器是使用次数最多的分区器。除了散列分区之外,用户可以根据需要对数据使用不一样的分区策略。
分区器接口:org.apache.kafka.clients.producer.Partitioner
public interface Partitioner extends Configurable, Closeable {
// 根据给定的记录来计算分区
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster);
public void close();
}
Metadata
客户端如何知道该往哪个节点发送请求?这里就要提到元数据的概念。元数据是指:
- 服务器活动状态
- 主题及主题的分区
- 分区都有哪些副本
- 以及哪个副本是Leader等信息
一般情况下,客户端会把元数据缓存起来,并直接往目标broker上发送生产请求和获取请求。客户端需要定时发送元数据请求来刷新这些信息。
元数据请求时序图
消息存储与同步
基本分配
副本分配有三个原则:
- 将副本平均分给Broker
- 对于分配给特定的Broker的分区,该分区的其他副本分布在其他的Broker上
- 如果所有Broker都有机架信息,则尽可能将每个分区的副本分配给不同的机架
无机架
- 从Broker列表中随机挑选一个Broker作为起始位置,通过轮询的方式分配每个分区的第一个副本
- 以增加位移的方式为每个分区分配剩余的副本
如下图:3个分区,三个副本
第一个Leader分区被分配到broker1上,那么它的其他两个分区会分配到broker2和broker3上。
有机架
如果配置了机架,就不是按照broker顺序来了,而是按照交替机架的方式来选择broker。
例:broker0、broker1、broker2放置在机架1上,broker3、broker4、broker5放置机架2上。
分区的选择不是按照0、1、2、3、4、5这样的顺序来选择,而是按照0、3、1、4、2、5这样的顺序进行选择,然后应用简单的循环分配方式。
这样做的好处是:如果有机架下线,仍然可以保证可用性。
如图,分配5个分区3个副本的方式如下:
分区号 | 第一个副本 | 第二个副本 | 第三个副本 |
---|---|---|---|
P0 | broker0 | broker3 | broker1 |
P1 | broker3 | broker1 | broker4 |
P2 | broker1 | broker4 | broker2 |
P3 | broker4 | broker2 | broker5 |
P4 | broker2 | broker5 | broker0 |
存储原理
Kafka中消息是以Topic分类的,生产者通过Topic向Kafka broker发送消息,消费者通过Topic读取数据。然而Topic在物理层面又能以partition为分组,一个Topic可以分成若干个partition,partition还可以细分为segment,一个partition物理上由多个segment组成。
每个segment有.log和.index两个文件,每个log文件承载详细的数据,每条消息都有一个offset,index文件是对log文件的索引。Consumer查找offset时运用的是二分法依据文件名去定位到哪个segment,然后解析msg,匹配到对应的offset的msg。
这相当于一个巨型文件被平均分配给多个大小相等的segment数据文件中(每个segment 文件中消息数量不一定相等)这样也方便old segment的删除,即方便已被消费的消息的处理,提高磁盘的利用率。
如上图:index索引文件存储大量的元数据,log文件存储大量的消息,索引文件中的元数据指向对应数据文件中message的物理偏移位置。其中以“.index”索引文件中的元数据(8,1325)为例,在“.log”数据文件表示第8个消息,即在全局partition中表示170410+8=170418个消息,该消息的物理偏移地址为1325。
副本同步机制
同事满足以下两个条件的节点成为“同步”。Leader会跟踪“同步”节点。如果Follower宕机,卡住或掉队,则Leader会将其从同步副本中删除。如何确定副本卡住和滞后是由副本lag.time.max.ms配置控制的。
- 节点必须能够与Zookeeper保持会话(Zookeeper心跳机制)
- 如果是Follower,则必须复制发生在Leader上的写入,并且不能落后太远
术语解释
- AR:Assigned Replicas的缩写。所有的副本(replicas)统称为Assigned Replicas,ISR是AR中的一个子集。
- ISR:In-sync replica的缩写,是指副本同步队列。任意一个follower超过同步阈值replica.lag.time.max.ms都会被剔出ISR。
- OSR:Outof-Sync Replicas的缩写。超过同步的follower都会被剔出ISR,存入OSR列表,新加入的follower也会先存放在OSR中。
- LEO:LogEndOffset的缩写,表示每个partition的log最后一条Message的位置
- HW:HighWatermark的缩写,是指ISR中所有节点都已经复制完的消息的offset。也是消费者所能获取到的消息的最大offset
同步流程:
- HW和LEO相等,此时表示Leader没有新写入的消息。Follower已经完全追赶上Leader。
- 当有新消息写入时,Leader会通知Follower同步消息。
- 以上图为例,Leader收到4、5、6三条消息,Follower1同步了4和5,而Follower2还没有同步到任何消息。此时LEO的位置就是最后一条消息6,而由于两个Follower没有完全同步,这是HW仍然为3.
- 如果这个过程中Follower2同步超过了replica.lag.time.max.ms设置的时间,Follower2会被移出ISR队列。
- 经过一段时间所有的Follower都同步了Leader新收到的4、5、6三条消息,这是LEO=HW,Leader又等待消息的写入。
当Follower被Leader移出ISR,Follower副本对应的分区也被称之为同步失效分区,即under-replicated分区。
当一个故障副本被重启后,首先从磁盘上恢复最新的HW并将日志截断到HW。副本成为Follower后,继续从Leader那里获取HW以后的消息。一旦完全追上Leader,这个副本重新被加入到ISR中。系统将重新回到fully replicated模式。
Consumer
消息消费者,向Kafka broker读取消息的客户端。
为了实现传统Message Queue消息只被消费一次的语义,Kafka保证每条消息在同一个Consumer Group里只会被某一个Consumer消费。与传统Message Queue不同的是,Kafka还允许不同的Consumer Group同时消费同一条消息,这一特性可以为消息的多元化处理提供支持。
每个Consumer属于一个特定的Consumer Group(可为每个Consumer指定group name,若不指定group name则属于默认的group)
消费者代码示例
public class ConsumerMessage {
private static final String TOPIC_NAME = "topic-02";
public static void main(String[] args) {
Properties props = new Properties();
props.setProperty("bootstrap.servers",
"192.168.56.105:9092,192.168.56.105:9093,192.168.56.105:9094");
props.setProperty("group.id", "ConsumerGroup1");
/* 是否自动确认offset */
props.setProperty("enable.auto.commit", "true");
/* 自动确认offset的时间间隔 */
props.setProperty("auto.commit.interval.ms", "1000");
// earliest 最早的偏移量
// latest 最后提交的偏移量
// none 未找到消费者组的之前偏移量,则向消费者抛出e异常
// props.put("auto.offset.reset", "earliest");
props.put("session.timeout.ms", "30000");
// 序列化类
props.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(TOPIC_NAME));
try {
for (; ; ) {
// poll一次会返回一批数据
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
System.out.printf("消费消息:topic=%s, partition=%d, offset=%d, key=%s, value=%s\n",
record.topic(), record.partition(), record.offset(), record.key(), record.value());
}
}
} finally {
consumer.close();
}
}
}
横向扩展
随着用户数量的增多,消息数据也会对应的增长。当达到某个阈值,Producer产生的数量大于Consumer能够消费的速度,那么Broker中未消费的消息就会逐渐增多,消息就会出现堆积。所以多线程的Consumer模型是非常有必要的。
多线程模式
一个Consumer里面包含多个线程
优点:横向扩展方便。
缺点:每个Partition上实现顺序处理比较困难。
消费者组模式
多个Consumer构成Consumer Group每个Consumer都有自己的线程
优点:容易实现。各个Partition的顺序容易实现。
缺点:消费者组中的Consumer不能超过分区数量。
往群组里面增加消费者是横向扩展的主要方式。当单个消费者无法跟上数据生成的速度,可以增加更多的消费者,每个消费者只处理部分分区的消息
客户端负载均衡
所有分片的副本都有自己的log文件(保存消息)和相同的offset。如果Consumer发生故障(如:宕机、连接失败等),就会发生负载均衡,这时需要Consumer Group中另外的Consumer来接管并继续消费。
分区所有权从一个消费者转到另一个消费者,这样被称为再均衡。
再均衡发生在什么时候:
- 当一个新的消费者加入群组,它读取到原来由其他消费者去读的消息。
- 当一个消费者被关闭或者发生崩溃,原本由它读取的分区将由群组内的其他消费者读取。
- 当主题发生变化,比如添加了新的分区,会发生分区重新分派。
再均衡监听器
消费者在退出和分区再均衡之前,需要做一些清理工作,如:关闭文件句柄、数据库连接等。消费者API提供监听器可以在为消费者分配新分区或者移除旧分区时,执行相应的清理工作。
org.apache.kafka.clients.consumer.ConsumerRebalanceListener
// 该方法会在再均衡开始之前和消费者停止读取之后被调用。
// 如果在这个方法中提交偏移量,则下一个消费者就可以获得读取的偏移量。
void onPartitionRevoked(Collection<TopicPartition partitons>);
// 该方法会在再均衡之后和消费者读取之前被调用
void onPartitionAssigned(Collection<TopicPartition partitons>)
代码示例
public class RebalanceListener {
private static final String TOPIC_NAME = "topic-02";
static Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
static KafkaConsumer<String, String> consumer;
private static class CustomerRebalancer implements ConsumerRebalanceListener {
/*
* 再均衡开始之前和消费者停止读取消息之后被调用
* 在这里提交偏移量,下一个消费者就知道从哪里读取
*/
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
// 如果发生再均衡,我们要在即将失去分区所有权时提交偏移量
// 要注意,提交的是最近处理过的偏移量,而不是批次中还在处理的最后一个偏移量
System.out.println("分区再均衡,提交当前偏移量:" + currentOffsets);
consumer.commitSync(currentOffsets);
}
/*
* 在重新分配分区之后和新的消费者开始读取消息之前被调用
*/
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
long committedOffset = -1;
for (TopicPartition topicPartition : partitions) {
// 获取该分区已经提交的偏移量
committedOffset = consumer.committed(topicPartition).offset();
System.out.println("重新分配分区,提交的偏移量:" + committedOffset);
// 重置偏移量到上一次提交的偏移量的下一个位置处开始消费
consumer.seek(topicPartition, committedOffset + 1);
}
}
}
public static void main(String[] args) {
Properties props = new Properties();
props.put("bootstrap.servers",
"192.168.56.105:9092,192.168.56.105:9093,192.168.56.105:9094");
props.put("group.id", "ConsumerGroup1");
/* 关闭自动确认选项 */
props.put("auto.commit.offset", false);
// props.put("auto.commit.interval.ms", "1000");
props.put("session.timeout.ms", "30000");
// 序列化类
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
consumer = new KafkaConsumer<>(props);
consumer.subscribe(Collections.singletonList(TOPIC_NAME), new CustomerRebalancer());
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
System.out.printf("消费消息:topic=%s, partition=%d, offset=%d, key=%s, value=%s\n",
record.topic(), record.partition(), record.offset(), record.key(), record.value());
currentOffsets.put(new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1, "no metadata"));
}
consumer.commitAsync(currentOffsets, null);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
consumer.commitSync(currentOffsets);
} catch (Exception e) {
e.printStackTrace();
} finally {
consumer.close();
System.out.println("Closed consumer successfully!");
}
}
}
}
偏移量
Kafka的消息所在的位置由Topic、Partition、Offset三个因素决定的。Offset代表Partition中的消息的顺序ID。例如:第一个进入的Offset为0,第二个为1,以此类推。由于消费者消费Kafka消息也与Offset和Consumer的group.id有关,故此维护好消息的Offset对于避免消息的重复消费和遗漏消费,确保消息的Exactly-once是至关重要的。
提交偏移量
当我们在程序中使用poll()拉取消息时,Kafka总是返回生产者写入但还没有被消费者消费过的消息。Kafka不会像RabbitMQ那样需要得到消费者的确认,而是使用提交偏移量的方式表示消费者已经消费过该消息。
如何提交偏移量?消费者消费消息后会往_consumer_offset的主题中发送消息,消息里包含每个分区的偏移量。Kafka就是使用消息在分区中的偏移量来追踪消息的,也就是说我们同样可以通过这种方式追踪到哪些记录是被群组里的哪个消费者消费的。
Group偏移量__consumer_offsets保存分区计算
根据group.id,Kafka会使用下面公式计算该group位移保存在__consumer_offsets的哪个分区上:
kafka.coordinator.group.GroupMetadataManager.scala
// 根据groupid的hashcode再和分区进行求模
def partitionFor(groupId: String): Int = Utils.abs(groupId.hashCode) % groupMetadataTopicPartitionCount
Kafka有五种提交偏移量的方式:
- 自动提交
- 提交当前偏移量
- 异步提交
- 同步异步提交
- 提交特定偏移量
自动提交
最简单也是Kafka默认的提交方式。这里有两个参数要特别注意:
- enable.auto.commit 是否开启自动提交
- auto.commit.interval.ms 提交偏移量的时间间隔
自动提交是在轮询中进行,消费者每次轮询时都会检查是否提交该偏移量。这种情况就会发生重复消费和丢失消息的情况。
private ConsumerRecords<K, V> poll(final Timer timer, final boolean includeMetadataInTimeout) {
// 获取锁(用轻量锁代替阻塞),并确保Consumer没有被关闭
acquireAndEnsureOpen();
try {
// 记录拉取开始时间
this.kafkaConsumerMetrics.recordPollStart(timer.currentTimeMs());
// 检查Consumer是否订阅主题或是否分配分区
if (this.subscriptions.hasNoSubscriptionOrUserAssignment()) {
throw new IllegalStateException("Consumer is not subscribed to any topics......");
}
// 轮询数据直到超时到期
do {
// 客户端被安全唤醒
client.maybeTriggerWakeup();
// 是否等待元数据
if (includeMetadataInTimeout) {
// 抓取数据的关键部分
updateAssignmentMetadataIfNeeded(time.timer(0L));
} else {
while (!updateAssignmentMetadataIfNeeded(time.timer(Long.MAX_VALUE))) {
log.warn("Still waiting for metadata");
}
}
final Map<TopicPartition, List<ConsumerRecord<K, V>>> records = pollForFetches(timer);
if (!records.isEmpty()) {
if (fetcher.sendFetches() > 0 || client.hasPendingRequests()) {
client.transmitSends();
}
// 记录将要返回给用户时调用onConsumer
return this.interceptors.onConsume(new ConsumerRecords<>(records));
}
} while (timer.notExpired());
return ConsumerRecords.empty();
} finally {
release();
// 记录拉取结束时间
this.kafkaConsumerMetrics.recordPollEnd(timer.currentTimeMs());
}
}
重复消费
消费者消费消息后还未提交偏移量,此时消费者宕机,发生了分区再均衡。该消费者消费的分区消息由另一个消费者消费,新的消费者会读取之前消费者最后提交的那个偏移量,这样就会发生重复消费。
如果消息处理成功,但是在保存offset时,consumer发生故障。这时来接管的consumer也只能从上一次保存的offset开始消费。这种情况下消息会被重复消费,但保证了at least once语义。
public class ConsumerMessage {
private static final String TOPIC_NAME = "topic-02";
public static void main(String[] args) {
Properties props = new Properties();
props.setProperty("bootstrap.servers",
"192.168.56.105:9092,192.168.56.105:9093,192.168.56.105:9094");
props.setProperty("group.id", "ConsumerGroup1");
/* 是否自动确认offset */
props.setProperty("enable.auto.commit", "true");
/* 自动确认offset的时间间隔 */
// 最关键部分:
// 自动提交偏移量时间长,但是业务处理时间很短
// 在消费者提交偏移量前,业务处理完成,消费者宕机,别的消费者接管后就会重复消费
props.setProperty("auto.commit.interval.ms", "600000");
props.put("session.timeout.ms", "30000");
// 序列化类
props.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(TOPIC_NAME));
try {
for (; ; ) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
System.out.printf("消费消息:topic=%s, partition=%d, offset=%d, key=%s, value=%s\n",
record.topic(), record.partition(), record.offset(), record.key(), record.value());
}
}
} finally {
consumer.close();
}
}
}
消息丢失
消费者抓取了新消息,并且提交了偏移量。但在消费者还没处理完就宕机了,此时会发生分区再均衡,由另一个消费者消费该分区的消息,新的消费者会读取旧消费者最后一次提交的偏移量。这通常就是造成消息丢失的原因。
保存offset成功,但是消息处理失败,consumer发生故障。这时来接管的consumer就只能从上次保存的offset继续消费。这种情况下就有可能丢消息,但是保证了at most once语义。
public class ConsumerMessage {
private static final String TOPIC_NAME = "topic-02";
public static void main(String[] args) {
Properties props = new Properties();
props.setProperty("bootstrap.servers",
"192.168.56.105:9092,192.168.56.105:9093,192.168.56.105:9094");
props.setProperty("group.id", "ConsumerGroup1");
/* 是否自动确认offset */
props.setProperty("enable.auto.commit", "true");
/* 自动确认offset的时间间隔 */
props.setProperty("auto.commit.interval.ms", "1000");
props.put("session.timeout.ms", "30000");
// 序列化类
props.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(TOPIC_NAME));
try {
for (; ; ) {
// poll一次会返回一批数据
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
// 关键部分:多线程方式模拟业务处理时间过长
// 但是自动提交偏移量时间只有1000ms
// 由于业务处理时间过长,而提交偏移量时间过短
// 一旦在处理完业务之前消费者宕机,由于早早提交了偏移量
// 故此消息会丢失
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(60000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.printf("消费消息:topic=%s, partition=%d, offset=%d, key=%s, value=%s\n",
record.topic(), record.partition(), record.offset(), record.key(), record.value());
}
}).start();
}
}
} finally {
consumer.close();
}
}
}
提交当前偏移量
用commitSync()提交由poll方法返回的最新偏移量,如果提交成功马上返回,提交失败则抛出异常。
/* 关闭自动确认选项 */
props.put("enable.auto.commit", false);
// 在必须要时提交偏移量,而不是基于时间
//props.put("auto.commit.interval.ms", "1000");
KafkaConsumer<String, String> consumer = new KafkaConsumer<String, String>(props);
consumer.subscribe(Arrays.asList(TOPIC_NAME));
for (; ; ) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
System.out.printf("topic=%s, partition=%d, offset=%d, key=%s, value=%s\n",
record.topic(), record.partition(), record.offset(), record.key(), record.value());
try {
// 这里进行提交
consumer.commitSync();
} catch (CommitFailedException e) {
System.out.println(e.getMessage());
}
}
}
异步提交
手动提交不足之处在于提交请求后,broker响应之前应用程序会一直阻塞,这样就会限制应用程序的吞吐量。虽然可以通过降低提交频率来提升吞吐量,但一旦发生再均衡,会增加重复消息的数量。
/* 关闭自动确认选项 */
props.put("enable.auto.commit", false);
// 在必须要时提交偏移量,而不是基于时间
//props.put("auto.commit.interval.ms", "1000");
for (; ; ) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
System.out.printf("topic=%s, partition=%d, offset=%d, key=%s, value=%s\n",
record.topic(), record.partition(), record.offset(), record.key(), record.value());
}
// 异步提交
consumer.commitAsync();
}
同步异步提交
如果提交失败发生在关闭消费者或者再均衡前的最后一次提交,那么就要确保提交能够成功。这个时候可以使用同步异步组合提交。
/* 关闭自动确认选项 */
props.put("enable.auto.commit", false);
// 在必须要时提交偏移量,而不是基于时间
//props.put("auto.commit.interval.ms", "1000");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList(TOPIC_NAME));
try {
for (; ; ) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
System.out.printf("topic=%s, partition=%d, offset=%d, key=%s, value=%s\n",
record.topic(), record.partition(), record.offset(), record.key(), record.value());
}
// 异步提交速度更快
consumer.commitAsync();
}
} finally {
// 关闭前或者再均衡前的最后一次提交,要确保能够提交成功
consumer.commitSync();
// 提交,并告知coordinate将要离开消费者组
consumer.close();
}
提交特定偏移量
如果想要在批次中间提交偏移量,消费者API允许在调用commitSync和commitAsync时传递希望提交的分区和偏移量。