主从同步
主从同步和集群的概念很类似,主从同步就可以算到集群中,最终的目的也是提高系统的承载能力和可靠性。
在RocketMQ学习笔记(一) 初遇篇我们已经搭建了一个RocketMQ了,是主节点,下面我们要搭建的就是从节点。RocketMQ的主从同步相对于MySQL的主从同步有点不一样,一般情况下MySQL中主从同步,只有主节点负责写,从负责读。但是RocketMQ有点不按套路出牌,写是向主节点上写,读却不是只读从,
消费位点 CommitLog
每个topic有多个队列,消费者从 当前什么位置拉去消息进行消费,哪个位置就是消费点。 CommitLog: 消息主体以及元数据的存储主体,存储Producer端写入的消息主体内容,消息内容不是定长的。
当Master Broker发现Consumer的消费位点与CommitLog的最新值的差值的容量超过该机器内存的百分比(accessMessageInMemoryMaxRatio=40%),会推荐Consumer从Slave Broker中去读取数据,降低Master Broker的IO。
主从同步的配置
一般情况下,不加特殊说明,都是在Linux下进行配置。
- hosts中配置从节点的域名映射 主节点和从节点做相同的配置
vi /etc/hosts --打开并编辑hosts文件,添加如下配置: 192.168.2.128 mqnameserver1 192.168.2.128 mqmaster1 192.168.2.129 mqnameserver2 192.168.2.129 mqmaster1slaver1 复制代码
nameServer是均等的,不存在主从,192.168.2.128这台计算机是主节点,192.168.2.129是从节点。
- 修改主节点的配置文件
- 配置从节点
其实可以在主节点上配置完之后,通过远程复制,复制到从节点。这里我们只展示从节点需要配置的。这里我们配置的是两主两从异步刷新,配置文件路径是/usr/rocketmq/conf/2m-2s-async。下面有如下配置文件:我们修改的是broker-a-s.properties这个配置文件。
- 需要配置的:
brokerId=1 -- 大于0是从 deleteWhen=04 fileReservedTime=48 flushDiskType=ASYNC_FLUSH brokerRole=SLAVE -- 角色 是从 autoCreateTopicEnable =true defaultTopicQueueNums = 4 listenPort = 10911 storePathRootDir=/usr/rocketmq/mqstore storePathCommitLog=/usr/rocketmq/mqstore/commitlog storePathConsuQueue=/usr/rocketmq/mqstore/commitlog/consumequeue storePathIndex=/usr/rocketmq/mqstore/commitlog/consumequeue maxMessageSize=65535 namesrvAddr = mqnameserver1:9876;mqnameserver2:9876 -- 配置nameserver的地址 复制代码
然后就配置完成了,接下来我们来启动一下,先主后从。 记得改一下从节点的RocektMQ的启动配置改为1G ,防火墙放行,这都是在初遇篇讲过的,忘记了再看下。 主节点启动命令:
nohup sh mqnamesrv & nohup sh mqbroker -c /usr/rocketmq/conf/2m-2s-async/broker-a.properties& 复制代码
从节点启动命令:
nohup sh mqnamesrv & -- 注意从节点的broker用broker-a-s.properties nohup sh mqbroker -c /usr/rocketmq/conf/2m-2s-async/ broker-a-s.properties& -- 启动成功的标志,输入JPS,出现下面这个: 2209 BrokerStartup 5527 Jps 2173 NamesrvStartup 复制代码
然后我们上监控看下:
现在可以再做下测试,向消息队列再发送消息,然后尝试干掉主节点,再启用消费者消费。
同步消息 异步消息 延时消息 单向消息
这里又碰到了同步和异步,这是个在开发领域很常见的名词,我们来解释一下: 同步和异步关注的是消息通信机制 (synchronous communication/ asynchronous communication)。
所谓同步,就是在发起一个调用时,在没得到结果之前,该调用就不返回,也就是说调用者在主动的等待这个调用的结果。
类似于烧水壶,烧水壶在没有提醒功能的时候,我们不知道水什么时候烧开,所以就在烧水的时候就一直在烧水壶旁边待着,看烧水壶开了没,
异步就是,在发起一个调用时,直接返回了,后续被调用方通过回调函数将调用结果发送给调用方。 类似于升级版的烧水壶,放上火之后,直接返回,烧开的时候会有提示。
比较通俗易懂的介绍:怎样理解阻塞非阻塞与同步异步的区别?
延迟消息: 就是等一段时间之后再发送。目前不支持自定义,只支持几个级别。 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h 单向消息就是发送完消息,没返回结果。常用于日志发送。 这里其实也就是介绍API的使用:
我比较喜欢RocketMQ的一个重要原因就是这个是文档写的比较好,而且是中文。注释已经打的很详细了,如果你不想看我写的demo也可以直接去看官方文档,下面是官方示例的地址:github.com/apache/rock…
可靠性有多高
之前我们在介绍消息队列引论的时候,我们说过RocketMQ是高可靠的,那么有多高呢? 影响消息可靠性的几种情况:
- Broker非正常关闭
- Broker异常Crash
- OS Crash(操作系统崩溃)
- 机器掉电,但是能立即恢复供电情况
- 机器无法开机(可能是cpu、主板、内存等关键设备损坏)
- 磁盘设备损坏
然后1、2、3、4四种情况都属于硬件资源可立即恢复情况,RocketMQ在这四种情况下能保证消息不丢,或者丢失少量数据(依赖刷盘方式是同步还是异步)。5、6属于单点故障,且无法恢复,一旦发生,在此单点上的消息全部丢失。RocketMQ在这两种情况下,通过异步复制,可保证99%的消息不丢,但是仍然会有极少量的消息可能丢失。通过同步双写技术可以完全避免单点,同步双写势必会影响性能,适合对消息可靠性要求极高的场合,例如与Money相关的应用。注:RocketMQ从3.0版本开始支持同步双写。
偏移量
偏移量: 消费者将要消费的位置。 那么这个偏移量存储在哪里呢? 不同的消费方式,存储位置不同。 如果是push模式,即消息队列主动的向消费者推送数据,那么偏移量就存储在服务器,因为此时消息队列是知道消费者消费到哪里了。 如果是pull模式,即消费者主动的向消息队列拉取数据,那么偏移量就应该存储在本地,因为消费者通常不止一个,通常情况下,我们会搭建消费者集群(后文会讲),每个消费者消费的进度不同。 偏移量主要与三个类有关: OffsetStore(接口)、LocalFileOffsetStore(实现类)、RemoteBrokerOffsetStore(实现类)
消费模式
push消费模式
广播模式: 将消息向所有的消费者发送,每个消费者拥有完整的消费。 集群模式: 也就是消费者集群,消费者在一个小组,即小组名称相同,那么该小组内的消费者即为一个集群。 设置也非常简单 , 向下面这样:
-- 这是广播模式 MessageModel 是一个枚举类型的 consumer.setMessageModel(MessageModel.BROADCASTING); -- 这是集群,不写也行,默认即为集群模式 consumer.setMessageModel(MessageModel.CLUSTERING); 复制代码
pull 消费模式
相比较push,pull(拉取模式)需要注意的就是,原本由服务器维护的偏移量,现在需要我们来维护。
- 无调度拉取
public class PullMsgDemo { private static final Map<MessageQueue, Long> offSetMap = new HashMap<>(); public static void main(String[] args) throws MQClientException, RemotingException, InterruptedException, MQBrokerException { DefaultMQPullConsumer defaultMQPullConsumer = new DefaultMQPullConsumer("pullGroup"); // 设置nameServer的地址 defaultMQPullConsumer.setNamesrvAddr(Constant.NAMESRV_ADDR.getEnumValue()); // 开启消费者 defaultMQPullConsumer.start(); // 拉取主题对应的队列 Set<MessageQueue> queues = defaultMQPullConsumer.fetchSubscribeMessageQueues("MyTopic4"); // 遍历消息 for (MessageQueue queue : queues) { // 因为消息的数量是不确定的,所以要用死循环 while (true) { // mq, 一个主题拥有许多队列,每个队列的消息又附带标签 // subExpression, // offset, 偏移量 // maxNums 一次拉取多少消息 // pullBlockIfNotFound 一共四个参数 // 从名字可以推断,如果拉不到就陷入阻塞 PullResult pullResult = defaultMQPullConsumer.pullBlockIfNotFound(queue, null, getOffset(queue), 20); // 将偏移量重设 setOffset(queue, pullResult.getNextBeginOffset()); // 获取消息 List<MessageExt> msgFoundList = pullResult.getMsgFoundList(); // 不能无限制的拉下去,通过pullResult来获取拉取状态, // 这是一个枚举值 PullStatus pullStatus = pullResult.getPullStatus(); // 说明有消息 if (pullStatus == PullStatus.FOUND){ // 遍历 for (MessageExt messageExt : msgFoundList) { System.out.println(messageExt); } } // 没有新消息了 if (pullStatus == PullStatus.NO_NEW_MSG){ break; } } } defaultMQPullConsumer.shutdown(); System.out.println("拉取结束"); } private static void setOffset(MessageQueue queue, long nextBeginOffset) { offSetMap.put(queue,nextBeginOffset); } //首次获取肯定为0 private static Long getOffset(MessageQueue queue) { return offSetMap.get(queue) == null ? 0L : offSetMap.get(queue); } } 复制代码
- 调度拉取 时间调度(每隔3秒获取)
private static void SchedulePullMsg() throws MQClientException { MQPullConsumerScheduleService consumerScheduleService = new MQPullConsumerScheduleService("pullGroup"); // 设置nameServer的地址 consumerScheduleService.getDefaultMQPullConsumer().setNamesrvAddr(Constant.NAMESRV_ADDR.getEnumValue()); // // 第二个是回调函数 consumerScheduleService.registerPullTaskCallback("MyTopic4", (mq, pullTaskContext) -> { // mq 是消息队列 获取消费者 MQPullConsumer pullConsumer = pullTaskContext.getPullConsumer(); // fromStore 是在本地进行维护偏移量 try { // 获取偏移量 long offSet = pullConsumer.fetchConsumeOffset(mq, false); PullResult pullResult = pullConsumer.pull(mq, "MyTags3", offSet, 20); PullStatus pullStatus = pullResult.getPullStatus(); if (pullStatus == PullStatus.FOUND) { List<MessageExt> msgFoundList = pullResult.getMsgFoundList(); // 遍历 for (MessageExt messageExt : msgFoundList) { System.out.println(messageExt); } } // 没有新消息了 // if (pullStatus == PullStatus.NO_NEW_MSG) { // System.out.println("消息拉取完毕"); // } pullConsumer.updateConsumeOffset(mq, pullResult.getNextBeginOffset()); } catch (MQClientException | RemotingException | MQBrokerException | InterruptedException e) { e.printStackTrace(); } // 设置拉取的频率 每3秒一次 pullTaskContext.setPullNextDelayTimeMillis(3000); }); // 这里跟 consumerScheduleService.start(); } 复制代码
消息局部有序
(轮询调度算法)Round-Robin Scheduling: 轮询调度算法的原理是每一次把来自用户的请求轮流分配给内部中的服务器,从1开始,直到N(内部服务器个数),然后重新开始循环。
很多时候两数相除的时候,我们只关心当一个整数除以一个正整数所得的余数,如果你还记得离散数学的话,离散数学中关于求余的理论我们称之为模算术,这也是数论的内容。求余在java中的符号是% , a 和 b 都是整数 , 显而易见, a % b的余数一定小于b。注意这个简单的定理,我们将在消息的局部有序中使用到它。
默认情况下,RocketMQ会采取Round Robin轮询,将消息发送到不同的队列,记得我们在刚配置的时候吗?我们配置了4个队列。而消费消息的时候是从多个队列上拉取消息,这种情况下发送和消费是不能保证有序的。但是如果控制发送的顺序消息只依次发送到一个队列中,消费的时候只从这个queue上消费,那么这就保证了顺序。当发送和消费的队列只有一个,我们称之为全局有序; 如果有多个queue参与则是分区有序,即相对于每个对了都是有序。下面我们结合一个例子来介绍分区有序。
一个订单的顺序流程是: 创建、付款、推送、完成。对于订单场景来说,生产者必然是很多的,消费者可能也不止一个,我们需要保证订单号相同的消息放入一个队列,在不使用全局有序的情况下。消费时,同一个OrderId获取到的肯定是同一个队列。我们默认是配置了4个队列。看到这里你可能已经想到了,对的,我们用OrderId 对 队列的大小进行模运算,也就是取余,这样我们就能保证相同订单的消息放入一个队列中
本来实例代码是打算放在GitHub上的,奈何这段时间访问GitHub的速度很慢,码云又要注册才能下载,所以就只好放在了文章中,例子来自于RocketMq官方示例的改装, 这一点我觉得RocketMQ的官方示例做的很好。
public class OrderProducer { public static class OrderStep { private Long orderId; private String desc; public OrderStep() { } public Long getOrderId() { return orderId; } public void setOrderId(Long orderId) { this.orderId = orderId; } public String getDesc() { return desc; } public void setDesc(String desc) { this.desc = desc; } @Override public String toString() { return "OrderStep{" + "orderId=" + orderId + ", desc='" + desc + '\'' + '}'; } } /** * 制作模拟数据 * * @return */ private List<OrderStep> BuilderOrders() { List<OrderStep> orderStepList = new ArrayList<>(); OrderStep orderStepDemo = new OrderStep(); orderStepDemo.setOrderId(15103111039L); orderStepDemo.setDesc("创建"); orderStepList.add(orderStepDemo); orderStepDemo = new OrderStep(); orderStepDemo.setOrderId(15103111065L); orderStepDemo.setDesc("创建"); orderStepList.add(orderStepDemo); orderStepDemo = new OrderStep(); orderStepDemo.setOrderId(15103111039L); orderStepDemo.setDesc("付款"); orderStepList.add(orderStepDemo); orderStepDemo = new OrderStep(); orderStepDemo.setOrderId(15103117235L); orderStepDemo.setDesc("创建"); orderStepList.add(orderStepDemo); orderStepDemo = new OrderStep(); orderStepDemo.setOrderId(15103111065L); orderStepDemo.setDesc("付款"); orderStepList.add(orderStepDemo); orderStepDemo = new OrderStep(); orderStepDemo.setOrderId(15103117235L); orderStepDemo.setDesc("付款"); orderStepList.add(orderStepDemo); orderStepDemo = new OrderStep(); orderStepDemo.setOrderId(15103111065L); orderStepDemo.setDesc("完成"); orderStepList.add(orderStepDemo); orderStepDemo = new OrderStep(); orderStepDemo.setOrderId(15103111039L); orderStepDemo.setDesc("推送"); orderStepList.add(orderStepDemo); orderStepDemo = new OrderStep(); orderStepDemo.setOrderId(15103117235L); orderStepDemo.setDesc("完成"); orderStepList.add(orderStepDemo); orderStepDemo = new OrderStep(); orderStepDemo.setOrderId(15103111039L); orderStepDemo.setDesc("完成"); orderStepList.add(orderStepDemo); return orderStepList; } public static void main(String[] args) throws InterruptedException, RemotingException, MQClientException, MQBrokerException { DefaultMQProducer defaultMQProducer = new DefaultMQProducer("myProducer"); defaultMQProducer.setNamesrvAddr(Constant.NAMESRV_ADDR.getEnumValue()); // 开启消息队列 defaultMQProducer.start(); // 设置nameServer的地址 List<OrderStep> orderList = new OrderProducer().BuilderOrders(); // 生产消息 for (int i = 0; i < 10 ; i++) { Message msg = new Message("MyTopic3","tagsA", orderList.get(i).toString().getBytes()); // 不用Lambda会更清晰一点 SendResult sendResult = defaultMQProducer.send(msg, new MessageQueueSelector() { @Override public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) { Long id = (Long) arg; long index = id % mqs.size(); return mqs.get((int) index); } }, orderList.get(i).getOrderId()); System.out.println(String.format("SendResult status:%s, queueId:%d, body:%s", sendResult.getSendStatus(), sendResult.getMessageQueue().getQueueId(), orderList.get(i).toString())); } defaultMQProducer.shutdown(); } } 复制代码
消费者:
public static void main(String[] args) throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("order"); consumer.setNamesrvAddr(Constant.NAMESRV_ADDR.getEnumValue()); consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET); consumer.subscribe("MyTopic3" , "*"); consumer.registerMessageListener(new MessageListenerOrderly() { @Override public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) { context.setAutoCommit(true); for (MessageExt msg : msgs) { System.out.println("consumeThread=" + Thread.currentThread().getName() + "queueId=" + msg.getQueueId() + ", content:" + new String(msg.getBody())); } return ConsumeOrderlyStatus.SUCCESS; } }); consumer.start(); System.out.println("Consumer Started."); } 复制代码
MQ事务简述
我们结合上面的示意图来介绍事务,RocketMQ中事务的状态由三种: 提交状态、回滚状态、中间状态.
- TransactionStatus.CommitTransaction: 提交事务,它允许消费者消费此消息。
- TransactionStatus.RollbackTransaction: 回滚事务,它代表该消息将被删除,不允许被消费。
- TransactionStatus.Unknown: 中间状态,它代表需要检查消息队列来确定状态。
通讯的过程大致是类似的,生产者向MQ投放消息类似于邮寄信件,发出去之后,通常必要的伴随过程是收到回信,或者收到信已成功送到。同样的生产者在发送消息也是一个双向的,生产者在向消息对了发送消息之后,并不清楚,自己是否成功发送消息,这通常需要MQ Server发送一个状态,来告知生产者是否是收到了消息。在生产者是事务生产者的情况下,生产者在收到MQ Server的响应之后,先去更新本地事务的状态,然后在进行提交或者回滚。我们都知道通信是不稳定的,生产者提交的commit/Rollback,MQ Server可能没收到。没收到之后,MQ Server就会请求生产者检查本地事务状态,生产者已经确认服务端收到了消息,那么生产者就会再发一次commit/Rollback,一般我们称之为RocketMQ的补偿机制。
示例:
TransactionMQProducer txProducer = new TransactionMQProducer("txPro"); txProducer.setNamesrvAddr(Constant.NAMESRV_ADDR.getEnumValue()); //开启事务 txProducer.setTransactionListener(new TransactionListener() { // 执行本地事务 将MQ Server的响应更新到本地事务中 @Override public LocalTransactionState executeLocalTransaction(Message message, Object o) { System.out.println(o); if ("msg0".equals(message.getTags())) { return LocalTransactionState.COMMIT_MESSAGE; } else if ("msg1".equals(message.getTags())) { return LocalTransactionState.ROLLBACK_MESSAGE; }else{ return LocalTransactionState.UNKNOW; } } // 检查本地事务的状态 @Override public LocalTransactionState checkLocalTransaction(MessageExt messageExt) { System.out.println("确实回查了"); return LocalTransactionState.COMMIT_MESSAGE; } }); txProducer.start(); for (int i = 0; i < 3; i++) { Message message = new Message("txTopic", "msg" + i, ("msg" + i + "content").getBytes()); // null代表对每个消息都进行事务控制 TransactionSendResult sendResult = txProducer.sendMessageInTransaction(message, null); System.out.println("发送:" + sendResult); } // 别把producer shutdown shutdown怎么重试 复制代码
现在去消费的话,是只会消费到两条消息。 注意: 延迟消息、批量消息不支持事务机制。
MQ概念补充
我们知道RocketMQ是保存在内存中的,那么机器总会有重启或者意外状况,那么我们就需要恢复数据,对于RocketMq来说,数据持久化到磁盘有两种方式: 同步刷盘、异步刷盘。主从之间的复制方式有: 同步复制、异步复制。异步速度快,同步则可靠高。 RocketMQ的nameserver能否用zookeeper代替? 可以,但是nameServer已经足够用了,zookeeper提供的功能很多,RocketMQ用不上,比如说一个挂掉之后,再选举一个。每个nameServe都是平等的,RocketMQ会向所有的nameServer注册。 nameServer需要完成的部分任务: topicQueue(主题队列)、brokerAddress(消息队列的地址)、brokerLive(哪些broker是存活的)。