kafka
服务端
主题包含多个分区(分区之间数据是分片的),分区包含一个分区首领、多个分区副本(他们之间数据是同步的),每个副本或者首领的数据存储在一个broker上(每个broker就是集群的一个节点)
副本分同步副本和非同步副本,当一个副本6秒内还没向zk汇报自己存活或者10秒内没有从首领副本同步数据,就认为该副本为非同步副本了。可以认为非同步副本已经被分区副本剔除了。
副本越多可用性越高,但是副本越多,副本同步数据的时间越长,这个副本数量就是可配置的,需要根据项目情况来取舍。
kafka参数配置分两种:
1、主题级别的,只对某个主题生效
2、broker级别的,对所有主题生效
副本数量
主题级别:replication.factor broker级别:default.replication.factor
这个值默认是3.
如果系统对安全性要求高的话,比如银行项目,则可以把这个值设置高一些,比如设置成5.
不可用副本是否可以选举为首领
unclean.leader.election只支持broker级别。
假设有1个首领,2个副本。当两个副本都不可用的时候,如果首领挂了,接下来需要选择新首领,如果不可用的副本也可以选为新首领,这种情况就会丢失数据。如果不可用的副本不可以选择为首领时,系统就会不可用了,只能等待恢复一个副本。
这种情况就需要做取舍,不允许丢失数据的系统就要把值设置为false。
最小同步副本
min.insync.replicas主题和broker级别都是这个
比如设置为2了,当可用副本数量小于2时,kafka就拒绝生产者继续存入消息,返回异常,但是消费者还是可以读取数据的。也就是kafka此时变成只读的了。
集群成员关系
kafka是通过zk来维护成员关系的,每个broker有一个唯一id,启动broker的时候会在zk上创建节点/brokers/id,如果两个broker的id重复了,在zk上注册时会报错。
控制器
整个kafka只有一个控制器,当borker启动的时候会在zk创建节点/controller,那么该broker就是控制器,也就是说第一个启动的broker为控制器,负责整个kafka的协调监控工作,其他broker启动的时候也会尝试创建/controller,失败后会watchzk的该节点,一旦之前创建的controller删除后就会通知watch该节点的broker,就重新生成控制器。
控制器选举分区首领:
当有一个borker离开的时候,控制器会判断他是否为分区首领,如果是,控制器会选择该分区副本列表的下一个分区为分区首领,同时通知该分区的其他分区副本。
复制
分区副本复制数据类似消费者获取数据,副本borker主动发起同步数据的请求,发送偏移量等。
分区分配
在创建主题的时候就会对主题下的分区分配到各个broker上,分配遵循一下几个原则:
1、每个broker上分配的分区尽量数量相等
2、每个分区的副本尽量分配到不同的broker上
3、如果指定了机架信息,则每个分区在每个机架上分配的副本尽量平衡
文件管理
以数据量10000为大小上限或者时间1周为时间上限为例子,当往分区写数据的时候,先往第一个片段写数据,当数据量大小达到10000条时或者文件的创建时间达到1周了,则会从新创建一个文件(生成一个新的片段),数据继续往新的片段中写入。
kafka每个分区对应一个文件目录,每个文件目录下存在多个片段,每个片段包含两个文件,一个数据文件和一个索引文件。
通过偏移量来对文件进行命名,比如一个片段包含1000条数据,那么第一个片段就叫00001000,第二个片段叫00002000,以此类推,当按照偏移量查找消息的时候,可以直接根据文件名定位到具体片段。
每个片段包含一个数据文件和一个索引文件,索引文件存储了片段的数据文件的索引信息。
生产者
概述
1、生产者创建producerRecord,包含了消息的基本信息
2、对producerRecord序列化
3、数据传递给分区器,如果producerRecord中指定了分区信息,则直接将数据流转到分区。如果没指定分区信息,则分区器会根据producerRecord的键来选择存入哪个分区
4、然后该条数据被添加到一个记录批次中,该批次的所有消息会发送到同一个分区上,由单独一个线程负责发送。
5、broker收到消息会写入kafka,如果成功会返回信息告诉producer,如果失败也返回信息告诉producer,producer会重试几次后返回失败
创建kafka生产者
Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
Producer<String, String> producer = new KafkaProducer<>(props);
bootstrap.servers:指定broker地址
key.serializer:broker只能接受字节数组类型的数据,所以传递消息之前要进行序列化,指定key的序列化方式,kafka默认提供了ByteArraySerializer、StringSerializer、IntegerSerializer。也可以自定序列化器。
value.serializer:值的序列化方式。
生产者还有一些可选配置
acks
acks=0:生产者给服务器发出消息后不管消息是否成功,就是说当服务器接受这条消息失败了,生产者是不知道的,也就是丢数据了,但是此配置吞吐量是最大的。
acks=1:服务器leader收到消息就会返回给生产者成功,不管followers是否同步成功
acks=all:要服务器所有节点都成功收到消息才算成功。数据安全性最高。
retries
服务端有事会返回临时错误(比如暂时没有leader),这种错误可能过短时间就好了,生产者接到这种错误可以隔段时间再次尝试发送到服务端,此参数配置尝试次数。
发送消息
有三种方式:发送并忘记(同步方式但是不去管返回对象)、同步、异步
发送并忘记
ProducerRecord<String, String> record = new ProducerRecord<>("CustomerCountry", "Precision Products", "France");
try{
producer.send(record);
} catch (Exception e) {
e.printStackTrace();
}
同步
ProducerRecord<String, String> record = new ProducerRecord<>("CustomerCountry", "Precision Products", "France");
try{
producer.send(record).get();
//send()方法返回一个Future对象,get()方法等待Kafka响应,如果没有发送错误,会得到一个RecordMetadata对象,可以用它获取消息的偏移量
} catch (Exception e) {
e.printStackTrace();
}
异步
private static class DemoProducerCallback implements Callback {
@Override
public void onCompletion(RecordMetadata recordMetadata, Exception e) {
if(e != null) {//此处异步返回e如果不为null,证明消息发送失败了,此处可以记录日子之
e.printStackTrace();
}
}
}
//异步发送消息
ProducerRecord<String, String> record = new ProducerRecord<>("CustomerCountry", "Biomedical Materials", "USA");
producer.send(record, new DemoProducerCallback());
序列化器
kafka默认支持的序列化器有ByteArraySerializer、IntegerSerializer、StringSerializer。但是这些序列化器不能满足所有场景,所以可以自定义序列化器、还可以使用序列化框架来创建消息(如avro、protobuf等)。
分区
如果消息的key为null,kafka会采用轮询的方式发送到某过分区下。如果key不为null,则会对key进行哈希算出值定位到一个分区。
有时会需要根据业务将固定业务的数据存储到同一个分区中,这就要实现自己的分区器了。
创建自定义分区器:
public class PhonenumPartitioner implements Partitioner{
@Override
public void configure(Map<String, ?> configs) {
// TODO nothing
}
@Override
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
// 得到 topic 的 partitions 信息
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
int numPartitions = partitions.size();
//业务逻辑判断
if(key.toString().equals("10000") || key.toString().equals("11111")) {
// 放到最后一个分区中
return numPartitions - 1;
}
String phoneNum = key.toString();
return phoneNum.substring(0, 3).hashCode() % (numPartitions - 1);
}
@Override
public void close() {
// TODO nothing
}
}
使用自定义分区器
public class PartitionerProducer {
private static final String[] PHONE_NUMS = new String[]{
"10000", "10000", "11111", "13700000003", "13700000004",
"10000", "15500000006", "11111", "15500000008",
"17600000009", "10000", "17600000011"
};
public static void main(String[] args) throws Exception {
Properties props = new Properties();
props.put("bootstrap.servers", "192.168.42.89:9092,192.168.42.89:9093,192.168.42.89:9094");
// 设置分区器
props.put("partitioner.class", "com.bonc.rdpe.kafka110.partitioner.PhonenumPartitioner");
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
Producer<String, String> producer = new KafkaProducer<>(props);
int count = 0;
int length = PHONE_NUMS.length;
while(count < 10) {
Random rand = new Random();
String phoneNum = PHONE_NUMS[rand.nextInt(length)];
ProducerRecord<String, String> record = new ProducerRecord<>("dev3-yangyunhe-topic001", phoneNum, phoneNum);
RecordMetadata metadata = producer.send(record).get();
String result = "phonenum [" + record.value() + "] has been sent to partition " + metadata.partition();
System.out.println(result);
Thread.sleep(500);
count++;
}
producer.close();
}
}
消费者
消费者和消费者群组
消费者和消费者群组关系如下几个图
场景:假设采集日志的需求,kafka服务端已经存储了1000万条日志数据,需要开启10个线程同时从kafka消费这些日志。
可以将这1000万条数据分配一个主题,然后分配10个分区。 开启10个线程,每个线程是一个消费者,将这10个消费者加入到一个消费者群组,订阅该主题即可。
消费者会以轮询方式主动向群组协调器报告自己是否存活,当消费者群组中有消费者死亡或者新进来的时候,就会发生一次再均衡,也就是重新把订阅主题内的分区分配给消费者群组中的消费者。
创建kafka消费者
Properties props = new Properties();
props.put("bootstrap.servers", "broker1:9092,broker2:9092");
props.put("group.id", "CountryCounter");//指定该消费者所属消费者群组
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
KafkaConsumer<String, String> consumer = new KafkaConsumer<String,String>(props);
订阅主题
consumer .subscribe(Collections.singletonList("customerCountries"));
还可以通过正则匹配订阅多个主题
consumer.subscribe("test.*");
轮询
轮询是消费者核心,所有消费者与外部交互都是在轮询的时候触发的。
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records)
{
log.debug("topic = %s, partition = %s, offset = %d,
customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());
int updatedCount = 1;
if (custCountryMap.countainsValue(record.value())) {
updatedCount = custCountryMap.get(record.value()) + 1;
}
custCountryMap.put(record.value(), updatedCount)
JSONObject json = new JSONObject(custCountryMap);
System.out.println(json.toString(4))
}
}
} finally {
consumer.close(); //消费者主动停止与服务端请求,会发起再均衡
}
上边已经创建好消费者了,拿着这个消费者无限循环调用poll方法,这个方法就是轮询,该方法从服务端拉取数据(包括主题、分区、偏移量、key、value),参数标识阻塞时间(当生产者没有数据的时候,消费者等待多久)。poll方法要一直调用,不停的向服务端发起请求,一旦中断请求达到一定时间,服务端就会认为该消费者死亡,就会剔除该消费者,然后做再均衡。
消费者可配置参数
fetch.min.bytes:消费者每次poll,从服务端获取数据的最小记录。当消费者poll的时候,服务端如果没有足够数据,则在服务端等待,直到数据达到参数配置大小,在返回给消费者。
fetch.max.wait.ms:和上面参数配合使用,比如fetch.min.bytes=1m fetch.max.wait.ms=500ms。这个参数组合的意义就是,消费者poll的时候,服务端让步等到消息累计到1m后返回给消费者,让步等到500毫秒,然后把所有可用数据返回给消费者。
max.partation.fetch.bytes:每个消费者可能被分配多个分区,当消费者poll的时候,每个分区可返回给消费者的数据量不能超过该参数配置。默认值为1m。
比如有20个分区,5个消费者,那么一个消费者分到4个分区,那么每个消费者每次poll的时候,可以获取数据量为4m。
偏移量
关于偏移量从存储位置看可以分为两种:一种是将偏移量存储在服务端,每次消费者拉取消息的时候在服务端读取偏移量,然后消费者在提交偏移量。
还有一种就是存储在消费者端,比如存在数据库中,每次消费者拉取消息的时候在数据库中读取偏移量,然后指定偏移量从服务端拉取。
接下来开始介绍存储在服务端的方式:
消费者提交偏移量的主要是消费者往一个名为_consumer_offset的特殊主题发送消息,消息中包含每个分区的偏移量。
偏移量可以自动提交也可以手动提交。
自动提交就是到设定一个时间值默认5s,每次轮询的时候会判断具体上次提交是否到5s了,如果到了,生产者会自动把偏移量提交到服务端,不需要代码实现。
自动提交会发生丢数据或者重复消费的问题,比如上次提交3s后发生了再均衡。
这时候就需要手动提交。
手动提交
同步提交
异步提交
异步+回调
同步+异步
在一个批次中随时提交
再均衡监听器
从特定位置开始读取
退出
同步提交
while (true) {
//超时时间为1000ms
ConsumerRecords<String, String> records = consumer.poll(1000);
for (ConsumerRecord<String, String> record : records)
{
System.out.printf("topic = %s, partition = %s, offset = %d, customer = %s, country = %s\n", record.topic(), record.partition(), record.offset(), record.key(), record.value());
}
try {
consumer.commitSync();//每次轮询完之后提交一次
} catch (CommitFailedException e) {
log.error("commit failed", e)
}
}
同步提交会阻塞,影响吞吐量
异步提交
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records)
{
System.out.printf("topic = %s, partition = %s,
offset = %d, customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());
}
consumer.commitAsync();//每次轮询完异步提交一次,然后消费者继续他的轮询
}
异步提交有一个漏洞:
当提交一个偏移量为2000,但是服务端没及时响应返回失败,又提交了一个3000的上去成功了,2000那个提交会重试,那么2000就把3000覆盖了。
那么这个时候可以通过回调的方式解决
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("topic = %s, partition = %s,
offset = %d, customer = %s, country = %s\n",
record.topic(), record.partition(), record.offset(),
record.key(), record.value());
}
consumer.commitAsync(new OffsetCommitCallback() {
public void onComplete(Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception) {
if (e != null)
log.error("Commit failed for offsets {}", offsets, e);
}
});
}
记录一个自增版本号初始为0,每次提交的时候+1,2000的线程提交的时候加入版本号+1,同时传递给服务端,此时3000的过来获取版本号为1,同时+1,当前版本号变成了2,然后提交给服务端。然后2000的返回失败,同时从回调函数中获取他提交时的版本号为1,然后比对当前最新版本号为2,发现被更新过,放弃重试。
很多情况下,多次提交只要最后一次提交成功就没问题,这种情况采用同步和异步结合的方式。
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records) {
System.out.printf("topic = %s, partition = %s, offset = %d,
customer = %s, country = %s\n",
record.topic(), record.partition(),
record.offset(), record.key(), record.value());
}
consumer.commitAsync();
}
} catch (Exception e) {
log.error("Unexpected error", e);
} finally {
try {
consumer.commitSync();
} finally {
consumer.close();
}
}
每次轮询都异步提交,假设其中有失败的也无所谓,在消费者完成这次任务要退出的时候来一次同步提交就可以保证最后一次成功。
在一次轮询期间提交
private Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
int count = 0;
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records)
{
System.out.printf("topic = %s, partition = %s, offset = %d, customer = %s, country = %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"));
if (count % 1000 == 0)//每处理一条数据count+1,然后这个判断就是处理1000数据后提交一次
consumer.commitAsync(currentOffsets, null);
count++;
} }
用一个map记录要提交的偏移量信息,key为主题,value为分区。每执行1000条数据提交一次。
再均衡监听器
当发生再均衡的时候,可能有一些事情要处理。再均衡监听器提供了一套api,当发生再均衡的时候,让你做一些处理。
private Map<TopicPartition, OffsetAndMetadata> currentOffsets = new HashMap<>();
//当要发生再均衡的时候,先停止消费者,然后重新分配分区,然后启动消费者
private class HandleRebalance implements ConsumerRebalanceListener {
//重新分配分区之后,启动消费者之前执行
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
}
//停止消费者之后,重新分配分区之前执行
//这个时候如果提交偏移量,则再均衡之后获取这个分区的消费者会从这个偏移量继续读取
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
System.out.println("Lost partitions in rebalance.
Committing current
offsets:" + currentOffsets);
consumer.commitSync(currentOffsets);
}
}
try {
consumer.subscribe(topics, new HandleRebalance());//这里边要把再均衡监听器传递给消费者
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records)
{
System.out.printf("topic = %s, partition = %s, offset = %d, customer = %s, country = %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 (WakeupException e) {
// ignore, we're closing
} catch (Exception e) {
log.error("Unexpected error", e);
} finally {
try {
consumer.commitSync(currentOffsets);
} finally {
consumer.close();
System.out.println("Closed consumer and we are done");
}
}
接下来介绍偏移量存储在消费者端:
public class SaveOffsetsOnRebalance implements ConsumerRebalanceListener {
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
//在消费者负责的分区被回收前提交数据库事务,保存消费的记录和位移
commitDBTransaction();
}
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
consumer.subscribe(topics, new SaveOffsetOnRebalance(consumer));
//在subscribe()之后poll一次,并从数据库中获取分区的位移,使用seek()来指定开始消费的位移
consumer.poll(0);
for (TopicPartition partition: consumer.assignment())
consumer.seek(partition, getOffsetFromDB(partition));
while (true) {
ConsumerRecords<String, String> records = consumer.poll(100);
for (ConsumerRecord<String, String> record : records){
processRecord(record);
//保存记录结果
storeRecordInDB(record);
//保存位移
storeOffsetInDB(record.topic(), record.partition(), record.offset());
}
//提交数据库事务,保存消费的记录以及位移
commitDBTransaction();
}
}
}
退出
在一般情况下,我们会在一个主线程中循环poll消息并进行处理。当需要退出poll循环时,我们可以使用另一个线程调用consumer.wakeup(),调用此方法会使得poll()抛出WakeupException。如果调用wakup时,主线程正在处理消息,那么在下一次主线程调用poll时会抛出异常。主线程在抛出WakeUpException后,需要调用consumer.close(),此方法会提交位移,同时发送一个退出消费组的消息到Kafka的组协调者。组协调者收到消息后会立即进行重平衡(而无需等待此消费者会话过期)。
//注册JVM关闭时的回调钩子,当JVM关闭时调用此钩子。
Runtime.getRuntime().addShutdownHook(new Thread() {
public void run() {
System.out.println("Starting exit...");
//调用消费者的wakeup方法通知主线程退出
consumer.wakeup();
try {
//等待主线程退出
mainThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
...
try {
// looping until ctrl-c, the shutdown hook will cleanup on exit
while (true) {
ConsumerRecords<String, String> records = consumer.poll(1000);
System.out.println(System.currentTimeMillis() + "-- waiting for data...");
for (ConsumerRecord<String, String> record : records) {
System.out.printf("offset = %d, key = %s, value = %s\n",record.offset(), record.key(), record.value());
}
for (TopicPartition tp: consumer.assignment())
System.out.println("Committing offset at position:" + consumer.position(tp));
consumer.commitSync();
}
} catch (WakeupException e) {
// ignore for shutdown
} finally {
consumer.close();
System.out.println("Closed consumer and we are done");
}
消息顺序性
订单系统,有大量订单,一个订单有多个事件下发,保障一个订单的不同事件按顺序消费,订单量大要支持并行消费。
1.直接开启多个分区,使用分区器保障同一个订单进入同一个分区,多个分区分摊订单量,开启多个消费者进行消费
2.所有消息存入同一个分区,一个消费者拉取消息,按照订单编号将同一个订单放进一个队列,最终将大量订单放入多个队列中,然后开启多个线程从队列拉取
kafka的消息都是存储在文件中的,每个分区会对应一个文件目录,写文件读文件都是顺序性的,所以对于同一个分区,消息也肯定能保证顺序性,不同分区无法保证消息的顺序性。
还有一种情况,一个消费者是按照顺序消费消息的,但是消费者读取消息后会开启多个线程对消息进行处理,这多个线程就无法保证顺序性了。
这时可以在消费者和数据处理线程之间增加一个内存队列层,消费者按照数据规则将消息扔到不同的队列,每个线程消费一个队列。