Hive面试题

1.原理题

介绍下Join操作的原理(mr)?

答:Hive中的join可以分为Common Join(Reduce阶段完成Join)和Map Join(Map阶段完成Join)。如果不指定MapJoin或者不符合MapJoin的条件,那么Hive解析器会将Join操作转换成Common Join。

  • Common Join

    1. Map阶段:Map输出的时候以join on条件中的列为key,如果join有多个关联键,则以这些关联键的组合作为key,Map输出的value为join之后所关心的列,同时在value中还会包含表的tag信息,用于标识此value对应哪个表
    2. shuffle阶段:根据key的值进行hash,并将数据按照hash值推送至不同的reduce中,这样能确保两个表中相同的key位于同一个reduce中
    3. reduce阶段:根据key的值完成join操作,期间通过tag来识别不同表中的数据
  • Map Join

    1. 首先会启动Local Task(在客户端本地执行的task),负责扫描小表的数据,将其转换成一个HashTable的数据结构,并写入本地的文件中,之后将该文件加载到DistributeCache中
    2. 启动Map Tasks扫描大表,在Map阶段,根据大表中的每一条记录去和DistributeCache中小表对应的HashTable关联,并直接输出结果,由于MapJoin没有Reduce,所以有Map直接输出结果文件,有多少个Map Task,就有多少个结果文件
  • Bucket Map Join

  • Sort Merge Bucket Map Join

  • Skew Join

动态分区过多会有哪些问题?如何进行优化?

答:小文件过多、OOM。https://blog.csdn.net/qq_36039236/article/details/118670123

窗口函数的range和rows的区别是什么?开窗函数中有无order by的区别是什么?

答:range表示按照值的范围进行范围的定义,而rows表示按照行的范围进行范围的定义,边界规则的可取值见下表:

可取值说明示例
CURRENT ROW当前行
n PRECEDING往前n行数据,单独使用包含当前行2 PRECEDING
UNBOUNDED PRECEDING一直到第一条记录,单独使用包含当前行
n FOLLOWING往后n行数据,单独使用包含当前行2 FOLLOWING
UNBOUNDED PRECEDING一直到最后一条记录,单独使用包含当前行

当使用框架时,必须要有order by子句,如果仅指定了order by子句而未指定框架,那么默认框架将采用RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW(表示当前行一直到第一行的数据);如果窗口函数没有指定order by子句,也就不存在ROWS/RANGE窗口的计算。
当未使用order by时,窗口范围为分组范围,当使用order by但未指定框架时,窗口范围为当前行到第一行的范围。

count(distinct)为什么会执行缓慢?distinct的原理是什么?单distinct和多distinct如何优化?

答:在进行没有distinct的count时,会在map端对输出结果进行预聚合,减少shuffle的数据量,减轻reduce的计算量;当有distinct时,无法在map端进行预聚合,shuffle数据量比较大,并且需要在reduce端进行去重操作,所以导致执行效率比较慢。单字段distinct、多字段distinct在底层实现上逻辑略有不同,单字段distinct会将distinct字段拼接到map的输出key中,相同key的数据发送到同一个recuce中,由于mr的shuffle会对数据进行排序,因此在reduce端只需要选择last key即可;多字段distinct会根据distinct字段数量生成序号,将序号和去重字段拼接到map的输出key中,再根据排序获取last key即可。
单字段distinct优化举以下两个场景进行说明:

--场景一:利用登录根据区域统计每个区域的活跃用户数
--已知area in ('北京', '上海', '广州', '深圳')数据量是其他area的10倍左右,并且同一用户行为记录日志会较多
select area, count(distinct uid) from user_behavior group by area;
--场景二:统计活跃用户数
select count(distinct uid) from user_behavior;

首先根据场景信息可以推断出两个信息:每一个用户都有多条数据需要进行去重处理、根据area进行分组时容易造成数据倾斜。针对去重处理可以将distinct uid改为group by uid,通过提高并行度来优化性能(这块涉及到hive的reduce数量是如何确定的);针对area数据倾斜问题,可以对重点区域的数据进行加盐处理,进而打散数据,使数据更均衡,但要结合去重字段进行打散,避免同一个用户的数据被打散到不同区域进行重复计算,具体过程如下所示:

--场景一:
--步骤一:优化distinct操作,此时去重操作性能会有所提升,但是还是会因为area的数据倾斜导致计算缓慢
with disct_tbl AS 
    (SELECT uid, area
    FROM user_behavior
    GROUP BY  area, uid)
SELECT area, count(uid)
FROM disct_tbl
GROUP BY  area
--步骤二:
with disct_tbl AS 
    (SELECT uid, area
    FROM user_behavior
    GROUP BY  area, uid), 
    salt_tbl AS 
    (SELECT uid,
         if(area IN '北京', '上海', '广州', '深圳'), concat_ws('_', area, substr(uid, -1)), area) area --如果是重点地区,则截取uid最后一位拼接在area之后
FROM disct_tbl)
SELECT area, sum(num)
FROM 
	(SELECT if(area IN '北京', '上海', '广州', '深圳'), split(area, '_')[0], area) area, --将之前添加的分组值去除
		num
	FROM 
    	(SELECT area, count(uid) num
    	FROM salt_tbl
    	GROUP BY  area) temp_res) res
GROUP BY area
--场景二:可以根据某一维度进行分组再汇总的操作,目的在于通过并行处理提升计算效率
with disct_tbl AS 
    (SELECT uid, area
    FROM user_behavior
    GROUP BY  area, uid), 
    salt_tbl AS 
    (SELECT uid,
         if(area IN '北京', '上海', '广州', '深圳'), concat_ws('_', area, substr(uid, -1)), area) area --如果是重点地区,则截取uid最后一位拼接在area之后
FROM disct_tbl)
SELECT sum(num)
FROM 
    (SELECT area,
         count(uid) num
    FROM salt_tbl
    GROUP BY  area) temp_res

Hive的reduce个数是如何确定的?

答:Hive估算Reduce数量的时候,使用的是下面的公式:

num_Reduce_tasks = min[${Hive.exec.Reducers.max}, 
                      (${input.size} / ${ Hive.exec.Reducers.bytes.per.Reducer})]

也就是说,根据输入的数据量大小来决定Reduce的个数,默认Hive.exec.Reducers.bytes.per.Reducer为1G,而且Reduce个数不能超过一个上限参数值,这个参数的默认取值为999。所以我们可以调整Hive.exec.Reducers.bytes.per.Reducer来设置Reduce个数。

HiveSql是如何转为MapReduce任务的?

答:

什么是MapReduce?

答:MapReduce是一个基于集群的计算平台,是一个简化分布式编程的计算框架,是一个将分布式计算抽象为Map和Reduce两个阶段的编程模型。

简述下MapReduce的整个流程

答:Input Split->Map->Shuffle->Reduce-Output。

简述下Shuffle流程

答:shuffle过程包含在Map和Reduce两端。在Map端饿shuffle过程是对Map的结果进行分区、排序、分割,然后将属于同一划分(分区)的输出合并在一起并写在磁盘上,最终得到一个分区有序的文件,分区有序的含义是Map输出的键值对按分区进行排序,具有相同partition值的键值对存储在一起,每个分区里面的键值对又按key值进行升序排列。

  • partition:在构造NewOutPutCollector对象时,会对分区数量进行判断,如果分区数量大于1的时,会根据job中指定的分区器为消息分配分区,如果job中未指定分区器,默认使用HashPartitionerHashPartitioner的逻辑是(key.hashCode() & Integer.MAX_VALUE) % numReduceTasks;如果分区数量不大于1,则分区号为分区数量减一
  • collector:map方法中根据业务逻辑一行行处理完之后,最终是调用的是context.write()方法将结果输出。至于输出的数据到哪里,在 MapTask 类中定义了两种情况,即:MR 程序是否有 Reducer 阶段?如果没有reducer阶段,则创建outputFormat,直接将处理的结果输出到指定目录文件中。如果有reducer阶段,则创建输出收集器NewOutputCollectorNewOutputCollector中核心方法是createSortingCollector,在createSortingCollector方法内部,核心是创建具体的输出收集器MapOutputBuffer,即map输出的缓冲区。在有 reduce 阶段的情况下,map 的输出结果不是直接写入磁盘的,而是先写入内存的缓冲区中,再溢写到磁盘。
  • spill:环形缓冲区虽然可以减少 IO 次数,但是总归有容量限制,不能把所有数据一直写入内存,数据最终还是要落入磁盘上存储的,所以需要在一定条件下将缓冲区中的数据临时写入磁盘,然后重新利用这块缓冲区,这个从内存往磁盘写数据的过程被称为Spill。溢写是由单独线程来完成,不影响往缓冲区继续写数据。整个缓冲区有个溢写的比例spill.percent。这个比例默认是0.8,也就是当缓冲区的数据已经达到阈值(buffer size * spill percent = 100MB * 0.8 = 80MB),spill 线程启动。spill线程是由startSpill()方法唤醒的,在进行 spill 操作的时候,此时 map 向 buffer 的写入操作并没有阻塞,剩下 20M 可以继续使用。在溢写的过程中,会对数据进行排序。排序规则是MapOutputBuffer.compare,采用的是QuickSort快速排序。先对 partition 进行排序其次对 key 值排序。这样,数据按分区排序,并且在每个分区内按键对数据排序。
  • merge:每次 spill 都会在磁盘上生成一个临时文件,有多次 spill 发生,磁盘上相应的就会有多个临时文件存在。这样将不利于 reducetask 处理数据。当最后一次溢出结束时,溢出线程终止,合并(merge)阶段开始。在合并阶段,会将所有临时文件合并成一个大文件,并保存到文件output/file.out中,同时生成相应的索引文件output/file.out.index。在进行文件合并过程中,MapTask以分区为单位进行合并,对于某个分区,它将采用多轮递归合并的方式,每轮合并io.sort.factor(默认10)个文件,并将产生的文件重新加入待合并列表中,对文件排序后,重复以上过程,直到最终得到一个大文件。让每个MapTask最终只生成一个数据文件,可避免同时打开大量文件和同时读取大量小文件产生的随机读取带来的开销。上述过程会在设置的本地工作目录下,生成两个文件:file.out(真正的存输key,value数据)和file.out.index(用来file.out中每个partition的起始)。具体逻辑可以看源码Merger.merge,可参考博客https://blog.csdn.net/weixin_43955361/article/details/111193111

在Reduce端,shuffle主要分为复制Map输出、排序合并两个阶段

  • copy:每个ReduceTask都会远程拷贝每个MapTask生成的文件中的对应区的数据,并针对某一片数据,如果其大小超过一定阈值,则写到磁盘上,否则直接放到内存中。
  • merge sort:在远程拷贝数据的同时,ReduceTask启动了两个后台线程对内存和磁盘上的文件进行合并,以防止内存使用过多或磁盘上文件过多。Merge有3种形式,分别是内存到内存,内存到磁盘,磁盘到磁盘。默认情况下第一种形式不启用,第二种Merge方式一直在运行(spill阶段)直到结束,然后启用第三种磁盘到磁盘的Merge方式生成最终的文件。按照MapReduce语义,用户编写reduce()函数输入数据是按key进行聚集的一组数据。为了将key相同的数据聚在一起,Hadoop采用了基于排序的策略。由于各个MapTask已经实现对自己的处理结果进行了局部排序,因此,ReduceTask只需对所有数据进行一次归并排序即可。

shuffle至少有几次排序?分别发生在什么时候?

答:3次。

  1. spill触发后,SortAndSpill先把KvBuffer中的数据按照partition值和key两个关键字升序排序,移动的只是索引数据,排序结果是KvMeta中数据按照partition为单位聚集在一起,同一partition内的按照key升序排列,采用的是快速排序,排序规则是MapOutputBuffer.compare
public int compare(final int mi, final int mj) {
      final int kvi = offsetFor(mi % maxRec);
      final int kvj = offsetFor(mj % maxRec);
      final int kvip = kvmeta.get(kvi + PARTITION);
      final int kvjp = kvmeta.get(kvj + PARTITION);
      // sort by partition
      if (kvip != kvjp) {
        return kvip - kvjp;
      }
      // sort by key
      return comparator.compare(kvbuffer,
          kvmeta.get(kvi + KEYSTART),
          kvmeta.get(kvi + VALSTART) - kvmeta.get(kvi + KEYSTART),
          kvbuffer,
          kvmeta.get(kvj + KEYSTART),
          kvmeta.get(kvj + VALSTART) - kvmeta.get(kvj + KEYSTART));
    }
  1. 第二次发生在Map端的merge阶段,在flush()的时候会调用mergeParts()对溢写的相同分区的文件进行归并排序合成一个文件
private void mergeParts() throws IOException, InterruptedException, 
                                     ClassNotFoundException {
      // get the approximate size of the final output/index files
      long finalOutFileSize = 0;
      long finalIndexFileSize = 0;
      final Path[] filename = new Path[numSpills];
      final TaskAttemptID mapId = getTaskID();

      for(int i = 0; i < numSpills; i++) {
        filename[i] = mapOutputFile.getSpillFile(i);
        finalOutFileSize += rfs.getFileStatus(filename[i]).getLen();
      }
      if (numSpills == 1) { //the spill is the final output
        sameVolRename(filename[0],
            mapOutputFile.getOutputFileForWriteInVolume(filename[0]));
        if (indexCacheList.size() == 0) {
          sameVolRename(mapOutputFile.getSpillIndexFile(0),
            mapOutputFile.getOutputIndexFileForWriteInVolume(filename[0]));
        } else {
          indexCacheList.get(0).writeToFile(
            mapOutputFile.getOutputIndexFileForWriteInVolume(filename[0]), job);
        }
        sortPhase.complete();
        return;
      }

      // read in paged indices
      for (int i = indexCacheList.size(); i < numSpills; ++i) {
        Path indexFileName = mapOutputFile.getSpillIndexFile(i);
        indexCacheList.add(new SpillRecord(indexFileName, job));
      }

      //make correction in the length to include the sequence file header
      //lengths for each partition
      finalOutFileSize += partitions * APPROX_HEADER_LENGTH;
      finalIndexFileSize = partitions * MAP_OUTPUT_INDEX_RECORD_LENGTH;
      Path finalOutputFile =
          mapOutputFile.getOutputFileForWrite(finalOutFileSize);
      Path finalIndexFile =
          mapOutputFile.getOutputIndexFileForWrite(finalIndexFileSize);

      //The output stream for the final single output file
      FSDataOutputStream finalOut = rfs.create(finalOutputFile, true, 4096);

      if (numSpills == 0) {
        //create dummy files
        IndexRecord rec = new IndexRecord();
        SpillRecord sr = new SpillRecord(partitions);
        try {
          for (int i = 0; i < partitions; i++) {
            long segmentStart = finalOut.getPos();
            FSDataOutputStream finalPartitionOut = CryptoUtils.wrapIfNecessary(job, finalOut);
            Writer<K, V> writer =
              new Writer<K, V>(job, finalPartitionOut, keyClass, valClass, codec, null);
            writer.close();
            rec.startOffset = segmentStart;
            rec.rawLength = writer.getRawLength() + CryptoUtils.cryptoPadding(job);
            rec.partLength = writer.getCompressedLength() + CryptoUtils.cryptoPadding(job);
            sr.putIndex(rec, i);
          }
          sr.writeToFile(finalIndexFile, job);
        } finally {
          finalOut.close();
        }
        sortPhase.complete();
        return;
      }
      {
        sortPhase.addPhases(partitions); // Divide sort phase into sub-phases
        
        IndexRecord rec = new IndexRecord();
        final SpillRecord spillRec = new SpillRecord(partitions);
        for (int parts = 0; parts < partitions; parts++) {
          //create the segments to be merged
          List<Segment<K,V>> segmentList =
            new ArrayList<Segment<K, V>>(numSpills);
          for(int i = 0; i < numSpills; i++) {
            IndexRecord indexRecord = indexCacheList.get(i).getIndex(parts);

            Segment<K,V> s =
              new Segment<K,V>(job, rfs, filename[i], indexRecord.startOffset,
                               indexRecord.partLength, codec, true);
            segmentList.add(i, s);

            if (LOG.isDebugEnabled()) {
              LOG.debug("MapId=" + mapId + " Reducer=" + parts +
                  "Spill =" + i + "(" + indexRecord.startOffset + "," +
                  indexRecord.rawLength + ", " + indexRecord.partLength + ")");
            }
          }

          int mergeFactor = job.getInt(JobContext.IO_SORT_FACTOR, 100);
          // sort the segments only if there are intermediate merges
          boolean sortSegments = segmentList.size() > mergeFactor;
          //merge
          @SuppressWarnings("unchecked")
          RawKeyValueIterator kvIter = Merger.merge(job, rfs,
                         keyClass, valClass, codec,
                         segmentList, mergeFactor,
                         new Path(mapId.toString()),
                         job.getOutputKeyComparator(), reporter, sortSegments,
                         null, spilledRecordsCounter, sortPhase.phase(),
                         TaskType.MAP);

          //write merged output to disk
          long segmentStart = finalOut.getPos();
          FSDataOutputStream finalPartitionOut = CryptoUtils.wrapIfNecessary(job, finalOut);
          Writer<K, V> writer =
              new Writer<K, V>(job, finalPartitionOut, keyClass, valClass, codec,
                               spilledRecordsCounter);
          if (combinerRunner == null || numSpills < minSpillsForCombine) {
            Merger.writeFile(kvIter, writer, reporter, job);
          } else {
            combineCollector.setWriter(writer);
            combinerRunner.combine(kvIter, combineCollector);
          }

          //close
          writer.close();

          sortPhase.startNextPhase();
          
          // record offsets
          rec.startOffset = segmentStart;
          rec.rawLength = writer.getRawLength() + CryptoUtils.cryptoPadding(job);
          rec.partLength = writer.getCompressedLength() + CryptoUtils.cryptoPadding(job);
          spillRec.putIndex(rec, parts);
        }
        spillRec.writeToFile(finalIndexFile, job);
        finalOut.close();
        for(int i = 0; i < numSpills; i++) {
          rfs.delete(filename[i],true);
        }
      }
    }
  1. 第三次发生在Reduce端的merge阶段,也是归并排序

MapReduce任务的Reduce个数是否可以设置?

答:可以设置,可以通过job.setNumReduceTasks()进行指定

MapReduce任务的Map数量是怎么确定的?

答:在MapReduce代码开发中,需要为每个作业指定相应的InputFormat,MapReduce依赖作业的InputFormat来:

  1. 验证作业的输入规范
  2. 将输入文件拆分为逻辑InputSplits,然后将每个逻辑InputSplits分配给单个Mapper
    InputFormat抽象类中定义了两个抽象方法:getSplits和createRecordReader,其中getSplits方法负责按逻辑分割作业的输入文件集。拿FileInputFormat举例
public List<InputSplit> getSplits(JobContext job) throws IOException {
    StopWatch sw = new StopWatch().start();
    //获取SplitSize的下限,当作业中未指定时,默认是1
    long minSize = Math.max(getFormatMinSplitSize(), getMinSplitSize(job));
    //获取SplitSize的上限,当作业中未指定时,默认是Long.MAX_VALUE
    long maxSize = getMaxSplitSize(job);

    // generate splits
    List<InputSplit> splits = new ArrayList<InputSplit>();
    List<FileStatus> files = listStatus(job);
    for (FileStatus file: files) {
      Path path = file.getPath();
      long length = file.getLen();
      if (length != 0) {
        BlockLocation[] blkLocations;
        if (file instanceof LocatedFileStatus) {
          blkLocations = ((LocatedFileStatus) file).getBlockLocations();
        } else {
          FileSystem fs = path.getFileSystem(job.getConfiguration());
          blkLocations = fs.getFileBlockLocations(file, 0, length);
        }
        //判断给定文件是否可以拆分
        if (isSplitable(job, path)) {
          //获取block的大小
          long blockSize = file.getBlockSize();
          //计算splitSize,逻辑是Math.max(minSize, Math.min(maxSize, blockSize))
          //默认配置下splitSize = blockSize
          long splitSize = computeSplitSize(blockSize, minSize, maxSize);

          long bytesRemaining = length;
          //SPLIT_SLOP的值是1.1
          //也就是说当未切分文件的大小比splitSize还多10%时,就继续切分,反之,停止切分
          //那当块大小为128M时,只要文件大小不超过140.8M就不会进行切分
          while (((double) bytesRemaining)/splitSize > SPLIT_SLOP) {
            int blkIndex = getBlockIndex(blkLocations, length-bytesRemaining);
            splits.add(makeSplit(path, length-bytesRemaining, splitSize,
                        blkLocations[blkIndex].getHosts(),
                        blkLocations[blkIndex].getCachedHosts()));
            bytesRemaining -= splitSize;
          }

          if (bytesRemaining != 0) {
            int blkIndex = getBlockIndex(blkLocations, length-bytesRemaining);
            splits.add(makeSplit(path, length-bytesRemaining, bytesRemaining,
                       blkLocations[blkIndex].getHosts(),
                       blkLocations[blkIndex].getCachedHosts()));
          }
        } else { // not splitable
          splits.add(makeSplit(path, 0, length, blkLocations[0].getHosts(),
                      blkLocations[0].getCachedHosts()));
        }
      } else { 
        //Create empty hosts array for zero length files
        splits.add(makeSplit(path, 0, length, new String[0]));
      }
    }
    // Save the number of input files for metrics/loadgen
    job.getConfiguration().setLong(NUM_INPUT_FILES, files.size());
    sw.stop();
    if (LOG.isDebugEnabled()) {
      LOG.debug("Total # of splits generated by getSplits: " + splits.size()
          + ", TimeTaken: " + sw.now(TimeUnit.MILLISECONDS));
    }
    return splits;
  }

shuffle优化

答:

reduce任务什么时候开始执行?Map读取数据的时候是否可以执行Reduce?

答:

MapReduce的jvm重用是否了解

答:可以通过mapred.job.reuse.jvm.num.tasks进行指定,默认是1。表示一个JVM上最多可以顺序执行的task数目(属于同一个Job)是1。也就是说一个task启一个JVM为每个task启动一个新的JVM将耗时1秒左右,对于运行时间较长(比如1分钟以上)的job影响不大,但如果都是时间很短的task,那么频繁启停JVM会有开销。如果想使用JVM重用技术来提高性能,那么可以将mapred.job.reuse.jvm.num.tasks设置成大于1的数。这表示属于同一job的顺序执行的task可以共享一个JVM,也就是说第二轮的map可以重用前一轮的JVM,而不是第一轮结束后关闭JVM,第二轮再启动新的JVM。那么最多一个JVM能顺序执行多少个task才关闭呢?这个值就是mapred.job.reuse.jvm.num.tasks。如果设置成-1,那么只要是同一个job的task(无所谓多少个),都可以按顺序在一个JVM上连续执行。如果task属于不同的job,那么JVM重用机制无效,不同job的task需要不同的JVM来运行。

Hive的stage是如何划分的?

答:https://www.aboutyun.com/forum.php?mod=viewthread&tid=30612

数据倾斜的原因和解决方法?

答:

  • map:文件大小差距大,导致任务在分片的时候有的任务处理的是块大小的,有的任务处理的只有几M或几K。
  • reduce:数据长尾分布,导致shuffle后大量数据集中在少数reduce中。
  • join:热点key导致关联时数据倾斜。

reduceByKey和groupByKey有什么区别?

答:https://www.cnblogs.com/cy0628/p/15568157.html

Spark为什么比MapReduce快?

答:

  1. Spark快的原因主要是源于DAG的计算模型,DAG相比Hadoop的MapReduce在大多数情况下可以减少shuffle的次数
  2. Spark会将中间计算结果在内存中进行缓存
    针对于DAG(有向无环图)模型,Spark的DAG实质上就是把计算和计算之间的编排变得更为细致紧密,使得很多MR任务中需要落盘的非Shuffle操作得以在内存中直接参与后续的运算,并且由于算子粒度和算子之间的逻辑关系使得其易于由框架自动地优化(换言之编排得好的MR其实也可以做到)
    https://blog.csdn.net/weixin_42307036/article/details/112246904
    https://www.jianshu.com/p/5f9f4ca10414

Spark的shuffle和MapReduce的shuffle有什么相同有什么不同?

答:https://zhuanlan.zhihu.com/p/55954840

Spark的stage是如何切分的?

答:

Spark的hash join和sort join有什么区别?

答:https://www.cnblogs.com/cenglinjinran/p/14889949.html

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值