【RocketMQ】消息的发送过程之 Broker 故障延迟或者容错机制

1  前言

上节我们主要看了下消息生产者的启动以及消息的发送过程,内容比较多,篇幅比较长,有一些细节没看到,比如 Broker 的故障延迟机制,所以这节我们就单独来看一下这块内容。

还有我们要知道的是,这个机制默认是关闭的:

// ClientConfig
/**
 * 开启消息发送的客户端容错机制
 * Enable the fault tolerance mechanism of the client sending process.
 * DO NOT OPEN when ORDER messages are required. 顺序消息不要开这个
 * Turning on will interfere with the queue selection functionality, 作用在选择消息队列的时候
 * possibly conflicting with the order message. 可能会跟顺序消息产生冲突
 * SEND_LATENCY_ENABLE = com.rocketmq.sendLatencyEnable
 * START_DETECTOR_ENABLE = com.rocketmq.startDetectorEnable
 */
private boolean sendLatencyEnable = Boolean.parseBoolean(System.getProperty(SEND_LATENCY_ENABLE, "false"));
private boolean startDetectorEnable = Boolean.parseBoolean(System.getProperty(START_DETECTOR_ENABLE, "false"));

2  选择消息队列

DefaultMQProducerImpl 在选择消息队列的时候,是交给了 MQFaultStrategy 来处理:

// DefaultMQProducerImpl#selectOneMessageQueue 选择i消息队列
public MessageQueue selectOneMessageQueue(final TopicPublishInfo tpInfo, final String lastBrokerName, final boolean resetIndex) {
    return this.mqFaultStrategy.selectOneMessageQueue(tpInfo, lastBrokerName, resetIndex);
}

也就是我们上节看到的这里:

// MQFaultStrategy#selectOneMessageQueue
// tpInfo tryToFindTopicPublishInfo获取的路由信息
// lastBrokerName 就是上一次选择的执行发送消息失败的Broker名称 启用 Broker 故障延迟机制用到
// resetIndex 第一次发送为false 重试发送为true
public MessageQueue selectOneMessageQueue(final TopicPublishInfo tpInfo, final String lastBrokerName, final boolean resetIndex) {
    // 获取当前线程的 Broker 过滤器
    BrokerFilter brokerFilter = threadBrokerFilter.get();
    // 重置当前线程的 Broker 过滤器
    brokerFilter.setLastBrokerName(lastBrokerName);
    // 是否开启Broker 故障延迟机制 默认关闭
    if (this.sendLatencyFaultEnable) {
        // 重置索引 重置 TopicPublishInfo内部的sendWhichQueue队列的索引
        if (resetIndex) {
            tpInfo.resetIndex();
        }
        // 尝试选择一个满足可用性过滤器和Broker过滤器的消息队列
        MessageQueue mq = tpInfo.selectOneMessageQueue(availableFilter, brokerFilter);
        if (mq != null) {
            return mq;
        }
        // 如果上述选择失败,尝试选择一个满足可访问性过滤器和Broker过滤器的消息队列
        mq = tpInfo.selectOneMessageQueue(reachableFilter, brokerFilter);
        if (mq != null) {
            return mq;
        }
        // 如果都选择失败,退而求其次,选择任意一个消息队列
        return tpInfo.selectOneMessageQueue();
    }
    // 如果未启用Broker 故障延迟机制,则直接使用Broker过滤器选择消息队列
    MessageQueue mq = tpInfo.selectOneMessageQueue(brokerFilter);
    if (mq != null) {
        return mq;
    }
    // 如果选择失败,退而求其次,选择任意一个消息队列
    return tpInfo.selectOneMessageQueue();
}

这块就是选择消息队列的核心思想:

对于不开启容错机制的,先根据当前线程的上次执行异常的 Broker 名称过滤筛选一个消息队列,当然 lastBrokerName 也可以是空的(比如第一次发送的时候),是空的话,那就跟下边的空参的 selectOneMessageQueue 类似,随机选择一个消息队列即可。

对于开启了容错机制的,它大概有四步:

(1)重试会重置当前线程的 index 

(2)根据 availiableFilter  + 名称过滤器 筛选消息队列

(3)如果步骤2为空,再次根据 reachableFilte + 名称过滤器r 筛选消息队列

(4)如果步骤3为空,进入兜底空参的 selectOneMessageQueue 随机选择一个消息队列。

可以看到两种方式最后的兜底其实都一样的,无论如何都要选择一个消息队列哈。然后对于开启了容错机制的,会有两次过滤,也是我们本节要理解的重点。

3  容错机制

3.1  核心类

我们先回顾下容错机制相关的核心类:

MQFaultStrategy:选择消息队列入口主逻辑控制以及内部的latencyFaultTolerance起到衔接筛选队列的作用

LatencyFaultTolerance 接口:维护容错信息筛选队列 实现类是 LatencyFaultToleranceImpl 内部的faultItemTable 维护当前出现故障的信息

FaultItem 故障项类:故障明细实例

TopicPublishInfo:从自己当前的队列中以及筛选器过滤出符合条件的消息队列

我这里画了一下这几个类的执行过程:

要理解好执行流程,就要理解好每个核心类的核心属性的由来很重要,接下来我们看看。

3.2  TopicPublishInfo

根据过滤器筛选队列的方法中,涉及到 messageQueueList 和 sendWhichQueue 两个属性,看看它的由来。

// TopicPublishInfo#selectOneMessageQueue
public MessageQueue selectOneMessageQueue(QueueFilter ...filter) {
    return selectOneMessageQueue(this.messageQueueList, this.sendWhichQueue, filter);
}

我们先看看 messageQueueList 属性,TopicPublishInfo 是 tryToFindTopicPublishInfo 方法来的,也就是根据某个 Topic 获取它的路由信息。而远程请求的响应结果是 TopicRouteData 类型的,它是经过 MQClientInstance#topicRouteData2TopicPublishInfo 方法进行转换得到的,

// MQClientInstance#topicRouteData2TopicPublishInfo
// 用于将请求的路由信息 TopicRouteData 转换为生产者适用的 TopicPublishInfo
public static TopicPublishInfo topicRouteData2TopicPublishInfo(final String topic, final TopicRouteData route) {
    // List<MessageQueue> messageQueueList = new ArrayList<>();
    TopicPublishInfo info = new TopicPublishInfo();
    info.setTopicRouteData(route);

    if (route.getOrderTopicConf() != null && route.getOrderTopicConf().length() > 0) {
        // 处理有序主题配置
        ...
    } else if (route.getOrderTopicConf() == null
        && route.getTopicQueueMappingByBroker() != null
        && !route.getTopicQueueMappingByBroker().isEmpty()) {
        // 处理静态主题配置
        ...
    } else {
        // 处理普通主题配置 我们主要看这里
        // 当前 Topic 的队列信息
        List<QueueData> qds = route.getQueueDatas();
        // 排序下(根据里边的 Broker 名称升序排序
        Collections.sort(qds);
        // 循环处理
        for (QueueData qd : qds) {
            // 队列是可写的话 计算公式:(perm & 2) == 2
            if (PermName.isWriteable(qd.getPerm())) {
                BrokerData brokerData = null;
                // 找这个队列的 Broker 信息
                for (BrokerData bd : route.getBrokerDatas()) {
                    if (bd.getBrokerName().equals(qd.getBrokerName())) {
                        brokerData = bd;
                        break;
                    }
                }
                // 没有的话 忽略掉这个队列
                if (null == brokerData) {
                    continue;
                }

                // broker 没有 master 信息 也忽略这个队列
                if (!brokerData.getBrokerAddrs().containsKey(MixAll.MASTER_ID)) {
                    continue;
                }
                // 遍历该队列可写的队列数  默认 4个
                for (int i = 0; i < qd.getWriteQueueNums(); i++) {
                    // 创建队列信息
                    MessageQueue mq = new MessageQueue(topic, qd.getBrokerName(), i);
                    // 放进 info 对象的 messageQueueList 集合中
                    info.getMessageQueueList().add(mq);
                }
            }
        }
        // 不是顺序消息的 Topic
        info.setOrderTopic(false);
    }

    return info;
}

好了,也就是获取到该 Topic 在 Broker 中的分布情况,然后根据队列的数量,创建出来对应的消息队列对象,最后统一存放到 TopicPublishInfo 的 messageQueueList 集合中。

再看下 sendWhichQueue 属性,它是 volatile 修饰的成员变量,也是随着实例化创建出来的:

public class TopicPublishInfo {
    private boolean orderTopic = false;
    private boolean haveTopicRouterInfo = false;
    private List<MessageQueue> messageQueueList = new ArrayList<>();
    private volatile ThreadLocalIndex sendWhichQueue = new ThreadLocalIndex();
}

再看下 ThreadLocalIndex 类:

public class ThreadLocalIndex {
    // 本地线程变量
    private final ThreadLocal<Integer> threadLocalIndex = new ThreadLocal<>();
    // 随机数
    private final Random random = new Random();
    private final static int POSITIVE_MASK = 0x7FFFFFFF;

    public int incrementAndGet() {
        // 获取当前线程的计数器
        Integer index = this.threadLocalIndex.get();
        // 第一次为空的话 随机数
        if (null == index) {
            index = random.nextInt();
        }
        // 设置到本地线程去
        this.threadLocalIndex.set(++index);
        // 与 POSITIVE_MASK 取模
        return index & POSITIVE_MASK;
    }
    
    // 重置
    public void reset() {
        // 获取随机数
        int index = Math.abs(random.nextInt(Integer.MAX_VALUE));
        if (index < 0) {
            index = 0;
        }
        // 设置进去
        this.threadLocalIndex.set(index);
    }
}

messageQueueList 存放当前 Topic 分布在所有 Broker 上的队列信息,比如有两个 Broker 都有分布吗,每个 Broker 创建 4个队列存储消息,那么这个集合里有8条数据,每个 Broker 个 4个消息队列。

sendWhichQueue:是一个存放在本地线程的随机数

3.3  FaultItem 故障明细

我们看下这个类:

public class FaultItem implements Comparable<FaultItem> {
    // broker 名称也就是哪个 broker 故障了
    private final String name;
    // 当前的延迟时间
    private volatile long currentLatency;
    // 恢复时间 也就是到哪个时间戳时就不故障了  System.currentTimeMillis() > startTimestamp 说明就不故障了
    private volatile long startTimestamp;
    // 检查时间 搭配 detect 使用 也就是背后有个线程来检查这些故障是不是好了  LatencyFaultToleranceImpl有调度线程池 下边会看
    private volatile long checkStamp;
    // 是否可达
    private volatile boolean reachableFlag;
    public FaultItem(final String name) {
        this.name = name;
    }
    // 更新延迟时间
    public void updateNotAvailableDuration(long notAvailableDuration) {
        // 当前时间戳 + 延迟时间 大于上次设置的恢复时间  说明是需要延长恢复时间
        if (notAvailableDuration > 0 && System.currentTimeMillis() + notAvailableDuration > this.startTimestamp) {
            // 重新设置恢复时间
            this.startTimestamp = System.currentTimeMillis() + notAvailableDuration;
            log.info(name + " will be isolated for " + notAvailableDuration + " ms.");
        }
    }
    ...
}

name、currentLatency、startTimestamp应该比较好理解,我i们看下 checkStamp 和 reachableFlag 两个属性值的由来。

首先看 checkStamp,它是跟检查相关的,在 LatencyFaultToleranceImpl 类里有个 startDetector 方法,提交一个调度任务,用来检查这些故障明细:

// LatencyFaultToleranceImpl#startDetector
// private volatile boolean startDetectorEnable = false;
// private final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
//     @Override
//     public Thread newThread(Runnable r) {
//         return new Thread(r, "LatencyFaultToleranceScheduledThread");
//     }
// });
public void startDetector() {
    this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
        @Override
        public void run() {
            try {
                // 是否开启了检测  默认不开启
                if (startDetectorEnable) {
                    // 检查
                    detectByOneRound();
                }
            } catch (Exception e) {
                log.warn("Unexpected exception raised while detecting service reachability", e);
            }
        }
    // 每隔 3秒 执行一趟
    }, 3, 3, TimeUnit.SECONDS);
}
// private int detectTimeout = 200; 检查超时时间
// private int detectInterval = 2000; 检查间隔时间 默认 2秒
// private final ServiceDetector serviceDetector; 检查的方法
public void detectByOneRound() {
    // 遍历
    for (Map.Entry<String, FaultItem> item : this.faultItemTable.entrySet()) {
        FaultItem brokerItem = item.getValue();
        // checkStamp 发挥作用了 初始为0 后续每检查一次重置检查时间
        if (System.currentTimeMillis() - brokerItem.checkStamp >= 0) {
            // 重置检查时间 = 当前时间 + 检查间隔时间 2秒
            brokerItem.checkStamp = System.currentTimeMillis() + this.detectInterval;
            // 获取到该 Broker 的地址
            String brokerAddr = resolver.resolve(brokerItem.getName());
            // 如果为空 直接移除 说明 Broker 不存在了都
            if (brokerAddr == null) {
                faultItemTable.remove(item.getKey());
                continue;
            }
            // 如果没设置检查 那都不知道怎么检查 还检查啥 不检查了
            if (null == serviceDetector) {
                continue;
            }
            // 判断是不是好了
            boolean serviceOK = serviceDetector.detect(brokerAddr, detectTimeout);
            // 如果好了的话 设置故障明细的 reachableFlag = true 表示可以给我发消息了
            if (serviceOK && !brokerItem.reachableFlag) {
                log.info(brokerItem.name + " is reachable now, then it can be used.");
                brokerItem.reachableFlag = true;
            }
        }
    }
}

那我们顺便看下 serviceDetector 以及启动故障发现 startDetector 的入口:

serviceDetector 是由实例化 MQFaultStrategy 的时候传进来的:

public MQFaultStrategy(ClientConfig cc, Resolver fetcher, ServiceDetector serviceDetector) {
    this.latencyFaultTolerance = new LatencyFaultToleranceImpl(fetcher, serviceDetector);
    this.latencyFaultTolerance.setDetectInterval(cc.getDetectInterval());
    this.latencyFaultTolerance.setDetectTimeout(cc.getDetectTimeout());
    this.setStartDetectorEnable(cc.isStartDetectorEnable());
    this.setSendLatencyFaultEnable(cc.isSendLatencyEnable());
}

而 MQFaultStrategy 的实例化又是在实例化 DefaultMQProducerImpl 的时候:

// DefaultMQProducerImpl
public DefaultMQProducerImpl(final DefaultMQProducer defaultMQProducer, RPCHook rpcHook) {
    ...
    // 检查
    ServiceDetector serviceDetector = new ServiceDetector() {
        @Override
        public boolean detect(String endpoint, long timeoutMillis) {
            Optional<String> candidateTopic = pickTopic();
            if (!candidateTopic.isPresent()) {
                return false;
            }
            try {
                MessageQueue mq = new MessageQueue(candidateTopic.get(), null, 0);
                mQClientFactory.getMQClientAPIImpl()
                        .getMaxOffset(endpoint, mq, timeoutMillis);
                return true;
            } catch (Exception e) {
                return false;
            }
        }
    };
    // 实例化 MQFaultStrategy
    this.mqFaultStrategy = new MQFaultStrategy(defaultMQProducer.cloneClientConfig(), new Resolver() {
        @Override
        public String resolve(String name) {
            return DefaultMQProducerImpl.this.mQClientFactory.findBrokerAddressInPublish(name);
        }
    }, serviceDetector);
}

那我们再看看 startDetector 的入口,相通的,是在启动生产者的时候,通过调用 MQFaultStrategy 的 startDetector方法继而调用 LatencyFaultToleranceImpl 的 startDetector 方法。

// DefaultMQProducerImpl#start
public void start(final boolean startFactory) throws MQClientException {
    switch (this.serviceState) {
        case CREATE_JUST:
            ...
            if (startFactory) {
                mQClientFactory.start();
            }
            // 启动 MQFaultStrategy 的检查
            this.mqFaultStrategy.startDetector();
            ...
            break;
        case RUNNING:
        case START_FAILED:
        case SHUTDOWN_ALREADY:
            throw new MQClientException("The producer service state not OK, maybe started once, "
                + this.serviceState
                + FAQUrl.suggestTodo(FAQUrl.CLIENT_SERVICE_NOT_OK),
                null);
        default:
            break;
    }
    this.mQClientFactory.sendHeartbeatToAllBrokerWithLock();
    RequestFutureHolder.getInstance().startScheduledTask(this);
}
// MQFaultStrategy#startDetector
public void startDetector() {
    this.latencyFaultTolerance.startDetector();
}

看完 checkStamp,它是配合检查的调度任务及时移除已经恢复的故障明细,以及启动故障检查的入口,我们继续看下 reachableFlag 的变动由来,它的值的变动是经过 updateFaultItem 方法来变化的:

// LatencyFaultToleranceImpl#updateFaultItem
// name broker名称
// currentLatency 延迟时间
// notAvailableDuration 不可用时间
// reachable 是否可达
// private final ConcurrentHashMap<String, FaultItem> faultItemTable = new ConcurrentHashMap<String, FaultItem>(16);
public void updateFaultItem(final String name, final long currentLatency, final long notAvailableDuration, final boolean reachable) {
    // map 中看有没有当前 broker 的信息
    FaultItem old = this.faultItemTable.get(name);
    // 如果不存在 直接创建对象放进去
    if (null == old) {
        final FaultItem faultItem = new FaultItem(name);
        faultItem.setCurrentLatency(currentLatency);
        faultItem.updateNotAvailableDuration(notAvailableDuration);
        faultItem.setReachable(reachable);
        old = this.faultItemTable.putIfAbsent(name, faultItem);
    }
    // 存在的话  直接更新
    if (null != old) {
        old.setCurrentLatency(currentLatency);
        old.updateNotAvailableDuration(notAvailableDuration);
        old.setReachable(reachable);
    }
    // 不可达 打印日志
    if (!reachable) {
        log.info(name + " is unreachable, it will not be used until it's reachable");
    }
}

那谁来调用 updateFaultItem 的呢?是由 MQFaultStrategy 来的:

// MQFaultStrategy#updateFaultItem
public void updateFaultItem(final String brokerName, final long currentLatency, boolean isolation, final boolean reachable) {
    // 当开启了故障延迟的话
    if (this.sendLatencyFaultEnable) {
        // 计算延迟时间 isolation 为 true 说明需要延长 每次延长 10秒 为false 直接用参数 currentLatency
        long duration = computeNotAvailableDuration(isolation ? 10000 : currentLatency);
        this.latencyFaultTolerance.updateFaultItem(brokerName, currentLatency, duration, reachable);
    }
}
// 计算延迟时间
// private long[] latencyMax = {50L, 100L, 550L, 1800L, 3000L, 5000L, 15000L};
// private long[] notAvailableDuration = {0L, 0L, 2000L, 5000L, 6000L, 10000L, 30000L};
private long computeNotAvailableDuration(final long currentLatency) {
    for (int i = latencyMax.length - 1; i >= 0; i--) {
        if (currentLatency >= latencyMax[i]) {
            return this.notAvailableDuration[i];
        }
    }
    return 0;
}

那它又是谁调用的呢? 是 DefaultMQProducerImpl 来的:

// DefaultMQProducerImpl#updateFaultItem
public void updateFaultItem(final String brokerName, final long currentLatency, boolean isolation, boolean reachable) {
    this.mqFaultStrategy.updateFaultItem(brokerName, currentLatency, isolation, reachable);
}

那它又是谁调用的呢?是在消息发送的核心方法里:

private SendResult sendDefaultImpl(Message msg, final CommunicationMode communicationMode, final SendCallback sendCallback, final long timeout) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
    ...
    // 获取当前 Topic 的路由信息
    TopicPublishInfo topicPublishInfo = this.tryToFindTopicPublishInfo(msg.getTopic());
    if (topicPublishInfo != null && topicPublishInfo.ok()) {
        MessageQueue mq = null;
        int times = 0;
        String[] brokersSent = new String[timesTotal];
        boolean resetIndex = false;
        for (; times < timesTotal; times++) {
            // 选择队列
            MessageQueue mqSelected = this.selectOneMessageQueue(topicPublishInfo, lastBrokerName, resetIndex);
            if (mqSelected != null) {
                mq = mqSelected;
                brokersSent[times] = mq.getBrokerName();
                try {
                    sendResult = this.sendKernelImpl(msg, mq, communicationMode, sendCallback, topicPublishInfo, timeout - costTime);
                    endTimestamp = System.currentTimeMillis();
                    // 1、都发送成功了 说明可达
                    this.updateFaultItem(mq.getBrokerName(), endTimestamp - beginTimestampPrev, false, true);
                    switch (communicationMode) {
                        case ASYNC:
                            return null;
                        case ONEWAY:
                            return null;
                        case SYNC:
                            if (sendResult.getSendStatus() != SendStatus.SEND_OK) {
                                if (this.defaultMQProducer.isRetryAnotherBrokerWhenNotStoreOK()) {
                                    continue;
                                }
                            }
                            return sendResult;
                        default:
                            break;
                    }
                } catch (MQClientException e) {
                    endTimestamp = System.currentTimeMillis();
                    // 2、客户端异常 还没开始请求 也可达
                    this.updateFaultItem(mq.getBrokerName(), endTimestamp - beginTimestampPrev, false, true);
                    log.warn("sendKernelImpl exception, resend at once, InvokeID: %s, RT: %sms, Broker: %s", invokeID, endTimestamp - beginTimestampPrev, mq, e);
                    log.warn(msg.toString());
                    exception = e;
                    continue;
                } catch (RemotingException e) {
                    endTimestamp = System.currentTimeMillis();
                    // 远程异常但不确定是不是 Broker 造成的 
                    if (this.mqFaultStrategy.isStartDetectorEnable()) {
                        // 3、如果开启了异常发现 设置为不可达 因为发现会让他恢复的
                        this.updateFaultItem(mq.getBrokerName(), endTimestamp - beginTimestampPrev, true, false);
                    } else {
                        // 4、不明确 设置可达
                        this.updateFaultItem(mq.getBrokerName(), endTimestamp - beginTimestampPrev, true, true);
                    }
                    log.warn("sendKernelImpl exception, resend at once, InvokeID: %s, RT: %sms, Broker: %s", invokeID, endTimestamp - beginTimestampPrev, mq, e);
                    if (log.isDebugEnabled()) {
                        log.debug(msg.toString());
                    }
                    exception = e;
                    continue;
                } catch (MQBrokerException e) {
                    endTimestamp = System.currentTimeMillis();
                    // 5、明确 Broker 异常 不可达
                    this.updateFaultItem(mq.getBrokerName(), endTimestamp - beginTimestampPrev, true, false);
                    log.warn("sendKernelImpl exception, resend at once, InvokeID: %s, RT: %sms, Broker: %s", invokeID, endTimestamp - beginTimestampPrev, mq, e);
                    if (log.isDebugEnabled()) {
                        log.debug(msg.toString());
                    }
                    exception = e;
                    if (this.defaultMQProducer.getRetryResponseCodes().contains(e.getResponseCode())) {
                        continue;
                    } else {
                        if (sendResult != null) {
                            return sendResult;
                        }
                        throw e;
                    }
                } catch (InterruptedException e) {
                    endTimestamp = System.currentTimeMillis();
                    // 6、中断异常  可达
                    this.updateFaultItem(mq.getBrokerName(), endTimestamp - beginTimestampPrev, false, true);
                    log.warn("sendKernelImpl exception, throw exception, InvokeID: %s, RT: %sms, Broker: %s", invokeID, endTimestamp - beginTimestampPrev, mq, e);
                    if (log.isDebugEnabled()) {
                        log.debug(msg.toString());
                    }
                    throw e;
                }
            } else {
                break;
            }
        }
        ...
        throw mqClientException;
    }
    validateNameServerSetting();
    throw new MQClientException("No route info of this topic: " + msg.getTopic() + FAQUrl.suggestTodo(FAQUrl.NO_TOPIC_ROUTE_INFO),
        null).setResponseCode(ClientErrorCode.NOT_FOUND_TOPIC_EXCEPTION);
}

总共大概 6个 地方会涉及到可达的更改。

好啦,关于 FaultItem 我们知道 checkStamp 是搭配 defect 来检查故障明细的,reachableFlag 的变动是在消息发送方法里根据当前的异常信息来做更新。

看了 TopicPublishInfo 以及 FaultItem 已经把 MQFaultStrategy 和 LatencyFaultToleranceImpl 都串着看了,就不看了哈。

4  小结

好啦,本节主要对选择消息队列时的 Broker 故障延迟机制的核心类进行了深入的了解以及他们的协同关系,有理解不对的地方还请指正哈。

posted @ 2024-10-29 19:36  酷酷-  阅读(10)  评论(0编辑  收藏  举报