Hbase读过程

Hbase读过程

和写流程相比,HBase读数据是一个更加复杂的操作流程,这主要基于两个方面的原因:其一是因为整个HBase存储引擎基于LSM-Like树实现(参考https://www.cnblogs.com/yanghuahui/p/3483754.html),因此一次范围查询可能会涉及多个分片、多块缓存甚至多个数据存储文件;其二是因为HBase中更新操作以及删除操作实现都很简单,更新操作并没有更新原有数据,而是使用时间戳属性实现了多版本。删除操作也并没有真正删除原有数据,只是插入了一条打上”deleted”标签的数据,而真正的数据删除发生在系统异步执行Major_Compact的时候。很显然,这种实现套路大大简化了数据更新、删除流程,但是对于数据读取来说却意味着套上了层层枷锁,读取过程需要根据版本进行过滤,同时对已经标记删除的数据也要进行过滤。

本篇文章对Hbase的读过程进行讲解。

1. hbase get和scan

get和scan是Hbase提供的两个查询方法,但是在服务器端,Get方法最终调用的还是Scan,二者殊途同归,所以只要理解Scan的流程即可。

Get操作调用的是RSRpcServices的get方法,调用过程首先找到包含数据的Region,然后从这个Region中获取需要的数据。

//RsRpcServices类
public GetResponse get(final RpcController controller,
    final GetRequest request) throws ServiceException {
  ...
  try {
    ...
    Region region = getRegion(request.getRegion());
    ...
    // 如果这行是在数据库中没有的 setClosestRowBefore 会试图去取前面的行如果有必要的话
    if (get.hasClosestRowBefore() && get.getClosestRowBefore()) {
      ...
      r = region.getClosestRowBefore(row, family);
    } else {
      ...
        r = region.get(clientGet);
      ...
    }
    ...
    return builder.build();
    ...
}

region.getClosestRowBefore实际也是调用的region.get方法,所以直接看get方法

public Result get(final Get get) throws IOException {
  checkRow(get.getRow(), "Get");
  // Verify families are all valid
  if (get.hasFamilies()) {
    for (byte [] family: get.familySet()) {
      checkFamily(family);
    }
  } else { // Adding all families to scanner
    for (byte[] family: this.htableDescriptor.getFamiliesKeys()) {
      get.addFamily(family);
    }
  }
  List<Cell> results = get(get, true);
  boolean stale = this.getRegionInfo().getReplicaId() != 0;
  return Result.create(results, get.isCheckExistenceOnly() ? 
                       !results.isEmpty() : null, stale);
}

这个方法特别有意思,如果这个请求里包含了family,就检查下这些family是否存在;如果这个请求里不包含family,就把这个表里所有的family都加到这个请求里。所以不设置family时,就会遍历所有的family来找到需要的数据。然后看List<Cell> results = get(get, true);,这个方法创建了一个Scan,调用了Scan,所以要了解Get,还看Scan!

public List<Cell> get(Get get, boolean withCoprocessor) throws IOException {
  ...
  Scan scan = new Scan(get);
  RegionScanner scanner = null;
  try {
    scanner = getScanner(scan);
    scanner.next(results);
  }
  ...
  return results;
}

2. scan客户端流程

本节主要探讨的是客户端是如何通过scan来扫描hbase的table的。

首先来看看一个简单的scan的客户端程序

public static void getAllData(HTable table) throws IOException {  
   Scan s = new Scan();  
   //获取所有版本的数据
   s.setMaxVersions();  
   //设定返回数据的最大列数
   s.setBatch(1000); 
   //设定缓存在内存中的行数,缓存得越多,以后查询结果越快,同时也消耗更多内存
   s.setCaching(100);
   //设置Scan的扫描顺序,默认是正向扫描(false),可以设置为逆向扫描(true)。注意:该方法0.98版本以后才可用!!
   s.setReverse(false);
   s.setSmall(false);
   ResultScanner rs = table.getScanner(s);  
   for (Result r : rs) {  
     for (KeyValue kv : r.raw()) {  
       System.out.print("rowkey : " + new String(kv.getRow()) + "  ");  
       System.out.println(new String(kv.getFamily()) + ":"  
           + new String(kv.getQualifier()) + " timestamp " + kv.getTimestamp()  
           + new String(kv.getValue()));  
     }  
   }  
 }  

上面的函数即是一个简单的scan table的示例。里面涉及到Scan的参数,需要讲解一下:

  • cache:
    在默认情况下,如果你需要从hbase中查询数据,在获取结果ResultScanner时,hbase会在你每次调用ResultScanner.next()操作时对返回的每个Row执行一次RPC操作。即使你使用ResultScanner.next(int nbRows)时也只是在客户端循环调用RsultScanner.next()操作,你可以理解为hbase将执行查询请求以迭代器的模式设计,在执行next()操作时才会真正的执行查询操作,而对每个Row都会执行一次RPC操作。

    因此显而易见的就会想如果我对多个Row返回查询结果才执行一次RPC调用,那么就会减少实际的通讯开销。这个就是hbase配置属性“hbase.client.scanner.caching”的由来,设置cache可以在hbase配置文件中显示静态的配置,也可以在程序动态的设置。

    cache值得设置并不是越大越好,需要做一个平衡。cache的值越大,则查询的性能就越高,但是与此同时,每一次调用next()操作都需要花费更长的时间,因为获取的数据更多并且数据量大了传输到客户端需要的时间就越长,一旦你超过了maximum heap the client process 拥有的值,就会报outofmemoryException异常。当传输rows数据到客户端的时候,如果花费时间过长,则会抛出ScannerTimeOutException异常。

  • batch:
    在cache的情况下,我们一般讨论的是相对比较小的row,那么如果一个Row特别大的时候应该怎么处理呢?要知道cache的值增加,那么在client process 占用的内存就会随着row的增大而增大。在hbase中同样为解决这种情况提供了类似的操作:Batch。可以这么理解,cache是面向行的优化处理,batch是面向列的优化处理。它用来控制每次调用next()操作时会返回多少列,比如你设置setBatch(5),那么每一个Result实例就会返回5列,如果你的列数为17的话,那么就会获得四个Result实例,分别含有5,5,5,2个列。

  • small

    (pread和seek+read参考:http://standalone.iteye.com/blog/446608

    http://www.doc88.com/p-545597648795.html)

    如果不是用small就默认使用seek+read,如果setSmall true就是使用pread。

首先从table.getScanner(s)这句话入手,也就是HTable的getScanner(Scan scan)方法,这个方法的具体实现如下:

/**
   * The underlying {@link HTable} must not be closed.
   * {@link HTableInterface#getScanner(Scan)} has other usage details.
   * 
   * HBase中scan的入口方法  Htable.getScanner
   */
  @Override
  public ResultScanner getScanner(final Scan scan) throws IOException {
	//设置参数
    ...
    
    /**
     * scan总共分为四种类型:
     * 1、reversed、small--ClientSmallReversedScanner
     * 2、reversed、big--ReversedClientScanner
     * 3、notReversed、small--ClientSmallScanner
     * 4、notReversed、big--ClientScanner
     */
    if (scan.isReversed()) {// 反向扫描 
      if (scan.isSmall()) {
        return new ClientSmallReversedScanner(getConfiguration(), scan, getName(),
            this.connection, this.rpcCallerFactory, this.rpcControllerFactory,
            pool, tableConfiguration.getReplicaCallTimeoutMicroSecondScan());
      } else {
        return new ReversedClientScanner(getConfiguration(), scan, getName(),
            this.connection, this.rpcCallerFactory, this.rpcControllerFactory,
            pool, tableConfiguration.getReplicaCallTimeoutMicroSecondScan());
      }
    }
 
    if (scan.isSmall()) {
      return new ClientSmallScanner(getConfiguration(), scan, getName(),
          this.connection, this.rpcCallerFactory, this.rpcControllerFactory,
          pool, tableConfiguration.getReplicaCallTimeoutMicroSecondScan());
    } else {
      //主要看这个
      return new ClientScanner(getConfiguration(), scan, getName(), this.connection,
          this.rpcCallerFactory, this.rpcControllerFactory,
          pool, tableConfiguration.getReplicaCallTimeoutMicroSecondScan());
    }
  }

以上参考 https://blog.csdn.net/u010967382/article/details/37878701

这里,我们先只研究ClientScanner,其他三种以后再说。

ClientScanner实现了ResultScanner接口,以下重点讲解ClientScanner的核心方法nextScanner()和next()。

/**
获取下一个region的scanner,从server端读取数据前均需先执行该方法,在该方法中会通过ScannerCallable进行RPC调用打开server端的scanner,打开成功后会获取到一个scannerId,client端在scan过程中每次均需把该id传给server端,client端通过该id判断是否需要open scanner以及close scanner,server端通过该id找到对应的RegionScanner查询数据以及close。一次scan,一个region只打开一个RegionScanner。
*/
private boolean nextScanner(int nbRows, final boolean done) throws IOException {
      if (this.callable != null) { // nextScanner被非首次调用时(即从不同的region获取数据时)
        this.callable.setClose();
        callable.withRetries();//RPC调用,关闭server端的RegionScanner
        this.callable = null;
      }
      byte [] localStartKey;
      if (this.currentRegion != null) { //nextScanner被非首次调用时
        byte [] endKey = this.currentRegion.getEndKey();
        //上次scan的region是最后一个region或者已经到了end rowKey等时终止查询
        if (endKey == null ||     
            Bytes.equals(endKey, HConstants.EMPTY_BYTE_ARRAY) ||
            checkScanStopRow(endKey) ||
            done) {
          close();
          return false;
        }
        //把上次查询region的endKey作为下一个region的startKey,因为scan的数据是一个连续的区间,因此可如此实现
        localStartKey = endKey;
      } else {
        localStartKey = this.scan.getStartRow(); //从第一个region查询数据时
      }
      try {
        callable = getScannerCallable(localStartKey, nbRows);
        callable.withRetries(); //RPC调用,执行HRegionServer的openScanner方法
        this.currentRegion = callable.getHRegionInfo();
      } catch (IOException e) {
      }
      return true;
}
public Result next() throws IOException {
      if (cache.size() == 0) {
        Result [] values = null;
        int countdown = this.caching;
        do {
          values = callable.withRetries();//RPC调用,执行HRegionServer的next()方法
          if (values != null && values.length > 0) {
            for (Result rs : values) {
              cache.add(rs); //cache可存储多region的数据
              countdown--; //如果该region的数据没有达到caching值则会大于0,在while条件中使用
            }
          }
        //如果没有查询到数据则values为null,这时nextScanner(int nbRows, final boolean done)将返回false,停止查询
        } while (remainingResultSize > 0 && countdown > 0 && nextScanner(countdown, values == null));
      }
      if (cache.size() > 0) {
        return cache.poll();
      }

    }

3. scan服务端流程

RegionServer接收到客户端的get/scan请求之后,先后做了两件事情:构建scanner体系(实际上就是做一些scan前的准备工作),在此体系基础上一行一行检索。

在这里插入图片描述

2.1 构建scanner体系-组建施工队

scanner体系的核心在于三层scanner:RegionScanner、StoreScanner以及StoreFileScanner。三者是层级的关系,一个RegionScanner由多个StoreScanner构成,一张表由多少个列族组成,就有多少个StoreScanner负责该列族的数据扫描。一个StoreScanner又是由多个StoreFileScanner组成。每个Store的数据由内存中的MemStore和磁盘上的StoreFile文件组成,相对应的,StoreScanner对象会雇佣一个MemStoreScanner和N个StoreFileScanner来进行实际的数据读取,每个StoreFile文件对应一个StoreFileScanner,注意:StoreFileScanner和MemstoreScanner是整个scan的最终执行者。

在这里插入图片描述

  1. RegionScanner会根据列族构建StoreScanner,有多少列族就构建多少StoreScanner,用于负责该列族的数据检索
  • 1.1 构建StoreFileScanner:每个StoreScanner会为当前该Store中每个HFile构造一个StoreFileScanner,用于实际执行对应文件的检索。同时会为对应Memstore构造一个MemstoreScanner,用于执行该Store中Memstore的数据检索。该步骤对应于监工在人才市场招募建楼所需的各种类型工匠。

  • 1.2 过滤淘汰StoreFileScanner:根据Time Range以及RowKey Range对StoreFileScanner以及MemstoreScanner进行过滤,淘汰肯定不存在待检索结果的Scanner。上图中StoreFile3因为检查RowKeyRange不存在待检索Rowkey所以被淘汰。该步骤针对具体的建楼方案,裁撤掉部分不需要的工匠,比如这栋楼不需要地暖安装,对应的工匠就可以撤掉。

    • 读取流程中如何使用BloomFilter(简称BF)对StoreFile进行过滤?
    • 过滤手段主要有三种:根据KeyRange过滤、根据TimeRange过滤、根据BF过滤。下面分别进行介绍:
    • (1)根据KeyRange过滤:因为StoreFile是中所有KV数据都是有序排列的,所以如果待检索row范围[startrow,stoprow]与文件起始key范围[firstkey,lastkey]没有交集,比如stoprow < firstkey 或者 startrow > lastkey,就可以过滤掉该StoreFile。
    • (2)根据TimeRange过滤:StoreFile中元数据有一个关于该File的TimeRange属性[minimumTimestamp, maxmumTimestamp],因此待检索的TimeRange如果与该文件时间范围没有交集,就可以过滤掉该StoreFile;另外,如果该文件所有数据已经过期,也可以过滤淘汰。
    • (3)根据BF过滤:
      现在来看看HBase中如何利用BF对StoreFile进行过滤(注:接下来所有关于HBase BF的说明都按照Row类型来,Row-Column类型类似),原理其实很简单:首先把BF数据加载到内存;然后使用hash函数对待检索row进行hash,根据hash后的结果在BF数据中进行寻址查看即可确定是否存在该HFile。第二步就是BF的原理,并没有什么好讲的,主要来看看HBase是如何将BF数据加载到内存的。(详细内容参考http://hbasefly.com/2017/06/11/hbase-scan-2/)

    我们说在实际scan之前就要使用BF对StoreFile进行过滤,那仔细想下,到底用哪个rowkey过滤?实际实现中系统使用scan的startrow作为过滤条件进行过滤,这是不是有问题?举个简单的例子,假设小明检索的数据为[row1, row4],如果此文件不包含row1,而包含row2,这样在scan前你利用row1就把该文件淘汰掉了,row2这条数据怎么办?不是会被遗漏?

    这里系统实现有个隐藏点,scan之前使用BF进行过滤只针对get查询以及scan单条数据的场景,scan多条数据并不会执行实际的BF过滤,而是在实际seek到新一行的时候才会启用BF根据新一行rowkey对所有StoreFile过滤。

  • 1.3 Seek rowkey:所有StoreFileScanner开始做准备工作,在负责的HFile中定位到满足条件的起始Row。工匠也开始准备自己的建造工具,建造材料,找到自己的工作地点,等待一声命下。就像所有重要项目的准备工作都很核心一样,Seek过程(此处略过Lazy Seek优化)也是一个很核心的步骤,它主要包含下面三步:

    • 定位Block Offset:在Blockcache中读取该HFile的索引树结构,根据索引树检索对应RowKey所在的Block Offset和Block Size。(关于Blockcache可以参考https://blog.csdn.net/bryce123phy/article/details/62051927)

    • Load Block:根据BlockOffset首先在BlockCache中查找Data Block,如果不在缓存,再在HFile中加载

    • Seek Key:在Data Block内部通过二分查找的方式定位具体的RowKey

    整体流程细节参见《HBase原理-探索HFile索引机制》,文中详细说明了HFile索引结构以及如何通过索引结构定位具体的Block以及RowKey

    关于blockcache详见(http://hbasefly.com/2016/04/08/hbase-blockcache-1/ http://hbasefly.com/tag/blockcache/)

  • 1.4 StoreFileScanner合并构建最小堆:将该Store中所有StoreFileScanner和MemstoreScanner合并形成一个heap(最小堆),所谓heap是一个优先级队列,队列中元素是所有scanner,排序规则按照scanner seek到的keyvalue大小由小到大进行排序。这里需要重点关注三个问题,首先为什么这些Scanner需要由小到大排序,其次keyvalue是什么样的结构,最后,keyvalue谁大谁小是如何确定的:

    • 为什么这些Scanner需要由小到大排序?
      最直接的解释是scan的结果需要由小到大输出给用户,当然,这并不全面,最合理的解释是只有由小到大排序才能使得scan效率最高。举个简单的例子,HBase支持数据多版本,假设用户只想获取最新版本,那只需要将这些数据由最新到最旧进行排序,然后取队首元素返回就可以。那么,如果不排序,就只能遍历所有元素,查看符不符合用户查询条件。这就是排队的意义。

    • HBase中KeyValue是什么样的结构?

    HBase中KeyValue并不是简单的KV数据对,而是一个具有复杂元素的结构体,其中Key由RowKey,ColumnFamily,Qualifier ,TimeStamp,KeyType等多部分组成,Value是一个简单的二进制数据。Key中元素KeyType表示该KeyValue的类型,取值分别为Put/Delete/Delete Column/Delete Family等。

    /**
     * KeyValue wraps a byte array and takes offsets and lengths into passed array at where to start
     * interpreting the content as KeyValue. The KeyValue format inside a byte array is:
     * <code>&lt;keylength> &lt;valuelength> &lt;key> &lt;value></code> Key is further   decomposed as:
     * <code>&lt;rowlength> &lt;row> &lt;columnfamilylength> &lt;columnfamily> &lt;columnqualifier>
     * &lt;timestamp> &lt;keytype></code>
     */
      @InterfaceAudience.Private
    public class KeyValue implements Cell, HeapSize, Cloneable, SettableSequenceId, SettableTimestamp {
      // KeyValue core instance fields.
      protected byte [] bytes = null;  // an immutable byte array that contains the KV
      protected int offset = 0;  // offset into bytes buffer KV starts at
      protected int length = 0;  // length of the KV starting from offset.
    }
    

    KeyValue可以表示为如下图所示:

在这里插入图片描述

了解了KeyValue的逻辑结构后,我们不妨再进一步从原理的角度想想HBase的开发者们为什么如此对其设计。这个就得从HBase所支持的数据操作说起了,HBase支持四种主要的数据操作,分别是Get/Scan/Put/Delete,其中Get和Scan代表数据查询,Put操作代表数据插入或更新(如果Put的RowKey不存在则为插入操作、否则为更新操作),特别需要注意的是HBase中更新操作并不是直接覆盖修改原数据,而是生成新的数据,新数据和原数据具有不同的版本(时间戳);Delete操作执行数据删除,和数据更新操作相同,HBase执行数据删除并不会马上将数据从数据库中永久删除,而只是生成一条删除记录,最后在系统执行文件合并的时候再统一删除。

HBase中更新删除操作并不直接操作原数据,而是生成一个新纪录,那问题来了,如何知道一条记录到底是插入操作还是更新操作亦或是删除操作呢?这正是KeyType和Timestamp的用武之地。上文中提到KeyType取值为分别为Put/Delete/Delete Column/Delete Family四种,如果KeyType取值为Put,表示该条记录为插入或者更新操作,而无论是插入或者更新,都可以使用版本号(Timestamp)对记录进行选择;如果KeyType为Delete,表示该条记录为整行删除操作;相应的KeyType为Delete Column和Delete Family分别表示删除某行某列以及某行某列族操作;

  • 不同KeyValue之间如何进行大小比较?

    上文提到KeyValue中Key由RowKey,ColumnFamily,Qualifier ,TimeStamp,KeyType等5部分组成,HBase设定Key大小首先比较RowKey,RowKey越小Key就越小;RowKey如果相同就看CF,CF越小Key越小;CF如果相同看Qualifier,Qualifier越小Key越小;Qualifier如果相同再看Timestamp,Timestamp越大表示时间越新,对应的Key越小。如果Timestamp还相同,就看KeyType,KeyType按照DeleteFamily -> DeleteColumn -> Delete -> Put 顺序依次对应的Key越来越大。

graph LR
RowKey--由大到小-->CF
CF--由小到大>Qualifier
Qualifier--由小到大-->Timestamp
Timestamp--由大到小-->KeyType
  • 2 StoreScanner合并构建最小堆:上文讨论的是一个监工如何构建自己的工匠师团队以及工匠师如何做准备工作、排序工作。实际上,监工也需要进行排序,比如一单元的监工排前面,二单元的监工排之后… StoreScanner一样,列族小的StoreScanner排前面,列族大的StoreScanner排后面。

2.2 scan查询-层层建楼

构建Scanner体系是为了更好地执行scan查询,就像组建工匠师团队就是为了盖房子一样。scan查询总是一行一行查询的,先查第一行的所有数据,再查第二行的所有数据,但每一行的查询流程却没有什么本质区别。盖房子也一样,无论是盖8层还是盖18层,都需要一层一层往上盖,而且每一层的盖法并没有什么区别。所以实际上我们只需要关注其中一行数据是如何查询的就可以。

对于一行数据的查询,又可以分解为多个列族的查询,比如RowKey=row1的一行数据查询,首先查询列族1上该行的数据集合,再查询列族2里该行的数据集合。同样是盖第一层房子,先盖一单元的一层,再改二单元的一层,盖完之后才算一层盖完,接着开始盖第二层。所以我们也只需要关注某一行某个列族的数据是如何查询的就可以。

还记得Scanner体系构建的最终结果是一个由StoreFileScanner和MemstoreScanner组成的heap(最小堆)么,这里就派上用场了。下图是一张表的逻辑视图,该表有两个列族cf1和cf2(我们只关注cf1),cf1只有一个列name,表中有5行数据,其中每个cell基本都有多个版本。cf1的数据假如实际存储在三个区域,memstore中有r2和r4的最新数据,hfile1中是最早的数据。现在需要查询RowKey=r2的数据,按照上文的理论对应的Scanner指向就如图所示:

在这里插入图片描述

这三个Scanner组成的heap为<MemstoreScanner,StoreFileScanner2, StoreFileScanner1>,Scanner由小到大排列。查询的时候首先pop出heap的堆顶元素,即MemstoreScanner,得到keyvalue = r2:cf1:name:v3:name23的数据,拿到这个keyvalue之后,需要进行如下判定:

- 1. 检查该KeyValue的KeyType是否是Deleted/DeletedCol等,如果是就直接忽略该列所有其他版本,跳到下列(列族)
- 2.  检查该KeyValue的Timestamp是否在用户设定的Timestamp Range范围,如果不在该范围,忽略
- 3. 检查该KeyValue是否满足用户设置的各种filter过滤器,如果不满足,忽略
- 4. 检查该KeyValue是否满足用户查询中设定的版本数,比如用户只查询最新版本,则忽略该cell的其他版本;反正如果用户查询所有版本,则还需要查询该cell的其他版本。

现在假设用户查询所有版本而且该keyvalue检查通过,此时当前的堆顶元素需要执行next方法去检索下一个值,并重新组织最小堆。即图中MemstoreScanner将会指向r4,重新组织最小堆之后最小堆将会变为<StoreFileScanner2, StoreFileScanner1, MemstoreScanner>,堆顶元素变为StoreFileScanner2,得到keyvalue=r2:cf1:name:v2:name22,进行一系列判定,再next,再重新组织最小堆…

不断重复这个过程,直至一行数据全部被检索得到。继续下一行…

在这里插入图片描述

参考:

  1. https://my.oschina.net/fenno/blog/95916
  2. https://blog.csdn.net/weixin_41279060/article/details/78829764
  3. https://blog.csdn.net/gzh1992n/article/details/46744937 客户端scan过程
  4. http://punishzhou.iteye.com/blog/1297015 客户端scan过程
  5. https://blog.csdn.net/gzh1992n/article/details/46744937 客户端scan过程
  6. https://blog.csdn.net/MasaWong/article/details/78177690
    HBase分析之Get、Scan(一) 有租约
  7. https://blog.csdn.net/MasaWong/article/details/78244214
    HBase分析之Get、Scan(二)RegionScanner
  8. https://blog.csdn.net/MasaWong/article/details/78244225 HBase分析之Get、Scan(三)StoreScanner
  9. https://bbs.huaweicloud.com/blogs/2f802eed5f3111e89fc57ca23e93a89f?page=1
  10. https://bbs.huaweicloud.com/blogs/2f802eed5f3111e89fc57ca23e93a89f scan客户端
  11. https://www.cnblogs.com/cenyuhai/p/3734512.html scan服务端
  12. https://www.cnblogs.com/foxmailed/p/3958546.html?utm_source=tuicool&utm_medium=referral scan服务端
  13. http://xxniao.iteye.com/blog/2068401 scan服务端

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值