转自 https://blog.csdn.net/qq_18581221/article/details/89766073
在使用kafka时,大多数场景对于数据少量的不一致(重复或者丢失)并不关注,比如日志,因为不会影响最终的使用或者分析,但是在某些应用场景(比如业务数据),需要对任何一条消息都要做到精确一次的消费,才能保证系统的正确性,kafka并不提供准确一致的消费API,需要我们在实际使用时借用外部的一些手段来保证消费的精确性,下面我们介绍如何实现
这篇文章KafkaConsumer使用介绍、参数配置介绍了如何kafka具有两种提交offset(消费偏移量)方式,我们在Kafka简介以及安装和使用可知每个分区具备一offset记录消费位置,如果消费者一直处于正常的运行转态,那么offset将没有什么用处,因为正常消费时,consumer记录了本次消费的offset和下一次将要进行poll数据的offset起始位置,但是如果消费者发生崩溃或者有新的消费者加入消费者组,就会触发再均衡Rebalance,Rebalance之后,每个消费者将会分配到新的分区,而消费者对于新的分区应该从哪里进行起始消费,这时候提交的offset信息就起作用了,提交的offset信息包括消费者组所有分区的消费进度,这时候消费者可以根据消费进度继续消费,提交offset提交自动提交是最不具确定性的,所以要使用手动提交来控制offset
/** * 手动提交offset * 实现至少一次的消费语义 at least once * 当手动提交位移失败,会重复消费数据 */ @Test public void testCommitOffset() { String topic = "first-topic"; String group = "g1"; Properties props = new Properties(); props.put("bootstrap.servers", "node00:9092,node03:9092"); //required props.put("group.id", group); //required props.put("enable.auto.commit", "false"); // 关闭自动提交 props.put("auto.commit.interval.ms", "1000"); props.put("auto.offset.reset", "latest"); //从最早的消息开始读取 props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); //required props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); //required Consumerconsumer = new KafkaConsumer<>(props); consumer.subscribe(Arrays.asList(topic)); //订阅topic final int minBatchSize = 10; // 缓存 List > buffer = new ArrayList<>(minBatchSize); try { while (true) { ConsumerRecords records = consumer.poll(1000); records.forEach(buffer::add); // 缓存满了才对数据进行处理 if (buffer.size() >= minBatchSize) { // 业务逻辑--插入数据库 // insertIntoDb(buffer); // 等数据插入数据库之后,再异步提交位移 // 通过异步的方式提交位移 consumer.commitAsync(((offsets, exception) -> { if (exception == null) { offsets.forEach((topicPartition, metadata) -> { System.out.println(topicPartition + " -> offset=" + metadata.offset()); }); } else { exception.printStackTrace(); // 如果出错了,同步提交位移 consumer.commitSync(offsets); } })); // 如果提交位移失败了,那么重启consumer后会重复消费之前的数据,再次插入到数据库中 // 清空缓冲区 buffer.clear(); } } } finally { consumer.close(); } }
代码实现
/** * 实现最多一次语义 * 在消费前提交位移,当后续业务出现异常时,可能丢失数据 */ @Test public void testAtMostOnce() { Properties props = new Properties(); props.put("enable.auto.commit", "false"); KafkaConsumerkafkaConsumer = KafkaFactory.buildConsumer(props); kafkaConsumer.subscribe(Arrays.asList("first-topic")); try { while (true) { ConsumerRecords records = kafkaConsumer.poll(500); // 处理业务之前就提交位移 kafkaConsumer.commitAsync(); // 下面是业务逻辑 records.forEach(record -> { System.out.println(record.value() + ", offset=" + record.offset()); }); } } catch (Exception e) { } finally { kafkaConsumer.close(); } }
从kafka的消费机制,我们可以得到是否能够精确的消费关键在消费进度信息的准确性,如果能够保证消费进度的准确性,也就保证了消费数据的准确性
这里简单说明一下实现思路
1) 利用consumer api的seek方法可以指定offset进行消费,在启动消费者时查询数据库中记录的offset信息,如果是第一次启动,那么数据库中将没有offset信息,需要进行消费的元数据插入,然后从offset=0开始消费
2) 关系型数据库具备事务的特性,当数据入库时,同时也将offset信息更新,借用关系型数据库事务的特性保证数据入库和修改offset记录这两个操作是在同一个事务中进行
3) 使用ConsumerRebalanceListener来完成在分配分区时和Relalance时作出相应的处理逻辑
4) 要弄清楚的是,我们在消费的时候,关闭了自动提交,我们也没有通过consumer.commitAsync()手动提交我们的位移信息,而是在每次启动一个新的consumer的时候,触发rebalance时,读取数据库中的位移信息,从该位移中开始读取partition的信息(初始化的时候为0),在没有出现异常的情况下,我们的consumer会不断从producer读取信息,这个位移是最新的那个消息位移,而且会同时把这个位移更新到数据库中,但是,当出现了rebalance时,那么consumer就会从数据库中读取开始的位移。
表设计
create table kafka_info( topic_group_partition varchar(32) primary key, //主题+组名+分区号 这里冗余设计方便通过这个主键进行更新提升效率 topic_group varchar(30), //主题和组名 partition_num tinyint,//分区号 offsets bigint default 0 //offset信息 );
代码
/** * @Description: 实现Kafka的精确一次消费 * @author: HuangYn * @date: 2019/10/15 21:10 */ public class ExactlyOnceConsume { private final KafkaConsumerconsumer; private Map tpOffsetMap; private List list; private JDBCHelper jdbcHelper = JDBCHelper.getInstance(); private String groupId; private String topic; public ExactlyOnceConsume(Properties props, String topic, String groupId) { this.consumer = KafkaFactory.buildConsumer(props); this.list = new ArrayList<>(100); this.tpOffsetMap = new HashMap<>(); this.groupId = groupId; this.topic = topic; this.consumer.subscribe(Arrays.asList(this.topic), new HandleRebalance()); } public void receiveMsg() { try { while (true) { ConsumerRecords records = consumer.poll(1000); if (!records.isEmpty()) { // 处理每个partition的记录 records.partitions().forEach(tp -> { List > tpRecords = records.records(tp); // 记录加到缓存中 tpRecords.forEach(record -> { System.out.println("partition=" + record.partition() + ", offset= " + record.offset() + ", value=" + record.value()); list.add(record); }); // 将partition对应的offset加到map中, 获取partition中最后一个元素的offset, // +1 就是下一次读取的位移,就是本次需要提交的位移 tpOffsetMap.put(tp, tpRecords.get(tpRecords.size() - 1).offset() + 1); }); } // 缓存中有数据 if (!list.isEmpty()) { // 将数据插入数据库,并且将位移信息也插入数据库 // 因此,每次读取到数据,都要更新本consumer在数据库中的位移信息 boolean success = insertIntoDB(list, tpOffsetMap); if (success) { list.clear(); tpOffsetMap.clear(); } } } } catch (Exception e) { e.printStackTrace(); } finally { consumer.close(); } } private boolean insertIntoDB(List list, Map tpOffsetMap) { // 这里应该是在同一个事务中进行的 // 为了方便就省略了 try { // TODO 将数据入库,这里省略了 // 将partition位移更新 String sql = "UPDATE kafka_info SET offsets = ? WHERE topic_group_partition = ?"; List
数据库中记录
分类: 大数据
好文要顶 关注我 收藏该文
yn_huang
关注 - 2
粉丝 - 0
+加关注
0
0
« 上一篇: Mysql加锁过程详解(6)-初步理解MySQL的gap锁