RocketMQ | 源码分析 | NameServer篇

前言

Namesrv是RocketMQ最简单的一部分,骨头我们先挑最软的一根啃,本次引用的源码来于4.8.0版本,由于笔者水平有限欢迎大家批评指正。

NameServer

NameServer 是一个 Broker 与 Topic 路由的注册中心,支持 Broker 的动态注册与发现,主要功能如下:

  • Broker管理:接收 Broker 集群的注册信息并且保存下来作为路由信息的基本数据,提供心跳监测机制,检查 Broker 是否还存活。
  • 路由信息管理:每个 NameServer中都保存 Broker 集群的整个路由信息可用于客户端查询队列信息,Producer 和Consumer 通过 NameServer可以获取整个 Broker 集群路由信息从而进行消息投递和消费。

NameSrv充当消息路由的提供者。生产者或消费者都能够通过NameSrv查找各个主题响应的BrokerIP列表。多个Namesrv实例组成集群,但互相独立,没有信息交换

问题引出

从上面的官网对Namesrv的介绍,我们可以引出如下三个问题。
● Namesrv是如何知道生产者与消费者状态信息的。
● 生产者与消费者是如何利用Namesrv获取路由信息的。
● 多个Namesrv实例组成集群互相独立没有信息交换是如何保证高可用的。

为什么自己实现服务状态管理NameServer

Producer 发送消息之前,先从 NameServer 中获取到 Broker 服务器列表,然后根据负载均衡策略选择一台 Broker 发送,消息消费时也是同样的道理。可以说 NameServer 是 RocketMQ 的大脑,想要实现路由分发的功能,那么在 NameServer 必然要维护着 Broker 服务器信息,这中间就会涉及到 Broker 服务器服务状态管理问题,这篇文章就来聊一聊 RocketMQ 是如何做服务状态管理的。
在聊服务状态管理之前,「先来讲一讲为何不用 ZooKeeper 来做路由中心?」
听闻早期的 RocketMQ 是使用 ZooKeeper 来做路由中心。我们知道 ZooKeeper 功能比较强大,包括自动 Master 选举等,强大的同时部署维护就变得复杂了,但是 ZooKeeper 的很多功能 RocketMQ 并不需要,RocketMQ 只需要一个轻量级的元数据服务器就够了。所以就造了 NameServer 这个轮子。
还有一个原因就是中间件对稳定性要求比较高,使用 ZooKeeper 作为注册和路由中心的话,就依赖了另一个中间件,提高了系统复杂性和维护成本,而 NameServer 只是 RocketMQ 中的一个模块,且只有少量代码,维护起来简单,稳定性也提高了。
好了,说回「服务状态管理」问题,其实这个并不陌生,在微服务领域有大量的中间件都涉及到了这个问题。对于服务状态管理,一般有两种解决思路。

「心跳模式」,如图:
在这里插入图片描述
被路由方每隔一段时间向路由方发送心跳包,路由方记录被路由方的心跳包,包括服务器IP、上报时间等。每一次上报后,更新对应的信息。路由方启动一个定时器,定期检测当前时间和节点,最近续约时间的差值,如果达到一个阈值(比如说90秒),那么认为这个服务节点不可用。
现在大部分需要服务状态管理的中间件,都采用「心跳模式」,没有太多的缺陷,也不会对服务器造成多大的压力。在 RocketMQ 中 NameServer 与 Broker 的通信也是采用 「心跳模式」。心跳模式中,有上报心跳、保存心跳信息、定时检测这个步骤。

源码

下面我们将从源码角度看看RocketMQ是如何实现的,打开Namesrv的源码包,我们发现NameSrv的代码量并不多。
在这里插入图片描述
我们先对如图所示的接个类进行一个大致的介绍。
● org.apache.rocketmq.namesrv.kvconfig.KVConfigManager:提供将Namesrv配置加载到内存和修改配置的一些方法。
● org.apache.rocketmq.namesrv.kvconfig.KVConfigSerializeWrapper:提供对KVConfigManager序列化的方法,序列化与反序列化方法继承自org.apache.rocketmq.remoting.protocol.RemotingSerializable
● org.apache.rocketmq.namesrv.processor.DefaultRequestProcessor:用来处理消费者或生产者(客户端)和Broker发送过来的请求。
● org.apache.rocketmq.namesrv.routeinfo.BrokerHousekeepingService:监听Broker状态。
● org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager:负责保存RocektMQ集群中消息的路由信息。
● org.apache.rocketmq.namesrv.NamesrvController:Namesrv的总控制器。
● org.apache.rocketmq.namesrv.NamesrvStartup:Namesrv启动的入口。

下面我们从Namesrv启动的流程如下进行源码分析(省略一些非关键代码例如日志打印与异常捕获等等)

// org.apache.rocketmq.namesrv.NamesrvStartup#main0

public static NamesrvController main0(String[] args) {
    try {
        // #① 创建一个Namesrv总控制器
        NamesrvController controller = createNamesrvController(args);
        // #② 启动Namesrv总控制器。
        start(controller);
        // #③ Namesrv启动成功时就会打印此日志。
        String tip = "The Name Server boot success. serializeType=" + RemotingCommand.getSerializeTypeConfigInThisServer();
        log.info(tip);
        System.out.printf("%s%n", tip);
        return controller;
    } catch (Throwable e) {
        e.printStackTrace();
        System.exit(-1);
    }
    return null;
}

①:创建一个Namesrv总控制器。
②:启动Namesrv总控制器。
③:Namesrv启动成功时就会打印此日志。

// org.apache.rocketmq.namesrv.NamesrvStartup#start

public static NamesrvController start(final NamesrvController controller) throws Exception {

    if (null == controller) {
        throw new IllegalArgumentException("NamesrvController is null");
    }
    //#①
    boolean initResult = controller.initialize();
    if (!initResult) {
        //#②
        controller.shutdown();
        System.exit(-3);
    }
    //#③
    Runtime.getRuntime().addShutdownHook(new ShutdownHookThread(log, new Callable<Void>() {
        @Override
        public Void call() throws Exception {
            controller.shutdown();
            return null;
        }
    }));
    //#④
    controller.start();
    return controller;
}

①:初始化NamesrvController。
②:初始化失败时需要关闭NamesrvController。
③:注册JVM钩子函数,在JVM正常停机的前一刻关闭NamesrvController。
④:正常初始化完之后启动NamesrvController。

// org.apache.rocketmq.namesrv.NamesrvStartup#initialize
public boolean initialize() {

    // #① 将Namesrv的配置加载到内存中。
    this.kvConfigManager.load();

    // todo 与远程通信有关的配置,后续介绍。
    this.remotingServer = new NettyRemotingServer(this.nettyServerConfig, this.brokerHousekeepingService);
    // #② 此线程池remotingExecutor专门用来处理客户端(生产者和消费者)Broker发送过来的请求DefaultRequestProcessor。
    this.remotingExecutor =
    Executors.newFixedThreadPool(nettyServerConfig.getServerWorkerThreads(), new ThreadFactoryImpl("RemotingExecutorThread_"));
    // #③ 将DefaultRequestProcessor与线程池remotingExecutor绑定起来。
    this.registerProcessor();
    // #④ 每隔 10s 进行扫描判断是否存在失效的Broker,如果存在则移除失效broker
    this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
        @Override
        public void run() {
            NamesrvController.this.routeInfoManager.scanNotActiveBroker();
        }
    }, 5, 10, TimeUnit.SECONDS);
    // #⑤ 定时任务执行线程池,每10s打印一次Namesrv的配置。
    this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
        @Override
        public void run() {
            NamesrvController.this.kvConfigManager.printAllPeriodically();
        }
    }, 1, 10, TimeUnit.MINUTES);
    return true;
}

Broker 视角触发路由删除,即 Broker 在正常关闭的情况下,会执行 unregisterBroker 指令这两种方式路由删除的方法都是一样的,都是从相关路由表中删除与该 broker 相关的信息。

核心属性分析

// org.apache.rocketmq.namesrv.processor.DefaultRequestProcessor#processRequest
// Namesrv对外暴露的接口(处理客户端(消费者和生产者)和Broker发送的请求)
// 此方法代码比较简单这里不进行详细分析了
public RemotingCommand processRequest(ChannelHandlerContext ctx,
                                      RemotingCommand request) throws RemotingCommandException
// org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager

// #① Namesrv默认在两分钟内没有收到Broker的信息,则任务Broker已宕机
private final static long BROKER_CHANNEL_EXPIRED_TIME = 1000 * 60 * 2;
// #② 读写锁
private final ReadWriteLock lock = new ReentrantReadWriteLock();
// #③ 主题与队列映射 topic路由信息,broker名称,读队列数,写队列数等
private final HashMap<String/* topic */, List<QueueData>> topicQueueTable;
// #④ Broker名称与Broker映射,一个主从Broker对应的Broker名称是相同的 broker信息,broker集群,broker名称,broker地址等
private final HashMap<String/* brokerName */, BrokerData> brokerAddrTable;
// #⑤ RocketMQ集群名称与Broker的映射,每个主从Broker都有一个Broker名称(同一个主从Broker内只有BrokerId不一样 id=0 的是主节点,id≠0 的是从节点)
// broker集群信息,broker名称列表
private final HashMap<String/* clusterName */, Set<String/* brokerName */>> clusterAddrTable;
// #⑥ 当前存活的Broker信息,broker状态信息,broker存活最新上报时间
private final HashMap<String/* brokerAddr */, BrokerLiveInfo> brokerLiveTable;
// #⑦ 消息过滤
private final HashMap<String/* brokerAddr */, List<String>/* Filter Server */> filterServerTable;

⑥:这里需要详细说明一下,Namesrv每10s扫描一次所有的Broker(通过上面所说的scheduledExecutorService定时任务线程池进行扫描)根据心跳包lastUpdateTimestamp的时间当前系统时间的差值是否超过BROKER_CHANNEL_EXPIRED_TIME来判断Broker是否存活,从这一点我们也可以看出此数据并非实时的,从而客户端无法实时感知Broker的状态。
路由元信息Map结构实例如下:
在这里插入图片描述

// org.apache.rocketmq.namesrv.routeinfo.BrokerLiveInfo
class BrokerLiveInfo {
    private long lastUpdateTimestamp;
    // # ① 利用版本号解决ABC问题,如果不懂ABA问题的可以百度一下。
    private DataVersion dataVersion;
    private Channel channel;
    private String haServerAddr;
    // ...
}

心跳上报

上报心跳开始,在 RocketMQ 中,默认情况下,Broker 服务器会每间隔 30秒向集群中的所有 NameServer 发送心跳包。源代码是BrokerController#start(),如下代码:

public void start() throws Exception {
        if (this.messageStore != null) {
            this.messageStore.start();
        }
        ......
        this.registerBrokerAll(true, false, true);
        this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                try {
                    BrokerController.this.registerBrokerAll(true, false, brokerConfig.isForceRegister());
                } catch (Throwable e) {
                    log.error("registerBrokerAll Exception", e);
                }
            }
        }, 1000 * 10, Math.max(10000, Math.min(brokerConfig.getRegisterNameServerPeriod(), 60000)), TimeUnit.MILLISECONDS);
        ......
    }

这里我们重点要说的是服务注册,所以上面省略了很多代码,重点关注注册流程就可以了。
start()方法中调用了registerBrokerAll(),该方法就是用来进行向所有的NameSrv注册Broker自身信息的。
细心的读者可能会发现,这里在调用了registerBrokerAll()后,又开启了一个定时任务,周期性的重复调用registerBrokerAll(),这其实就是Broker在定期向NameSrv发送心跳包,NameSrv会每隔一段时间扫一遍broker列表,剔除长时间没发送心跳包的Broker。

registerBrokerAll()会调用到doRegisterBrokerAll(),这里可以看见Broker向NameSrv注册了哪些信息,如Broker节点地址、Name、id、topic等信息。

private void doRegisterBrokerAll(boolean checkOrderConfig, boolean oneway,
        TopicConfigSerializeWrapper topicConfigWrapper) {
        List<RegisterBrokerResult> registerBrokerResultList = this.brokerOuterAPI.registerBrokerAll(
            this.brokerConfig.getBrokerClusterName(),
            this.getBrokerAddr(),
            this.brokerConfig.getBrokerName(),
            this.brokerConfig.getBrokerId(),
            this.getHAServerAddr(),
            topicConfigWrapper,
            this.filterServerManager.buildNewFilterServerList(),
            oneway,
            this.brokerConfig.getRegisterBrokerTimeoutMills(),
            this.brokerConfig.isCompressedRegister());

       ......
    }

this.brokerOuterAPI.registerBrokerAll()内部,就是封装请求头,然后遍历NameSrv列表,向每个NameSrv发起注册请求。
其中上报心跳的时间用户是可以自定义的,但是不会低于 10秒高于 60秒。当然这只是一个定时器,具体发送心跳包的方法是org.apache.rocketmq.broker.out.BrokerOuterAPI#registerBrokerAll(),代码如下:

public List<RegisterBrokerResult> registerBrokerAll(
        final String clusterName,
        final String brokerAddr,
        final String brokerName,
        final long brokerId,
        final String haServerAddr,
        final TopicConfigSerializeWrapper topicConfigWrapper,
        final List<String> filterServerList,
        final boolean oneway,
        final int timeoutMills,
        final boolean compressed) {

        final List<RegisterBrokerResult> registerBrokerResultList = Lists.newArrayList();
        // 获取所有 NameServer 服务器
        List<String> nameServerAddressList = this.remotingClient.getNameServerAddressList();
        if (nameServerAddressList != null && nameServerAddressList.size() > 0) {
            // 构建 broker 信息
            final RegisterBrokerRequestHeader requestHeader = new RegisterBrokerRequestHeader();
            requestHeader.setBrokerAddr(brokerAddr);
            requestHeader.setBrokerId(brokerId);
            requestHeader.setBrokerName(brokerName);
            requestHeader.setClusterName(clusterName);
            requestHeader.setHaServerAddr(haServerAddr);
            requestHeader.setCompressed(compressed);

            RegisterBrokerBody requestBody = new RegisterBrokerBody();
            requestBody.setTopicConfigSerializeWrapper(topicConfigWrapper);
            requestBody.setFilterServerList(filterServerList);
            final byte[] body = requestBody.encode(compressed);
            final int bodyCrc32 = UtilAll.crc32(body);
            requestHeader.setBodyCrc32(bodyCrc32);
            final CountDownLatch countDownLatch = new CountDownLatch(nameServerAddressList.size());
            // 向 NameServer 逐个上报
            for (final String namesrvAddr : nameServerAddressList) {
                brokerOuterExecutor.execute(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            RegisterBrokerResult result = registerBroker(namesrvAddr,oneway, timeoutMills,requestHeader,body);
                            if (result != null) {
                                registerBrokerResultList.add(result);
                            }

                            log.info("register broker[{}]to name server {} OK", brokerId, namesrvAddr);
                        } catch (Exception e) {
                            log.warn("registerBroker Exception, {}", namesrvAddr, e);
                        } finally {
                            countDownLatch.countDown();
                        }
                    }
                });
            }

            try {
                countDownLatch.await(timeoutMills, TimeUnit.MILLISECONDS);
            } catch (InterruptedException e) {
            }
        }

        return registerBrokerResultList;
    }

心跳包发送完之后,就是 NameServer 处理心跳包了,NameServer 会将心跳信息保存起来,保存心跳信息的源代码我就不贴了,涉及的东西比较多,有兴趣的可以查看org.apache.rocketmq.namesrv.processor.DefaultRequestProcessor#processRequest()#RequestCode.REGISTER_BROKER,一步一步 Debug 就知道保存过程。
来看看最后一个操作「定时检测」,NameServer 会开启一个探测线程,源代码在org.apache.rocketmq.namesrv.NamesrvController#initialize()下,代码如下:

// 检测 broker
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
    @Override
    public void run() {
        NamesrvController.this.routeInfoManager.scanNotActiveBroker();
    }
}, 5, 10, TimeUnit.SECONDS);

NameServer 每 10秒会发起一次检测。具体检测源代码是org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager#scanNotActiveBroker(),代码如下:

/**
* 检测 broker 状态
*/
public void scanNotActiveBroker() {
   // 遍历 broker 存活列表
   Iterator<Entry<String, BrokerLiveInfo>> it = this.brokerLiveTable.entrySet().iterator();
   while (it.hasNext()) {
       Entry<String, BrokerLiveInfo> next = it.next();
       long last = next.getValue().getLastUpdateTimestamp();
       // 如果最后一次上报时间已经超过两分钟,则移出
       if ((last + BROKER_CHANNEL_EXPIRED_TIME) < System.currentTimeMillis()) {
           RemotingUtil.closeChannel(next.getValue().getChannel());
           it.remove();
           log.warn("The broker channel expired, {} {}ms", next.getKey(), BROKER_CHANNEL_EXPIRED_TIME);
           this.onChannelDestroy(next.getKey(), next.getValue().getChannel());
       }
   }
}

NameServer 会遍历 Broker 存活列表,如果最后一次发送心跳包的时间超过 「120秒」,则认为 Broker 服务器不可用,将 Broker 从各种配置列表中移出。
到此为止,RocketMQ 的心跳模式实现就完成了,上面的源代码都是一些粗略的,具体的实现细节还是比较繁琐的,有兴趣的可以深入研究源码,获取更多详细信息。

服务发现

// org.apache.rocketmq.namesrv.processor.DefaultRequestProcessor#processRequest
// Namesrv对外暴露的接口(处理客户端(消费者和生产者)和Broker发送的请求)
// 此方法代码比较简单这里不进行详细分析了
public RemotingCommand processRequest(ChannelHandlerContext ctx,
                                      RemotingCommand request) throws RemotingCommandException;

public RemotingCommand registerBroker(ChannelHandlerContext ctx, RemotingCommand request) throws RemotingCommandException;

在这里插入图片描述

RegisterBrokerResult result = registerBroker(namesrvAddr,oneway, timeoutMills,requestHeader,body);

看下实现:

private RegisterBrokerResult registerBroker(
        final String namesrvAddr,
        final boolean oneway,
        final int timeoutMills,
        final RegisterBrokerRequestHeader requestHeader,
        final byte[] body
    ) throws RemotingCommandException, MQBrokerException, RemotingConnectException, RemotingSendRequestException, RemotingTimeoutException,
        InterruptedException {
        RemotingCommand request = RemotingCommand.createRequestCommand(RequestCode.REGISTER_BROKER, requestHeader);
        request.setBody(body);

        if (oneway) {
            try {
                this.remotingClient.invokeOneway(namesrvAddr, request, timeoutMills);
            } catch (RemotingTooMuchRequestException e) {
                // Ignore
            }
            return null;
        }

        RemotingCommand response = this.remotingClient.invokeSync(namesrvAddr, request, timeoutMills);
        assert response != null;
        switch (response.getCode()) {
            case ResponseCode.SUCCESS: {
                RegisterBrokerResponseHeader responseHeader =
                    (RegisterBrokerResponseHeader) response.decodeCommandCustomHeader(RegisterBrokerResponseHeader.class);
                RegisterBrokerResult result = new RegisterBrokerResult();
                result.setMasterAddr(responseHeader.getMasterAddr());
                result.setHaServerAddr(responseHeader.getHaServerAddr());
                if (response.getBody() != null) {
                    result.setKvTable(KVTable.decode(response.getBody(), KVTable.class));
                }
                return result;
            }
            default:
                break;
        }

        throw new MQBrokerException(response.getCode(), response.getRemark());
    }

RocketMQ 的网络请求是基于Netty库的,RocketMQ 基于 Netty 封装了 NettyRemotingClient,上面的代码就是调用了 NettyRemotingClient,向NameSrv连接地址发起网络请求。
当 NameSrv收到来自Broker的注册请求后,会触发 RouteInfoManager 的 registerBroker(),将Broker信息存放起来:

public RegisterBrokerResult registerBroker(
        final String clusterName,
        final String brokerAddr,
        final String brokerName,
        final long brokerId,
        final String haServerAddr,
        final TopicConfigSerializeWrapper topicConfigWrapper,
        final List<String> filterServerList,
        final Channel channel) {
        RegisterBrokerResult result = new RegisterBrokerResult();
        try {
            try {
                //读写锁,保证并发时broker的正确性
                this.lock.writeLock().lockInterruptibly();
                Set<String> brokerNames = this.clusterAddrTable.get(clusterName);
                if (null == brokerNames) {
                    brokerNames = new HashSet<String>();
                    this.clusterAddrTable.put(clusterName, brokerNames);
                }
                brokerNames.add(brokerName);
                boolean registerFirst = false;
                BrokerData brokerData = this.brokerAddrTable.get(brokerName);
                if (null == brokerData) {
                    registerFirst = true;
                    brokerData = new BrokerData(clusterName, brokerName, new HashMap<Long, String>());
                    this.brokerAddrTable.put(brokerName, brokerData);
                }
                String oldAddr = brokerData.getBrokerAddrs().put(brokerId, brokerAddr);
                registerFirst = registerFirst || (null == oldAddr);

                if (null != topicConfigWrapper
                    && MixAll.MASTER_ID == brokerId) {
                    if (this.isBrokerTopicConfigChanged(brokerAddr, topicConfigWrapper.getDataVersion())
                        || registerFirst) {
                        ConcurrentMap<String, TopicConfig> tcTable =
                            topicConfigWrapper.getTopicConfigTable();
                        if (tcTable != null) {
                            for (Map.Entry<String, TopicConfig> entry : tcTable.entrySet()) {
                                this.createAndUpdateQueueData(brokerName, entry.getValue());
                            }
                        }
                    }
                }

                BrokerLiveInfo prevBrokerLiveInfo = this.brokerLiveTable.put(brokerAddr,
                    new BrokerLiveInfo(
                        System.currentTimeMillis(),
                        topicConfigWrapper.getDataVersion(),
                        channel,
                        haServerAddr));
                if (null == prevBrokerLiveInfo) {
                    log.info("new broker registered, {} HAServer: {}", brokerAddr, haServerAddr);
                }

                if (filterServerList != null) {
                    if (filterServerList.isEmpty()) {
                        this.filterServerTable.remove(brokerAddr);
                    } else {
                        this.filterServerTable.put(brokerAddr, filterServerList);
                    }
                }

                if (MixAll.MASTER_ID != brokerId) {
                    String masterAddr = brokerData.getBrokerAddrs().get(MixAll.MASTER_ID);
                    if (masterAddr != null) {
                        BrokerLiveInfo brokerLiveInfo = this.brokerLiveTable.get(masterAddr);
                        if (brokerLiveInfo != null) {
                            result.setHaServerAddr(brokerLiveInfo.getHaServerAddr());
                            result.setMasterAddr(masterAddr);
                        }
                    }
                }
            } finally {
                this.lock.writeLock().unlock();
            }
        } catch (Exception e) {
            log.error("registerBroker Exception", e);
        }

        return result;
    }

需要注意的是,上面这段代码用了读写锁,当写入Broker信息的时候进行了写锁,保证了Broker信息并发写时的正确性。
RocketMQ 的路由发现采用的是 Pull 模型,当 Topic 路由信息发生变化时,NameServer 不会主动推送给客户端,而是客户端定时拉取主题最新的路由,默认让客户端每 30 秒会拉取一次最新的路由。(Push、Pull、Long Polling 三种模型的优缺点?)
在这里插入图片描述

NameServer 启动时序图

在这里插入图片描述

总结

从上面的源码我们可以找到一开始我们抛出的三个问题的答案。
①:Namesrv通过定时扫描Broker主动上报心跳信息来判断Broker是否存活。
②:通过org.apache.rocketmq.namesrv.processor.DefaultRequestProcessor#processRequest方法获取org.apache.rocketmq.namesrv.routeinfo.RouteInfoManager类维护的路由信息。
③:通过源码分析我们没有看到Namesrv模块没有提供任何Namesrv之间通信的接口,而且每个Namesrv都维护一套完整的Broker集群、客户端集群(生产者和消费者)信息,因此只要RocketMQ集群充还存在一个可用的Namesrv那么集群就能继续正常工作。
NameServer设计的亮点
● 读写锁使用,Map 结构存储路由信息;
● NameServer 之间不通信(AP,最终一致性);
● NameServer 只管理 Broker 集群等信息。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值