rocketMq-Consumer的两种消费模式:push和pull(含源码解读)

     在RocketMQ中,虽然从概念上讲Consumer有两种消费模式:Push和Pull,push模式让人感觉由broker将消息主动push到consumer。但在实际实现时,这两种模式都采用了类似长轮询(long polling)的机制,即由Consumer主动向Broker拉取消息。不过,它们的具体行为有所不同,我们主要介绍一下Push模式。

Push模式:

      在Push模式下,Consumer通过DefaultMQPushConsumer类与Broker建立连接,并保持心跳。Consumer定期向Broker发送请求获取消息,但Broker并不会立即返回空响应,而是会挂起Consumer的请求,直到有新消息到达或者达到预设的最大等待时间(例如5秒)才返回消息。
从外部表现来看,仿佛是Broker将消息推送到Consumer,但实际上还是Consumer主动发起拉取操作,只是Broker对Consumer的拉取请求进行了优化处理,使其看起来像是推送。

实现过程:

图片来自

1. 消费组与订阅:

      消费者会先向Broker声明其所属的消费组和要订阅的主题,Broker记录这些信息以便后续的消息分配。

DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("YourConsumerGroup");

// 订阅指定的主题,并可以设置Tag表达式过滤消息
consumer.subscribe("YourTopic", "*"); // "*" 表示订阅该主题下的所有Tag


// 实现MessageListener接口或使用其子类如MessageListenerConcurrently、MessageListenerOrderly等
consumer.registerMessageListener(new MessageListenerConcurrently() {
    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
        // 在这里实现具体的消费逻辑,例如处理接收到的消息
        for (MessageExt msg : msgs) {
            try {
                // 处理消息内容...
                String message = new String(msg.getBody(), RemotingHelper.DEFAULT_CHARSET);
                System.out.println("Received message: " + message);
                // 根据业务需求返回消费状态
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            } catch (Exception e) {
                // 消费异常时记录日志或者进行其他处理
                log.error("Failed to consume message", e);
                return ConsumeConcurrentlyStatus.RECONSUME_LATER;
            }
        }
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }
});


// 配置其他参数如Namesrv地址、消费策略等...

// 启动消费者
consumer.start();

      MessageListenerConcurrently是并发消费,MessageListenerOrderly是顺序消费。

      调用consumer.registerMessageListener(new MessageListenerConcurrently(){})方法,将MessageListenerConcurrently或MessageListenerOrderly是实例注册到消费者中,当消费者拉取到消息后,调对应实例的方法来处理。

2. 拉取请求与响应队列:


     在Push模式下,消费者内部 PullMessageService类 维护一个PullRequest队列,用于存放待处理的拉取消息请求。(类图的第一列)
    当消费者启动时,它会启动RebalanceService来负责负载均衡和构建拉取请求(PullRequest),并将这些请求放入PullRequestQueue队列中。

     我们看下PullMessageService的类图,PullMessageService 继承了ServiceThread类,是个线程类。PullMessageService类内部维护了一个变量:pullRequestQueue 拉取请求集合;

private final LinkedBlockingQueue<PullRequest> pullRequestQueue = new LinkedBlockingQueue<PullRequest>();

     执行executePullRequestImmediately(final PullRequest pullRequest)方法时,会执行this.pullRequestQueue.put(pullRequest)方法,将拉取请求放到队列中。

    那什么时候执行executePullRequestImmediately(final PullRequest pullRequest)方法呢?

有两个地方:

1)DefaultMQPushConsumerImpl类,每次拉取到消息后,构建下一次拉取消息的请求放到队列中;(类图的第二列)

2)RebalancePushImpl类,负载均衡时,构建拉取请求放到队列中。

     所以从这里我们就不难看出,Push模式consumer也向broker发送拉取请求。

3. 消息拉取服务:

     PullMessageService作为消息拉取服务,它会从PullRequestQueue中取出拉取请求并发送给对应的Broker节点。这个过程中,尽管称为Push模式,但实际上是Consumer主动发起拉取请求,不过这个请求不是简单的一次性拉取操作,而是带有等待时间的长轮询。

    PullMessageService做为异步线程类,执行run()时,将PullRequestQueue中的对象取出,发送请求。

@Override
    public void run() {
        log.info(this.getServiceName() + " service started");

        while (!this.isStopped()) {
            try {
                PullRequest pullRequest = this.pullRequestQueue.take();
                if (pullRequest != null) {
                    this.pullMessage(pullRequest);
                }
            } catch (InterruptedException e) {
            } catch (Exception e) {
                log.error("Pull Message Service Run Method exception", e);
            }
        }

        log.info(this.getServiceName() + " service end");
    }
private void pullMessage(final PullRequest pullRequest) {
        final MQConsumerInner consumer = this.mQClientFactory.selectConsumer(pullRequest.getConsumerGroup()); 
        if (consumer != null) {
            DefaultMQPushConsumerImpl impl = (DefaultMQPushConsumerImpl) consumer;
            impl.pullMessage(pullRequest);
        } else {
            log.warn("No matched consumer for the PullRequest {}, drop it", pullRequest);
        }
    }

(类图的第三列)

impl.pullMessage(pullRequest)会执行哪些内容呢?(源码见文章最后)

 1)选择MessageQueue。

消费者根据其所属的消费组以及该消费组订阅的主题,在内部实现负载均衡算法来决定从哪些MessageQueue上拉取消息。RocketMQ支持多种负载均衡策略,如平均轮询、一致性Hash等,默认情况下使用的是平均轮询方式。

2)校验processQueue状态(注意不是pullRequestQueue)

ProcessQueue通常指的是已经从Broker拉取到、但尚未完全消费(即处理完成)的消息队列。ProcessQueue在RocketMQ客户端内部用于管理和维护消息的消费状态,它包含了一系列有序且待消费的消息列表,并且记录了相关的消费偏移量和状态信息。

如果processQueue的dropped状态是true,意味着这个消息队列已经被标记为丢弃(Dropped),不再继续进行消费,直到后续恢复或重新调度时才可能再次启用。

3)写入最近的消息拉取时间(系统当前时间戳)

这一步的作用是为了在rebalance时校验processQueue的消息拉取是否超时使用。 

如果超时,将processQueue的dropped状态设置为true,processQueue将不可用,停止执行processQueue中的任务,直到异常恢复。(对应了第2)步)

4)校验消费者的状态

消费者状态可用时,才会拉取消息。如果不可用,将执行executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_EXCEPTION)方法,默认延迟3s拉取。

makeSureStateOK()和isPause()方法通常用于检查消费者(Consumer)的状态:
1.makeSureStateOK():通常是用来确保消费者的当前状态是合法的、可以进行消息消费操作的。它可能会检查以下几种情况:
消费者是否已经启动并成功连接到了Broker。
消费者是否处于正常运行状态,没有被关闭或者发生错误。
消费者所属的消费组以及其他必要的配置信息是否有效。

2. isPause():
这个方法主要用于判断消费者是否处于暂停状态。在RocketMQ中,消费者支持动态地暂停和恢复消息消费。如果isPause()返回true,意味着消费者当前已暂停从MessageQueue拉取消息,并且不会继续处理新的消息,直到通过调用相应的接口将其恢复到运行状态。

5) 流控和broker异常校验

消费者在满足一定条件时,也将开启流控,延迟消息拉取。

1.校验processQueue的msgCount 有没有超过阈值(默认1000条)

2.校验processQueue的和msgSize有没有超过阈值(默认100MiB)

如果超过阈值,将执行executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_EXCEPTION)方法,默认延迟50ms拉取。

3.非顺序消费,校验processQueue中待处理消息的偏移是否超过阈值

如果超过阈值(默认2000),将执行executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_EXCEPTION)方法,默认延迟50ms拉取。

4.顺序消费

如果brokerBusy,延迟3s拉取。否则获取最新offset,从最新offset拉取消息。

6)校验订阅的topic信息是否正常

7)构造回调方法PullCallback(),设置消息拉取成功和失败对应的处理逻辑。

8)构建偏移量、过滤模式等参数

9)发送拉取请求

public PullResult pullKernelImpl(
        final MessageQueue mq,
        final String subExpression,
        final String expressionType,
        final long subVersion,
        final long offset,
        final int maxNums,
        final int sysFlag,
        final long commitOffset,
        final long brokerSuspendMaxTimeMillis,
        final long timeoutMillis,
        final CommunicationMode communicationMode,
        final PullCallback pullCallback
    ) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {
        FindBrokerResult findBrokerResult =
            this.mQClientFactory.findBrokerAddressInSubscribe(mq.getBrokerName(),
                this.recalculatePullFromWhichNode(mq), false); // 获取broker地址
        if (null == findBrokerResult) {
            this.mQClientFactory.updateTopicRouteInfoFromNameServer(mq.getTopic());
            findBrokerResult =
                this.mQClientFactory.findBrokerAddressInSubscribe(mq.getBrokerName(),
                    this.recalculatePullFromWhichNode(mq), false);
        }

        if (findBrokerResult != null) {
            {
                // check version
                if (!ExpressionType.isTagType(expressionType)
                    && findBrokerResult.getBrokerVersion() < MQVersion.Version.V4_1_0_SNAPSHOT.ordinal()) {
                    throw new MQClientException("The broker[" + mq.getBrokerName() + ", "
                        + findBrokerResult.getBrokerVersion() + "] does not upgrade to support for filter message by " + expressionType, null);
                }
            }
            int sysFlagInner = sysFlag;

            if (findBrokerResult.isSlave()) {
                sysFlagInner = PullSysFlag.clearCommitOffsetFlag(sysFlagInner);
            }

            PullMessageRequestHeader requestHeader = new PullMessageRequestHeader();
            requestHeader.setConsumerGroup(this.consumerGroup);
            requestHeader.setTopic(mq.getTopic());
            requestHeader.setQueueId(mq.getQueueId());
            requestHeader.setQueueOffset(offset);
            requestHeader.setMaxMsgNums(maxNums);
            requestHeader.setSysFlag(sysFlagInner);
            requestHeader.setCommitOffset(commitOffset);
            requestHeader.setSuspendTimeoutMillis(brokerSuspendMaxTimeMillis);
            requestHeader.setSubscription(subExpression);
            requestHeader.setSubVersion(subVersion);
            requestHeader.setExpressionType(expressionType);

            String brokerAddr = findBrokerResult.getBrokerAddr();
            if (PullSysFlag.hasClassFilterFlag(sysFlagInner)) {
                brokerAddr = computPullFromWhichFilterServer(mq.getTopic(), brokerAddr);
            }

            PullResult pullResult = this.mQClientFactory.getMQClientAPIImpl().pullMessage(
                brokerAddr,
                requestHeader,
                timeoutMillis,
                communicationMode,
                pullCallback);

            return pullResult;
        }

        throw new MQClientException("The broker[" + mq.getBrokerName() + "] not exist", null);
    }

4. Broker端处理:

     当Broker收到消费者的拉取请求后,如果此时没有新消息,则不会立即返回响应,而是保持连接,并在有新消息到达或者达到预设的最大等待时间后,才将消息推送给消费者。一旦有消息满足条件(例如属于消费组所订阅的主题并且满足偏移量要求),Broker就会将消息批量发送回给消费者。那么在broker是如何处理的 Push请求的呢? 

     根据消息拉取命令RequestCode.PULL_MESSAGE,可以找到broker端处理消息拉取的入口:

org.apache.rocketmq.broker.processor.PullMessageProcessor #processRequest

broker如何执行的呢?是如何将消息推送给消费者的呢? 考虑到篇幅问题,我们放在下一篇:

rocketMq-push模式下broker如何将消息推送给consumer?-CSDN博客

5. 消息消费与确认:

      消费者接收到Broker发来的消息后开始消费,并在消费完成后发送消费确认(acknowledge)到Broker,Broker根据确认结果更新消息状态。见3.7)

6. 流控与重试:

     在整个过程中,RocketMQ还提供了丰富的流控策略以防止消费者过载,比如可以根据消费者实际处理能力动态调整消息推送速度。见3.5)

      以上就是Push模式的整体过程了,大家可以先看图,再看代码,理解rocketMq使用多线程的巧妙之处,希望能给读者朋友带来一些帮助。


      这个过程梳理并不容易,看了别人写的文章,总觉得别人总结的更好,总觉得自己得再努努力,再整理下,整理到最好再发放出来。于是,拖着拖着,从22年拖到现在。进入24年,深刻的认识到‘最好’是动态的,今天认为的最好在以后看来未必还是。既然这样就不追求‘最好’了,每天做到‘更好’就可以了。同时,学习和应用深度结合才能碰撞出更激烈的智慧的火花,才能内化为自己的知识。所以,这部分知识并没有结束,我们在实战中继续相见吧。


源码版本:4.2.0

 org.apache.rocketmq.client.impl.consumer.DefaultMQPushConsumerImpl#pullMessage

public void pullMessage(final PullRequest pullRequest) {
        final ProcessQueue processQueue = pullRequest.getProcessQueue();
        if (processQueue.isDropped()) {
            log.info("the pull request[{}] is dropped.", pullRequest.toString());
            return;
        }

        pullRequest.getProcessQueue().setLastPullTimestamp(System.currentTimeMillis());

        try {
            this.makeSureStateOK();
        } catch (MQClientException e) {
            log.warn("pullMessage exception, consumer state not ok", e);
            this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_EXCEPTION);
            return;
        }

        if (this.isPause()) {
            log.warn("consumer was paused, execute pull request later. instanceName={}, group={}", this.defaultMQPushConsumer.getInstanceName(), this.defaultMQPushConsumer.getConsumerGroup());
            this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_SUSPEND);
            return;
        }

        long cachedMessageCount = processQueue.getMsgCount().get();
        long cachedMessageSizeInMiB = processQueue.getMsgSize().get() / (1024 * 1024);

        if (cachedMessageCount > this.defaultMQPushConsumer.getPullThresholdForQueue()) {
            this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_FLOW_CONTROL);
            if ((queueFlowControlTimes++ % 1000) == 0) {
                log.warn(
                    "the cached message count exceeds the threshold {}, so do flow control, minOffset={}, maxOffset={}, count={}, size={} MiB, pullRequest={}, flowControlTimes={}",
                    this.defaultMQPushConsumer.getPullThresholdForQueue(), processQueue.getMsgTreeMap().firstKey(), processQueue.getMsgTreeMap().lastKey(), cachedMessageCount, cachedMessageSizeInMiB, pullRequest, queueFlowControlTimes);
            }
            return;
        }

        if (cachedMessageSizeInMiB > this.defaultMQPushConsumer.getPullThresholdSizeForQueue()) {
            this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_FLOW_CONTROL);
            if ((queueFlowControlTimes++ % 1000) == 0) {
                log.warn(
                    "the cached message size exceeds the threshold {} MiB, so do flow control, minOffset={}, maxOffset={}, count={}, size={} MiB, pullRequest={}, flowControlTimes={}",
                    this.defaultMQPushConsumer.getPullThresholdSizeForQueue(), processQueue.getMsgTreeMap().firstKey(), processQueue.getMsgTreeMap().lastKey(), cachedMessageCount, cachedMessageSizeInMiB, pullRequest, queueFlowControlTimes);
            }
            return;
        }

        if (!this.consumeOrderly) {
            if (processQueue.getMaxSpan() > this.defaultMQPushConsumer.getConsumeConcurrentlyMaxSpan()) {
                this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_FLOW_CONTROL);
                if ((queueMaxSpanFlowControlTimes++ % 1000) == 0) {
                    log.warn(
                        "the queue's messages, span too long, so do flow control, minOffset={}, maxOffset={}, maxSpan={}, pullRequest={}, flowControlTimes={}",
                        processQueue.getMsgTreeMap().firstKey(), processQueue.getMsgTreeMap().lastKey(), processQueue.getMaxSpan(),
                        pullRequest, queueMaxSpanFlowControlTimes);
                }
                return;
            }
        } else {
            if (processQueue.isLocked()) {
                if (!pullRequest.isLockedFirst()) {
                    final long offset = this.rebalanceImpl.computePullFromWhere(pullRequest.getMessageQueue());
                    boolean brokerBusy = offset < pullRequest.getNextOffset();
                    log.info("the first time to pull message, so fix offset from broker. pullRequest: {} NewOffset: {} brokerBusy: {}",
                        pullRequest, offset, brokerBusy);
                    if (brokerBusy) {
                        log.info("[NOTIFYME]the first time to pull message, but pull request offset larger than broker consume offset. pullRequest: {} NewOffset: {}",
                            pullRequest, offset);
                    }

                    pullRequest.setLockedFirst(true);
                    pullRequest.setNextOffset(offset);
                }
            } else {
                this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_EXCEPTION);
                log.info("pull message later because not locked in broker, {}", pullRequest);
                return;
            }
        }

        final SubscriptionData subscriptionData = this.rebalanceImpl.getSubscriptionInner().get(pullRequest.getMessageQueue().getTopic());
        if (null == subscriptionData) {
            this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_EXCEPTION);
            log.warn("find the consumer's subscription failed, {}", pullRequest);
            return;
        }

        final long beginTimestamp = System.currentTimeMillis();

        PullCallback pullCallback = new PullCallback() {
            @Override
            public void onSuccess(PullResult pullResult) {
                if (pullResult != null) {
                    pullResult = DefaultMQPushConsumerImpl.this.pullAPIWrapper.processPullResult(pullRequest.getMessageQueue(), pullResult,
                        subscriptionData);

                    switch (pullResult.getPullStatus()) {
                        case FOUND:
                            long prevRequestOffset = pullRequest.getNextOffset();
                            pullRequest.setNextOffset(pullResult.getNextBeginOffset());
                            long pullRT = System.currentTimeMillis() - beginTimestamp;
                            DefaultMQPushConsumerImpl.this.getConsumerStatsManager().incPullRT(pullRequest.getConsumerGroup(),
                                pullRequest.getMessageQueue().getTopic(), pullRT);

                            long firstMsgOffset = Long.MAX_VALUE;
                            if (pullResult.getMsgFoundList() == null || pullResult.getMsgFoundList().isEmpty()) {
                                DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
                            } else {
                                firstMsgOffset = pullResult.getMsgFoundList().get(0).getQueueOffset();

                                DefaultMQPushConsumerImpl.this.getConsumerStatsManager().incPullTPS(pullRequest.getConsumerGroup(),
                                    pullRequest.getMessageQueue().getTopic(), pullResult.getMsgFoundList().size());

                                boolean dispathToConsume = processQueue.putMessage(pullResult.getMsgFoundList());
                                DefaultMQPushConsumerImpl.this.consumeMessageService.submitConsumeRequest(
                                    pullResult.getMsgFoundList(),
                                    processQueue,
                                    pullRequest.getMessageQueue(),
                                    dispathToConsume);

                                if (DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval() > 0) {
                                    DefaultMQPushConsumerImpl.this.executePullRequestLater(pullRequest,
                                        DefaultMQPushConsumerImpl.this.defaultMQPushConsumer.getPullInterval());
                                } else {
                                    DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
                                }
                            }

                            if (pullResult.getNextBeginOffset() < prevRequestOffset
                                || firstMsgOffset < prevRequestOffset) {
                                log.warn(
                                    "[BUG] pull message result maybe data wrong, nextBeginOffset: {} firstMsgOffset: {} prevRequestOffset: {}",
                                    pullResult.getNextBeginOffset(),
                                    firstMsgOffset,
                                    prevRequestOffset);
                            }

                            break;
                        case NO_NEW_MSG:
                            pullRequest.setNextOffset(pullResult.getNextBeginOffset());

                            DefaultMQPushConsumerImpl.this.correctTagsOffset(pullRequest);

                            DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
                            break;
                        case NO_MATCHED_MSG:
                            pullRequest.setNextOffset(pullResult.getNextBeginOffset());

                            DefaultMQPushConsumerImpl.this.correctTagsOffset(pullRequest);

                            DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
                            break;
                        case OFFSET_ILLEGAL:
                            log.warn("the pull request offset illegal, {} {}",
                                pullRequest.toString(), pullResult.toString());
                            pullRequest.setNextOffset(pullResult.getNextBeginOffset());

                            pullRequest.getProcessQueue().setDropped(true);
                            DefaultMQPushConsumerImpl.this.executeTaskLater(new Runnable() {

                                @Override
                                public void run() {
                                    try {
                                        DefaultMQPushConsumerImpl.this.offsetStore.updateOffset(pullRequest.getMessageQueue(),
                                            pullRequest.getNextOffset(), false);

                                        DefaultMQPushConsumerImpl.this.offsetStore.persist(pullRequest.getMessageQueue());

                                        DefaultMQPushConsumerImpl.this.rebalanceImpl.removeProcessQueue(pullRequest.getMessageQueue());

                                        log.warn("fix the pull request offset, {}", pullRequest);
                                    } catch (Throwable e) {
                                        log.error("executeTaskLater Exception", e);
                                    }
                                }
                            }, 10000);
                            break;
                        default:
                            break;
                    }
                }
            }

            @Override
            public void onException(Throwable e) {
                if (!pullRequest.getMessageQueue().getTopic().startsWith(MixAll.RETRY_GROUP_TOPIC_PREFIX)) {
                    log.warn("execute the pull request exception", e);
                }

                DefaultMQPushConsumerImpl.this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_EXCEPTION);
            }
        };

        boolean commitOffsetEnable = false;
        long commitOffsetValue = 0L;
        if (MessageModel.CLUSTERING == this.defaultMQPushConsumer.getMessageModel()) {
            commitOffsetValue = this.offsetStore.readOffset(pullRequest.getMessageQueue(), ReadOffsetType.READ_FROM_MEMORY);
            if (commitOffsetValue > 0) {
                commitOffsetEnable = true;
            }
        }

        String subExpression = null;
        boolean classFilter = false;
        SubscriptionData sd = this.rebalanceImpl.getSubscriptionInner().get(pullRequest.getMessageQueue().getTopic());
        if (sd != null) {
            if (this.defaultMQPushConsumer.isPostSubscriptionWhenPull() && !sd.isClassFilterMode()) {
                subExpression = sd.getSubString();
            }

            classFilter = sd.isClassFilterMode();
        }

        int sysFlag = PullSysFlag.buildSysFlag(
            commitOffsetEnable, // commitOffset
            true, // suspend
            subExpression != null, // subscription
            classFilter // class filter
        );
        try {
            this.pullAPIWrapper.pullKernelImpl(
                pullRequest.getMessageQueue(),
                subExpression,
                subscriptionData.getExpressionType(),
                subscriptionData.getSubVersion(),
                pullRequest.getNextOffset(),
                this.defaultMQPushConsumer.getPullBatchSize(),
                sysFlag,
                commitOffsetValue,
                BROKER_SUSPEND_MAX_TIME_MILLIS,
                CONSUMER_TIMEOUT_MILLIS_WHEN_SUSPEND,
                CommunicationMode.ASYNC,
                pullCallback
            );
        } catch (Exception e) {
            log.error("pullKernelImpl exception", e);
            this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_EXCEPTION);
        }
    }

  • 17
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小王师傅66

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值