metaq杂记
Name Server:维护broker的地址列表,以及topic和topic对应的队列的地址列表。每个broker与每个Name Server之间使用长连接来保持心跳,并向其定时注册topic信息。可以从两个维度来理解Name Server的能力: 1)Name Server可以提供一个特定的topic对应的broker地址列表;2)Name Server可以提供一台broker上包含的所有topic列表。轻量级的名称服务。几乎无状态的节点,相互之间不会有数据同步。 主要提供topic路由信息的注册及查询。(MetaQ 1.x和MetaQ 2.x是依赖ZooKeeper的,由于ZooKeeper功能过重,RMetaQ 3.x去掉了对ZooKeeper依赖,采用自己的NameServer)
Producer和集群中某一个Name Server间采用长连接,Producer定期从Name Server中获取到Topic对应的broker地址列表,即Topic路由信息,并缓存在本地,然后选择一台合适的master broker发布消息。 注意producer发布消息是将消息发布到与对应的master broker上,再由master broker同步到slave broker上。
Consumer和集群中某一个Name Server间采用长连接,并定期从Name Server中获取到Topic路由信息,然后选择合适的broker拉取消息进行消费。
在 Metaq2.x 之前版本,队列也称为“分区”,两者描述的是一个概念。 但是按照 2.x 的实现,使用队列描 述更合适。
数据存储分为两级,物理队列+逻辑队列。
物理队列:一个broker只有一个物理队列,所有发到broker的数据都会顺序写入该队列,当一个文件被写满时(默认为1G),会新建文件继续写入。
逻辑队列:当consumer消费数据时,consumer先根据nameServer提供的路由信息定位到broker,再从broker的消费队列读取index,从而定位到物理队列的位置。一个topic有多个分区,每个分区对应一个消费队列,而消费队列由index组成。
commitlog消息文件:broker在接收到生产者发送来的消息后,是如何对其进行存储的呢?在MetaQ中,真正的消息本身实际上是存放在broker本地一个名为commitlog的文件中的,并且这个commitlog的写入是不区分Topic的,即不论什么Topic的消息,都会在接收到之后顺序写入commitlog文件中,commitlog的文件名就是起始字节位置 写满后,产生一个新的文件。
索引文件:读取的时候又是怎么从commitlog中找到消息的呢?的确,仅仅只存储消息本身是无法做到这个的(因为在仅有commitlog文件的前提下,消息的长度、类型等信息都是无法确定的),所以MetaQ还有索引文件(在一些文档中也称为Message Queue)。broker将消息存储到commitlog文件后,会将该消息在文件的物理位置(offset),消息大小,消息类型等信息封装成一个固定大小的数据结构,称为索引单元。其中,offset是java long型,有64位,从理论上讲,offset在100年内都不会发生溢出,所以可以认为message queue长度无限。从而简单地,可以把message queue理解为是一个长度无限的数组,offset就是下标。多个索引单元组成一个索引文件,和commitlog文件一样,文件名是起始字节位置,写满后,产生一个新的文件。
broker 将消息存储到文件后,会将该消息在文件的物理位置,消息大小,消息类型封装成一个固定大 小的数据结构,暂且称这个数据结构为索引单元吧,大小固定为 16k,消息在物理文件的位置称为 offset。
多个索引单元组成了一个索引文件,索引文件默认固定大小为 20M,和消息文件一样,文件名是 起始字节位置,写满后,产生一个新的文件。
metaq的消息存储由commit log和逻辑队列consume queue配合完成。 首先会将所有的消息分topic存在commit log文件中,commit log文件最大为1G,超过1G会生成新文件,文件以起始字节大小命名。consume queue逻辑队列相当于是commit log文件的索引,记录offset偏移量,size长度,消息的hashcode等信息。物理队列只有一个(也就是commit log文件),采用固定大小的文件顺序存储消息。逻辑队列有多个(每个对应一个topic),每个逻辑队列有多个分区(topicA_1分区,topicA_2分区,topicA_3分区),每个分区有多个索引单元。MetaQ中将每一个Topic分为了几个区,每个区对应了一个消费队列,这些消费队列就由各个索引文件组成。消费端在拉取消息时,只要知道自己是订阅的Topic从nameserver获取broker地址建立连接后,就能根据消费队列中的索引文件,去物理队列中获取订阅的消息。如下图所示,topicA在broker1 和 broker2分别有topicA_1分区, topicA_2分区;topicA_3 分区,topicA_4 分区。每个分区里存是的commit log文件的索引信息。消费信息时,需要通过索引信息,到commit log文件中获取真正的数据信息进行消费。仔细观察图metaq_arch_1/2/3.png
offset:这个概念其实放在这里讲略微有些早了,可以在了解完MetaQ 的消息生产模型之后再来了解。两个“offset”,刚开始还是有点迷惑的,梳理之后才理出了头绪,于是这里把这两种offset拿出来专门加以区分。
- 消息存储过程中的offset: 这个offset就是指索引单元中的offset,它标志着消息在commitlog文件中的物理位置。这个offset的存在也是MetaQ能正确在commitlog文件中定位消息的前提和保障。
- 消息消费时的offset: 这个offset是指当前消息被消费到的位置(接下来从哪个位置开始消费),这个offset是消费者顺序消费消息的依据和保障。这个offset被存储在消费者本地、数据库,还会定时更新到broker中。消费者每次拉取请求就需要offset这个参数,如果没有这个offset,MetaQ就不能实现顺序读了。MetaQ文件目录下有两个文件用于持久化消费进度,每次将offset写入consumerOffset.json文件,然后备份到 consumerOffset.json.bak文件中。在代码实现上,offset的持久化实际上是存储进一个以Topic和groupId的组合字符串为key,以ConcurrentMap为value的ConcurrentMap,而这个value上的ConcurrentMap又是以queueId为key,以offset为value的。
对于某一特定Topic而言,brokerId和分区号组合起来就是一个有序的分区列表, 如Topic “hello”对应的有序分区列表为{A-0,A-1,B-0,B-1,B-2},生产者生产消息实际上可以理解为是以topic下的分区为单位进行的,即生产者按照一定规则向“brokerId-分区号”组成的有序分区列表对应的分区发送消息。发送的规则可以定制,一般采用轮询的方式。
消息的存储就是借助之前介绍过的commitlog文件和索引文件来实现的。
消费者消费消息也是以topic下的分区为单位,分组内一个消费者对应消耗一个分区的消息。这样一来就出现以下两种情况:
- 一个Group中的消费者个数大于总的分区数目:在这种情况下,多出来的消费者空闲,不参与消费;
- 一个Group中的消费者个数小于总的分区数目:在这种情况下,有部分消费者需要承担额外的消费任务。
当Topic下的分区数足够大的时候,可以认为消费者负载是平均分配的。
于是,消息的拉取过程描述如下:
(1) 根据Topic和分区号找到对应的逻辑消费队列,记为A;
(2) 根据A和offset找到对应的待消费的索引位置,记为B;
(3) 从B开始,读取B所对应的commitlog文件中的消息放入消费者队列中,直到读取到的消息长度等于给定的maxSize。在这个过程中,offset同时后移更新。
(4) 返回结果,结果中包含更新后的offset值,并将offset保存下来作为下一次消费开始的位置标志。
首先,从以上截图可以验证:
(1) 一个broker上可以发布多个Topic(图中有TopicA、TopicB)
(2) 一个Topic下可以对应多个分区(图中TopicA下有0和1两个分区,TopicB下有0,1,2三个分区)
(3) commitlog文件是不区分Topic的消息存储文件,所有Topic下的消息都会顺序写到同一个commitlog文件中
(3) 不同的ConsumerGroup之间独立进行消息的消费,消费过程组间互不影响。不同的消费者分组有自己独有的消费队列。
可以看到,针对每一个ConsumerGroup,都维护有一个重试队列(RETRY Queue)和一个死信队列(DLQ Queue)。 其中,重试队列用于消费失败后的重试,并且设置有最大重试次数(默认是16次),死信队列存放多次重试后依旧无法消费的消息。消费者在消费消息的过程中,如果消费失败,则将这条消息加入到重试队列中,由重试线程继续进行重试消费,如果重试最大次数后还是消费失败,则将消息加入到死信队列中,加入到死信队列中的消息需要进行人工干预。在这个过程中,主线程继续往后推进,从而实现消息的乱序消费和相对顺序消费。
当前例子是PushConsumer用法,使用方式给用户感觉是消息从MetaQ服务器推到了应用客户端。 但是实际PushConsumer内部是使用长轮询Pull方式从MetaQ服务器拉消息,然后再回调用户Listener方法
RocketMQ消息有有序保证实例:
@Test
public void testQueueProducer() {
try {
MetaProducer producer = new MetaProducer(GROUP_NAME);
//队列的并行度是15个
producer.setDefaultTopicQueueNums(15);
producer.start();
// 设置tag是为了让消费端过滤不想要的tag
String[] tags = new String[]{"TagA", "TagB", "TagC", "TagD", "TagE"};
//do {
for (int i = 0; i < 50000; i++) {
// 保证订单ID相同的消息放在同一个队列中
int orderId = i % 10;
Message msg = new Message(TOPIC, tags[i % tags.length], "KEY" + i, ("Hello MetaQ " + i).getBytes());
SendResult sendResult = producer.send(msg, new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
//arg就是orderId
Integer id = (Integer) arg;
System.out.println(String.format("mq size=%d,arg=%d", mqs.size(), id));
// id即orderId取队列大小的模 目的就是设置订单相同放在一个队列中
int index = id % mqs.size();
System.out.println("index=" + index);
return mqs.get(index);
}
}, orderId);
System.out.println(sendResult);
}
Thread.sleep(10000l);
//} while (true);
producer.shutdown();
} catch (Exception e) {
e.printStackTrace();
}
// SendResult [sendStatus=SEND_OK, msgId=0A6549250000277400000AC0059D66F9, messageQueue=MessageQueue [topic=orderTestTopic, brokerName=taobaodaily-f, queueId=3], queueOffset=653]
}
@Test
public void testOrderConsumerQueue() throws MQClientException, InterruptedException {
MetaPushConsumer consumer = new MetaPushConsumer(GROUP_NAME);
consumer.subscribe(TOPIC, "TagB");
consumer.registerMessageListener(new MessageListenerOrderly() {
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
context.setAutoCommit(true);
try {
System.out.println(Thread.currentThread().getName() + " Receive New Messages: " + new String(msgs.get(0).getBody(), "UTF-8"));
} catch (Exception e) {
e.printStackTrace();
}
return ConsumeOrderlyStatus.SUCCESS;
}
});
consumer.start();
System.out.println("Consumer Started.");
Thread.sleep(10000l);
// ConsumeMessageThread_7 Receive New Messages: Hello MetaQ 67
}
从输出结果可以看出mq size=8, 分布在两台机器上,brokerName=taobaodaily-05-tx, queueId=0/1/2/3, brokerName=taobaodaily-04-timer, queueId=0/1/2/3
总结
发送顺序消息无法利用集群FailOver特性
消费顺序消息的并行度依赖于队列数量
队列热点问题,个别队列由于哈希不均导致消息过多,消费速度跟不上,产生消息堆积问题
遇到消息失败的消息,无法跳过,当前队列消费暂停
发送消息负载均衡:发送消息通过轮询队列的方式 发送,每个队列接收平均的消息量。通过增加机器,可以水平扩展队列容量。 另外也可以自定义方式选择发往哪个队列。
订阅消息负载均衡:如果有 5 个队列,2 个 consumer,那么第一个 Consumer 消费 3 个队列,第二 consumer 消费 2 个队列。 这样即可达到平均消费的目的,可以水平扩展 Consumer 来提高消费能力。但是 Consumer 数量要小于等于队列数 量,如果 Consumer 超过队列数量,那么多余的 Consumer 将不能消费消息。
事务消息:
图transaction
- 发送方向 MQ 服务端发送消息;
- MQ Server 将消息持久化成功之后,向发送方 ACK 确认消息已经发送成功,此时消息为半消息。
- 发送方开始执行本地事务逻辑。
- 发送方根据本地事务执行结果向 MQ Server 提交二次确认(Commit 或是 Rollback),MQ Server 收到 Commit 状态则将半消息标记为可投递,订阅方最终将收到该消息;MQ Server 收到 Rollback 状态则删除半消息,订阅方将不会接受该消息。
- 在断网或者是应用重启的特殊情况下,上述步骤4提交的二次确认最终未到达 MQ Server,经过固定时间后 MQ Server 将对该消息发起消息回查。
- 发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
- 发送方根据检查得到的本地事务的最终状态再次提交二次确认,MQ Server 仍按照步骤4对半消息进行操作。
事务消息发送对应步骤1、2、3、4,事务消息回查对应步骤5、6、7。
消息过滤:
(1). 在 Broker 端进行 Message Tag 比对,先遍历 Consume Queue,如果存储的 Message Tag 与订阅的 Message Tag 不符合,则跳过,继续比对下一个,符合则传输给 Consumer。注意:Message Tag 是字符串形式,ConsumeQueue 中存储的是其对应的 hashcode,比对时也是比对 hashcode。
(2). Consumer 收到过滤后的消息后,同样也要执行在 Broker 端的操作,但是比对的是真实的 Message Tag 字符串,而不是 Hashcode。
零拷贝原理:
图zero_copy_1,zero_copy_2
在broker向消费端发送消息时,若采用传统的IO方式b会从磁盘拷贝数据到页缓存->用户空间从页缓存读数据->用户空间再将数据写入socket缓存中->通过网络发送数据,显然这样做使得用户空间和内核空间产生了多余的读写。MetaQ采用零拷贝的方式,通过mmap的方式使得页缓存与用户空间缓存共享数据,之后直接将数据从页缓存中将数据传入socket缓存中加快效率。
metaq如何解决事务问题:一般分布式事务采用的kv存储方式,通过key寻找message,第二阶段的回滚或者提交需要修改消息状态;然而metaq第一阶段发送prepared消息,拿到offset,第二阶段通过offset访问消息,修改数据状态。通过offset访问更改数据的缺点是系统脏也过多。
消息无序性,如何保证其顺序性: 消息的有序消费是指按照消息发送的先后顺序消费。正常情况下,单线程下produder同一个topic(一个topic逻辑上是一个队列,物理上分为多个队列)下,metaq支持局部顺序消费。分两种顺序消费方式普通顺序消费和严格顺序消费。普通顺序消费,当一个broker宕机或者重启时,允许该部分消息延迟消费,其他broker照常消费;严格消费,当某broker宕机或者重启时,牺牲分布式的failover特性,整个集群都不能使用,大大降低服务可用性,目前使用场景中数据库binlog同步强依赖严格顺序消费,其他应用场景用普通顺序消费可满足。然而在平时的应用中,单线程producer几乎不可能,我们可以通过设计规避这个缺点。例如同一个订单需要发送的创建订单消息、订单付款消息、订单完成按消费顺序消费才有意义,按优先级发送消息(metaq按优先级发消息开销比较大,没有特意支持这个特性)同一个队列,优先级高的消息先发送,可以在消费端做异常情况的处理逻辑。
非顺序消息如何实现队列内并行:PullService根据rebalance结果从对应的队列中获取数据,并将其缓存到客户端本地内存,然后根据客户端设置规则(一次批量消费消息的数目)将数据分成多段派发给下游的消费线程。消费端一个队列只维护一个offset,消费成功后只提交最小的offset。将拉回来的数据分成了三段(0-10,10-20,20-30)分别派发给消费线程。 0-10,20-30这两部分数据消费完成,但10-20这段数据还未消费完成。提交服务端消费位点则为10(消费成功的是10和30),知道这段数据消费完成提交位点才会为30。
PS:所以并发消费有可能出现重复消费的问题。如中间有部分数据一直没有被成功消费,此时重新负载,别的消费端拿到当前队列就会导致重复消费。
最佳实践
Producer最佳实践
1、每个消息在业务层面的唯一标识码,要设置到 keys 字段,方便将来定位消息丢失问题。由于是哈希索引,请务必保证 key 尽可能唯一,这样可以避免潜在的哈希冲突。
2、消息发送成功或者失败,要打印消息日志,务必要打印 sendresult 和 key 字段。
3、对于消息不可丢失应用,务必要有消息重发机制。例如:消息发送失败,存储到数据库,能有定时程序尝试重发或者人工触发重发。
4、某些应用如果不关注消息是否发送成功,请直接使用sendOneWay方法发送消息。
Consumer最佳实践
1、消费过程要做到幂等(即消费端去重)
2、尽量使用批量方式消费方式,可以很大程度上提高消费吞吐量。
3、优化每条消息消费过程