ACL权限控制

可以从源码包中寻找到指定的官方文档介绍,其内容和路径如下

1.权限控制特性介绍

权限控制(ACL)主要为RocketMQ提供Topic资源级别的用户访问控制。用户在使用RocketMQ权限控制时,可以在Client客户端通过 RPCHook注入AccessKey和SecretKey签名;同时,将对应的权限控制属性(包括Topic访问权限、IP白名单和AccessKey和SecretKey签名等)设置在distribution/conf/plain_acl.yml的配置文件中。Broker端对AccessKey所拥有的权限进行校验,校验不过,抛出异常; ACL客户端可以参考:org.apache.rocketmq.example.simple包下面的AclClient代码。

2. 权限控制的定义与属性值

2.1权限定义

对RocketMQ的Topic资源访问权限控制定义主要如下表所示,分为以下四种

权限

含义

DENY

拒绝

ANY

PUB 或者 SUB 权限

PUB

发送权限

SUB

订阅权限

2.2 权限定义的关键属性

字段

取值

含义

globalWhiteRemoteAddresses

*;192.168.*.*;192.168.0.1

全局IP白名单

accessKey

字符串

Access Key

secretKey

字符串

Secret Key

whiteRemoteAddress

*;192.168.*.*;192.168.0.1

用户IP白名单

admin

true;false

是否管理员账户

defaultTopicPerm

DENY;PUB;SUB;PUB\

SUB

defaultGroupPerm

DENY;PUB;SUB;PUB\

SUB

topicPerms

topic=权限

各个Topic的权限

groupPerms

group=权限

各个ConsumerGroup的权限

2.3客户端使用acl

引入依赖

<dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-acl</artifactId>
            <version>4.7.1</version>
        </dependency>

打开acl

在broker.conf中设置:aclEnable=true

aclEnable=true

配置信息

#全局白名单,不受ACL控制 
#通常需要将主从架构中的所有节点加进来 
globalWhiteRemoteAddresses: 
- 10.10.103.* 
- 192.168.0.* 
accounts: 
#第一个账户 
- accessKey: RocketMQ 
secretKey: 12345678 
whiteRemoteAddress: 
admin: false 
defaultTopicPerm: DENY #默认Topic访问策略是拒绝 
defaultGroupPerm: SUB #默认Group访问策略是只允许订阅 
topicPerms: 
- topicA=DENY #topicA拒绝 
- topicB=PUB|SUB #topicB允许发布和订阅消息 
- topicC=SUB #topicC只允许订阅 
groupPerms: 
# the group should convert to retry topic 
- groupA=DENY 
- groupB=PUB|SUB 
- groupC=SUB 
#第二个账户,只要是来自192.168.1.*的IP,就可以访问所有资源 
- accessKey: rocketmq2 
secretKey: 12345678 
whiteRemoteAddress: 192.168.1.* 
# if it is admin, it could access all resources 
admin: true

private static final Map<MessageQueue, Long> OFFSE_TABLE = new HashMap<MessageQueue, Long>();//设置用户密码private static final String ACL_ACCESS_KEY = "RocketMQ";private static final String ACL_SECRET_KEY = "1234567";public static void main(String[] args) throws MQClientException, InterruptedException {//生产者发消息producer();//消费者等待推消息pushConsumer();//消费者拉取消息pullConsumer();}public static void producer() throws MQClientException {DefaultMQProducer producer = new DefaultMQProducer("ProducerGroupName", getAclRPCHook());producer.setNamesrvAddr("127.0.0.1:9876");producer.start();for (int i = 0; i < 128; i++)try {{Message msg = new Message("TopicTest","TagA","OrderID188","Hello world".getBytes(RemotingHelper.DEFAULT_CHARSET));SendResult sendResult = producer.send(msg);System.out.printf("%s%n", sendResult);}} catch (Exception e) {e.printStackTrace();}producer.shutdown();}public static void pushConsumer() throws MQClientException {//getAclRPCHook就是ACL的权限校验DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_5", getAclRPCHook(), new AllocateMessageQueueAveragely());consumer.setNamesrvAddr("127.0.0.1:9876");consumer.subscribe("TopicTest", "*");consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);// Wrong time format 2017_0422_221800consumer.setConsumeTimestamp("20180422221800");consumer.registerMessageListener(new MessageListenerConcurrently() {@Overridepublic ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);printBody(msgs);return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;}});consumer.start();System.out.printf("Consumer Started.%n");}public static void pullConsumer() throws MQClientException {DefaultMQPullConsumer consumer = new DefaultMQPullConsumer("please_rename_unique_group_name_6", getAclRPCHook());consumer.setNamesrvAddr("127.0.0.1:9876");consumer.start();Set<MessageQueue> mqs = consumer.fetchSubscribeMessageQueues("TopicTest");for (MessageQueue mq : mqs) {System.out.printf("Consume from the queue: %s%n", mq);SINGLE_MQ:while (true) {try {PullResult pullResult =consumer.pullBlockIfNotFound(mq, null, getMessageQueueOffset(mq), 32);System.out.printf("%s%n", pullResult);putMessageQueueOffset(mq, pullResult.getNextBeginOffset());printBody(pullResult);switch (pullResult.getPullStatus()) {case FOUND:break;case NO_MATCHED_MSG:break;case NO_NEW_MSG:break SINGLE_MQ;case OFFSET_ILLEGAL:break;default:break;}} catch (Exception e) {e.printStackTrace();}}}consumer.shutdown();}private static void printBody(PullResult pullResult) {printBody(pullResult.getMsgFoundList());}private static void printBody(List<MessageExt> msg) {if (msg == null || msg.size() == 0)return;for (MessageExt m : msg) {if (m != null) {System.out.printf("msgId : %s  body : %s  \n\r", m.getMsgId(), new String(m.getBody()));}}}private static long getMessageQueueOffset(MessageQueue mq) {Long offset = OFFSE_TABLE.get(mq);if (offset != null)return offset;return 0;}private static void putMessageQueueOffset(MessageQueue mq, long offset) {OFFSE_TABLE.put(mq, offset);}static RPCHook getAclRPCHook() {return new AclClientRPCHook(new SessionCredentials(ACL_ACCESS_KEY,ACL_SECRET_KEY));}

3. 支持权限控制的集群部署

在distribution/conf/plain_acl.yml配置文件中按照上述说明定义好权限属性后,打开aclEnable开关变量即可开启RocketMQ集群的ACL特性。这里贴出Broker端开启ACL特性的properties配置文件内容:

brokerClusterName=DefaultCluster
brokerName=broker-a
brokerId=0
deleteWhen=04
fileReservedTime=48
brokerRole=ASYNC_MASTER
flushDiskType=ASYNC_FLUSH
storePathRootDir=/data/rocketmq/rootdir-a-m
storePathCommitLog=/data/rocketmq/commitlog-a-m
autoCreateSubscriptionGroup=true
## if acl is open,the flag will be true
aclEnable=true
listenPort=10911
brokerIP1=XX.XX.XX.XX1
namesrvAddr=XX.XX.XX.XX:9876

ACL主要流程分为两部分,主要包括权限解析和权限校验。

4.1 权限解析

Broker端对客户端的RequestCommand请求进行解析,拿到需要鉴权的属性字段。 主要包括: (1)AccessKey:类似于用户名,代指用户主体,权限数据与之对应; (2)Signature:客户根据 SecretKey 签名得到的串,服务端再用SecretKey进行签名验证;

4.2 权限校验

Broker端对权限的校验逻辑主要分为以下几步:

(1)检查是否命中全局 IP 白名单;如果是,则认为校验通过;否则走 2;

(2)检查是否命中用户 IP 白名单;如果是,则认为校验通过;否则走 3;

(3)校验签名,校验不通过,抛出异常;校验通过,则走 4;

(4)对用户请求所需的权限 和 用户所拥有的权限进行校验;不通过,抛出异常; 用户所需权限的校验需要注意已下内容:

  • 特殊的请求例如 UPDATE_AND_CREATE_TOPIC 等,只能由 admin 账户进行操作;
  • 对于某个资源,如果有显性配置权限,则采用配置的权限;如果没有显性配置权限,则采用默认的权限;

5. 热加载修改后权限控制定义

RocketMQ的权限控制存储的默认实现是基于yml配置文件。用户可以动态修改权限控制定义的属性,而不需重新启动Broker服务节点。

6. 权限控制的使用限制

(1)如果ACL与高可用部署(Master/Slave架构)同时启用,那么需要在Broker Master节点的distribution/conf/plain_acl.yml配置文件中 设置全局白名单信息,即为将Slave节点的ip地址设置至Master节点plain_acl.yml配置文件的全局白名单中。

(2)如果ACL与高可用部署(多副本Dledger架构)同时启用,由于出现节点宕机时,Dledger Group组内会自动选主,那么就需要将Dledger Group组 内所有Broker节点的plain_acl.yml配置文件的白名单设置所有Broker节点的ip地址。

7. ACL mqadmin配置管理命令

7.1 更新ACL配置文件中“account”的属性值

该命令的示例如下:

sh mqadmin updateAclConfig -n 192.168.1.2:9876 -b 192.168.12.134:10911 -a RocketMQ -s 1234567809123 -t topicA=DENY,topicD=SUB -g groupD=DENY,groupB=SUB

说明:如果不存在则会在ACL Config YAML配置文件中创建;若存在,则会更新对应的“accounts”的属性值; 如果指定的是集群名称,则会在集群中各个broker节点执行该命令;否则会在单个broker节点执行该命令。

参数

取值

含义

n

eg:192.168.1.2:9876

namesrv地址(必填)

c

eg:DefaultCluster

指定集群名称(与broker地址二选一)

b

eg:192.168.12.134:10911

指定broker地址(与集群名称二选一)

a

eg:RocketMQ

Access Key值(必填)

s

eg:1234567809123

Secret Key值(可选)

m

eg:true

是否管理员账户(可选)

w

eg:192.168.0.*

whiteRemoteAddress,用户IP白名单(可选)

i

eg:DENY;PUB;SUB;PUB\

SUB

u

eg:DENY;PUB;SUB;PUB\

SUB

t

eg:topicA=DENY,topicD=SUB

topicPerms,各个Topic的权限(可选)

g

eg:groupD=DENY,groupB=SUB

groupPerms,各个ConsumerGroup的权限(可选)

7.2 删除ACL配置文件里面的对应“account”

该命令的示例如下:

sh mqadmin deleteAccessConfig -n 192.168.1.2:9876 -c DefaultCluster -a RocketMQ

说明:如果指定的是集群名称,则会在集群中各个broker节点执行该命令;否则会在单个broker节点执行该命令。 其中,参数"a"为Access Key的值,用以标识唯一账户id,因此该命令的参数中指定账户id即可。

参数

取值

含义

n

eg:192.168.1.2:9876

namesrv地址(必填)

c

eg:DefaultCluster

指定集群名称(与broker地址二选一)

b

eg:192.168.12.134:10911

指定broker地址(与集群名称二选一)

a

eg:RocketMQ

Access Key的值(必填)

7.3 更新ACL配置文件里面中的全局白名单

该命令的示例如下:

sh mqadmin updateGlobalWhiteAddr -n 192.168.1.2:9876 -b 192.168.12.134:10911 -g 10.10.154.1,10.10.154.2

说明:如果指定的是集群名称,则会在集群中各个broker节点执行该命令;否则会在单个broker节点执行该命令。 其中,参数"g"为全局IP白名的值,用以更新ACL配置文件中的“globalWhiteRemoteAddresses”字段的属性值。

参数

取值

含义

n

eg:192.168.1.2:9876

namesrv地址(必填)

c

eg:DefaultCluster

指定集群名称(与broker地址二选一)

b

eg:192.168.12.134:10911

指定broker地址(与集群名称二选一)

g

eg:10.10.154.1,10.10.154.2

全局IP白名单(必填)

7.4 查询集群/Broker的ACL配置文件版本信息

该命令的示例如下:

sh mqadmin clusterAclConfigVersion -n 192.168.1.2:9876 -c DefaultCluster

说明:如果指定的是集群名称,则会在集群中各个broker节点执行该命令;否则会在单个broker节点执行该命令。

参数

取值

含义

n

eg:192.168.1.2:9876

namesrv地址(必填)

c

eg:DefaultCluster

指定集群名称(与broker地址二选一)

b

eg:192.168.12.134:10911

指定broker地址(与集群名称二选一)

7.5 查询集群/Broker的ACL配置文件全部内容

该命令的示例如下:

sh mqadmin getAccessConfigSubCommand -n 192.168.1.2:9876 -c DefaultCluster

说明:如果指定的是集群名称,则会在集群中各个broker节点执行该命令;否则会在单个broker节点执行该命令。

参数

取值

含义

n

eg:192.168.1.2:9876

namesrv地址(必填)

c

eg:DefaultCluster

指定集群名称(与broker地址二选一)

b

eg:192.168.12.134:10911

指定broker地址(与集群名称二选一)

特别注意开启Acl鉴权认证后导致Master/Slave和Dledger模式下Broker同步数据异常的问题, 在社区[4.5.1]版本中已经修复,具体的PR链接为:https://github.com/apache/rocketmq/pull/1149;

消息轨迹

由于mq消息在分布式环境下的复杂性,可能会经过多个不同的服务节点,因此其消息发送接收的轨迹也会很复杂难以追踪。

1. 消息轨迹数据关键属性

Producer端

Consumer端

Broker端

生产实例信息

消费实例信息

消息的Topic

发送消息时间

投递时间,投递轮次

消息存储位置

消息是否发送成功

消息是否消费成功

消息的Key值

发送耗时

消费耗时

消息的Tag值

2. 支持消息轨迹集群部署

2.1 Broker端配置文件

这里贴出Broker端开启消息轨迹特性的properties配置文件内容:

brokerClusterName=DefaultCluster
brokerName=broker-a
brokerId=0
deleteWhen=04
fileReservedTime=48
brokerRole=ASYNC_MASTER
flushDiskType=ASYNC_FLUSH
storePathRootDir=/data/rocketmq/rootdir-a-m
storePathCommitLog=/data/rocketmq/commitlog-a-m
autoCreateSubscriptionGroup=true
## if msg tracing is open,the flag will be true
traceTopicEnable=true
listenPort=10911
brokerIP1=XX.XX.XX.XX1
namesrvAddr=XX.XX.XX.XX:9876

2.2 普通模式

RocketMQ集群中每一个Broker节点均用于存储Client端收集并发送过来的消息轨迹数据。因此,对于RocketMQ集群中的Broker节点数量并无要求和限制。

2.3 物理IO隔离模式

对于消息轨迹数据量较大的场景,可以在RocketMQ集群中选择其中一个Broker节点专用于存储消息轨迹,使得用户普通的消息数据与消息轨迹数据的物理IO完全隔离,互不影响。在该模式下,RockeMQ集群中至少有两个Broker节点,其中一个Broker节点定义为存储消息轨迹数据的服务端。

2.4 启动开启消息轨迹的Broker

nohup sh mqbroker -c ../conf/2m-noslave/broker-a.properties &

3. 保存消息轨迹的Topic定义

RocketMQ的消息轨迹特性支持两种存储轨迹数据的方式:

3.1 系统级的TraceTopic

在默认情况下,消息轨迹数据是存储于系统级的TraceTopic中(其名称为:RMQ_SYS_TRACE_TOPIC)。该Topic在Broker节点启动时,会自动创建出来(如上所叙,需要在Broker端的配置文件中将traceTopicEnable的开关变量设置为true)。

3.2 用户自定义的TraceTopic

如果用户不准备将消息轨迹的数据存储于系统级的默认TraceTopic,也可以自己定义并创建用户级的Topic来保存轨迹(即为创建普通的Topic用于保存消息轨迹数据)。下面一节会介绍Client客户端的接口如何支持用户自定义的TraceTopic。

4. 支持消息轨迹的Client客户端实践

为了尽可能地减少用户业务系统使用RocketMQ消息轨迹特性的改造工作量,作者在设计时候采用对原来接口增加一个开关参数(enableMsgTrace)来实现消息轨迹是否开启;并新增一个自定义参(customizedTraceTopic)数来实现用户存储消息轨迹数据至自己创建的用户级Topic。

4.1 发送消息时开启消息轨迹

        DefaultMQProducer producer = new DefaultMQProducer("ProducerGroupName",true);producer.setNamesrvAddr("XX.XX.XX.XX1");producer.start();try {{Message msg = new Message("TopicTest","TagA","OrderID188","Hello world".getBytes(RemotingHelper.DEFAULT_CHARSET));SendResult sendResult = producer.send(msg);System.out.printf("%s%n", sendResult);}} catch (Exception e) {e.printStackTrace();}

4.2 订阅消息时开启消息轨迹

       DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("CID_JODIE_1",true);consumer.subscribe("TopicTest", "*");consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);consumer.setConsumeTimestamp("20181109221800");consumer.registerMessageListener(new MessageListenerConcurrently() {@Overridepublic ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;}});consumer.start();System.out.printf("Consumer Started.%n");

4.3 支持自定义存储消息轨迹Topic

在上面的发送和订阅消息时候分别将DefaultMQProducer和DefaultMQPushConsumer实例的初始化修改为如下即可支持自定义存储消息轨迹Topic。

##其中Topic_test11111需要用户自己预先创建,来保存消息轨迹;

       ##其中Topic_test11111需要用户自己预先创建,来保存消息轨迹;DefaultMQProducer producer = new DefaultMQProducer("ProducerGroupName",true,"Topic_test11111");......DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("CID_JODIE_1",true,"Topic_test11111");......

作用

可用于分布式环境下的数据日志分析

MQ基础概念

消息模型(Message Model)

RocketMQ主要由 Producer、Broker、Consumer 三部分组成,其中Producer 负责生产消息,Consumer 负责消费消息,Broker 负责存储消息。Broker 在实际部署过程中对应一台服务器,每个Broker 可以存储多个Topic的消息,每个Topic的消息也可以分片存储于不同的 Broker。MessageQueue 用于存储消息的物理地址,每个Topic中的消息地址存储于多个 Message Queue 中。ConsumerGroup 由多个Consumer 实例构成。

消息生产者(Producer)

负责生产消息,一般由业务系统负责生产消息。一个消息生产者会把业务应用系统里产生的消息发送到broker服务器。

RocketMQ提供多种发送方式

  • 同步发送
  • 异步发送
  • 顺序发送
  • 单向发送

同步和异步方式均需要Broker返回确认信息,单向发送不需要。

生产者中,会把同一类Producer组成一个集合,叫做生产者组,这类Producer发送同一类消息且发送逻辑一致。如果发送的是事务消息且原始生产者在发送之后崩溃,则Broker服务器会联系同一生产者组的其他生产者实例以提交或回溯消费。

消息消费者(Consumer)

负责消费消息,一般是后台系统负责异步消费。一个消息消费者会从Broker服务器拉取消息、并将其提

供给应用程序。从用户应用的角度而言提供了两种消费形式:

  • 拉取式消费:应用通常主动调用Consumer的拉消息方法从Broker服务器拉消息、主动权由应用控制。一旦获取了批量消息,应用就会启动消费过程
  • 推动式消费 :Broker收到数据后会主动推送给消费端,该消费模式一般实时性较高

消费者同样会把同一类Consumer组成一个集合,叫做消费者组,这类Consumer通常消费同一类消息

且消费逻辑一致。消费者组使得在消息消费方面,实现负载均衡和容错的目标变得非常容易。要注意的

是,消费者组的消费者实例必须订阅完全相同的Topic。

RocketMQ 支持两种消息模式:

  • 集群消费 Clustering):相同Consumer Group的每个Consumer实例平均分摊消息
  • 广播消费(Broadcasting) :相同Consumer Group的每个Consumer实例都接收全量的消息

主题(Topic)

表示一类消息的集合,每个主题包含若干条消息,每条消息只能属于一个主题,是RocketMQ进行消息订阅的基本单位。同一个Topic下的数据,会分片保存到不同的Broker上,而每一个分片单位,就叫做MessageQueue。 MessageQueue是生产者发送消息与消费者消费消息的最小单位。

代理服务器(Broker Server)

消息中转角色,负责存储消息、转发消息。代理服务器在RocketMQ系统中负责接收从生产者发送来的消息并存储、同时为消费者的拉取请求作准备。代理服务器也存储消息相关的元数据,包括消费者组、消费进度偏移和主题和队列消息等。

Broker Server是RocketMQ真正的业务核心,包含了多个重要的子模块:

  • Remoting Module:整个Broker的实体,负责处理来自clients端的请求。
  • Client Manager:负责管理客户端(Producer/Consumer)和维护Consumer的Topic订阅信息
  • Store Service:提供方便简单的API接口处理消息存储到物理硬盘和查询功能。
  • HA Service:高可用服务,提供Master Broker 和 Slave Broker之间的数据同步功能。
  • Index Service:根据特定的Message key对投递到Broker的消息进行索引服务,以提供消息的快速查询。

而Broker Server要保证高可用需要搭建主从集群架构。

普通集群

这种集群模式下会给每个节点分配一个固定的角色,master负责响应客户端的请求,并存储消息。

slave则只负责对master的消息进行同步保存,并响应部分客户端的读请求。消息同步方式分为同步同步和异步同步。这种集群模式下各个节点的角色无法进行切换,也就是说,master节点挂了,这一组Broker就不可用了。

Dledger高可用集群

Dledger是RocketMQ自4.5版本引入的实现高可用集群的一项技术。这个模式下的集群会随机选出一个节点作为master,而当master节点挂了后,会从slave中自动选出一个节点升级成为master。

Dledger职责

  • 接管Broker的CommitLog消息存储
  • 从集群中选举出master节点
  • 完成master节点往slave节点的消息同步

选举

Dledger的关键部分是在他的节点选举上。Dledger是使用Raft算法来进行节点选举的

在Dledger集群下,每个节点有三个状态,Leader,follower和candidate(候选人)。正常运行的情况下,集群中会有一个leader,其他都是follower,follower只响应Leader和Candidate的请求,而客户端的请求全部由Leader处理,即使有客户端请求到了一个follower,也会将请求转发到leader

Raft算法的选举过程

集群刚启动时,每个节点都是follower状态,之后集群内部会发送一个timeout信号,所有follower就转成candidate去拉取选票,获得大多数选票的节点选为leader,其他候选人转为follower。如果一个timeout信号发出时,选票被平均瓜分,没有选出leader,将会重新开始一次新的选举。而Leader节点会往其他节点发送心跳信号,确认他的leader状态。 然后会启动定时器,如果在指定时间内没有收到Leader的心跳,就会转为Candidate状态,然后向其他成员发起投票请求,如果收到半数以上成员的投票,则Candidate会晋升为Leader。然后leader也有可能会退化成follower。

term

在Raft协议中,会将时间分为一些任意时间长度的时间片段,叫做term。term会使用一个全局唯一,连续递增的编号(termid)作为标识,也就是起到了一个逻辑时钟的作用。 在每一个term时间片里,都会进行新的选举,每一个Candidate都会努力争取成为leader。

被选举为leader的节点,在一个term时间片里会保持leader状态。这样,就会保证在同一时间段内,集群中只会有一个Leader。在某些情况下,选票可能会被各个节点瓜分,形成不了多数派,那这个term可能直到结束都没有leader,直到下一个term再重新发起选举,这也就没有了Zookeeper中的脑裂问题。而在每次重新选举的过程中,leader也有可能会退化成为follower。也就是说,在这个集群中, leader节点是会不断变化的。然后,每次选举的过程中,每个节点都会存储当前term编号,并在节点之间进行交流时,都会带上自己的term编号。如果一个节点发现他的编号比另外一个小,那么他就会将自己的编号更新为较大的那一个。而如果leader或者candidate发现自己的编号不是最新的,他就会自动转成follower。如果接收到的请求term编号小于自己的编号,term将会拒绝执行。

在选举过程中,Raft协议会通过心跳机制发起leader选举。节点都是从follower状态开始的,如果收到了来自leader或者candidate的心跳RPC请求,那他就会保持follower状态,避免争抢成为candidate。而leader会往其他节点发送心跳信号,来确认自己的地位。如果follower一段时间(两个timeout信号)内没有收到Leader的心跳信号,他就会认为leader挂了,发起新一轮选举。选举开始后,每个follower会增加自己当前的term,并将自己转为candidate。然后向其他节点发起投票请求,请求时会带上自己的编号和term,也就是说都会默认投自己一票。之后candidate状态可能会发生以下三种变化:

  • 赢得选举,成为leader:如果它在一个term内收到了大多数的选票,将会在接下的剩余term时间内称为leader,然后就可以通过发送心跳确立自己的地位。(每一个server在一个term内只能投一张选票,并且按照先到先得的原则投出)
  • 其他节点成为leader:在等待投票时,可能会收到其他server发出心跳信号,说明其他leader已经产生了。这时通过比较自己的term编号和RPC过来的term编号,如果比对方大, 说明leader的term过期了,就会拒绝该RPC,并继续保持候选人身份; 如果对方编号不比自己小,则承认对方的地位,转为follower。
  • 选票被瓜分,选举失败: 如果没有candidate获取大多数选票, 则没有leader产生, candidate们等待超时后发起另一轮选举. 为了防止下一次选票还被瓜分,必须采取一些额外的措施, raft采用随机election timeout(随机休眠时间)的机制防止选票被持续瓜分。通过将timeout随机设为一段区间上的某个值, 因此很大概率会有某个candidate率先超时然后赢得大部分选票。

以三个节点的集群为例:

1. 集群启动时,三个节点都是follower,发起投票后,三个节点都会给自己投票。这样一轮投票下来,三个节点的term都是1,是一样的,这样是选举不出Leader的。

2. 当一轮投票选举不出Leader后,三个节点会进入随机休眠,例如A休眠1秒,B休眠3秒,C休眠2秒。

3. 一秒后,A节点醒来,会把自己的term加一票,投为2。然后2秒时,C节点醒来,发现A的term已经是2,比自己的1大,就会承认A是Leader,把自己的term也更新为2。实际上这个时候,A已经获得了集群中的多数票,2票,A就会被选举成Leader。这样,一般经过很短的几轮选举,就会选举出一个Leader来。

4. 到3秒时,B节点会醒来,他也同样会承认A的term最大,他是Leader,自己的term也会更新为2。这样集群中的所有Candidate就都确定成了leader和follower.

5. 然后在一个任期内,A会不断发心跳给另外两个节点。当A挂了后,另外的节点没有收到A的心跳,就会都转化成Candidate状态,重新发起选举。

小结

相比Zookerper,不会产生脑裂问题;但其选举会更加的频繁。

数据同步

Dledger还会采用Raft协议进行多副本的消息同步

简单来说,数据同步会通过两个阶段,一个是uncommitted阶段,一个是commited阶段。

  • Leader Broker上的Dledger收到一条数据后,会标记为uncommitted状态,然后他通过自己的DledgerServer组件把这个uncommitted数据发给Follower Broker的DledgerServer组件。
  • 接着Follower Broker的DledgerServer收到uncommitted消息之后,必须返回一个ack给Leader Broker的Dledger。然后如果Leader Broker收到超过半数的Follower Broker返回的ack之后,就会把消息标记为committed状态。
  • 再接下来, Leader Broker上的DledgerServer就会发送committed消息给Follower Broker上的DledgerServer,让他们把消息也标记为committed状态。

这样,就基于Raft协议完成了两阶段的数据同步。

名字服务(Name Server)

名称服务充当路由消息的提供者。Broker Server会在启动时向所有的Name Server注册自己的服务信息,并且后续通过心跳请求的方式保证这个服务信息的实时性。生产者或消费者能够通过名字服务查找各主题相应的Broker IP列表。多个Namesrv实例组成集群,但相互独立,没有信息交换。

这种特性也就意味着NameServer中任意的节点挂了,只要有一台服务节点正常,整个路由服务就不会有影响。当然,这里不考虑节点的负载情况。

消息(Message)

消息系统所传输信息的物理载体,生产和消费数据的最小单位,每条消息必须属于一个主题Topic。RocketMQ中每个消息拥有唯一的Message ID,且可以携带具有业务标识的Key。系统提供了通过Message ID和Key查询消息的功能。

并且Message上有一个为消息设置的标志,Tag标签。用于同一主题下区分不同类型的消息。来自同一业务单元的消息,可以根据不同业务目的在同一主题下设置不同标签。标签能够有效地保持代码的清晰度和连贯性,并优化RocketMQ提供的查询系统。消费者可以根据Tag实现对不同子主题的不同消费逻辑,实现更好的扩展性。

消息存储

存储消息的时机

生产者发送消息给MQ的Broker,再由Broker发送给消费者进行消费;在这个过程中要对生产的消息进行持久化存储将数据存储到内存当中。

大体步骤

  • MQ收到一条消息后,需要向生产者返回一个ACK响应,并将消息存储起来。
  • MQ Push一条消息给消费者后,等待消费者的ACK响应,需要将消息标记为已消费。如果没有标记为消费,MQ会不断的尝试往消费者推送这条消息。
  • MQ需要定期删除一些过期的消息,这样才能保证服务一直可用

#文件删除时间,默认凌晨4点
deleteWhen = 04
#文件保留时间,默认48小时
fileReservedTime = 48

消息存储介质

RocketMQ采用的是类似于Kafka的文件存储机制,即直接用磁盘文件来保存消息,因为mysql和redis当中会有索引,而索引也是占据一定的磁盘空间的;而使用文件存储机制,理论上读写是会更慢的,但实际不是,因为mq的消息存储是一个顺序的IO,而不是普通文件读写的随机IO,因此,即使是使用文件存储的机制来保留消息,其效率也是很快的。

磁盘保存文件

磁盘如果使用得当,磁盘的速度完全可以匹配上网络 的数据传输速度。目前的高性能磁盘,顺序写速度可以达到600MB/s, 超过了一般网卡的传输速度。但是磁盘随机写的速度只有大概100KB/s,和顺序写的性能相差6000倍!因为有如此巨大的速度差别,好的消息队列系统会比普通的消息队列系统速度快多个数量级。RocketMQ的消息用顺序写,保证了消息存储的速度。

零拷贝

在linux系统中,操作系统分为内核态用户态,一般来说涉及到两个状态之间的切换的事件,都是一个很重的操作,而且还伴随着数据的复制。而在MQ中,由于消息的传递涉及到持久化的操作,在mq存储和传递消息的时候,肯定是涉及内核态和用户态的切换问题。

一台服务器把本机磁盘文件的内容发送到客户端,一般分为两个步骤:

1)read;读取本地文件内容

2)write;将读取的内容通过网络发送出去

这两个看似简单的操作,实际进行了4 次数据复制,分别是:

1. 从磁盘复制数据到内核态内存;

2. 从内核态内存复制到用户态内存;

3. 然后从用户态内存复制到网络驱动的内核态内存;

4. 最后是从网络驱动的内核态内存复制到网卡中进行传输。而通过使用mmap的方式,可以省去向用户态的内存复制,提高速度。这种机制在Java中是通过NIO包中的MappedByteBuffffer实现的。RocketMQ充分利用了上述特性,也就是所谓的“零拷贝”技术,提高消息存盘和网络发送的速度。

正常数据复制流程

mmap方式

减少了用户态将数据复制到应用中的过程,而是传递数据文件对应的映射地址,当需要使用数据时,直接根据映射的地址处理,缺点在于文件的大小存在限制。

这里需要注意的是,采用MappedByteBuffffer这种内存映射的方式有几个限制,其中之一是一次只能映射1.5~2G 的文件至用户态的虚拟内存,这也是为何RocketMQ默认设置单个CommitLog日志数据文件为1G的原因了,关于零拷贝,JAVA的NIO中提供了两种实现方式,mmap和sendfifile,其中mmap适合比较小的文件,而sendfifile适合传递比较大的文件。

消息存储结构

  • CommitLog:存储消息的元数据。所有消息都会顺序存入到CommitLog文件当中。CommitLog由多个文件组成,每个文件固定大小1G。以第一条消息的偏移量为文件名。
  • ConsumerQueue:存储消息在CommitLog的索引。一个MessageQueue一个文件,记录当前MessageQueue被哪些消费者组消费到了哪一条CommitLog。
  • IndexFile:为了消息查询提供了一种通过key或时间区间来查询消息的方法,这种通过IndexFile来查找消息的方法不影响发送与消费消息的主流程

时间戳过滤

consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);

public enum ConsumeFromWhere {CONSUME_FROM_LAST_OFFSET,/** @deprecated */@DeprecatedCONSUME_FROM_LAST_OFFSET_AND_FROM_MIN_WHEN_BOOT_FIRST,/** @deprecated */@DeprecatedCONSUME_FROM_MIN_OFFSET,/** @deprecated */@DeprecatedCONSUME_FROM_MAX_OFFSET,CONSUME_FROM_FIRST_OFFSET,CONSUME_FROM_TIMESTAMP;private ConsumeFromWhere() {}
}

额外文件

  • abort:这个文件是RocketMQ用来判断程序是否正常关闭的一个标识文件。正常情况下,会在启动时创建,而关闭服务时删除。但是如果遇到一些服务器宕机,或者kill -9这样一些非正常关闭服务的情况,这个abort文件就不会删除,因此RocketMQ就可以判断上一次服务是非正常关闭的,后续就会做一些数据恢复的操作。
  • checkpoint:数据存盘检查点
  • confifig/*.json:这些文件是将RocketMQ的一些关键配置信息进行存盘保存。例如Topic配置、消费者组配置、消费者组消息偏移量Offffset 等等一些信息。

刷盘机制

RocketMQ需要将消息存储到磁盘上,这样才能保证断电后消息不会丢失。同时这样才可以让存储的消息量可以超出内存的限制。RocketMQ为了提高性能,会尽量保证磁盘的顺序写。消息在写入磁盘时,有两种写磁盘的方式,同步刷盘和异步刷盘。

同步刷盘

在返回写成功状态时,消息已经被写入磁盘。具体流程是,消息写入内存的PAGECACHE后,立刻通知刷盘线程刷盘, 然后等待刷盘完成,刷盘线程执行完成后唤醒等待的线程,返回消息写成功的状态,同步操作不会丢失消息,安全性更高,但是体量和效率会更低。

异步刷盘

在返回写成功状态时,消息可能只是被写入了内存的PAGECACHE,写操作的返回快,吞吐量大;当内存里的消息量积累到一定程度时,统一触发写磁盘动作,快速写入,但异步操作可能会有丢消息的可能。

配置方式

刷盘方式是通过Broker配置文件里的flflushDiskType参数设置的,这个参数被配置成SYNC_FLUSH、ASYNC_FLUSH中的 一个。

消息主从复制

如果Broker以一个集群的方式部署,会有一个master节点和多个slave节点,消息需要从Master复制到Slave上。而消息复制的方式分为同步复制和异步复制。

同步复制

同步复制是等Master和Slave都写入消息成功后才反馈给客户端写入成功的状态。

在同步复制下,如果Master节点故障,Slave上有全部的数据备份,这样容易恢复数据。但是同步复制会增大数据写入的延迟,降低系统的吞吐量。

异步复制

异步复制是只要master写入消息成功,就反馈给客户端写入成功的状态。然后再异步的将消息复制给Slave节点。 在异步复制下,系统拥有较低的延迟和较高的吞吐量。但是如果master节点故障,而有些数据没有完成

复制,就会造成数据丢失。

配置方式

消息复制方式是通过Broker配置文件里的brokerRole参数进行设置的,这个参数可以被设置成ASYNC_MASTER、 SYNC_MASTER、SLAVE三个值中的一个。

负载均衡

Producer负载均衡

Producer发送消息时,默认会轮询目标Topic下的所有MessageQueue,并采用递增取模的方式往不同的MessageQueue上发送消息,以达到让消息平均落在不同的queue上的目的。而由于MessageQueue是分布在不同的Broker上的,所以消息也会发送到不同的broker上。

同时,生产者在发送消息时,可以指定一个MessageQueueSelector。通过这个对象来将消息发送到自己指定的MessageQueue上。这样可以保证消息局部有序。

Consumer负载均衡

Consumer也是以MessageQueue为单位来进行负载均衡,分为集群模式和广播模式

集群模式

在集群消费模式下,每条消息只需要投递到订阅这个topic的Consumer Group下的一个实例即可。

RocketMQ采用主动拉取的方式拉取并消费消息,在拉取的时候需要明确指定拉取哪一条messagequeue。

而每当实例的数量有变更,都会触发一次所有实例的负载均衡,这时候会按照queue的数量和实例的数量平均分配queue给每个实例。

每次分配时,都会将MessageQueue和消费者ID进行排序后,再用不同的分配算法进行分配。内置的分配的算法共有六种,分别对应AllocateMessageQueueStrategy下的六种实现类,可以在consumer中直接set来指定。

AllocateMessageQueueAveragely:平均分配。将所有MessageQueue平均分给每一个消费者

AllocateMessageQueueAveragelyByCircle: 轮询分配。轮流的给一个消费者分配一个MessageQueue。

AllocateMessageQueueByConfifig: 不分配,直接指定一个messageQueue列表。类似于广播模式,直接指定所有队列。

AllocateMessageQueueByMachineRoom:按逻辑机房的概念进行分配。又是对BrokerName和ConsumerIdc有定制化的配置。

AllocateMessageQueueConsistentHash。源码中有测试代码

AllocateMessageQueueConsitentHashTest。这个一致性哈希策略只需要指定一个虚拟节点数, 是用的一个哈希环的算法,虚拟节点是为了让Hash数据在换上分布更为均匀。

默认情况下使用的是最简单的平均分配策略。

轮询分配

广播模式

广播模式下,每一条消息都会投递给订阅了Topic的所有消费者实例,所以也就没有消息分配这一说。

而在实现上,就是在Consumer分配Queue时,所有Consumer都分到所有的Queue。

消息重试

首先对于广播模式的消息, 是不存在消息重试的机制的,即消息消费失败后,不会再重新进行发送,而只是继续消费新的消息。而对于普通的消息,当消费者消费消息失败后,你可以通过设置返回状态达到消息重试的结果。

重试机制

集群消费方式下,消息消费失败后期望消息重试,需要在消息监听器接口的实现中明确进行配置。可以有三种配置方式:

  • 返回Action.ReconsumeLater-推荐
  • 返回null
  • 抛出异常

重试消息处理

重试的消息会进入一个 “%RETRY%”+ConsumeGroup 的队列中,然后RocketMQ默认允许每条消息最多重试16次,每次重试的间隔时间如下:

重试次数

与上次重试的间隔时间

重试次数

与上次重试的间隔时间

1

10 秒

9

7 分钟

2

30 秒

10

8 分钟

3

1 分钟

11

9 分钟

4

2 分钟

12

10 分钟

5

3 分钟

13

20 分钟

6

4 分钟

14

30 分钟

7

5 分钟

15

1 小时

8

6 分钟

16

2 小时

这个重试时间跟延迟消息的延迟级别是对应的。不过取的是延迟级别的后16级别。

messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h 

重试次数

如果消息重试16次后仍然失败,消息将不再投递。转为进入死信队列。

另外一条消息无论重试多少次,这些重试消息的MessageId始终都是一样的。

然后关于这个重试次数,RocketMQ可以进行定制。例如通过consumer.setMaxReconsumeTimes(20);将重试次数设定为20次。当定制的重试次数超过16次后,消息的重试时间间隔均为2小时。

MessageId

在老版本的RocketMQ中,一条消息无论重试多少次,这些重试消息的MessageId始终都是一样的。但是在4.7.1版本中,每次重试MessageId都会重建。

配置覆盖

消息最大重试次数的设置对相同GroupID下的所有Consumer实例有效。并且最后启动的Consumer会覆盖之前启动的Consumer的配置。

死信队列

当一条消息消费失败,RocketMQ就会自动进行消息重试。而如果消息超过最大重试次数,RocketMQ就会认为这个消息有问题。但是此时,RocketMQ不会立刻将这个有问题的消息丢弃,而会将其发送到这个消费者组对应的一种特殊队列:死信队列。 死信队列的名称是%DLQ%+ConsumGroup。

特征

  • 一个死信队列对应一个ConsumGroup,而不是对应某个消费者实例。
  • 如果一个ConsumeGroup没有产生死信队列,RocketMQ就不会为其创建相应的死信队列。
  • 一个死信队列包含了这个ConsumeGroup里的所有死信消息,而不区分该消息属于哪个Topic。
  • 死信队列中的消息不会再被消费者正常消费。
  • 死信队列的有效期跟正常消息相同。默认3天,对应broker.conf中的fifileReservedTime属性。超过这个最长时间的消息都会被删除,而不管消息是否消费过。

通常,一条消息进入了死信队列,意味着消息在消费处理的过程中出现了比较严重的错误,并且无法自行恢复。此时,一般需要人工去查看死信队列中的消息,对错误原因进行排查。然后对死信消息进行处理,比如转发到正常的Topic重新进行消费,或者丢弃。

注意:默认创建出来的死信队列,他里面的消息是无法读取的,在控制台和消费者中都无法读取。这是因为这些默认的死信队列,他们的权限perm被设置成了2:禁读(这个权限有三种 2:禁读,4:禁写,6:可读可写)。需要手动将死信队列的权限配置成6,才能被消费(可以通过mqadmin指定或者web控制台)。

消息幂等

概念

在MQ系统中,对于消息幂等有三种实现语义

  • at most once 最多一次:每条消息最多只会被消费一次
  • at least once 至少一次:每条消息至少会被消费一次
  • exactly once 刚刚好一次:每条消息都只会确定的消费一次

业务场景

  • 其中,at most once是最好保证的。RocketMQ中可以直接用异步发送、sendOneWay等方式就可以保证。
  • 而at least once这个语义,RocketMQ也有同步发送、事务消息等很多方式能够保证。
  • 而这个exactly once是MQ中最理想也是最难保证的一种语义,需要有非常精细的设计才行。RocketMQ只能保证at least once,保证不了exactly once。所以,使用RocketMQ时,需要由业务系统自行保证消息的幂等性。

关于这个问题,官网上有明确的回答:

Are messages delivered exactly once?
RocketMQ ensures that all messages are delivered at least once. In most cases, the
messages are not repeated. 

必要性

在互联网应用中,尤其在网络不稳定的情况下,消息队列 RocketMQ 的消息有可能会出现重复,这个重复简单可以概括为以下情况:

发送时消息重复

当一条消息已被成功发送到服务端并完成持久化,此时出现了网络闪断或者客户端宕机,导致服务端对客户端应答失败。 如果此时生产者意识到消息发送失败并尝试再次发送消息,消费者后续会收到两条内容相同并且Message ID 也相同的消息。

投递时消息重复

消息消费的场景下,消息已投递到消费者并完成业务处理,当客户端给服务端反馈应答的时候网络闪断。 为了保证消息至少被消费一次,消息队列 RocketMQ 的服务端将在网络恢复后再次尝试投递之前已被处理过的消息,消费者后续会收到两条内容相同并且 Message ID 也相同的消息。

负载均衡时消息重复

(包括但不限于网络抖动、Broker 重启以及订阅方应用重启)

当消息队列 RocketMQ 的 Broker 或客户端重启、扩容或缩容时,会触发 Rebalance,此时消费者可能会收到重复消息。

处理方式

从上面的分析中,我们知道,在RocketMQ中,是无法保证每个消息只被投递一次的,所以要在业务上自行来保证消息消费的幂等性。

而要处理这个问题,RocketMQ的每条消息都有一个唯一的MessageId,这个参数在多次投递的过程中是不会改变的,所以业务上可以用这个MessageId来作为判断幂等的关键依据。 但是,这个MessageId是无法保证全局唯一的,也会有冲突的情况。所以在一些对幂等性要求严格的场景,最好是使用业务上唯一的一个标识比较靠谱。例如订单ID。而这个业务标识可以使用Message的Key来进行传递。

Rocket快速实战与高级原理详解相关推荐

  1. Redis实战和核心原理详解(4)Redis存储Key的一种设计实现方式:模式匹配

    注意:此方案仅为演示Redis 的使用,正式生产环境切勿使用! 相关文章: Redis实战和核心原理详解(1)Centos7.0下安装Redis 5.0详细过程和使用常见问题 Redis实战和核心原理 ...

  2. 【BCH码2】BCH码的快速BM迭代译码原理详解及MATLAB实现(不使用MATLAB库函数-代码见CSDN同名资源)

    关注公号[逆向通信猿]更精彩!!! 理论基础 订阅<信道编码>专栏,首先查阅各子程序的详解 [有限域生成]本原多项式生成有限域的原理及MATLAB实现 [有限域除法]二元多项式除法电路原理 ...

  3. Redis实战和核心原理详解(8)使用快照RDB和AOF将Redis数据持久化到硬盘中

    一.前言 我们知道Redis是一款内存服务器,就算我们对自己的服务器足够的信任,不会出现任何软件或者硬件的故障,但也会有可能出现突然断电等情况,造成Redis服务器中的数据失效.因此,我们需要向传统的 ...

  4. Android涂鸦画板原理详解——从初级到高级(二)

    前言 前面写了<Android涂鸦画板原理详解--从初级到高级(一)>,讲了涂鸦原理初级和中级的应用,现在讲解高级应用.如果没有看过前面一篇文章的同学,建议先去看看哈. 准备 高级涂鸦涉及 ...

  5. Git 原理详解及实用指南

    Git 原理详解及实用指南 什么是版本控制系统(VCS) 很多人认为 Git 难以理解的第一个门槛在于:所谓的「Git 是一个分布式版本控制系统」这句话的具体含义不够清楚.其实分布式版本控制系统(Di ...

  6. SVM分类器原理详解

    SVM分类器原理详解 标签: svm文本分类java 2015-08-21 11:51 2399人阅读 评论(0) 收藏 举报  分类: 数据挖掘 文本处理(16)  机器学习 分类算法(10)  目 ...

  7. 大数据是什么和大数据技术十大核心原理详解

     一.数据核心原理   从"流程"核心转变为"数据"核心   大数据时代,计算模式也发生了转变,从"流程"核心转变为"数据&quo ...

  8. 单片机_PWM输出原理详解

    单片机_PWM输出原理详解 理论篇   博主自己的经历告诉我,PWM波的理解和应用确实还是挺重要的,这里专门花一期详细介绍一下 什么是PWM?   PWM,英文名Pulse Width Modulat ...

  9. 计算机网络知识详解之:TCP连接原理详解

    网络知识详解之:TCP连接原理详解 计算机网络相关知识体系详解 网络知识详解之:TCP连接原理详解 网络知识详解之:HTTP协议基础 网络知识详解之:HTTPS通信原理剖析(对称.非对称加密.数字签名 ...

最新文章

  1. 用java写一个折半查找_用Java写一个折半查找。
  2. H5网页App和纯原生的App差距在哪?
  3. live555 源码分析:RTSPServer 组件结构
  4. Linux PAM 插件认证
  5. c语言 sizeof_c语言详解sizeof
  6. 求1到100中9的个数
  7. SpringMVC请求中的普通、POJO、数组集合类型传参与类转换器
  8. 爬虫练习--爬取CNNVD相关漏洞
  9. int与byte[]之间的相互转换
  10. 3DGIS产品层次结构
  11. 计算机工业设计id,我所认识的工业设计(ID)
  12. win10无需密码退出天擎
  13. 什么是逻辑主键和业务主键
  14. A COMBINED CORNER AND EDGE DETECTOR
  15. visio2013 如何快速画出所有箭头
  16. 认识数据库管理工具 dbForge Edge,您的多数据库解决方案!
  17. 【BZOJ4864】【BeiJing 2017 Wc】神秘物质(Splay)
  18. 无线ap死机无法联接服务器,无法连接无线接入点 这几步帮你轻松解决无线连接问题...
  19. 这些女强人,颠覆了整个世界
  20. 笔记分享 | 免疫组化染色Protocol

热门文章

  1. leetCode-289. 生命游戏
  2. layui 数据表格 单元格点击复制
  3. 麦芒9不支持鸿蒙系统,盘点两款值得选择和两款不值得选择的华为手机!
  4. 进入Docker 容器 docker exec [CONTAINER ID] bin/bash报错问题
  5. 海享租 河科大 空调定时关机 开机
  6. 作为一个工程师结果读不懂英文官方文档,这可怎么办
  7. 如何查看本地服务器开启了哪些端口
  8. python面试宝典1
  9. bd-java_BDShop是国内少有前后端完全分离的java商城项目.
  10. ccxprocess可以禁用么_Mac怎么禁用Adobe无用自启项 Mac禁用Adobe无用自启项图文教程...