Lucene源码分析 - FST

本文详细分析了Lucene的FST(Finite State Transducer)实现,从源码层面探讨了FIXED ARRAY存储方式、Arc的反向存储原因、FST遍历优化及压缩策略等内容,旨在帮助读者深入了解Lucene中FST的数据结构和操作原理。

本文的代码以lucene-core 6.3.0为准,包含构建空FST的addNode函数,pack压缩函数等整个类所有代码的解析。转载请注明出处。

0 基本信息

   lucene的FST的二进制存储和查询方式都是由FST这个类来实现。如果要读这些代码,首先需要了解这个类里面的一些基本的东西。

  • 整个FST存到了BytesStore类的bytes对象里,bytes相当于一个大的字节数组,提供正向遍历和反向遍历数组的方式,bytes中存的是Node的Arc的内容,包括label,output,nextFinalOutput,target等。
  • Arc中,target是指Arc指向的Node,这个值可以是Node的ID,也就是ord,或者Node的Address。
  • nodeAddress存的是Node ID->Address的映射,inCounts存的是Node ID -> Node的入度(指向该Node的Arc数量),这两个类都是GrowableWriter,包了一下PackedInts,关于PackedInts可以参考下作者@iteye_14612的PackedInts源码分析
  • cachedRootArcs缓存一部分从根节点出发的Arc,INPUT_TYPE是Arc的label所占用的字节数。
  • 文中多次提到Node的Arc会按照label的字典序存,这与Builder构建FST有关,可以看下我的另一篇Lucene源码分析 - FST-Builder

1 源码分析

   先看下构建空的FST。addNode函数把Builder中的UnCompiledNode写入FST的bytes对象,写入的这个Node的ID就是nodeCount,这里把Node的Arc顺序写到字节数组中,由于FST的Builder是按照label的字典序调用构建的FST,所以这里的Arc也是按照label排序的,代码如下:

	// 处理没有Arc的空节点
    if (nodeIn.numArcs == 0) {
      if (nodeIn.isFinal) {
        return FINAL_END_NODE;
      } else {
        return NON_FINAL_END_NODE;
      }
    }
	// 记录当前Node在Address
    final long startAddress = builder.bytes.getPosition();
    // 判断是否用FIXED_ARRAY方式存Arc的信息
    final boolean doFixedArray = shouldExpand(builder, nodeIn);
    if (doFixedArray) {
      if (builder.reusedBytesPerArc.length < nodeIn.numArcs) {
      	//reusedBytesPerArc 用来存下每个Arc的字节长度
        builder.reusedBytesPerArc = new int[ArrayUtil.oversize(nodeIn.numArcs, 1)];
      }
    }
1.1 什么是 FIXED ARRAY

   如果这里的doFixedArraytrue,那么每个Arc都用一样的字节长度(后文都用长度代替),由于有的Arc可能有outputnextFinalOutput,而有的却没有,所以每个Arc的长度可能是不一样。FIXED_ARRAY方式下,每个Arc的存储长度都用最长的那个Arc的长度。
   很明显,这种FIXED_ARRAY方式会导致Arc与Arc之间存在一些内存碎片,好处就是能快速定位Node下的第N个Arc的位置,所以在查询Node下是否有Arc的label匹配一个确定的值的时候,可以用二分查找,这样就是空间换了时间。
   当然用这种模式也是有条件的,shouldExpand函数中给出了条件,比如Node下Arc数量要超过10,Arc数量太少的话,二分的效果比起遍历没有优势,而且还浪费了一些内存,反而亏了。

1.2 存储Node的Arc

   接下来是遍历这个Node的所有的Arc,并且把每个Arc的信息等写入bytes,前后两个Arc紧挨着存的,没有内存空隙:

for(int arcIdx=0;arcIdx<nodeIn.numArcs;arcIdx++) {
	  ...  //省略flags处理逻辑
	  // 存标志位信息
      builder.bytes.writeByte((byte) flags);
      writeLabel(builder.bytes, arc.label);
      
	  // 这里可以看到,每个Arc的长度不一样,主要是下面这三个write的内容不一样
      if (arc.output != NO_OUTPUT) {
        outputs.write(arc.output, builder.bytes);
      }

      if (arc.nextFinalOutput != NO_OUTPUT) {
        outputs.writeFinalOutput(arc.nextFinalOutput, builder.bytes);
      }

      if (targetHasArcs && (flags & BIT_TARGET_NEXT) == 0) {
        builder.bytes.writeVLong(target.node);
      }
	  
      if (doFixedArray) {
        // 记录下每个Arc的长度
        builder.reusedBytesPerArc[arcIdx] = (int) (builder.bytes.getPosition() - lastArcStart); 
        lastArcStart = builder.bytes.getPosition();
        // 记录最长的Arc的长度
        maxBytesPerArc = Math.max(maxBytesPerArc, builder.reusedBytesPerArc[arcIdx]);   
      }
    }
1.3 将Arc紧邻而存转变成 FIXED ARRAY 存储

   所有Arc存下之后,如果doFixedArraytrue,由于前面Arc内容写入bytes的时候是紧挨着写入的,所以还需要调整一下,每个Arc的长度要调整为最长的Arc的长度,所以从最后一个Arc开始,基本上每个Arc都需要向后移动到正确的位置上。代码如下:

    if (doFixedArray) {
      final int MAX_HEADER_SIZE = 11;    // header(byte) + numArcs(vint) + numBytes(vint)
      
      byte header[] = new byte[MAX_HEADER_SIZE]; 
      ByteArrayDataOutput bad = new ByteArrayDataOutput(header);
      bad.writeByte(ARCS_AS_FIXED_ARRAY);	   // 写入标志位,代表ARC是FIXED_ARRAY
      bad.writeVInt(nodeIn.numArcs);      // 写入Arc的数量
      bad.writeVInt(maxBytesPerArc);		// 写入最大的边的长度
      int headerLen = bad.getPosition();
      
      final long fixedArrayStart = startAddress + headerLen;
	
	  //记录最后一个Arc的Address,注意这里是反向读取
      long srcPos = builder.bytes.getPosition();	
      //记录FIXED_ARRAY方式下,最后一个Arc调整之后的Address
      long destPos = fixedArrayStart + nodeIn.numArcs*maxBytesPerArc;		

      if (destPos > srcPos) {
      	// bytes 移动到调整的位置
        builder.bytes.skipBytes((int) (destPos - srcPos));
        for(int arcIdx=nodeIn.numArcs-1;arcIdx>=0;arcIdx--) {		// 从最后一个Arc开始调整
          // 计算应该调整后的位置
          destPos -= maxBytesPerArc;
          srcPos -= builder.reusedBytesPerArc[arcIdx];		

          if (srcPos != destPos) {
            // 如果Arc调整后的位置与当前所处位置相比发生了变化,那么就移动Arc到调整之后的位置
            builder.bytes.copyBytes(srcPos, destPos, builder.reusedBytesPerArc[arcIdx]);
          }
        }
      }
      // 写header
      builder.bytes.writeBytes(startAddress, header, 0, headerLen);
    }
1.4 反向存储

   最后将当前Node的Address写入nodeAddress对象就完成了对Node的写入。但是在写nodeAddress之前有一行代码:

builder.bytes.reverse(startAddress, thisNodeAddress);

   这里会将Node存储的Address和终止位置之间的内容翻转,这里之所以这么做,是读取构建好的FST的时候,用的ReverseBytesReader类,会对整个FST反向读取。以下图为例,假设aazabyzacdyzafgyzbe构建一个FST:
在这里插入图片描述

图 1
1.5 Arc反向存储的原因

   上图的FST的Node的存储顺序是[()1, (z)2, (y)3, (d)4, (g)5, (f,c,b)6, ()7, (e)8, (b,a)9],小括号中的内容代表Node的Arc的label,每个Node的所有Arc的label都被翻转了,是反向字典序,最后再整体通过ReverseBytesReader对象翻转读取整个FST得到的是[9(a,b), 8(e), 7(), 6(b,c,f), 5(g), 4(d), 3(y), 2(z), 1()],这样每个Node的所有Arc信息都是正向字典序的,根节点也相当于移动到了字节数组的最前面。
   当FST的所有Node都写入之后,还需要调用finish函数完成构建过程,finish函数中的cacheRootArcs和构建完成之后的pack过程放到文章末尾再讲。

1.6 遍历FST

   除了上述的内容,还可以看到FST中有一些查询函数,要查询FST就需要初始化bytes对象,在FST的构造函数中,会用一个DataInput来初始化bytes。遍历FST的函数中,getFirstArc的主要作用是拿到FST的根节点的ID,拿到根节点的ID以后,就可以用readFirstTargetArc函数或者readFirstRealTargetArc函数来读取从Node出发的第一个Arc的信息,readNextArc则可以读取Arc紧邻的下一个Arc,这些都内容都比较简单。只是需要注意的是,Arc的isLast()为true的时候,说明已经遍历到了从Node出发的最后一个Arc,isFinal()为true的时候,说明这个Arc的target是终止节点。
   还需要关注下的是findTargetArc函数的后半段二分查询的代码:

private Arc<T> findTargetArc(int labelToMatch, Arc<T> follow, Arc<T> arc, BytesReader in, boolean useRootArcCache) throws IOException {
	// ... 省略一部分无关紧要的逻辑
	// 如果ARCS存储的时候是FIXED_ARRAY,那么就二分查找label的值为labelToMatch的Arc
    if (in.readByte() == ARCS_AS_FIXED_ARRAY) {
      arc.numArcs = in.readVInt();
      if (packed || version >= VERSION_VINT_TARGET) {
        arc.bytesPerArc = in.readVInt();
      } else {
        arc.bytesPerArc = in.readInt();
      }
      arc.posArcsStart = in.getPosition();
      int low = 0;
      int high = arc.numArcs-1;
      // 二分查找
      while (low <= high) {
        int mid = (low + high) >>> 1;
        in.setPosition(arc.posArcsStart);
        in.skipBytes(arc.bytesPerArc*mid + 1);
        int midLabel = readLabel(in);
        final int cmp = midLabel - labelToMatch;
        if (cmp < 0) {
          low = mid + 1;
        } else if (cmp > 0) {
          high = mid - 1;
        } else {
          arc.arcIdx = mid-1;
          return readNextRealArc(arc, in);
        }
      }

      return null;
    }

    // 如果不是FIXED_ARRAY, 那就顺序遍历直到找到label的值为labelToMatch的Arc
    readFirstRealTargetArc(follow.target, arc, in);
    while(true) {
      if (arc.label == labelToMatch) {
        return arc;
      } else if (arc.label > labelToMatch) {
        return null;
      } else if (arc.isLast()) {
        return null;
      } else {
        readNextRealArc(arc, in);	//循环遍历follow.target的所有Arc
      }
    }

   这里可以看到Arc的FIXED_ARRAY存储方式在查询Node中是否存在Arc的label匹配某个值的时候,用的二分查找。
   剩余的一些遍历FST的函数都比较简单了,这里节省篇幅,不再列出来。

1.7 遍历FST的优化: 缓存根节点的Arc

        在了解了getFirstArc等遍历的函数之后可以看下cacheRootArcs,可以缓存从根节点出发的128个Arc,并且Arc的label值不能超过128,这样的优化可以在findTargetArc查询函数中提升一点效率,代码如下:

  private void cacheRootArcs() throws IOException {
    final Arc<T> arc = new Arc<>();
    getFirstArc(arc);		//获取根节点编号,存到arc变量中
    if (targetHasArcs(arc)) {		//判断从根节点出发是否还有Arc
      final BytesReader in = getBytesReader();
      Arc<T>[] arcs = (Arc<T>[]) new Arc[0x80];		//最多cache的Arc数量
      readFirstRealTargetArc(arc.target, arc, in);		//读取从根节点出发的第一个Arc
      int count = 0;
      while(true) {
        if (arc.label < arcs.length) {		//如果Arc的label的值小于128,就把这个Arc加入cache数组
          arcs[arc.label] = new Arc<T>().copyFrom(arc);
        } else {			//如果Arc的值大于128,就终止循环,因为Arc存储是按照label排序的,接下来的Arc的label值肯定也大于128
          break;
        }
        if (arc.isLast()) {		//是否已经到了最后一个Arc
          break;
        }
        readNextRealArc(arc, in);		//读取下一个Arc
        count++;
      }

      int cacheRAM = (int) ramBytesUsed(arcs);
      // 当cache的Arc数量大于FIXED_ARRAY_NUM_ARCS_SHALLOW并且这些Arc的内存小于整个FST内存的20%的时候才cache它们
      if (count >= FIXED_ARRAY_NUM_ARCS_SHALLOW && cacheRAM < ramBytesUsed()/5) {
        cachedRootArcs = arcs;
        cachedArcsBytesUsed = cacheRAM;
      }
    }
  }
1.8 怎样压缩FST

   最后就是pack的代码,pack的目的是压缩之前存的Arc的内容,根据上面addNode代码,可以看到,每个Arc存的内容是一个字节的flag,Arc的output,Arc的nextFinalOutput,VLong格式的target.node,前面三项基本上没什么压缩的空间,只剩下target.node(后续用target表示)还有点优化的余地。下面两个FST是等价的:
在这里插入图片描述

   这里只是对Node的ID重新编号了,改变Arca和Arcb的target的值,并不会改变FST的结构。target是用VLong存的,如果值小于128,只需要一个字节存,如果target的值大于128,那至少要2个字节。下面给个例子:
在这里插入图片描述

   上图中,左右两个FST是等价的,但是左边FST占用的内存明显比右边大,因为Node129和Node130的target占了两个字节,而右边的只占了一个字节。
   所以如果能把那些inCounts比较大,而且ID也比较大的Node重新编号,把ID变小,那么就会有很多Arc的target的值变小了,就达到了减少了内存占用。
   pack函数的主要目的是替换Arc的target,其基本逻辑是,先对所有的Node按照inCounts从大到小排序,得到一个新数组,每个Node的ID变成了在新数组中的位置,也就是数组的下标,这就是对Node重新编号。然后把bytes的内容复制到一个新的bytes中。原来的FST是反向存放的,pack后的FST是正向存放的,在每个Arc复制到新的bytes的时候,会用编号之后的ID替换掉原来Arc的target。
   pack函数的参数maxDerefNodes表示重新编码的Node的数量,被编码的Node会存到topNodeMap,表示旧ID->新ID的映射,而没被编码的Node,只能那么Arc的target就只能存这些Node的Address了。minInCountDeref表示重新编码的Node必须满足Node的inCount数量超过minInCountDeref
   具体的代码中有4个循环,第二层循环for是将所有的Node写到新的bytes中,注意这里是Node ID比较大的先写入新的bytes,这就是正向存储,第四层循环是将Node的所有Arc都写入bytes,这两个点比较好理解。第三层循环是针对FIXED_ARRAY存储模式的,当Node使用FIXED_ARRAY存储的时候,一次循环只能确定Arc的最大长度的值,第二次循环才能将每个Arc都用最大长度去存储。
   重点就是第一层循环,由于pack后的FST是正向存储,以图 1为例,先写入了跟节点的Arca,但是能计算出Arca的target,Node 6的Address吗?显然,不一定能计算出来,因为Node 9和Node 6之间还要存Node 7,Node 8,这两个Node如果没有压缩,那Node 6的Address就能算出,如果这两个Node压缩了,那Arca的target存的Address就不正确,所以这里只有当所有的Node的Address不再变化的时候,也就是所有Node都压缩了一遍之后,循环才能终止,所以最外层是需要循环的。具体代码如下:

  FST<T> pack(Builder<T> builder, int minInCountDeref, int maxDerefNodes, float acceptableOverheadRatio) throws IOException{
    if (nodeAddress == null) {
      throw new IllegalArgumentException("this FST was not built with willPackFST=true");
    }
    T NO_OUTPUT = outputs.getNoOutput();
    Arc<T> arc = new Arc<>();
    final BytesReader r = getBytesReader();
    // maxDerefNodes 表示最多允许重新编码的Node数量
    final int topN = Math.min(maxDerefNodes, inCounts.size());
    // 大小为topN的优先队列
    NodeQueue q = new NodeQueue(topN);

    NodeAndInCount bottom = null;
    // 所有的Node写入优先队列中,得到的是inCounts最大的topN个Node
    for(int node=0; node<inCounts.size(); node++) {
      if (inCounts.get(node) >= minInCountDeref) {
        if (bottom == null) {
          q.add(new NodeAndInCount(node, (int) inCounts.get(node)));
          if (q.size() == topN) {
            bottom = q.top();
          }
        } else if (inCounts.get(node) > bottom.count) {
          q.insertWithOverflow(new NodeAndInCount(node, (int) inCounts.get(node)));
        }
      }
    }

    inCounts = null;
    
    // 将这topN个Node的ID重新编码
    final Map<Integer,Integer> topNodeMap = new HashMap<>();
    for(int downTo=q.size()-1;downTo>=0;downTo--) {
      NodeAndInCount n = q.pop();
      topNodeMap.put(n.node, downTo);
    }
	// 这里第二个参数表示Node的数量, +1 是因为 bytes 会有个 bytes.writeByte((byte) 0);
    final GrowableWriter newNodeAddress = new GrowableWriter(
                                                             PackedInts.bitsRequired(builder.bytes.getPosition()), (int) (1 + builder.nodeCount), acceptableOverheadRatio);

    // 记录Node的Address
    for(int node=1;node<=builder.nodeCount;node++) {
      newNodeAddress.set(node, 1 + builder.bytes.getPosition() - nodeAddress.get(node));
    }

    int absCount;
    int deltaCount;
    int topCount;
    int nextCount;

    FST<T> fst;
    // 这里循环是因为,一次遍可能无法正确计算出所有Arc的target
    // 如果maxDerefNodes = inCounts.size(),在这种情况一次遍历就能计算出来
    while(true) {

      boolean changed = false;

      boolean negDelta = false;

      fst = new FST<>(inputType, outputs, builder.bytes.getBlockBits());
      
      final BytesStore writer = fst.bytes;

      writer.writeByte((byte) 0);

      absCount = deltaCount = topCount = nextCount = 0;

      int changedCount = 0;

      long addressError = 0;
      // 从Node ID 大的Node开始,往新的bytes里面写,从上图1可以看到,Node ID最大的Node是根节点
      // 这里的目的就是为了正向存储
      for(int node=(int) builder.nodeCount;node>=1;node--) {
        final long address = writer.getPosition();
		// 计算本次写入的Node的Address与上次写入时候的差值,并且更新Node的Address
        if (address != newNodeAddress.get(node)) {
          // 这个值只有当Arc没有压缩的时候才是正确的
          addressError = address - newNodeAddress.get(node);
          changed = true;
          newNodeAddress.set(node, address);
          changedCount++;
        }

        int nodeArcCount = 0;
        int bytesPerArc = 0;

        boolean retry = false;

        // 没太多意义的变量
        boolean anyNegDelta = false;

	    //这里的循环主要是 FIXED_ARRAY 存储模式下,如果Node的最长的Arc的长度变化了,那么需要更新最长Arc的长度,并且重新把Node的所有Arc按照新的长度再写入一次
        writeNode:
        while(true) { 
        
          readFirstRealTargetArc(node, arc, r);

          final boolean useArcArray = arc.bytesPerArc != 0;
          if (useArcArray) {
            // Write false first arc:
            if (bytesPerArc == 0) {
              bytesPerArc = arc.bytesPerArc;
            }
            writer.writeByte(ARCS_AS_FIXED_ARRAY);
            writer.writeVInt(arc.numArcs);
            writer.writeVInt(bytesPerArc);
          }

          int maxBytesPerArc = 0;

		  // 循环写入Node的所有Arc
          while(true) { 
            final long arcStartPos = writer.getPosition();
            nodeArcCount++;

			//处理 Arc 的 flag 逻辑
            byte flags = 0;

            if (arc.isLast()) {
              flags += BIT_LAST_ARC;
            }

            if (!useArcArray && node != 1 && arc.target == node-1) {
              flags += BIT_TARGET_NEXT;
              if (!retry) {	//没实际意义
                nextCount++;
              }
            }
            if (arc.isFinal()) {
              flags += BIT_FINAL_ARC;
              if (arc.nextFinalOutput != NO_OUTPUT) {
                flags += BIT_ARC_HAS_FINAL_OUTPUT;
              }
            } else {
              assert arc.nextFinalOutput == NO_OUTPUT;
            }
            if (!targetHasArcs(arc)) {
              flags += BIT_STOP_NODE;
            }

            if (arc.output != NO_OUTPUT) {
              flags += BIT_ARC_HAS_OUTPUT;
            }

            final long absPtr;
            final boolean doWriteTarget = targetHasArcs(arc) && (flags & BIT_TARGET_NEXT) == 0;
            if (doWriteTarget) {

              final Integer ptr = topNodeMap.get(arc.target);
              if (ptr != null) {
                absPtr = ptr;
              } else {
              	// 如果Node不在重编号Map中,那么absPtr就用Node的Address
                absPtr = topNodeMap.size() + newNodeAddress.get((int) arc.target) + addressError;
              }
			  // 计算target Node和 当前Node起始的Address之间的差值,这里的-2我不是很懂,如果看懂了请联系我,谢谢
              long delta = newNodeAddress.get((int) arc.target) + addressError - writer.getPosition() - 2;
              if (delta < 0) {
                anyNegDelta = true;
                delta = 0;
              }
			  // 两个值哪个小存哪个
              if (delta < absPtr) {
                flags |= BIT_TARGET_DELTA;
              }
            } else {
              absPtr = 0;
            }
			
			// 写入Arc 的信息
            writer.writeByte(flags);

            fst.writeLabel(writer, arc.label);

            if (arc.output != NO_OUTPUT) {
              outputs.write(arc.output, writer);
            }
            if (arc.nextFinalOutput != NO_OUTPUT) {
              outputs.writeFinalOutput(arc.nextFinalOutput, writer);
            }

            if (doWriteTarget) {
			  //  计算target Node和当前Arc的Address之间的差值
              long delta = newNodeAddress.get((int) arc.target) + addressError - writer.getPosition();
              if (delta < 0) {
                anyNegDelta = true;
                delta = 0;
              }
			  // delta 和 absPtr 只有一个值会被写入
              if (flag(flags, BIT_TARGET_DELTA)) {
                writer.writeVLong(delta);
                if (!retry) {
                  deltaCount++;
                }
              } else {
                writer.writeVLong(absPtr);
                if (!retry) {
                  if (absPtr >= topNodeMap.size()) {
                    absCount++;
                  } else {
                    topCount++;
                  }
                }
              }
            }

            if (useArcArray) {
              // 这里是FIXED_ARRAY存储的时候,每个Arc都要跳过一段内存空隙
              writer.skipBytes((int) (arcStartPos + bytesPerArc - writer.getPosition()));
            }

            if (arc.isLast()) {
              break;
            }

            readNextRealArc(arc, r);		//遍历与当前Arc相邻的下一个Arc
          }

          if (useArcArray) {
          	// 如果是 FIXED_ARRAY 存储模式,那么只有当Node两次写入的maxBytesPerArc 的值不再变化时,才能终止循环
          	// 实际上每个Node最多写两次
            if (maxBytesPerArc == bytesPerArc || (retry && maxBytesPerArc <= bytesPerArc)) {
              break;
            }
          } else {
            break;
          }

		  // 更新 FIXED_ARRAY 存储模式时的信息
          bytesPerArc = maxBytesPerArc;
          writer.truncate(address);
          nodeArcCount = 0;
          retry = true;
          anyNegDelta = false;
        }

        negDelta |= anyNegDelta;
      }

      if (!changed) {
        break;
      }
    }

    long maxAddress = 0;
    for (long key : topNodeMap.keySet()) {
      maxAddress = Math.max(maxAddress, newNodeAddress.get((int) key));
    }

    PackedInts.Mutable nodeRefToAddressIn = PackedInts.getMutable(topNodeMap.size(),
        PackedInts.bitsRequired(maxAddress), acceptableOverheadRatio);
    for(Map.Entry<Integer,Integer> ent : topNodeMap.entrySet()) {
      nodeRefToAddressIn.set(ent.getValue(), newNodeAddress.get(ent.getKey()));
    }
    fst.nodeRefToAddress = nodeRefToAddressIn;
    
    fst.startNode = newNodeAddress.get((int) startNode);

    if (emptyOutput != null) {
      fst.setEmptyOutput(emptyOutput);
    }
    fst.bytes.finish();
    fst.cacheRootArcs();
    return fst;
  }

   以上就是FST的所有源码及我的解读,如果有错误的地方,请联系我修改,谢谢!(企鹅号308384127)

评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值