学习视频:千锋教育_索尔 https://www.bilibili.com/video/BV1Xy4y1G7zA?spm_id_from=333.337.search-card.all.click
消息队列解决的具体是什么问题 —— 通信问题
目前消息队列的中间件选型有很多种:
那这些消息队列中间件有什么区别?
这些流派可以分为:
这个流派通常有一台服务器作为broker,所有的消息都通过它中转。生产者把消息发送给它就结束自己的任务了,broker则把消息主动推送给消费者(或者消费者主动轮询)
Kafka、RocketMQ、JMS(ActiveMQ)
整个broker,依据topic来进行消息的中转,在重topic的消息队列里必然需要topic的存在
Kafka是最初由Linkedin公司开发,是一个分布式、支持分区的、多副本的、基于zookeeper协调的分布式消息系统。
它的最大特性就是可以实时的处理大量数据以满足各种需求场景:比如基于hadoop的批处理系统、低延时的实时系统、storm/spark流式处理引擎、web/nginx日志、访问日志、消息服务等。
用scala语言编写,Linkedin于2010年贡献给了Apache基金会,并成为顶级开源项目。
# broker.id属性在kafka集群中必须是唯一的
broker.id=0
#kafka部署的机器ip和提供服务的端口号
listeners=PLAINTEXT:192.168.0.1:9092
#kafka的消息存储文件
log.dir=/usr/local/data/kafka-logs
#kafka连接zookeeper的地址
zookeeper.connect=192.168.0.2:2181
./kafka-server-start.sh -daemon ../config/server.properties
名称 | 解释 |
---|---|
broker | 消息中间件处理节点,一个Kafka节点就是一个broker,一个或多个broker可以组成一个Kafka集群 |
topic | Kafka根据topic对消息进行归类,发布到Kafka集群的每条消息都需要指定一个topic |
producer | 消息生产者,向broker发送消息的客户端 |
consumer | 消息消费者,从broker读取消息的客户端 |
通过Kafka命令向zk中创建一个主题
./kafka-topics.sh --create --zookeeper 192.168.0.2:2181 --replication-factor 1 --partitions 1 --topic test
查看当前zk中的所有主题
./kafka-topics.sh --list --zookeeper 192.168.0.2:2181
把消息发送到broker中某个topic,打开一个Kafka发送消息的客户端,然后开始用客户端向Kafka服务器发送消息。默认情况下,每一行都会被当作一个独立的消息
./kafka-console-producer.sh --broker-list 192.168.0.1:9092 --topic test
打开一个消费消息的客户端,向Kafka服务器的某个topic消费消息。默认是消费最新的消息
方式一:从当前主题最后一条消息的偏移量+1的位置开始消费,即消费最新消息
./kafka-console-consumer.sh --bootstrap-server 192.168.0.1:9092 --topic test
方式二:从当前主题中第一条消息开始消费,即每次都从头开始消费
./kafka-console-consumer.sh --bootstrap-server.sh 192.168.0.1:9092 --from-beginning --topic test
生产者将消息发送给broker,broker会将消息保存在本地的日志文件中
/usr/local/kafka/data/kafka-logs/主题-分区/00000000.log
在Kafka的topic中,启动两个消费者,一个生产者。问:生产者发送消息,这条消息是否能同时会被两个消费者消费?
回答 如果多个消费者属于同一个消费组,那么只有一个消费者能订阅到topic中的消息。换言之,同一个消费组中只能有一个消费者能消费topic中的消息。
不同的消费组订阅同一个topic,每个消费组中只有一个消费者能收到消息。实际上也是多个消费组中的消费者收到了同一topic中的消息。
./kafka-console-consumer.sh --bootstrap-server 192.168.0.1:9092 --consumer-property group.id=testGroup1 --topic test // 启动一个teatGroup1下的consumer实例
./kafka-console-consumer.sh --bootstrap-server 192.168.0.1:9092 --consumer-property group.id=testGroup2 --topic test // 启动一个teatGroup2下的consumer实例
查看消费组列表
./kafka-consumer-groups.sh --bootstrap-server 192.168.0.1:9092 --list
./kafka-consumer-groups.sh --bootstrap-server 192.168.0.1:9092 --describe --group testGroup
重点关注以下几个信息:
主题topic在Kafka中是一个逻辑的概念,Kafka通过topic将消息进行分类。发送到不同topic上的消息会被订阅该topic的消费者消费。
但是有一个问题,如果说这个topic中的消息非常多,多到需要几T来存,因为消息是会被保存到log日志文件中的。为了解决这个文件过大的问题,Kafka提出了partition分区的概念。
通过_partition将一个topic中的消息分区存储_。这样有多个好处:
./kafka-topics.sh --create --zookeeper 192.168.0.2:2181 --replication-factor 1 --partitions 2 --topic test1
创建三个server.properties文件
# 主要修改以下三处配置
# 0, 1, 2 节点id
broker.id=0
# 9092 9093 9094 服务监听端口
listeners=PLAINTEXT://192.168.0.1:9092
# kafka-logs kafka-logs-1 kafka-logs-1 消息存储路径
log.dir=/usr/local/data/kafka-logs
通过命令启动三台broker
./kafka-server-start.sh -daemon ../config/server.properties
./kafka-server-start.sh -daemon ../config/server1.properties
./kafka-server-start.sh -daemon ../config/server2.properties
校验是否启动成功,进入到zk中查看/brokers/ids中是否有三个znode(0,1,2)节点
在创建主题时,除了指明主题的分区数之外,还指明了副本数,那副本是个什么概念呢?
副本是为了给主题中的分区创建多个备份,多个副本在Kafka集群的多个broker中,会有一个副本作为leader,其他的是follower。(副本就是分区在不同节点上的备份)
此时,broker、主题、分区、副本 这些概念就全部展现了:
./kafka-console-producer.sh --broker-list 192.168.0.1:9092,192.168.0.1:9093,192.168.0.1:9094 --topic my-replicated-topic
# 不指定消费组来消费
./kafka-console-consumer.sh --bootstrap-server 192.168.0.1:9092,192.168.0.1:9093,192.168.0.1:9094 --from-beginning --consumer-property --topic my-replicated-topic
# 指定消费组来消费
./kafka-console-consumer.sh --bootstrap-server 192.168.0.1:9092,192.168.0.1:9093,192.168.0.1:9094 --from-beginning --consumer-property group.id=testGroup1 --topic my-replicated-topic
<dependency>
<groupId>org.apache.kafkagroupId>
<artifactId>kafka-clientsartifactId>
<version>2.4.1version>
dependency>
public class MyProducer {
private final static String TOPIC_NAME = "my-replicated-topic";
public static void main(String[] args) throws ExecutionException, InterruptedException {
Properties props = new Properties();
// 配置要发送的broker地址
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.0.1:9092,192.168.0.1:9093,192.168.0.1:9094");
// 把发送消息的key从字符串序列化为字节数组
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// 把发送消息的value从字符串序列化为字节数组
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// 加载配置,创建producer实例
Producer<String, String> producer = new KafkaProducer<>(props);
Order order = new Order(1, 2);
// 创建消息
ProducerRecord<String, String> producerRecord = new ProducerRecord<>(TOPIC_NAME, order.getOrderId().toString(), JSON.toJSONString(order));
// 同步发送消息,得到消息发送的元数据并输出
RecordMetadata metadata = producer.send(producerRecord).get();
System.out.println("同步方式发送消息结果:"+"topic-"+metadata.topic()+" |partition-"+metadata.partition()+" |offset-"+metadata.offset());
}
}
默认情况下,如果生产者发送消息后没有收到ack,生产者会阻塞,阻塞3s的时间,如果还没有收到消息,会进行重试,重试次数为3次。
如果重试3次后还是失败,就会抛异常。需要人工介入。
// 限制:发送响应5次消息后,执行后续操作
CountDownLatch countDownLatch = new CountDownLatch(5);
// 异步发送消息
producer.send(producerRecord, new Callback() {
@Override
public void onCompletion(RecordMetadata metadata, Exception e) {
if(e != null){
System.err.println("发送消息失败:"+e.getStackTrace());
}
if(metadata != null) {
System.out.println("同步方式发送消息结果:"+"topic-"+metadata.topic()+" |partition-"+metadata.partition()+" |offset-"+metadata.offset());
}
countDownLatch.countDown();
}
});
countDownLatch.await(5, TimeUnit.SECONDS); // 如果不为0,则等待5秒
producer.close();
异步发送,生产者发送完消息后就可以执行后续的业务,broker在收到消息后异步调用生产者提供的callback回调方法。
对于ack来说,会有三个参数配置(默认为1,推荐配置≥2):
props.put(ProducerConfig.ACKS_CONFIG, "1");
如果没有收到ack,就开启重试:
# 重试次数设置
props.put(ProducerConfig.RETRIES_CONFIG, 3);
# 重试间隔设置
props.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 300);
props.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 33554432);
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 16384);
props.put(ProducerConfig.LINGER_MS_CONFIG, 10);
public class MyConsumer {
private final static String TOPIC_NAME = "my-replicated-topic";
private final static String CONSUMER_GROUP_NAME = "testGroup";
public static void main(String[] args) {
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.0.1:9092,192.168.0.1:9093,192.168.0.1:9094");
// 配置消费组名
props.put(ConsumerConfig.GROUP_ID_CONFIG, CONSUMER_GROUP_NAME);
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// 创建消费者实例
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
// 消费者订阅主题列表
consumer.subscribe(Arrays.asList(TOPIC_NAME));
while(true){
// poll() 拉取消息的长轮询
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for(ConsumerRecord<String, String> record: records){
System.out.printf("收到消息:partition = %d, offset = %d, key = %s, value = %s%n", record.partition(), record.offset(), record.key(), record.value());
}
}
}
}
无论是自动提交还是手动提交,都需要把所属的消费组+消费的主题+消费的分区+消费的偏移量,这样的信息提交到集群的_consumer_offsets主题中。
消费者poll到消息后,就会自动提交offset(此时还未进行业务处理,即消费端未真正开始消费消息)
// 是否自动提交offset,默认是true
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true");
// 自动提交offset的间隔时间
props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000");
注意:自动提交会导致消息丢失。因为消费者在消费前提交offset,有可能提交后还未消费,消费者就挂了。
为防止消息丢失,需要把自动提交的配置改成false
// 是否自动提交offset
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
手动提交又分为两种:
while(true){
// poll() 拉取消息的长轮询
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for(ConsumerRecord<String, String> record: records){
System.out.printf("收到消息:partition = %d, offset = %d, key = %s, value = %s%n", record.partition(), record.offset(), record.key(), record.value());
}
// 所有消息已消费完
if(records.count() > 0){
// 手动同步提交offset,当前线程会阻塞直到offset提交成功
// 一般使用同步提交,因为提交之后一般也没有什么逻辑代码了
consumer.commitSync();
}
}
while(true){
// poll() 拉取消息的长轮询
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for(ConsumerRecord<String, String> record: records){
System.out.printf("收到消息:partition = %d, offset = %d, key = %s, value = %s%n", record.partition(), record.offset(), record.key(), record.value());
}
// 所有消息已消费完
if(records.count() > 0){
// 手动异步提交offset,当前线程提交offset不会阻塞,可以继续处理后面的程序逻辑
consumer.commitAsync(new OffsetCommitCallback() {
@Override
public void onComplete(Map<TopicPartition, OffsetAndMetadata> map, Exception e) {
if(e != null){
System.err.println("Commit failed for " + map);
System.err.println("Commit failed exception: " + e.getStackTrace());
}
}
});
}
}
默认情况下,消费者一次会poll500条消息
// 一次poll最大拉取消息的条数,可以根据消费速度的快慢来设置
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500);
代码中设置了长轮询的时间是1000ms
while(true){
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for(ConsumerRecord<String, String> record: records){
System.out.printf("收到消息:partition = %d, offset = %d, key = %s, value = %s%n", record.partition(), record.offset(), record.key(), record.value());
}
}
意味着:
如果两次poll的时间间隔超过30s,集群会认为该消费者的消费能力过弱,该消费者会被踢出消费组,触发rebalance机制,rebalance机制会造成性能开销。
// 如果两次poll的时间超过了30s的时间间隔,Kafka会认为其消费能力过弱,将其踢出消费组。将分区分配给其他消费者 - rebalance
props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 30*1000);
消费者每隔1s向Kafka集群发送心跳续约,集群发现如果有超过10s没有续约的消费者,就会将其踢出消费组,触发该消费者的rebalance机制,将该分区交给消费组里的其他消费者进行消费。
// consumer给broker发送心跳的间隔时间
props.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, 1000);
// kakfa如果超过10s没有收到消费者的心跳,就会把消费者踢出消费组,进行rebalance,把分区分配给其他消费者
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 10*1000);
指定分区消费
consumer.assign(Arrays.asList(new TopicPartition(TOPIC_NAME, 0)));
从头消费
consumer.assign(Arrays.asList(new TopicPartition(TOPIC_NAME, 0)));
consumer.seekToBeginning(Arrays.asList(new TopicPartition(TOPIC_NAME, 0)));
指定offset消费
consumer.assign(Arrays.asList(new TopicPartition(TOPIC_NAME, 0)));
consumer.seek(new TopicPartition(TOPIC_NAME, 0), 10);
指定时间消费
根据时间,去所有的partition中确定该时间对应的offset,然后去所有的partition中找到该offset之后的消息开始消费
List<PartitionInfo> topicPartitions = consumer.partitionsFor(TOPIC_NAME);
// 从1小时前开始消费
long fetchDataTime = new Date().getTime() - 1000*60*60;
Map<TopicPartition, Long> map = new HashMap<>();
for(PartitionInfo par: topicPartitions){
map.put(new TopicPartition(TOPIC_NAME, par.partition()), fetchDataTime);
}
Map<TopicPartition, OffsetAndTimestamp> parMap = consumer.offsetsForTimes(map);
for(Map.Entry<TopicPartition, OffsetAndTimestamp> entry: parMap.entrySet()){
TopicPartition key = entry.getKey();
OffsetAndTimestamp value = entry.getValue();
if(key == null || value == null) continue;
Long offset = value.offset();
System.out.println("partition-"+key.partition()+"|offset-"+offset);
// 根据消息里的timestamp确定offset
if(value != null){
consumer.assign(Arrays.asList(key));
consumer.seek(key, offset);
}
}
新消费组中的消费者在启动以后,默认会从当前分区的最后一条消息的offset+1位置开始消费。可以通过以下设置,让新的消费者第一次从头开始消费,之后再消费新消息
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
<dependency>
<groupId>org.springframework.kafkagroupId>
<artifactId>spring-kafkaartifactId>
dependency>
server:
port: 8080
spring:
kafka:
bootstrap-servers: 192.168.0.131:9092 # Kafka集群地址 (broker节点地址)
producer: # 生产者
retries: 3 # 重试次数
batch-size: 16384 # 每次拉取缓冲区中16kb数据发送到broker
buffer-memory: 33554432 # 缓冲区大小32MB
acks: 1 # 默认值为1 (多副本之间的leader收到消息,并写入到本地log中,返回ack给生产者)
# 指定消息key和消息体的编解码方式
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
consumer: # 消费者
group-id: default-group # 消费组id
enable-auto-commit: false # 手动提交offset(即先消费,再提交offset)
auto-offset-reset: earliest # 第一次消费会从最开始的位置消费,之后消费新消息
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
max-poll-records: 500 # 一次最多拉取500条消息
listener:
# RECORD -- 每一条记录被消费者监听器处理后提交
# BATCH -- 每一批poll()的数据被消费者监听器处理后提交
# TIME -- 每一批poll()的数据被消费者监听器处理之后,距离上次提交的时间大于TIME时提交
# COUNT -- 每一批poll()的数据被消费者监听器处理之后,被处理的record数量大于等于COUNT时提交
# COUNT_TIME -- COUNT | TIME 有一个条件满足时提交
# MANUAL -- 每一批poll()的数据被消费者监听器处理之后,手动调用Acknowledgment.acknowledge()后提交
# MANUAL_IMMEDIATE -- 手动调用Acknowledgment.acknowledge()后立即提交,一般使用这种
ack-mode: MANUAL_IMMEDIATE
redis:
host: 127.0.0.1
@RestController
@RequestMapping("/msg")
public class KafkaController {
private final static String TOPIC_NAME = "my-replicated-topic";
@Resource
private KafkaTemplate<String, String> kafkaTemplate;
@RequestMapping("/send")
public String sendMessage(){
kafkaTemplate.send(TOPIC_NAME, "key", "this is a message!");
return "send success!";
}
}
@Component
public class MyConsumer {
@KafkaListener(topics = "my-replicated-topic", groupId = "MyGroup1")
public void listenGroup(ConsumerRecord<String, String> record, Acknowledgment ack){
String value = record.value();
System.out.println(value);
System.out.println(record);
ack.acknowledge(); // 手动提交offset(如果没有设置手动提交offset,消息会被重复消费)
}
}
# 在zookeeper的bin目录下执行
./zkServer.sh start
# 执行后,验证是否启动成功
ps -ef | grep zookeeper
# 在kafka的bin目录下执行
./kafka-server-start.sh -daemon ../config/server.properties
# 执行后,验证是否启动成功
ps -ef | grep kafka
@KafkaListener(groupId = "testGroup", topicPartitions = {
@TopicPartition(topic = "topic1", partitions = {"0", "1"}), // 指定消费topic1下的0,1分区
// 指定消费topic2下的0分区,从offset+1开始消费;消费topic2下的1分区,指定从offset为100的位置开始消费
@TopicPartition(topic = "topic2", partitions = "0", partitionOffsets = @PartitionOffset(partition = "1", initialOffset = "100"))
})
public void listenGroup2(ConsumerRecord<String, String> record, Acknowledgment ack){
String value = record.value();
System.out.println(value);
System.out.println(record);
ack.acknowledge(); // 手动提交offset(如果没有设置手动提交offset,消息会被重复消费)
}
由于kafka集群是一个分布式系统,需要有一个协调者,controller就是Kafka集群中的协调者。它本身也是一个broker,只不过负责了一个额外的工作。
集群中谁来充当controller?
每个broker启动时会向zk创建一个临时序号节点,最先在zookeeper上创建临时节点成功的broker将会作为集群中的controller。
controller重度依赖zk,依赖zk保存元数据、进行服务发现。controller大量使用watch功能实现对集群的协调管理,主要负责这么几件事:
如果controller所在的broker发生了故障,kafka集群就必须选举出一个新的controller。
这里存在一个问题:很难确定broker是宕机了还是暂时故障?但是为了集群的正常运行,必须选举出一个新的controller,如果之前故障的controller又恢复正常了,不知道自己已经被更换,那么集群中就出现了两个controller,这就是脑裂问题。
脑裂现象:简单描述,就是在分布式系统环境下,由于网络或者其他原因导致master出现假死,这时会触发系统进行新的master选举,此时系统中就会出现两个master,产生一系列问题。
Kafka是通过使用epoch number来处理脑裂问题的
epoch number只是一个单调递增的数。第一次选择controller时,epoch number值为1。如果再次选择新的controller,epoch number为2,依次单调递增。
每个新选举出的controller通过zookeeper的条件递增操作获得一个新的更大的epoch number,当其他broker知道当前controller的epoch number时,如果他们从controller收到包含旧(较小)epoch number的消息,则会忽略这些消息。即broker根据最大的epoch number来区分谁是最新的Controller。
LEO是某个副本最后的消息位置(log-end-offset)
HW是已完成同步的位置。消息在写入broker,且每个broker完成这条消息的同步后,HM才会变化。在这之前消费者是消费不到这条消息的。在同步完成之后,HW更新之后,消费者才能消费到这条消息,这样的目的是为防止消息的丢失。
LEO(Log End Offset):标识当前日志文件中下一条待写入的消息的 offset
HW(High Watermark):高水位,它标识了一个特定的消息偏移量 offset(此水位之上的消息已被所有副本同步,可以安全消费),消费者只能拉取到这个水位 offset 之前的消息
发送方:使用同步发送,把ack设置成1或者-1或者all;并设置同步分区数≥2,多副本,数据备份
消费方:把自动提交改成手动提交
在防止消息丢失的方案中,如果生产者发送完消息后,因为网络抖动,没有收到ack,但实际上broker已经收到了。此时生产者就会进行重试,于是broker就会收到多条相同的消息,从而造成消费者对消息的重复消费。
怎么解决:
发送方:保证消息按顺序消费,且消息不丢失 —— 使用同步的发送,ack设置成非0的值
接收方:主题只能设置一个分区,消费组中只能有一个消费者。(分区数过少,消费者较少,会极大影响Kafka的性能)
Kafka的顺序消费使用场景不多,因为会牺牲性能
消息端的消费速度远不及生产端生产消息的速度,导致Kafka中有大量的数据没有被消费。随着没有被消费的数据堆积越多,消费者寻址的性能会越来越差,最后导致整个Kafka对外提供服务的性能很差,从而造成其他服务的访问速度也变慢,造成服务雪崩。
主要从三个角度进行分析:
生产端在调用send()方法时,不会立即把消息发送出去,而是放入缓存池中。把缓存池中的消息划分成一批批的数据,按批次将消息发送给mq服务端。
消息的批量发送减少了生产端与服务端的请求处理次数,从而提高了整体的消息处理能力。
序列化方式和压缩格式都能减小消息的体积,从而节省网络资源开销。
服务端的高性能主要体现在三个方面:
服务端会将消息持久化到磁盘文件中。写入文件的时候,操作系统会先将消息数据写入内存中的PageCache中,然后再一批批写入磁盘中,减少了磁盘IO的开销。
读取文件时,也是先从PageCache中读取,加快读取速率。
kafka服务端的结构为一个topic对应多个partition,每个partition对应一个单独的文件夹。Kafka在分区中实现文件的顺序写,即可支持多个分区文件同时写入,更能发挥磁盘IO的性能
弊端:Kafka在写入消息时,IO性能会随着topic,分区数量的增长而先升后降。所以需要合理的设置topic和分区数量。
使用零拷贝技术读取数据时,可以减少一次数据的拷贝(从内核空间拷贝到用户空间),直接通过DMA拷贝完成数据的复制,不需要cpu参与,加快数据的读取速度。
消费端通过设置多个消费组,实现消息的并行消费。从而提高消费速度
参考链接:https://mp.weixin.qq.com/s/IL9Yqu_NlN7sKmz9FBCKog
给kafka-eagle配置环境变量 (/etc/profile)
export KE_HOME=/usr/local/kafka-eagle
export PATH=$PATH:$KE_HOME/bin
修改kafka-eagle配置文件(/conf/system-config.properties)
进入到bin中,通过命令启动
./ke.sh start