shardingjdbc服务治理自定义注册中心

1. 背景

最近参与一个项目,涉及到多数据源,然后再对某一数据源进行分表操作;分表按月份进行分表,查询也只能查询出近3个月内的数据(相当于需要动态改变shardingjdbc的分表配置);
在这里插入图片描述

2. shardingjdbc分表实现

2.1 代码方式配置数据源


@Configuration
@Component
@MapperScan(basePackages = "com.xxx.db.mapper.mysql", sqlSessionTemplateRef = "mysqlSqlSessionTemplate")
public class MysqlDatabaseConfiguration {

    @Bean(name = "mysqlDataSourceOriginal")
    @ConfigurationProperties(prefix = "spring.datasource.mysql")
    public DataSource dataSource() {
        DataSource build = DataSourceBuilder.create().build();
        return build;
    }

    @Bean(name = "mysqlDataSourceShardingJdbc")
    @Primary
    public DataSource shardingJdbcDataSource(@Qualifier("mysqlDataSourceOriginal") DataSource dataSource) {
        ShardingJdbcConfig shardingJdbcConfig = new ShardingJdbcConfig();
        return shardingJdbcConfig.dataSourceWarp(dataSource);
    }

    @Bean(name = "mysqlSqlSessionFactory")
    @Primary
    public SqlSessionFactory mysqlSqlSessionFactory(@Qualifier("mysqlDataSourceShardingJdbc") DataSource dataSource) throws Exception {
        final SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        sessionFactory.setDataSource(dataSource);
        sessionFactory.setMapperLocations(resolver.getResources("classpath:mapper/mysql/**/*.xml"));
        sessionFactory.setConfigLocation(resolver.getResource("classpath:mybatis-config.xml"));
        sessionFactory.setTypeAliasesPackage("com.xx.xx.xx.xx.db.entity.mysql.*");
        return sessionFactory.getObject();
    }

    @Bean(name = "mysqlDataSourceTransactionManager")
    @Primary
    public DataSourceTransactionManager mysqlTransactionManager(@Qualifier("mysqlDataSourceShardingJdbc") DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }

    @Bean(name = "mysqlSqlSessionTemplate")
    @Primary
    public SqlSessionTemplate mysqlSqlSessionTemplate(@Qualifier("mysqlSqlSessionFactory") SqlSessionFactory sqlSessionFactory) throws Exception {
        return new SqlSessionTemplate(sqlSessionFactory);
    }
}

public class ShardingJdbcConfig {

    private static final String MYSQL_DATA_SOURCE = "mysqlDbMaster";
    private static final String TEMP_TABLE = "d_table";
    private static final String TEMP_ROW_KEY = "create_time";

    public DataSource dataSourceWarp(DataSource dataSource) {
        Map<String, DataSource> dataSourceMap = new HashMap<>();
        dataSourceMap.put(MYSQL_DATA_SOURCE, dataSource);

        String tempActualDataNodes = buildTableActualDataNodes();

        TableRuleConfiguration tableRuleConfig = new TableRuleConfiguration(TEMP_TABLE, tempActualDataNodes);
        tableRuleConfig.setTableShardingStrategyConfig(new StandardShardingStrategyConfiguration(TEMP_ROW_KEY,
                new CurstomPreciseStrategy(), new CurstomRangeStrategy()));

        ShardingRuleConfiguration shardingRuleConfig = new ShardingRuleConfiguration();
        shardingRuleConfig.getTableRuleConfigs().add(tableRuleConfig);

//        shardingRuleConfig.setDefaultDataSourceName(MYSQLDATASOURCE);
//        shardingRuleConfig.setBindingTableGroups(Arrays.asList("t_table_temp"));

        DataSource shardingDataSource = null;
        try {
            shardingDataSource = ShardingDataSourceFactory.createDataSource(dataSourceMap, shardingRuleConfig, new Properties());
        } catch (SQLException e) {
            e.printStackTrace();
        }
        return shardingDataSource;
    }

    private String buildTableActualDataNodes() {
        String actualDataNodes = "mysqlDbMaster.d_table$->{2021..2099}0$->{1..9},mysqlDbMaster.d_table$->{2021..2099}1$->{0..2}";
        return actualDataNodes;
    }
}

2.2 自定义分表策略

// 区间
public class CurstomRangeStrategy implements RangeShardingAlgorithm<Date> {

    private static final String TABLE_FORMAT = "_yyyyMM";
    @Override
    public Collection<String> doSharding(Collection<String> tableNames, RangeShardingValue<Date> rangeShardingValue) {
        Range<Date> rangeDate = rangeShardingValue.getValueRange();
        Date startDate = rangeDate.lowerEndpoint();
        Date endDate = rangeDate.upperEndpoint();

        List<String> yearsAndMonths = null;
        try {
            yearsAndMonths = DateUtil.getMonthBetween(startDate, endDate,TABLE_FORMAT,rangeShardingValue.getLogicTableName());
        } catch (Exception e) {
            e.printStackTrace();
        }
        return yearsAndMonths;
    }
}

// 精确分片
public class CurstomPreciseStrategy implements PreciseShardingAlgorithm<Date> {

    @Override
    public String doSharding(final Collection<String> tableNames, final PreciseShardingValue<Date> shardingValue) {
        Date date = shardingValue.getValue();
        String logicTableName = shardingValue.getLogicTableName()+"_" + String.format("%tY", date) + String.format("%tm", date);

        for (String each : tableNames) {
            if (each.equals(logicTableName)) {
                return each;
            }
        }
        throw new UnsupportedOperationException();
    }
}

RangeShardingAlgorith:使用的过程中发现,只有between and的时候才会走这个分片策略,而> < 并没有走此算法(希望得到大佬的指点!);

3. shardingjdbc服务治理

3.1 理解shardingjdbc配置/注册中心

配置中心的作用及功能
配置中心:配置中心的作用是将shardingjdbc的配置统一化管理,可以起到修改配置自动分发到对应机器做动态修改,它的功能就是管理shardingjdbc分库分表的配置;

注册中心的作用及功能
注册中心:注册中心又是干嘛的呢?相对于配置中心管理配置数据,注册中心存放运行时的动态/临时状态数据!注意:是运行时的数据,比如:某数据源出现问题,你想在不停服的情况下进行数据源的切换。这些操作都是需要用到注册中心!

以上两份文档中对于配置中心和注册中心的作用描述的很清楚,但是,如何用,怎么用好像并没有说太多,接下来以zookeeper为例进行演示使用方式;

3.2 基于zookeeper的配置中心

需要额外再引入相关的依赖包:

版本我使用的是:4.0.0-RC1
<dependency>
    <groupId>org.apache.shardingsphere</groupId>
    <artifactId>sharding-jdbc-orchestration</artifactId>
    <version>${sharding-sphere.version}</version>
</dependency>

<!--若使用zookeeper, 请加入下面Maven坐标-->
<dependency>
    <groupId>org.apache.shardingsphere</groupId>
    <artifactId>sharding-orchestration-center-zookeeper-curator</artifactId>
    <version>${sharding-sphere.version}</version>
</dependency>
3.2.1 代码配置方式

不同的shardingjdbc版本,API差距很大,本文使用的是 4.0.0-RC1

// 创建注册中心配置,type=zookeeper表示使用zookeeper做用注册和配置中心;
RegistryCenterConfiguration regConfig =new RegistryCenterConfiguration("zookeeper");
regConfig.setServerLists("zookeeper_ip:2181,zookeeper_ip:2182,zookeeper_ip:2183");
// sharding-sphere-orchestration:zookeeper根节点名称,可随意改;
regConfig.setNamespace("sharding-sphere-orchestration");
regConfig.setOperationTimeoutMilliseconds(50000);

// 配置治理,orchestration-sharding-data-source:固定;
OrchestrationConfiguration orchConfig =new OrchestrationConfiguration("orchestration-sharding-data-source", regConfig,false);

shardingDataSource = OrchestrationShardingDataSourceFactory.createDataSource(dataSourceMap, shardingRuleConfig, p,
        orchConfig);
3.2.2 yml配置方式
orchestration:
  name: #治理实例名称
  overwrite: #本地配置是否覆盖注册中心配置。如果可覆盖,每次启动都以本地配置为准
  registry: #注册中心配置
    type: #配置中心类型。如:zookeeper
    serverLists: #连接注册中心服务器的列表。包括IP地址和端口号。多个地址用逗号分隔。如: host1:2181,host2:2181
    namespace: #注册中心的命名空间
    digest: #连接注册中心的权限令牌。缺省为不需要权限验证
    operationTimeoutMilliseconds: #操作超时的毫秒数,默认500毫秒
    maxRetries: #连接失败后的最大重试次数,默认3次
    retryIntervalMilliseconds: #重试间隔毫秒数,默认500毫秒
    timeToLiveSeconds: #临时节点存活秒数,默认60秒

配置好zookeeper之后,启动服务,会发现在zookeeper中会创建出相应节点以及具体的配置值:
在这里插入图片描述
shardingjdbc会监听这些value的update事件,如果更新了,则会动态更新shardingjdbc的配置到服务中;

查了shardingjdbc官网,上面也说支持了Apollo,但orchestration模块里面并没有一些其它的配置/注册中心;
在这里插入图片描述
里面有orchestration-zookeeper,但没有其它的实现,所以我觉得如果有其它的配置中心,则需要我们通过SPI机制自己去实现;

3.2.3 zk做为shardingjdbc注册中心原理
  1. 找到sharding-orchestration-center-zookeeper-curator.jar包,里面有一项SPI配置:
    在这里插入图片描述
  2. 查看org.apache.shardingsphere.orchestration.reg.api.RegistryCenter文件内容:
org.apache.shardingsphere.orchestration.reg.zookeeper.curator.CuratorZookeeperRegistryCenter

里面配置了个CuratorZookeeperRegistryCenter的全路径;

  1. 查看CuratorZookeeperRegistryCenter的类内容:
// shardingjdbc使用zookeeper作为注册/配置中心
public final class CuratorZookeeperRegistryCenter implements RegistryCenter {

    // 这个类会监控ZK的一个路径,路径下所有的节点变动,数据变动都会被响应
    private final Map<String, TreeCache> caches = new HashMap();
    private CuratorFramework client;
    private Properties properties = new Properties();

    public CuratorZookeeperRegistryCenter() {
    }

    // 注册中心/配置中心初始化方法,此类实例化时会先调用此方法,它这里对前端传过来的一些zookeeper配置信息做CuratorFramework实例化;
    // sharding-orchestration-reg-zookeeper-curator 使用Curator作为zookeeper客户端;
    @Autowired
    public void init(RegistryCenterConfiguration config) {
        this.client = this.buildCuratorClient(config);
        this.initCuratorClient(config);
    }

    private CuratorFramework buildCuratorClient(RegistryCenterConfiguration config) {
        Builder builder = CuratorFrameworkFactory.builder().connectString(config.getServerLists()).retryPolicy(new ExponentialBackoffRetry(config.getRetryIntervalMilliseconds(), config.getMaxRetries(), config.getRetryIntervalMilliseconds() * config.getMaxRetries())).namespace(config.getNamespace());
        if (0 != config.getTimeToLiveSeconds()) {
            builder.sessionTimeoutMs(config.getTimeToLiveSeconds() * 1000);
        }

        if (0 != config.getOperationTimeoutMilliseconds()) {
            builder.connectionTimeoutMs(config.getOperationTimeoutMilliseconds());
        }

        if (!Strings.isNullOrEmpty(config.getDigest())) {
            builder.authorization("digest", config.getDigest().getBytes(Charsets.UTF_8)).aclProvider(new ACLProvider() {
                public List<ACL> getDefaultAcl() {
                    return Ids.CREATOR_ALL_ACL;
                }

                public List<ACL> getAclForPath(String path) {
                    return Ids.CREATOR_ALL_ACL;
                }
            });
        }

        return builder.build();
    }

    private void initCuratorClient(RegistryCenterConfiguration config) {
        this.client.start();

        try {
            if (!this.client.blockUntilConnected(config.getRetryIntervalMilliseconds() * config.getMaxRetries(), TimeUnit.MILLISECONDS)) {
                this.client.close();
                throw new OperationTimeoutException();
            }
        } catch (OperationTimeoutException | InterruptedException var3) {
            CuratorZookeeperExceptionHandler.handleException(var3);
        }

    }
    // 以上3个方法就是为了初始化CuratorFramework client实例;
    // =========================================================

    // 从注册中心通过key查询对应的value
    // 它是从本地先获取对应的节点,如果没有,再去zk中获取;
    public String get(String key) {
        TreeCache cache = this.findTreeCache(key);
        if (null == cache) {
            return this.getDirectly(key);
        } else {
            ChildData resultInCache = cache.getCurrentData(key);
            if (null != resultInCache) {
                return null == resultInCache.getData() ? null : new String(resultInCache.getData(), Charsets.UTF_8);
            } else {
                return this.getDirectly(key);
            }
        }
    }

    private TreeCache findTreeCache(String key) {
        Iterator var2 = this.caches.entrySet().iterator();

        Entry entry;
        do {
            if (!var2.hasNext()) {
                return null;
            }

            entry = (Entry)var2.next();
        } while(!key.startsWith((String)entry.getKey()));

        return (TreeCache)entry.getValue();
    }

    // 从注册中心通过key查询对应的value,直接从zk中获取;
    public String getDirectly(String key) {
        try {
            return new String((byte[])this.client.getData().forPath(key), Charsets.UTF_8);
        } catch (Exception var3) {
            CuratorZookeeperExceptionHandler.handleException(var3);
            return null;
        }
    }

    // 判断节点是否存在
    public boolean isExisted(String key) {
        try {
            return null != this.client.checkExists().forPath(key);
        } catch (Exception var3) {
            CuratorZookeeperExceptionHandler.handleException(var3);
            return false;
        }
    }

    // 获取子节点
    public List<String> getChildrenKeys(String key) {
        try {
            List<String> result = (List)this.client.getChildren().forPath(key);
            Collections.sort(result, new Comparator<String>() {
                public int compare(String o1, String o2) {
                    return o2.compareTo(o1);
                }
            });
            return result;
        } catch (Exception var3) {
            CuratorZookeeperExceptionHandler.handleException(var3);
            return Collections.emptyList();
        }
    }

    // 创建节点
    public void persist(String key, String value) {
        try {
            if (!this.isExisted(key)) {
                ((ACLBackgroundPathAndBytesable)this.client.create().creatingParentsIfNeeded().withMode(CreateMode.PERSISTENT)).forPath(key, value.getBytes(Charsets.UTF_8));
            } else {
                this.update(key, value);
            }
        } catch (Exception var4) {
            CuratorZookeeperExceptionHandler.handleException(var4);
        }

    }

    // 更新节点
    public void update(String key, String value) {
        try {
            ((CuratorTransactionBridge)((CuratorTransactionBridge)this.client.inTransaction().check().forPath(key)).and().setData().forPath(key, value.getBytes(Charsets.UTF_8))).and().commit();
        } catch (Exception var4) {
            CuratorZookeeperExceptionHandler.handleException(var4);
        }

    }

    // 创建临时节点
    public void persistEphemeral(String key, String value) {
        try {
            if (this.isExisted(key)) {
                this.client.delete().deletingChildrenIfNeeded().forPath(key);
            }

            ((ACLBackgroundPathAndBytesable)this.client.create().creatingParentsIfNeeded().withMode(CreateMode.EPHEMERAL)).forPath(key, value.getBytes(Charsets.UTF_8));
        } catch (Exception var4) {
            CuratorZookeeperExceptionHandler.handleException(var4);
        }

    }

    // 监听,这个方法重要;
    public void watch(String key, final DataChangedEventListener dataChangedEventListener) {
        String path = key + "/";
        if (!this.caches.containsKey(path)) {
            this.addCacheData(key);
        }

        TreeCache cache = (TreeCache)this.caches.get(path);
        // 为路径添加zookeeper监听;
        cache.getListenable().addListener(new TreeCacheListener() {
            public void childEvent(CuratorFramework client, TreeCacheEvent event) throws UnsupportedEncodingException {
                ChildData data = event.getData();
                if (null != data && null != data.getPath()) {
                    // 获取事件类型
                    ChangedType changedType = CuratorZookeeperRegistryCenter.this.getChangedType(event);
                    if (ChangedType.IGNORED != changedType) {
                        // UPDATED , DELETED 进行更新;
                        dataChangedEventListener.onChange(new DataChangedEvent(data.getPath(), null == data.getData() ? null : new String(data.getData(), "UTF-8"), changedType));
                    }

                }
            }
        });
    }

    private ChangedType getChangedType(TreeCacheEvent event) {
        switch(event.getType()) {
            case NODE_UPDATED:
                return ChangedType.UPDATED;
            case NODE_REMOVED:
                return ChangedType.DELETED;
            default:
                return ChangedType.IGNORED;
        }
    }

    private void addCacheData(String cachePath) {
        TreeCache cache = new TreeCache(this.client, cachePath);

        try {
            cache.start();
        } catch (Exception var4) {
            CuratorZookeeperExceptionHandler.handleException(var4);
        }

        this.caches.put(cachePath + "/", cache);
    }

    // 关闭时,进行释放资源;
    public void close() {
        Iterator var1 = this.caches.entrySet().iterator();

        while(var1.hasNext()) {
            Entry<String, TreeCache> each = (Entry)var1.next();
            ((TreeCache)each.getValue()).close();
        }

        this.waitForCacheClose();
        CloseableUtils.closeQuietly(this.client);
    }

    private void waitForCacheClose() {
        try {
            Thread.sleep(500L);
        } catch (InterruptedException var2) {
            Thread.currentThread().interrupt();
        }

    }

    // type=zookeeper,所以你在创建RegistryCenterConfiguration时,指定的是zookeeper的值;
    public Properties getProperties() {
        return this.properties;
    }

    public void setProperties(Properties properties) {
        this.properties = properties;
    }
}

读完CuratorZookeeperRegistryCenter之后,是不是迫不及待的想自己实现一个shardingjdbc的注册中心功能了;

CuratorZookeeperRegistryCenter思路:通过zookeeper节点变更通知机制,当节点内容变更时,发出通知,shardingjdbc提供dataChangedEventListener.onChange进行更新相关配置;

zookeeper里面保存的shardingjdbc内容是yml格式的,可以读出相关的配置进行修改后,就会触发通知;

3.2.4 代码配置zk为shardingjdbc注册中心
RegistryCenterConfiguration regConfig =new RegistryCenterConfiguration("zookeeper");
regConfig.setServerLists("zookeeper_ip:2181,zookeeper_ip:2182,zookeeper_ip:2183");
regConfig.setNamespace("sharding-sphere-orchestration"); // namespace zk根节点;
regConfig.setOperationTimeoutMilliseconds(50000);

OrchestrationConfiguration orchConfig =new OrchestrationConfiguration("orchestration-sharding-data-source", regConfig,false);

shardingDataSource = OrchestrationShardingDataSourceFactory.createDataSource(dataSourceMap, shardingRuleConfig, p,
        orchConfig);

3.3 基于SPI自定义配置中心

目前,ShardingSphere内部支持Zookeeper和etcd这种常用的配置中心/注册中心,如果你需要替换成其它的配置中心,则需要通过shardingSphere进行自定义;这里我以数据为例进行演示自定义shardingjdbc的注册中心;代码仅供个人学习,bug有很多,只是为了说明配置流程!

3.3.1 配置shardingjdbc SPI

在这里插入图片描述
org.apache.shardingsphere.orchestration.reg.api.RegistryCenter

com.xxx.xx.xx.xx.db.config.CustromShardJdbcRegistryCenter
3.3.2 重新配置shardingdatasource

修改datasource配置

// 实现基于DB的注册中心
Properties properties = new Properties();
properties.setProperty("jdbc-url","");
properties.setProperty("username","");
properties.setProperty("password","");
properties.setProperty("driverClassName","");

RegistryCenterConfiguration dbRegConfig =new RegistryCenterConfiguration("db",properties);

// overwrite:启动时是否从远程拉取配置
OrchestrationConfiguration orchConfig =new OrchestrationConfiguration("orchestration-sharding-data-source", dbRegConfig,true);

shardingDataSource = OrchestrationShardingDataSourceFactory.createDataSource(dataSourceMap, shardingRuleConfig, p,
        orchConfig);

在配置datasource时,我们将数据库的信息通过properties传递到注册中心;注意:type=db,这里是我们自己定义的;

3.3.3 创建数据库注册表
idkeyvalue
3.3.4 CustromShardJdbcRegistryCenter

代码有很多BUG,这里只是为了说明定义sharding注册中心的流程;

public class CustromShardJdbcRegistryCenter implements RegistryCenter {

    private Properties properties;

    private DBTemplate dbTemplate;

    private RegistryCenterConfiguration config;

    private Map<String, String> localCaches = new HashMap<>();

    private Map<String, DataChangedEventListener> listeners = new HashMap<>();

    private Timer timer = new Timer();

    private boolean timerStartFlag = false;

    
    @Override
    public void init(RegistryCenterConfiguration config) {
        this.config = config;
        this.properties = config.getProperties();

        // 获取数据库信息;
        String url = properties.getProperty("jdbc-url", "");
        String username = properties.getProperty("username", "");
        String password = properties.getProperty("password", "");
        String driverClassName = properties.getProperty("driverClassName", "");

        // 完成初始化
        dbTemplate = new DBTemplate(url, username, password, driverClassName);
    }

    @Override
    public String get(String key) {
        if (localCaches.containsKey(key)) {
            return localCaches.get(key);
        }
        return dbTemplate.getByKey(key);
    }

    @Override
    public String getDirectly(String key) {
        return dbTemplate.getByKey(key);
    }

    @Override
    public boolean isExisted(String key) {
        return dbTemplate.existed(key);
    }

    @Override
    public List<String> getChildrenKeys(String key) {
        // 模糊查询即可;
        return dbTemplate.getChildrenKeys(key);
    }

    @Override
    public void persist(String key, String value) {
        localCaches.put(key, value);
        dbTemplate.save(key);
    }

    @Override
    public void update(String key, String value) {
        localCaches.put(key, value);
        dbTemplate.update(key);
    }

    @Override
    public void persistEphemeral(String key, String value) {
        localCaches.put(key, value);
        dbTemplate.update(key);
    }

    @Override
    public void watch(String key, DataChangedEventListener dataChangedEventListener) {
        if (null != dataChangedEventListener) {
            // zk的方式这里是直接进行监听节点就行了,而用数据库的话,没有办法进行监控数据库变化 ,直接把listener缓存起来;
            listeners.put(key, dataChangedEventListener);

            // 开启一个线程,定时监控 localCaches与数据库中的配置信息是否一致,如果不一致,则更新对应的Listener
            if (!timerStartFlag) {
                timer.schedule(new TimerTask() {
                    public void run() {
                        // key:配置名称,value:新值;
                        Map<String, String> changeRuleMap = dbTemplate.contrast(localCaches);
                        for (Map.Entry<String, String> entry : changeRuleMap.entrySet()) {
                            listeners.get(entry.getKey())
                                    .onChange(new DataChangedEvent(entry.getKey(), entry.getValue(),
                                            DataChangedEvent.ChangedType.UPDATED));
                            localCaches.put(entry.getKey(), entry.getValue());
                        }
                    }
                }, 20000, 1000);
                timerStartFlag = true;
            }
        }
    }

    @Override
    public void close() {
        if (null != dbTemplate) {
            try {
                dbTemplate.close();
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }
    }

    @Override
    public String getType() {
        // 和shardingjdbc配置中的type保持一致;
        return "db";
    }

    @Override
    public Properties getProperties() {
        return properties;
    }

    @Override
    public void setProperties(Properties properties) {
        this.properties = properties;
    }

    private Connection initDbDataSource(String url, String username, String password, String driverClassName) {
        if (null != conn) {
            return null;
        }
        try {
            Class.forName(driverClassName);
        } catch (Exception ex) {
            throw new RuntimeException(ex);
        }

        try {
            conn = DriverManager.getConnection(url, username, password);
        } catch (SQLException ex) {
            if (null != conn) {
                try {
                    conn.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
            throw new RuntimeException(ex);
        }
        return conn;
    }
}
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值