一、JMS
1.1.JMS简介
JMS:(Java Message Service) JMS是一个Java API,用来让应用创建、发送、接受、读取消息.它定义了一组通用接口和相关语义,允许java编程语言编写的程序与其它消息提供者进行通讯.
JMS不仅支持松散耦合的通讯,还支持:
- 异步: JMS提供者(实现组件) 可以在消息到达时,将消息发送到客户端,这样客户端就不用一直请求消息了.
- 可靠: JMS API可以保证消息只传递一次.对于能够承受丢失消息或接收重复消息的应用程序,可靠性级别较低.
1.2.JMS API基本概念
1.2.1.消息传递模式
1.2.1.1.Point-to-Point 消息模式
Point-to-Point(PTP) : 点对点消息模式,基于消息队列、发送方、接收方的概念构建.每条消息都会被寻址到一个特定的消息队列,接收端从该消息队列中提取消息,
消息队列保留发送给他们所有消息,直到消息被使用或过期.
PTP消息模式有如下特点:
- 每个消息只能被一个消费者消费
- 发送者和接收者没有时间依赖,接收者能够收到消息队列保存的所有信息,即接收者可以收到连到消息队列前存在的消息.
- 接收者确认消息消费成功
1.2.1.2.Publish/Subscrilbe消息模式
Publish/Subscrilbe(pub/sub发布订阅模式): 客户端将消息发送到主题,该主题的功能有点像公告板,发布者和订阅者通常是匿名的,可以动态发布或订阅内容.
JMS提供者负责将主题的消息分发到多个订阅者.
Pub/Sub消息模式有以下特点:
- 每个消息可以有多个消费者
- 发布者和订阅者有时间依赖关系. 订阅者只能收到订阅开始后的消息,并且如果要想一直消费,订阅者需要保持一直可用
1.2.2.JMS API编程模型
一个JMS应用需要以下模块:
- Administered objects: connection factories and destinations 管理对象: 连接工厂(Connection factory) 和 目标地址
- Connections:连接
- Sessions : 会话
- Message producers : 消息生产者
- Message consumers : 消息消费者
- Messages : 消息
工作原理如下:
二、RabbitMQ
2.1.RabbitMQ架构
各部分介绍:
- Producer : 消息生产者
- Consumer: 消息消费者
- Connection: 一个到Broker 中的VirtualHost的连接
- Broker: 接收和分发消息的应用.这里指RabbitMQ server.
- Virtual Host: 出于多租户和安全因素设计的,把 AMQP 的基本组件划分到一个虚拟的分组中,类似于网络中的 namespace 概念。当多个不同的用户使用同一个 RabbitMQ server 提供的服务时,可以划分出多个vhost,每个用户在自己的 vhost 创建 exchange/queue 等
- Exchanger: message 到达broker的第一站,可以理解为一个路由分发,根据不同的路由策略分发到不同的消息队列
- queue: 消息保存的地方,会有消息消费者consumer从队列中取走消息,消费.
- binding 将消息队列与exchanger绑定,并规定分发策略.
2.2.RabbitMQ工作模式
rabbitmq一共支持以下6公工作模式:简单模式(点对点)、work queues,Pub/Sub发布订阅模式,Routing模式、Topics主题模式、RPC模式
使用步骤:
一般在管理平台声明队列,不建议编码控制.
-
生产消息
- 创建ConnectionFactory
- 获取Connection
- 获取channel
- 向指定exchanger或队列发送消息
-
消费消息
- 创建ConnectionFactory
- 获取Connection
- 获取channel
- 从指定队列消费消息
特别注意:发送可以往exchanger发送消息,但是消费一定是从队列消费的,和exchanger没有关系.
2.2.1.简单模式
代码:
//已经在管理平台创建
private static String SIMPLE_QUEUE="simple";
public static void sendMessage() throws IOException, TimeoutException {
//创建连接工厂
ConnectionFactory factory =getFactory();
//创建连接
Connection connection = factory.newConnection();
//获取通道
Channel channel= connection.createChannel();
String message = "hello";
channel.basicPublish("",SIMPLE_QUEUE,null,message.getBytes());
System.out.println("发送成功");
}
public static void consumeMessage() throws IOException, TimeoutException {
ConnectionFactory factory =getFactory();
Connection connection = factory.newConnection();
Channel channel= connection.createChannel();
channel.basicConsume(SIMPLE_QUEUE,true,new DefaultConsumer(channel){
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
System.out.println("消费消息:"+new String(body));
super.handleDelivery(consumerTag, envelope, properties, body);
}
});
}
public static ConnectionFactory getFactory(){
ConnectionFactory factory = new ConnectionFactory();
factory.setVirtualHost("/");
factory.setHost("192.168.56.101");
factory.setPort(5672);
return factory;
}
public static void main(String[] args) {
try {
//sendMessage();
consumeMessage();
} catch (IOException e) {
e.printStackTrace();
} catch (TimeoutException e) {
e.printStackTrace();
}
}
2.2.2.work queues
该模式与简单模式类似,只不过消息队列中的消息会均匀分发到该队列的所有消费者中.
2.2.3.Pub/Sub模式
代码:
public static String PUB_QUEUE="pub_queue1";
public static String PUB_QUEUE_2="pub_queue2";
private static String PUB_EXCHANGER="exchanger_pub";
private static AtomicInteger id = new AtomicInteger(1);
public static void sendMessage() throws IOException, TimeoutException {
ConnectionFactory factory =getFactory();
Connection connection = factory.newConnection();
Channel channel= connection.createChannel();
String message = "hello";
channel.basicPublish(PUB_EXCHANGER,"",null,message.getBytes());
System.out.println("发送成功");
}
public static void consumeMessage(String queue) throws IOException, TimeoutException {
ConnectionFactory factory =getFactory();
Connection connection = factory.newConnection();
Channel channel= connection.createChannel();
String name = "consumer_"+id.getAndIncrement();
channel.basicConsume(queue,true,new DefaultConsumer(channel){
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
System.out.println("消费者:"+name+",queue:"+queue+",消费消息:"+new String(body));
super.handleDelivery(consumerTag, envelope, properties, body);
}
});
}
往exchanger发送10个消息,输出:
消费者:consumer_2,queue:pub_queue2,消费消息:hello
消费者:consumer_1,queue:pub_queue1,消费消息:hello
消费者:consumer_2,queue:pub_queue2,消费消息:hello
消费者:consumer_1,queue:pub_queue1,消费消息:hello
消费者:consumer_2,queue:pub_queue2,消费消息:hello
消费者:consumer_1,queue:pub_queue1,消费消息:hello
消费者:consumer_2,queue:pub_queue2,消费消息:hello
消费者:consumer_1,queue:pub_queue1,消费消息:hello
消费者:consumer_2,queue:pub_queue2,消费消息:hello
消费者:consumer_1,queue:pub_queue1,消费消息:hello
消费者:consumer_2,queue:pub_queue2,消费消息:hello
消费者:consumer_1,queue:pub_queue1,消费消息:hello
消费者:consumer_2,queue:pub_queue2,消费消息:hello
消费者:consumer_1,queue:pub_queue1,消费消息:hello
消费者:consumer_1,queue:pub_queue1,消费消息:hello
消费者:consumer_2,queue:pub_queue2,消费消息:hello
消费者:consumer_2,queue:pub_queue2,消费消息:hello
消费者:consumer_1,queue:pub_queue1,消费消息:hello
消费者:consumer_2,queue:pub_queue2,消费消息:hello
消费者:consumer_1,queue:pub_queue1,消费消息:hello
2.2.4.Routing模式
public static String ROUTING_QUEUE_1="routing_queue_1";
public static String ROUTING_QUEUE_2="routing_queue_2";
private static String ROUTING_EXCHANGER="exchanger_routing";
private static AtomicInteger id = new AtomicInteger(1);
public static void sendMessage(String routingKey) throws IOException, TimeoutException {
ConnectionFactory factory =getFactory();
Connection connection = factory.newConnection();
Channel channel= connection.createChannel();
String message = "hello";
channel.basicPublish(ROUTING_EXCHANGER,routingKey,null,message.getBytes());
System.out.println("发送成功,routingKey:"+routingKey);
}
public static void consumeMessage(String queue) throws IOException, TimeoutException {
ConnectionFactory factory =getFactory();
Connection connection = factory.newConnection();
Channel channel= connection.createChannel();
String name = "consumer_"+id.getAndIncrement();
channel.basicConsume(queue,true,new DefaultConsumer(channel){
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
System.out.println("消费者:"+name+",queue:"+queue+",消费消息:"+new String(body));
super.handleDelivery(consumerTag, envelope, properties, body);
}
});
}
public static ConnectionFactory getFactory(){
ConnectionFactory factory = new ConnectionFactory();
factory.setVirtualHost("/");
factory.setHost("192.168.56.101");
factory.setPort(5672);
return factory;
}
往不同的routkey发送,输出结果:
发送成功,routingKey:routing_1
发送成功,routingKey:routing_2
发送成功,routingKey:routing_2
发送成功,routingKey:routing_2
发送成功,routingKey:routing_2
发送成功,routingKey:routing_2
发送成功,routingKey:routing_2
发送成功,routingKey:routing_1
发送成功,routingKey:routing_1
发送成功,routingKey:routing_1
在routing模式下,exchanger会根据routingKey,把消息分发到不同的消息队列.
2.2.5.Topics模式
topics模式与routing模式类似,不同的是topic在指定routingkey时,可以指定匹配模式.
2.3.RabbitMQ高级特性
rabbitmq的投递过程:
Producer->broker->exchanger->queue->consumer
RabbitMQ提供了两种方式来控制消息的投递可靠性模式:
- Confirm确认模式
- Return 退回模式
2.3.1.可靠性投递
2.3.1.1.Confirm机制
2.3.1.1.1.使用方式
异步模式
原生rabbitmq: amqp-client包:
ConnectionFactory factory =getFactory();
Connection connection = factory.newConnection();
Channel channel= connection.createChannel();
channel.confirmSelect();
channel.addConfirmListener(new ConfirmListener() {
//发送成功
@Override
public void handleAck(long deliveryTag, boolean multiple) throws IOException {
System.out.println("routkey:"+routingKey+",handleAck:"+deliveryTag+",multiple:"+multiple);
}
//发送失败
@Override
public void handleNack(long deliveryTag, boolean multiple) throws IOException {
System.out.println("handleNack:"+deliveryTag);
}
});
关键设置:
- 设置当前channel的confirm模式: channel.confirmSelect()
- 设置confirm回调:
- ConfirmListener
- ConfirmCallback ackCallback, ConfirmCallback nackCallback
参数说明:
ConfirmListener或者ConfirmCallback中,deliveryTag和multiple 的说明:
- deliveryTag : 当前消息的序号,一个自增的long类型
- multiple : true,多个消息,false:单个消息
代码实例:
private static SortedSet<Long> confirmIdSet = Collections.synchronizedSortedSet(new TreeSet<Long>());
public static ConcurrentHashMap<String,Channel> channelMap = new ConcurrentHashMap<>(8);
public static void sendMessage(String routingKey) throws IOException, TimeoutException {
if(!channelMap.containsKey(routingKey)){
synchronized (channelMap){
if(!channelMap.containsKey(routingKey)){
ConnectionFactory factory =getFactory();
Connection connection = factory.newConnection();
Channel channel= connection.createChannel();
channel.confirmSelect();
channel.addConfirmListener(new ConfirmListener() {
@Override
public void handleAck(long deliveryTag, boolean multiple) throws IOException {
System.out.println("routkey:"+routingKey+",handleAck:"+deliveryTag+",multiple:"+multiple);
if(multiple){
confirmIdSet.headSet(deliveryTag+1).clear();
}else {
confirmIdSet.remove(deliveryTag);
}
}
@Override
public void handleNack(long deliveryTag, boolean multiple) throws IOException {
System.out.println("handleNack:"+deliveryTag);
}
});
channelMap.put(routingKey,channel);
}
}
}
Channel curChannel = channelMap.get(routingKey);
String message = "hello";
Long id =curChannel.getNextPublishSeqNo();
confirmIdSet.add(id);
curChannel.basicPublish(ROUTING_EXCHANGER,routingKey, MessageProperties.TEXT_PLAIN,message.getBytes());
System.out.println("发送成功,routingKey:"+routingKey);
}
使用说明:
-
如果channel重新获取,并且设置confirm模式,那么deliveryTag将从1开始
-
每次发送消息前,记录当前当前消息的deliveryTag、 deliveryTag和消息主键的关系
-
handleAck,handleNack调用时
- 如果multiple为true,代表多条消息(<=deliveryTag的消息)发送成功/发送失败,
- 如果multiple为false,代表单条消息发送成功/发送失败
- 如果失败,根据deliveryTag,处理对应的消息,可以维护一个deliveryTag和消息主键的对应关系.
同步模式
curChannel.basicPublish(ROUTING_EXCHANGER,routingKey, MessageProperties.TEXT_PLAIN,message.getBytes());
boolean flag = curChannel.waitForConfirms();
如果在发送完消息后,调用 curChannel.waitForConfirms()方法,那么当前线程就会阻塞到发送结果返回,消息一条一条发送.
2.3.1.2.Return机制
Return机制是指:当exchanger收到消息后,在路由到queue的过程中发生了错误,会调用客户端设置的ReturnCallback或ReturnListener.
只有在路由失败时才会调用.
设置:
ConnectionFactory factory =getFactory();
Connection connection = factory.newConnection();
Channel channel= connection.createChannel();
channel.addReturnListener(new ReturnListener() {
@Override
public void handleReturn(int replyCode, String replyText, String exchange, String routingKey, AMQP.BasicProperties properties, byte[] body) throws IOException {
System.out.println("replyCode:"+replyCode+",replyText:"+replyText+",exchange:"+exchange+",routingKey:"+routingKey+",message:"+new String(body));
}
});
curChannel.basicPublish(ROUTING_EXCHANGER,routingKey,true, MessageProperties.TEXT_PLAIN,message.getBytes());
配置说明:
- 设置channel的ReturnListener 或ReturnCallback
- 发送消息时指定mandatory为true,
- 当mandatory标志位设置为true时,如果exchange根据自身类型和消息routeKey无法找到一个符合条件的queue,那么会调用basic.return方法将消息返回给生产者(Basic.Return + Content-Header + Content-Body);当mandatory设置为false时,出现上述情形broker会直接将消息扔掉。
2.3.1.3.可靠性投递总结
rabbitmq在消息生产者端通过confirm机制来保障可靠性发送消息:
- 消息正确到达交换机,触发ConfirmCallback回调,返回ack;
- 消息没有正确到达交换机,触发ConfirmReturnCallback回调,返回nack;
- 消息正确的从交换机路由到队列,不触发ReturnCallback回调;
- 消息没有正确的从交换机路由到队列,设置mandory=true的情况下,触发ReturnCallback回调;
2.3.2.可靠性消费
消费端通过ack机制来保障消息的可靠性,rabbitmq提供如下三种ack选择:
- 自动确认(NONE): 消息投递到消费者即认为消息消费成功,不管是否业务真正的消费成功了消息
- 手动确认(MANUAL): 通过编码的方式,在业务消费消息成功后,调用对应的basicAck来ack消息
- 根据异常情况确认: 根据异常情况来决定消息是否ack
public enum AcknowledgeMode {
NONE,
MANUAL,
AUTO;
}
代码:
ConnectionFactory factory =getFactory();
Connection connection = factory.newConnection();
Channel channel= connection.createChannel();
String name = "consumer_"+id.getAndIncrement();
channel.basicConsume(queue,true,new DefaultConsumer(channel){
@Override
public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
System.out.println("消费者:"+name+",queue:"+queue+",消费消息:"+new String(body));
channel.basicAck(envelope.getDeliveryTag(),true);
}
});
设置:
- basicConsume,设置autoAck为true
- basicAck:
- deliveryTag:当前消息的序号
- multiple:是否ack当前deliveryTag之前的消息,
- true:ack当前deliveryTag之前的消息
- false:ack当前消息
2.3.3.消费端限流
rabbitmq提供了消费端限流接口,来控制消费速率:
//Channel
void basicQos(int prefetchCount) throws IOException;
void basicQos(int prefetchCount, boolean global) throws IOException;
void basicQos(int prefetchSize, int prefetchCount, boolean global) throws IOException;
参数说明:
- prefetchSize: 消息大小,0:代表不限制
- prefetchCount:消息数量,表示当前消费者,每次最多从消息队列拿几条消息
- global :是否全局,如果true,表示通过该channel访问的队列都每次最多拿prefetchCount条消息.
2.3.4.TTL消息
rabbitmq支持ttl消息,当消息到达存活时间后,还没被消费,将会被自动清除.
rabbitmq可以对消息设置过期时间,也可以对整个消息队列设置过期时间.
ttl设置:
队列过期时间:
x-message-ttl : 队列过期时间
消息的过期时间:
AMQP.BasicProperties.Builder properties = new AMQP.BasicProperties.Builder().expiration("1000");
curChannel.basicPublish(ROUTING_EXCHANGER,routingKey,true, properties.build(),message.getBytes());
2.3.5.死信队列
死信队列,DLX:Dead Letter Exchange (死信交换机),当信息成为Dead message后,可以被重新发送到另一个交换机,这个交换机就是DLX.
什么情况下消息会成为死信:
- 队列消息长度到达限制
- 消费者拒接消费消息,basicNack/basicReject,并且不把消息重新放入原目标队列,requeue=false
- 原队列存在消息过期设置,消息到达超时时间未被消费.
队列绑定死信交换机:
给队列设置参数: x-dead-letter-exchange 和x-dead-letter-routing-key
死信队列和交换机也是正常的交换机和队列,只不过被别的队列通过参数设置为死信队列.
2.3.6.延迟队列
rabbitmq本身未提供延迟队列的功能,但是可以通过ttl消息+死信队列组合的方式来实现延迟队列的功能.
三、Kafka
3.1.Kafka整体介绍
kafka的概念介绍:
-
消息和批次
- 消息:kafka的数据单元,
- 批次:为了提高效率,消息在从生产者发送到主题分区时,是分批发送的.
-
主题和分区
- 主题: 主题是kafka消息的逻辑划分,可以理解为一个类别的名称.
- 分区: 分区的概念就类似数据表的分表概念, 因为在kafka中消息是会被持久化化保存到磁盘日志文件的,如果同一个主题的文件都保存到一个日志文件,那么该日志文件将会很大,分区概念有以下两个优点:
- 分区存储,解决文件过大的问题
- 提高读写的吞吐量,因为读写将会同时发生在多个分区进行.
-
生产者和消费者
- 生产者: 消息的来源.
- 消费者:消息的关注方
- 消费组: kafka中特有的概念,一个消费组可以对应消费一个topic内的消息,每个分区至多被一个消费者消费.
- 所以当分区数>消费组内消费者数量时,会出现一个消费者消费多个分区的情况
- 当分区数< 消费组消费者数量时,将会出现有的消费者无分区可消费
- 最佳状态分区数=消费组消费者数量.
- 消费组: kafka中特有的概念,一个消费组可以对应消费一个topic内的消息,每个分区至多被一个消费者消费.
-
broker和集群
- Broker:一个独立的kafka服务器,负责接收消息,为消息设置偏移量,并提交消息到磁盘保存.
- Controller :集群控制器,当多个broker组成一个集群时,集群中就会选举出一个broker作为controller,负责管理工作
- 将分区分配给broker和监控broker
- 选举分区的leader
- Replication: 同一个分区Partition可能有多个副本,
- Replication Leader : 一个Partition的多个分区,需要一个Leader负责该Partition上与Producer和Consumer交互,一个Partition只对应一个Replication Leader
- Replication Follower: Follower跟随Leader,所有写亲故都会广播给所有Follower,Follower与Leader数据保持数据同步,但是Follower不支持数据读写,只负责记录数据
-
offset偏移量: 偏移量也是一种元数据,它是一个不断递增的整数值,在创建消息时,kafka会把它添加到消息里面.
- 在分区内,每个消息的偏移量唯一的
- 偏移量保存在zookeeper或kafka上,如果消费者关闭或重启,它的读取状态不会丢失.
3.2.Kafka生产者
3.2.1.Kafka生产者发送消息原理
kafka生产者发送流程:
生产者发送流程,有两个线程协作发送.
3.2.1.1.两个线程的工作流程
主线程
主线程工作流程如下:
- 有KafkaProducer创建消息
- 调用ProducerInterceptors的onsend方法,可以修改、新增消息内容
- 获取当前topic的分区信息
- 调用key和value的序列化器执行序列化操作
- 获取当前消息的分区
- 如果消息设置分区,则使用设置的分区数值
- 如果消息没有设置分区,
- 设置自定义分区器,使用分区器
- 没有设置,执行DefaultPartitioner默认分区策略
- 加入RecordAccumulator缓冲
Sender发送线程
sender线程工作流程如下:
-
从RecordAccumulator按node取出当前node的ProducerBatch数据,封装成ClientRequest
-
将ClientRequest 封装成InFlightRequest加入InFlightRequests缓存
-
通过selector发送数据
-
等待数据返回client.poll(pollTimeout, currentTimeMs);
-
处理返回数据
- 移除当前缓存中的node对应的request
- 清除RecordAccumulator中的缓冲空间
3.2.1.2.重要参数配置
- acks: 指定分区中必须要有多少个副本收到这条消息,生产者才认为这条消息是写入成功的
- acks=1: 默认值,只要分区的Lead副本写入成功,就认为消息写入成功.可能会造成消息丢失
- acks = 0: 不需要等待服务端响应.可达到最大吞吐量,也是消息丢失可能性最高的.
- acks = -1 或all : 所有副本都写入成功,才代表消息写入成功, 安全性最高,吞吐量最低.
- min.insync.replicas:限制必须写入的最少副本数量
- max.request.size: 客户端能发送消息的最大值,默认1MB
- retries 和retry.backoff.ms
- retries: 生产者发送消息的重试次数,默认为0,不进行重试.
- retry.backoff.ms: 重试间隔
- compression.type:消息的压缩方式,默认为none,消息不被压缩,其它值:gzip, snappy,lz4
- connections.max.idle.ms: 连接最大空闲时间,默认为9分钟
- linger.ms: 一个ProducerBatch最大的时间分片,也就是一个ProdcuerBatch等待时间超过该值就会被发送出去,默认为0.
- Receive.buffer.bytes: socket接收消息缓冲区,默认为32KB
- send.buffer.bytes: 发送缓冲区,默认为128kb
- request.timeout.ms : producer等待请求响应的最长时间,默认为30000ms, 请求超时后,可以进行重试
- batch.size: 默认16KB,即一个ProducerBatch的内存大小
- buffer.memory: 消息缓存内存池的大小,为32MB
3.3.Kafka服务端
3.3.1.kafka服务处理请求流程
3.3.2.持久化原理
3.3.2.1.kafka日志文件设计
kafka日志总结:
- topic下的每个分区的日志: 位于broker日志目录下以topic-分区号的文件夹下
- 每个分区的日志文件夹下会有多个段的日志,每个段的日志都包含以下三种类型,并且文件名是首个消息偏移量的值,共20位,前面通过0补齐.
- 偏移量.index: 日志的偏移量稀疏索引,每一条记录为: 当前消息的相对偏移量+物理位置
- 偏移量.log : 日志的真实内容,记录每一条消息日志
- 偏移量.timeindex : 日志的时间戳稀疏索引,每一条记录为: 当前消息的时间戳+ 当前日志的相对偏移量
- 只有最后一个日志文件才是可写的
3.3.2.1.1.稀疏索引
什么是稀疏索引
每隔一定字节或者时间,为当前数据建立一条索引,避免索引文件过大,这样可以把索引文件加载到内存中,它的缺点是在数据查询过程中,需要小范围的顺序扫描.
kafka的稀疏索引分为两种: 偏移量稀疏索引(以.index结尾) 和时间戳稀疏索引(以.timeindex结尾).
kafka中的稀疏索引通过MappedByteBuffer将索引文件映射到内存中,以加快索引的查询速度.查找时:
- 偏移量索引文件中的偏移量是单调递增的,查询指定偏移量时,使用二分查找快速定位偏移量的位置,当偏移量不在索引文件时,返回小于该偏移量的最大偏移量.
- 时间戳索引文件中的时间戳也保持严格的单调递增,查询时间戳时,也使用二分查找快速查找不大于该时间戳的最大偏移量,然后再根据该偏移量在偏移量索引文件中查找不大于该偏移量的最大偏移量,进而得到对应的物理文件位置.
- 时间戳索引文件,timestamp的值由broker端log.message.timestamp.type设置
- LogAppendTime: 时间戳能保持单调递增
- CreateTime: 不能保证,因为这是客户端时间,客户端的时钟可能不一致.
- 时间戳索引文件,timestamp的值由broker端log.message.timestamp.type设置
索引文件增加内容条件:
- 当日志内容每增加 log.index.interval.bytes数量的消息时,就会对应增加一条偏移量索引和时间戳索引的记录
3.3.2.1.2.log日志切分条件
当满足以下条件时,将对.log日志进行切分:
- 当前日志大小超过broker参数:log.segment.bytes,默认为1GB
- 当前日志的最大时间戳与当前系统的时间戳差值大于log.roll.ms 或log.roll.hours时
- 如果这两个参数同时配置,log.roll.ms优先级高
- 默认情况下,只配置了log.roll.hours ,默认值为168,即7天
- 偏移量索引文件或时间戳索引文件大小达到broker端配置参数:log.index.size.max.bytes,默认为10MB
- log.index.size.max.bytes 值必须为8的整数倍.
- 当前要追加的消息的偏移量与当前日志的第一条消息偏移量的差值大于Integer.MAX_VALUE
3.3.2.2.日志清理
Kafka提供两种日志清理策略:
- 日志删除
- 日志压缩
3.3.2.2.1.日志删除
Kafka会有日志删除任务来周期性检测和删除不符合保留条件的日志分段文件.
周期控制参数: log.retention.check.interval.ms: 默认300000 ,即5分钟.
保留策略由三种:
- 基于时间的保留策略
- 基于日志大小的保留策略
- 基于日志起始偏移量的保留策略
基于时间的保留策略
通过以下参数配置保留时间,优先级依次降低:
- log.retention.ms
- log.retention.minutes
- log.retention.hours: 默认下配置该项,默认值为168,即7天.
查询日志中最大消息的时间戳是否距现在超过设置值,如果超过,删除该文件
基于日志大小的保留策略
大小设置参数: log.retention.bytes ,默认为-1,表示无穷大. 该值是代表所有.log日志总大小.
从日志的第一个分段日志开始检查,删除到总大小小于参数值.
基于日志起始偏移量的保留策略
通过脚本设置logStartOffset,如果某一个日志的下一个日志的起始偏移量小于该偏移量,那么该日志文件应该被删除.
3.3.3.分区管理
3.3.3.1.副本的选举
ISR机制:
- AR: Assigned Replicas 一个分区内的所有副本,包含leader和follower
- ISR: In-Sync Replicas 能够和Leader副本保持同步的follower+leader副本的集合
- OSR: Out-Sync Replicas 不能和Leader保持同步的follower集合
AR=ISR+OSR 正常情况OSR为空.
一个副本能保持ISR中的条件:
- replica.lag.time.max.ms : 默认值为10000 ms 即10s, follower在过去的replica.lag.time.max.ms时间内,已经追赶上leader一次,就可以是ISR
- replica.lag.max.messages: follower落后leader的消息数量,0.9版本后移除该参数
当leader宕机后,controller会选出ISR中的第一个作为新的Leader副本.
分区自动均衡:
broker参数 auto.leader.rebalance.enable ,默认为true.
kafka会开启定时任务,轮询所有的broker节点,保证broker中的不平衡率=非优先副本的Leader个数/分区总数 不超过leader.imbalance.per.broker.percentage参数.
生产环境不建议开启,因为该过程会造成leader副本的迁移,阻塞请求.
3.3.3.2.分区重分配
kafka提供了在集群扩容、broker节点失效的场景下对分区进行迁移.
主要操作是通过:Kafka-reassign-partitions.sh脚本来执行,其操作过程主要分为三步:
- 创建一个包含主题清单的json问津啊
- 根据主题清单和broker节点清单生成一份重分配方案,
- 根据这份方案执行具体的重分配动作
3.3.4.offset元数据
在旧的客户端中,offset是保存在zookeeper中,但是在新的消费者客户端中,offset存储在kafka内部主题_consumer_offsets中.
_consumer_offsets主题创建时机
一般情况下,当集群中第一次有消费者消费消息时会自动创建主题_consumer_offsets,副本和分区有以下参数控制:
- 副本因子: offsets.topic.replication.factor ,默认为3
- 分区数: offsets.topic.num.partitions参数设置,默认为50
_consumer_offsets主题消息内容
_consumer_offsets主题的消息内容最主要包括以下部分:
-
group_id : 当前消费组的id
-
topics: 该消费者消费的所有主题信息
- topic :某一个主题
- Partitions: 该主题下分区信息
- Paritition: 分区号
- offset: 该分区对应的偏移量
后面会说到消费者重连时如何获取分区的offset.
3.4.Kafka消费者
Kafka中有消费组的概念,消费者一定要有对应的消费组, 当消息发布到主题后,只会被投递给订阅它的每个消费组中的一个消费者.
对于消息中间件而言,一般有两种投递模式: 点对点模式和发布/订阅模式.在第一章说过点对点和发布订阅的区别.
Kafka同时支持这两种消息投递模式:
- 点对点:如果所有的消费者都隶属于同一个消费组,那么所有的消息都会被均衡的投递给每一个消费者,即每条消息只会被一个消费者处理.
- 发布/订阅: 如果所有的消费者都隶属于不同的消费组,那么所有的消息都会被广播给所有的消费者,即每条消息都会被所有的消费者处理.
消费组配置:客户端通过group.id 客户端参数来指定所属的消费组.
我们下面要从以下几个方面,来总结消费者相关的知识:
- 消费组中消费者与主题中分区的对应关系?
- 当消费组中消费者数量变化时,对应关系如何处理?
- 消费者的消息消费-- offset提交、指定offset消费、多线程消费、消费者拦截器
- 消费者的参数设置
3.4.1.订阅主题和分区
消费者订阅主题和分区总结:
- 消费者可以消费多个主题的消息.如topic1,topic2
- 消费者可以消费指定匹配模式的主题消息,如 topic*
- 消费者可以消费主题的指定分区消息: 如topic1下的partition1
分区分配策略
分区分配策略是指: 在消费者不指定消费分区的情况下,kafka是怎么分配分区给消费者的.
kafka提供三种分区策略和自定义分区策略:
- RangeAssignor: 默认的分区侧露
- RoundRobinAssignor
- StickyAssignor
在客户端通过partition.assignment.strategy设置,可以配置多个,以逗号分隔.
RangeAssignor分区策略
RangeAssignor 分区策略按照消费者总数和分区总数进行整除来获得一个跨度,然后将分区按照跨度进行平均分配.
对于每一个主题,RangeAssignor策略会将消费组内所有订阅这个主题的消费者按照名称的字典排序,然后为每一个消费者划分固定的分区范围,如果不够平均分配,那么字典序靠前的消费者将会多被分一个分区.
假如主题t0 ,t1, 每个主题有四个分区,那么分区标识为: t0p0,t0p1,t0p2,t0p3,t1p0,t1p1,t1p2,t1p3,
假如有两个消费者,则:
C1: t0p0,t0p1, t1p0,t1p1
C2: t0p2,t0p3, t1p2,t1p3
假如有三个消费者:
C1:t0p0,t0p1, t1p0,t1p1
C2:t0p2,t1p2
C3:t0p3,t1p3
可以看出RangeAssignor有可能导致分区不均匀,进而导致部分broker负载过大.
RoundRobinAssignor分区策略
RoundRobinAssignor分区策略的原理是将消费组内所有消费者以及消费者订阅的所有主题的分区按照字段序排序,然后通过轮训的方式逐个将分区分配给消费者.
假如主题t0 ,t1, 每个主题有四个分区,那么分区标识为: t0p0,t0p1,t0p2,t0p3,t1p0,t1p1,t1p2,t1p3,
假如有三个消费者,都订阅这两个主题
C1:t0p0,t0p3,t1p2
C2:t0p1,t1p0,t1p3
C3:t0p2,t1p1
假如存在t0,t1,t2三个主题,每个主题存在三个分区,则分区字典序为:
t0p0,t0p1,t0p2,
t1p0,t1p1,t1p2,
t2p0,t2p1,t2p2,
消费者订阅情况: C1 订阅 t0, C2订阅t0,t1, C3订阅:t0,t1,t2
则分区分配情况为:
C1:t0p0,
C2:t0p1,t1p0,t1p2,
C3:t0p2,t1p1,t2p0,t2p1,t2p2
也不是最优解,可以将t1p1分配到C2.
StickyAssignor 分区策略
比较复杂,这里不详谈.
自定义分区策略;
可以通过实现org.apache.kafka.clients.consumer.ConsumerPartitionAssignor接口来实现自定义的分区策略.
3.4.2.再均衡原理
如果消费者客户端中配置了两个分配策略,彼此不相同,那么如何协同.
在kafka中有消费者协调器(ConsumerCoordinator)和组协调器(GroupCoordinator) 来完成.
触发再均衡的情况有以下:
- 有新的消费者假如消费组
- 有消费者宕机下线
- 有消费者主动退出消费组
- 消费组对应的GroupCoordinator节点发生变更
- 消费组内所订阅的任一主题或者主题的分区数量发生变化.
3.4.3.消费消息
3.4.3.1.消息消费模式
消息的消费一般有两种模式:推模式和拉模式.
- 推模式: 服务端主动将消息推送给消费者
- 拉模式:消费者主动向服务端请求消息
Kafka采取的是拉模式.
ConsumerRecords<K, V> poll(final Duration timeout)
3.4.3.2.消息offset提交
kafka消费端offset提交通过客户端参数,enable.auto.commit设置,
- false: 手动提交
- true: 自动提交,默认方式.
- Auto.commit.interval.ms: 默认5s , 自动提交周期
- 消费者每隔5s,拉取每个分区中的最大消息位移进行提交
自动提交模式下会造成消息丢失和消息重复消费的情况.
- 消息重复消费: 拉去到一批消息,在消费过程中,消费者宕机, 那么这批已经处理但是还未提交的消息,还会被再次拉取消费.
- 消息丢失:在多线程消费消息的模式下,如果线程A拉取到offset=x的消息,线程B拉取到offset=x+3的消息,如果线程B成功提交,线程A出现异常未正常提交,那么就会丢失线程A处理的消息.
3.4.3.3.指定offset消费
auto.offset.reset
earliest
当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,从头开始消费
latest
当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,消费新产生的该分区下的数据
none
topic各分区都存在已提交的offset时,从offset后开始消费;只要有一个分区不存在已提交的offset,则抛出异常
指定offset
KafkaConsumer可以通过以下函数指定开始消费的offset
seek(TopicPartition partition, long offset)
3.4.3.4.多线程消费
KafkaConsumer是非线程安全的,要实现多线程消费,可以通过线程封闭的手段,为每个线程分配一个KafkaConsumer.
3.4.4.参数设置
消费者客户端参数设置:
- 必要参数
- Bootstrap.servers: kafka服务端broker列表,可以设置一个或多个,以逗号分开
- Group.id : 消费组名称,必须设置
- Key.deserializer和value.deserializer :key和value的反序列化器
- 重要参数
- fetch.min.bytes : 每次拉取的最小数据量,默认为1B
- fetch.max.bytes: 每次拉取的最大数据量,默认50MB,该参数不是绝对的最大值. 如果该参数设置为1B,而消息大小都为10B,也是可以拉取的.
- fetch.max.wait.ms : 拉取的等待时间
- max.partition.fetch.bytes: 从分区每次拉取的消息最大数据量,默认为1MB
- max.poll.records: 一次拉取时最大的消息数,默认为500条
- connections.max.idle.ms: 连接最大空闲时间,默认9分钟
- send.buffer.bytes : Socket发送消息缓冲区,默认为128KB
- request.timeout.ms: Consumer等待请求响应的最长时间,默认为30000ms, 30s
- metadata.max.age.ms: 元数据过期时间,默认5分钟
- retry.backoff.ms : 重试发送间隔,默认为100ms
3.5.高级应用
3.5.1.kafka支持的高级特性
3.5.1.1.TTL消息
kafka本身不支持TTL消息,但是可以通过其扩展接口来实现.
public class DefaultConsumerInterceptor implements ConsumerInterceptor<String,String> {
@Override
public ConsumerRecords<String, String> onConsume(ConsumerRecords<String, String> records) {
return null;
}
@Override
public void onCommit(Map<TopicPartition, OffsetAndMetadata> offsets) {
}
@Override
public void close() {
}
@Override
public void configure(Map<String, ?> configs) {
}
}
实现ConsumerInterceptor 接口,在onConsume方法中对拉取到的消息进行过滤,你的ttl相关的数据可以放到消息的headers中或消息内容中.
public ProducerRecord(String topic, Integer partition, K key, V value, Iterable<Header> headers) {
this(topic, partition, null, key, value, headers);
}
3.5.1.2.延时队列
kafka原生概念不支持延时队列功能.
但是可以通过别的方式实现:
假如支持2h内延迟,可以在kafka中创建多个延迟主题,如下:
1min, 5min,10min,30min,1h,2h等延迟主题,然后使用一个延迟服务去消费消息,将消息在多个主题间转移,直到消息延迟时间达到,然后发送到真正的主题.
3.5.1.3.死信队列
kafka不支持死信队列.
3.5.2.Kafka应用探讨
3.5.2.1.Kafka可靠性分析
Kafka的保证:
- Kafka可以保证分区消息的顺序.
- 只有当消息被写入分区的所有同步副本时,它才被认为已提交. 生产者可以通过acks选择接收不同类型的确认.
- 只要还有一个副本是活跃的,那么已经提交的消息就不会丢失
- 消费者只能读取已经提交的消息.
3.5.2.2.Kafka顺序性
消息生产则,发送端:
设置参数
max.in.flight.requests.per.connection :1,该参数的含义是最多只有一个请求在发送缓冲区.
假如你现在发送消息1,消息2,如果者两个消息正好放在了两个ProducerBatch里面,而者两个ProducerBatch正好在两个请求里面,那么如果消息1所在的请求,如果在发送时,遇到网络波动发送失败,这时消息2所在的请求要是发送成功,那么会造成消息2比消息1先到达,造成异常发生.所以设置该参数,如果已经有一个请求在发送中,要等待该请求成功响应了,才能继续发送.
topic和分区、消费者
kafka保证分区有序性,发送时把需要有序的消息类型分区到同一个分区即可.
消费者也要做好顺序性控制: 比如消费者每次需要处理消息1->消息2->消息3 那么可以通过状态位来确定消费顺序是否正确.
四、消息中间件常见问题
4.1.消息可靠性保障
4.1.1.消息补偿
- 发送消息:
- 正常消息
- 延迟消息: 用于检测当前消息是否被成功消费
- 消费成功消息: 消费成功的消息应先于延迟消息,被回调服务所消费,并落库,作为消费记录
- 回调检查服务
- 当消费延迟消息时,根据主键查看当前消息是否被成功消费
- 定时检查服务
- 兜底服务:用于延迟消息发送失败、延迟消息消费失败、消费成功消息发送失败的检查
- 检查producer落库记录和回调检查服务落库记录
- 定时检查服务可设置游标,选择消息中的一个递增主键,避免重复数据扫描.
4.2.消息堆积
当消息生产的速度长时间,远远大于消费的速度时。就会造成消息堆积。
- 消息堆积的影响
- 可能导致新消息无法进入队列
- 可能导致旧消息无法丢失
- 消息等待消费的时间过长,超出了业务容忍范围。
- 产生堆积的情况
- 生产者突然大量发布消息
- 消费者消费失败
- 消费者出现性能瓶颈。
- 消费者挂掉
- 解决方法
- 排查消费者的消费性能瓶颈
- 增加消费者的多线程处理
- 部署增加多个消费者
- 场景介绍
具体解决方案:
一般步骤
如果生产上出现上述的情况,即MQ里积压了成百上千万的消息,那基本上只能临时紧急扩容了,具体操作步骤和思路如下:
先修复消息者端的程序问题,然后将现有consumer全部停掉;
新建一个Topic,partition是原来的10倍(如果是使用RabbitMQ,则临时建立原先10倍/20倍的queue数量);
写一个临时分发数据的consumer程序,将这个程序部署上去消费积压的数据,消费之后不做任何处理而是直接轮询写入上述临时建立好的queue;
临时征用10倍的机器来部署原来步骤1中停掉的consumer,每一批consumer消费一个临时queue的数据;
这种解决方案相当于临时将queue资源和consumer资源扩大10倍,以10倍于正常的速度去消费数据,等快速消费完积压消息之后,再恢复原先的部署。
磁盘问题
还有一个问题:如果消息大量积压在MQ里,导致磁盘快写满了咋办?
这种情况很可能是系统规划时没有合理分配磁盘资源,没考虑到消息积压的异常场景,并没有很好的解决办法,为了保证正常业务不受影响,可以采用以下方案:
临时写个程序,不断去消费积压的消息,消费一个丢弃一个,都不要了,目的是快速消费掉所有的消息,避免磁盘撑爆导致系统没法正常运行,然后走上述的临时紧急扩容方案,等系统正常后晚上再进行数据重发,补数据。
4.3、消息丢失
在实际的生产环境中有可能出现一条消息因为一些原因丢失,导致消息没有消费成功,从而造成数据不一致等问题,造成严重的影响,比如:在一个商城的下单业务中,需要生成订单信息和扣减库存两个动作,如果使用RabbitMQ来实现该业务,那么在订单服务下单成功后需要发送一条消息到库存服务进行扣减库存,如果在此过程中,一条消息因为某些原因丢失,那么就会出现下单成功但是库存没有扣减,从而导致超卖的情况,也就是库存已经没有了,但是用户还能下单,这个问题对于商城系统来说是致命的。
消息丢失的场景主要分为
- 消息在生产者丢失
- 消息在RabbitMQ丢失
- 消息在消费者丢失
4.3.1、生产者丢失
场景:
消息生产者发送消息成功,但是MQ没有收到该消息,消息在从生产者传输到MQ的过程中丢失,一般是由于网络不稳定的原因。
解决方案:
采用RabbitMQ 发送方消息确认机制,当消息成功被MQ接收到时,会给生产者发送一个确认消息,表示接收成功。
RabbitMQ 发送方消息确认模式有以下三种:
- 普通确认模式
- 批量确认模式
- 异步监听确认模式
spring整合RabbitMQ后只使用了异步监听确认模式。
4.3.2、RabbitMQ消息丢失
场景:
消息成功发送到MQ,消息还没被消费却在MQ中丢失,比如MQ服务器宕机或者重启会出现这种情况
解决方案:
持久化交换机,队列,消息,确保MQ服务器重启时依然能从磁盘恢复对应的交换机,队列和消息。
spring整合后默认开启了交换机,队列,消息的持久化,所以不修改任何设置就可以保证消息不在RabbitMQ丢失。但是为了以防万一,还是可以申明下。
4.3.3、消费者丢失消息
场景:
消息费者消费消息时,如果设置为自动回复MQ,消息者端收到消息后会自动回复MQ服务器,MQ则会删除该条消息,如果消息已经在MQ被删除但是消费者的业务处理出现异常或者消费者服务宕机,那么就会导致该消息没有处理成功从而导致该条消息丢失。
设置为手动回复MQ服务器,当消费者出现异常或者服务宕机时,MQ服务器不会删除该消息,而是会把消息重发给绑定该队列的消费者,如果该队列只绑定了一个消费者,那么该消息会一直保存在MQ服务器,直到消息者能正常消费为止。本解决方案以一个队列绑定多个消费者为例来说明,一般在生产环境上也会让一个队列绑定多个消费者也就是工作队列模式来减轻压力,提高消息处理效率
MQ重发消息场景:
1.消费者未响应ACK,主动关闭频道或者连接
2.消费者未响应ACK,消费者服务挂掉
4.4、有序消息消费
4.4.1、一个队列多个消费者
当RabbitMQ采用work Queue模式,此时只会有一个Queue但是会有多个Consumer,同时多个Consumer直接是竞争关系,此时就会出现MQ消息乱序的问题。
解决方案:
4.4.2、消费者多线程消费
当RabbitMQ采用简单队列模式的时候,如果消费者采用多线程的方式来加速消息的处理,此时也会出现消息乱序的问题。
解决方案:
4.5、重复消费消息
场景:
为了防止消息在消费者端丢失,会采用手动回复MQ的方式来解决,同时也引出了一个问题,消费者处理消息成功,手动回复MQ时由于网络不稳定,连接断开,导致MQ没有收到消费者回复的消息,那么该条消息还会保存在MQ的消息队列,由于MQ的消息重发机制,会重新把该条消息发给和该队列绑定的消息者处理,这样就会导致消息重复消费。而有些操作是不允许重复消费的,比如下单,减库存,扣款等操作。
MQ重发消息场景:
1.消费者未响应ACK,主动关闭频道或者连接
2.消费者未响应ACK,消费者服务挂掉
解决方案:
如果消费消息的业务是幂等性操作(同一个操作执行多次,结果不变)就算重复消费也没问题,可以不做处理,如果不支持幂等性操作,如:下单,减库存,扣款等,那么可以在消费者端每次消费成功后将该条消息id保存到数据库,每次消费前查询该消息id,如果该条消息id已经存在那么表示已经消费过就不再消费否则就消费。本方案采用redis存储消息id,因为redis是单线程的,并且性能也非常好,提供了很多原子性的命令,本方案使用setnx命令存储消息id。
既然有重复消费问题的存在,那么消费者就必须自己做好消费接口的幂等性设计,接口幂等设计的方式有很多,主要有两种:
数据库方式
基于数据库唯一索引,先根据索引查一下,如果记录都有了,就别插入了,update一下。
缓存方式
比如基于Redis实现一套接口的防重框架 。生产者发送每条数据的时候,里面加一个全局唯一的id,消费时先根据这个id去Redis里查一下之前是否消费过?如果没有消费过,就正常处理,然后将这个id写redis;如果消费过了,就别处理了。