学习了解nacos原理以及源码解析


干就完了

Nacos介绍

英文全称Dynamic Naming and Configuration Service,Na为naming/nameServer即注册中心,co为configuration即注册中心,service是指该注册/配置中心都是以服务为核心(相当于Spring Cloud的Eureka+Config)。

  • 服务发现(服务注册中心)

  • 配置管理(服务配置中心)

nacos配置管理

配置管理:动态管理发布配置,无需重启服务,更好保证服务的可用。(其实这里的无需重启服务是针对某些情况下的,只是为了说明nacos配置发生变更后,服务端和客户端会进行通信,然后获取到变更的信息;但是有些配置信息是在应用服务初始化启动时加载进来的,在服务运行时不再获取配置信息的,如果配置信息发生变更,则需要重启服务。比如:数据库连接池大小初始化时加载。)
nacos配置页面

Nacos使用手册

配置管理

1.发布配置——打开nacos控制台,并点击菜单配置管理->配置列表,新增配置。
(yml与properties一样,只是yml更简洁)
2.添加依赖——nacos-config
3.创建配置文件

空间切换

在实际开发中,通常有多套不同的环境(默认只有public),那么这个时候可以根据指定的环境来创建不同的namespce,例如,开发、测试和生产(还有预生产)三个不同的环境,那么使用一套 nacos 集群可以分别建以下三个不同的 namespace。以此来实现多环境的隔离。
空间域名
在项目模块中,修改bootstrap.properties添加如下配置:

#指定命名空间
spring.cloud.nacos.config.namespace=命名空间ID

补充:

(1)配置文件加载顺序

  • bootstrap.yml(bootstrap.properties)先加载——应用程序上下文的引导
  • application.yml(application.properties)后加载——由父Spring ApplicationContext加载

(2)配置多个开发环境
修改项目bootstrap.properties配置文件,添加一行配置

spring.profiles.active=xxx

Nacos原理

服务注册

服务注册:Nacos注册中心分为server与client,server采用Java编写,为client提供注册发现服务与配置服务。而client可以用多语言实现,client与微服务嵌套在一起,Nacos提供sdk和openApi。(理解zk)
nacos服务注册
补充:服务注册的策略的是每5秒向nacos server发送一次心跳,心跳带上了服务名,服务ip,服务端口等信息。同时 nacos server也会向client 主动发起健康检查,支持tcp/http检查。如果15秒内无心跳且健康检查失败则认为实例不健康,如果30秒内健康检查失败则剔除实例。就这啊

服务发现

nacos支持两种服务发现方式:

  • 一种是直接去Nacos服务端拉取某个服务的实例列表,就像Eureka那样定时去拉取注册表信息;
  • 另一种是服务订阅的方式,就是订阅某个服务,然后这个服务下面的实例列表一旦发生变化,Nacos服务端就会使用UDP的方式通知客户端,并将实例列表带过去。
  • SpringBoot的启动类,需要添加 @EnableDiscoveryClient 注解,将扫描到的接口注册到Nacos服务中心

Istio

sitio作用
Istio 就是我们上述提到的 service mesh 的一种实现。
istio

cap

Consistency 一致性,在分布式系统中的所有数据备份,在同一时刻是否同样的值;
Availability 可用性,只要收到用户的请求,服务器就必须给出回应;
Partition tolerance 分区容错性。(zk:CP,Eureka:AP,nacos:CP+AP)
Raft协议:通俗的就是“民主投票任期责任制”C。
Distro协议:通俗的就是“服务联产承包责任制”A。
五名字
cap

CAP——CP(Raft)

一般来说,如果需要在服务级别编辑或者存储配置信息,那么CP是必须的;如果对数据的一致性要求很高,那么就需要CP模式。 (zk:CP)
服务模式

CAP——AP(Distro)

一般来说,如果不需要存储服务级别的信息且服务实例是通过nacos-client注册,并能够保持心跳上报,那么就可以选择AP模式。( Eureka:AP)
Distro协议服务端节点发现使用寻址机制来实现服务端节点的管理。之所以使用临时服务的模式,是因为临时数据通常和服务器保持一个session会话, 该会话只要存在,数据就不会丢失

  • 客户端与服务端有两个重要的交互,服务注册与心跳发送;
  • 心跳包需要带上注册服务的全部信息,在客户端看来,服务端节点对等,所以请求的节点是随机的;
  • 服务端节点都存储所有数据,但每个节点只负责其中一部分服务,在接收到客户端的“写”(注册、心跳、下线等)请求后,服务端节点判断请求的服务是否为自己负责,如果是,则处理,否则交由负责的节点处理
  • 服务端在接收到客户端的服务心跳后,如果该服务不存在,则将该心跳请求当做注册请求来处理;
  • 节点在收到读请求后直接从本机获取后返回,无论数据是否为最新(基本没啥一致性可言)。

Nacos源码分析

源码架构

源码架构

结构说明:

①cmdb顾名思义,配置管理数据库。用于管理nacos的各种配置资源(提前配置好的,不需要我们使用者的初始化创建)。它是支撑自动化交付平台(DevOps的持续交付)的核心基础模块;
②nacos-example提供了使用nacos的示例代码,从这里也能看出来,我们使用nacos时真正关心的只有服务器配置、配置中心管理以及注册中心管理(示例代码静态配置写死);
nacos配置示例
③Istio基于Service Mesh的理念,承担着服务发现、服务通信、负载均衡、限流熔断、监控等等功能。Nacos直接采用Istio,可以让nacos不用关心这些底层逻辑,专注于nacos本身的业务的开发和Istio的服务功能访问即可。

服务注册源码分析

直接拉取客户端

在客户端调用服务订阅接口时,会将客户端的UPD信息(IP和端口)上送到注册中心,注册中心以PushClient对象来进行封装和存储。当注册中心有实例变化时,会发布一个ServiceChangeEvent事件,注册中心监听到这个事件之后,会遍历存储的PushClient,基于UDP协议对客户端进行通知。客户端接收到UDP通知,即可更新本地缓存的实例列表。
程序通过创建一个NamingService ,接着注册了一个服务实例,最后是调用了getAllInstances方法获取某个服务的实例列表。

	@Override
    public List<Instance> getAllInstances(String serviceName, String groupName, List<String> clusters,
            boolean subscribe) throws NacosException {
        ServiceInfo serviceInfo;
        String clusterString = StringUtils.join(clusters, ",");
        //是否订阅,默认是订阅的,也就是subscribe =true
        if (subscribe) {
            serviceInfo = serviceInfoHolder.getServiceInfo(serviceName, groupName, clusterString);
            if (null == serviceInfo) {
                serviceInfo = clientProxy.subscribe(serviceName, groupName, clusterString);
            }
        } else {
            //不进行订阅
            serviceInfo = clientProxy.queryInstancesOfService(serviceName, groupName, clusterString, 0, false);
        }
        List<Instance> list;
        if (serviceInfo == null || CollectionUtils.isEmpty(list = serviceInfo.getHosts())) {
            return new ArrayList<Instance>();
        }
        return list;
    }

:UDP端口是0 ,因为这里是不订阅的,另外,这个UDP是给订阅的接收通知用的。

  • udpPort:UDP端口,如果为0表示不订阅;
  • clientIP:客户端IP地址,此处获取本地IP地址;
  • healthyOnly:单个健康实例

最后调用reqApi 选择server 发送请求了,请求url是 /nacos/…/instance/list ,请求方法是get。

	@Override
    public ServiceInfo queryInstancesOfService(String serviceName, String groupName, String clusters, int udpPort,
            boolean healthyOnly) throws NacosException {
        final Map<String, String> params = new HashMap<String, String>(16);
        params.put(CommonParams.NAMESPACE_ID, namespaceId);
        params.put(CommonParams.SERVICE_NAME, NamingUtils.getGroupedName(serviceName, groupName));
        params.put(CLUSTERS_PARAM, clusters);
        params.put(UDP_PORT_PARAM, String.valueOf(udpPort));//UDP=0,即为不订阅
        params.put(CLIENT_IP_PARAM, NetUtils.localIP());
        params.put(HEALTHY_ONLY_PARAM, String.valueOf(healthyOnly));
        //生成url
        String result = reqApi(UtilAndComs.nacosUrlBase + "/instance/list", params, HttpMethod.GET);
        if (StringUtils.isNotEmpty(result)) {
            return JacksonUtils.toObj(result, ServiceInfo.class);
        }
        return new ServiceInfo(NamingUtils.getGroupedName(serviceName, groupName), clusters);
    }

直接拉取服务端

InstanceController-list方法
在这里插入图片描述
获取对应的service对象,并判断UDP以及客户端等信息是否适合推送。紧接着根据cluster获取这个服务下面对应的实例集合,并进行筛选操作。

		Service service = serviceManager.getService(namespaceId, serviceName);//获取服务
        long cacheMillis = switchDomain.getDefaultCacheMillis();//默认cache时间
        // now try to enable the push
        try {//判断这个客户端是否可用,(客户端语言、配置、版本等信息)
            if (subscriber.getPort() > 0 && pushService.canEnablePush(subscriber.getAgent())) {
                subscriberServiceV1.addClient(namespaceId, serviceName, cluster, subscriber.getAgent(),
                        new InetSocketAddress(clientIP, subscriber.getPort()), pushDataSource, StringUtils.EMPTY,
                        StringUtils.EMPTY);//添加客户端
                cacheMillis = switchDomain.getPushCacheMillis(serviceName);
            }
        } catch (Exception e) {
            Loggers.SRV_LOG.error("[NACOS-API] failed to added push client {}, {}:{}", clientInfo, clientIP,
                    subscriber.getPort(), e);
            cacheMillis = switchDomain.getDefaultCacheMillis();
        }//异常信息
        if (service == null) {
            if (Loggers.SRV_LOG.isDebugEnabled()) {
                Loggers.SRV_LOG.debug("no instance to serve for service: {}", serviceName);
            }
            result.setCacheMillis(cacheMillis);
            return result;
        }//判断是否有服务
        checkIfDisabled(service);//检查服务是否可用
        List<com.alibaba.nacos.naming.core.Instance> srvedIps = service
                .srvIPs(Arrays.asList(StringUtils.split(cluster, StringUtils.COMMA)));//从服务中找到实例
        // filter ips using selector:使用过滤器过滤
        if (service.getSelector() != null && StringUtils.isNotBlank(clientIP)) {
            srvedIps = selectorManager.select(service.getSelector(), clientIP, srvedIps);
        }

用来遍历区分健康(true)的实例与不健康(false)的实例,接着就是判断是否检查,默认是false的,获取服务保护的阈值,默认是0 ; 如果健康的服务实例数量占比小于这个阈值的话,就会将不健康的实例也放到健康的里面,这就是nacos的服务保护机制。问题:可以联想下Eureka的服务保护机制?

        //遍历所有实例,区分开健康实例 不健康实例
        for (com.alibaba.nacos.naming.core.Instance ip : srvedIps) {
            if (!ip.isEnabled()) {
                continue;
            }// remove disabled instance:
            ipMap.get(ip.isHealthy()).add(ip);
            total += 1;
        }
        //保护边界阈值
        double threshold = service.getProtectThreshold();
        List<Instance> hosts;
        if ((float) ipMap.get(Boolean.TRUE).size() / total <= threshold) {
            //如果存活的不健康实例阈值小于既定阈值的话,则进行保护加到健康实例中;
            Loggers.SRV_LOG.warn("protect threshold reached, return all ips, service: {}", result.getName());
            result.setReachProtectionThreshold(true);
            hosts = Stream.of(Boolean.TRUE, Boolean.FALSE).map(ipMap::get).flatMap(Collection::stream)
                    .map(InstanceUtil::deepCopy)
                    // set all to `healthy` state to protect
                    .peek(instance -> instance.setHealthy(true)).collect(Collectors.toCollection(LinkedList::new));
        } else {
            result.setReachProtectionThreshold(false);
            hosts = new LinkedList<>(ipMap.get(Boolean.TRUE));
            if (!healthOnly) {
                hosts.addAll(ipMap.get(Boolean.FALSE));
            }
        }

订阅通知客户端

ServiceInfoHolder:先是根据clusters与serviceName生成一个订阅key,接着就是调用getServiceInfo0 方法获取本地的一个缓存,然后去serviceInfoMap 这个map中获取,它你可以理解成一个本地的缓存。紧接着就是调用serverProxy 的queryInstancesOfService。

        //群组服务名称
        String groupedServiceName = NamingUtils.getGroupedName(serviceName, groupName);
        String key = ServiceInfo.getKey(groupedServiceName, clusters);//根据name 和名称生成key
        if (failoverReactor.isFailoverSwitch()) {
            return failoverReactor.getService(key);
        }
        return serviceInfoMap.get(key);//返回key

ServiceInfoUpdateService-scheduleUpdateIfAbsent:如果有了的话直接返回,很显然第一次请求肯定是没有的,然后通过调用了addTask方法添加一个task,然后返回一个future,并缓存到map中去。

    public void scheduleUpdateIfAbsent(String serviceName, String groupName, String clusters) {
        String serviceKey = ServiceInfo.getKey(NamingUtils.getGroupedName(serviceName, groupName), clusters);
        if (futureMap.get(serviceKey) != null) {//查看是否已经存在
            return;
        }
        synchronized (futureMap) {//同步 根据futureMap加锁进行双重校验
            if (futureMap.get(serviceKey) != null) {
                return;
            }
            ScheduledFuture<?> future = addTask(new UpdateTask(serviceName, groupName, clusters));//添加任务
            futureMap.put(serviceKey, future);//将任务添加到map中
        }
    }

不难看出,就是将task扔到一个任务调度线程池,然并且延迟1s调度。
在这里插入图片描述
这里先是更新一下任务里面维护的这个lastRefTime时间值,接着就是判断如果唤醒监听列表中没有订阅这个服务并且 futureMap(任务集合)里面没有这个的话,就说明被任务被停了, 接着就是计算下延迟时间,然后放到调度线程池中执行,普通情况延迟10s,失败的话就多延迟会,但是不会超过60s。

            try {//先检查是否已订阅这个服务 如果map中也没有的话 终止服务
                if (!changeNotifier.isSubscribed(groupName, serviceName, clusters) && !futureMap.containsKey(serviceKey)) {
                    NAMING_LOGGER
                            .info("update task is stopped, service:{}, clusters:{}", groupedServiceName, clusters);
                    return;
                }
                
                ServiceInfo serviceObj = serviceInfoHolder.getServiceInfoMap().get(serviceKey);
                if (serviceObj == null) {//null的话立即更新服务
                    serviceObj = namingClientProxy.queryInstancesOfService(serviceName, groupName, clusters, 0, false);
                    serviceInfoHolder.processServiceInfo(serviceObj);
                    lastRefTime = serviceObj.getLastRefTime();
                    return;
                }
                if (serviceObj.getLastRefTime() <= lastRefTime) {
                    serviceObj = namingClientProxy.queryInstancesOfService(serviceName, groupName, clusters, 0, false);
                    serviceInfoHolder.processServiceInfo(serviceObj);
                }
                lastRefTime = serviceObj.getLastRefTime();
                if (CollectionUtils.isEmpty(serviceObj.getHosts())) {
                    incFailCount();
                    return;
                }//如果hosts为空的话,增加失败次数
                // 延迟时间值由服务端决定 为10s
                delayTime = serviceObj.getCacheMillis() * DEFAULT_UPDATE_CACHE_TIME_MULTIPLE;
                resetFailCount();
            } catch (Throwable e) {
                incFailCount();
                NAMING_LOGGER.warn("[NA] failed to update serviceName: {}", groupedServiceName, e);
            } finally {
                executor.schedule(this, Math.min(delayTime << failCount, DEFAULT_DELAY * 60), TimeUnit.MILLISECONDS);
            }

订阅通知服务端

调用了pushService 组件的addClient 方法,这个pushService 组件主要就是用来进行推送的, 比如我们订阅了某个服务,然后这个服务下面的实例信息发生了变化,pushService组件就会通知所有的订阅客户端,将新的数据给客户端推过去。通过PushClient生成一个serviceKey ,然后去clientMap中获取,最后就是将PushClient 转成字符串当作key,去clients这个map中获取。

    public void addClient(PushClient client) {
        // client is stored by key 'serviceName' because notify event is driven by serviceName change
        String serviceKey = UtilsAndCommons.assembleFullServiceName(client.getNamespaceId(), client.getServiceName());
        ConcurrentMap<String, PushClient> clients = clientMap.get(serviceKey);
        if (clients == null) {
            clientMap.putIfAbsent(serviceKey, new ConcurrentHashMap<>(1024));
            clients = clientMap.get(serviceKey);
        }
        
        PushClient oldClient = clients.get(client.toString());
        if (oldClient != null) {
            oldClient.refresh();
        } else {
            PushClient res = clients.putIfAbsent(client.toString(), client);
            if (res != null) {
                Loggers.PUSH.warn("client: {} already associated with key {}", res.getAddrStr(), res);
            }
            Loggers.PUSH.debug("client: {} added for serviceName: {}", client.getAddrStr(), client.getServiceName());
        }
    }

配置管理源码分析

配置初始化

ConfigLongPoll_CITCase的init方法进行初始化创建configService实例,并加载properties配置信息。

        // use local config first(优先使用本地配置)
        String content = LocalConfigInfoProcessor.getFailover(worker.getAgentName(), dataId, group, tenant);
        if (content != null) {
            LOGGER.warn("[{}] [get-config] get failover ok, dataId={}, group={}, tenant={}, config={}",
                    worker.getAgentName(), dataId, group, tenant, ContentUtils.truncateContent(content));
            cr.setContent(content);
            String encryptedDataKey = LocalEncryptedDataKeyProcessor
                    .getEncryptDataKeyFailover(agent.getName(), dataId, group, tenant);
            cr.setEncryptedDataKey(encryptedDataKey);
            configFilterChainManager.doFilter(null, cr);
            content = cr.getContent();
            return content;
        }
        //获取远程配置
        ConfigResponse response = worker.getServerConfig(dataId, group, tenant, timeoutMs, false);
        cr.setContent(response.getContent());
        cr.setEncryptedDataKey(response.getEncryptedDataKey());
        configFilterChainManager.doFilter(null, cr);
        content = cr.getContent();

通过dataId, group, fileExtension加载配置文件信息,并通过RPC方式远程加载配置参数。

配置同步源码分析

配置同步

客户端请求

初始请求配置完成后,会通过 WorkClient 进行长轮询查询配置。进行初始化使用了两个线程池:

  • 主要是用来初始化做长轮询的;
  • Delay:用来做检查的,会每间隔5秒钟执行一次登录检查方法。
   ScheduledExecutorService executorService = Executors
           .newScheduledThreadPool(ThreadUtils.getSuitableThreadCount(1), r -> {
               Thread t = new Thread(r);
               t.setName("com.alibaba.nacos.client.Worker");
               t.setDaemon(true);
               return t;
           });
   agent.setExecutor(executorService);
   agent.start();

   if (securityProxy.isEnabled()) {
       securityProxy.login(serverListManager.getServerUrls());
       
       this.executor.scheduleWithFixedDelay(new Runnable() {
           @Override
           public void run() {
               securityProxy.login(serverListManager.getServerUrls());
           }
       }, 0, this.securityInfoRefreshIntervalMills, TimeUnit.MILLISECONDS);
       
   }

在这个方法里面主要是分配任务,给每个task分配一个taskId,后面会去检查本地配置和远程配置,最终调用的是executeConfigListen方法。

  • 检查本地配置信息;
  • 通过 dataId 去检查服务端是否有变动的配置信息;
  • 添加监听;
  • 通过 dataId , group 来获取 cache 本地缓存的配置信息;
  • 再将 Listener 也传给 cache 统一管理
	 //check local listeners consistent.
	 if (cache.isSyncWithServer()) {
	     cache.checkListenerMd5();
	     if (!needAllSync) {
	         continue;
	     }
	 }
     //get listen  config
     if (!cache.isUseLocalConfigInfo()) {
         List<CacheData> cacheDatas = listenCachesMap.get(String.valueOf(cache.getTaskId()));
         if (cacheDatas == null) {
             cacheDatas = new LinkedList<CacheData>();
             listenCachesMap.put(String.valueOf(cache.getTaskId()), cacheDatas);
         }
         cacheDatas.add(cache);
         
     }
     @Override
     public void startInternal() throws NacosException {
         executor.schedule(new Runnable() {
             @Override
             public void run() {
                 while (!executor.isShutdown() && !executor.isTerminated()) {
                     try {
                         listenExecutebell.poll(5L, TimeUnit.SECONDS);
                         if (executor.isShutdown() || executor.isTerminated()) {
                             continue;
                         }
                         executeConfigListen();
                     } catch (Exception e) {
                         LOGGER.error("[ rpc listen execute ] [rpc listen] exception", e);
                     }
                 }
             }
         }, 0L, TimeUnit.MILLISECONDS);
         
     }

服务端响应

当服务端收到请求后,会持住当前请求,如果有变化就返回,如果没有变化就等待超时之前返回无变化。

    public void addLongPollingClient(HttpServletRequest req, HttpServletResponse rsp, Map<String, String> clientMd5Map,
            int probeRequestSize) {
        
        String str = req.getHeader(LongPollingService.LONG_POLLING_HEADER);
        String noHangUpFlag = req.getHeader(LongPollingService.LONG_POLLING_NO_HANG_UP_HEADER);
        String appName = req.getHeader(RequestUtil.CLIENT_APPNAME_HEADER);
        String tag = req.getHeader("Vipserver-Tag");
        int delayTime = SwitchService.getSwitchInteger(SwitchService.FIXED_DELAY_TIME, 500);
        
        // Add delay time for LoadBalance, and one response is returned 500 ms in advance to avoid client timeout.
        long timeout = Math.max(10000, Long.parseLong(str) - delayTime);
        if (isFixedPolling()) {
            timeout = Math.max(10000, getFixedPollingInterval());
            // Do nothing but set fix polling timeout.
        } else {
            long start = System.currentTimeMillis();
            List<String> changedGroups = MD5Util.compareMd5(req, rsp, clientMd5Map);
            if (changedGroups.size() > 0) {
                generateResponse(req, rsp, changedGroups);
                LogUtil.CLIENT_LOG.info("{}|{}|{}|{}|{}|{}|{}", System.currentTimeMillis() - start, "instant",
                        RequestUtil.getRemoteIp(req), "polling", clientMd5Map.size(), probeRequestSize,
                        changedGroups.size());
                return;
            } else if (noHangUpFlag != null && noHangUpFlag.equalsIgnoreCase(TRUE_STR)) {
                LogUtil.CLIENT_LOG.info("{}|{}|{}|{}|{}|{}|{}", System.currentTimeMillis() - start, "nohangup",
                        RequestUtil.getRemoteIp(req), "polling", clientMd5Map.size(), probeRequestSize,
                        changedGroups.size());
                return;
            }
        }
        String ip = RequestUtil.getRemoteIp(req);
        
        // Must be called by http thread, or send response.
        final AsyncContext asyncContext = req.startAsync();
        
        // AsyncContext.setTimeout() is incorrect, Control by oneself
        asyncContext.setTimeout(0L);
        
        ConfigExecutor.executeLongPolling(
                new ClientLongPolling(asyncContext, clientMd5Map, ip, probeRequestSize, timeout, appName, tag));
    }

Nacos常见问题解析

大量的无效日志打印,这些日志的打印会迅速占用完用户的磁盘空间,同时也让有效日志难以查找。
1、access 日志大量打印,access日志不能自动清理和滚动并且不能控制日期以及文件大小限制。这个日志是 Spring Boot 提供的 Tomcat 访问日志打印。
——server.tomcat.accesslog.enabled=false(生产环境磁盘允许的话,不建议删除
2、服务端业务日志大量打印
#调整naming模块的naming-raft.log的级别为error:

curl -X PUT '$nacos_server:8848/nacos/v1/ns/operator/log?logName=naming-raft&logLevel=error'

#调整config模块的config-dump.log的级别为warn:

curl -X PUT '$nacos_server:8848/nacos/v1/cs/ops/log?logName=config-dump&logLevel=warn‘

3、客户端日志大量打印(心跳日志、轮询日志)
(如果允许的话,可以进行二次开发,对于轮询以及心跳不设置日志信息输出,或者采用超时+时间片的方式进行控制输出)

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值