Solr.IndexWriter源码分析.6

2021SC@SDUSC

/**
  * 删除索引中的所有文档。
   *
   * <p>
   * 此方法将删除所有缓冲的文档并删除所有段
   * 来自索引。直到 {@link #commit()}
   * 已被调用。可以使用 {@link #rollback()} 回滚此方法。
   * </p>
   *
   * <p>
   * 注意:此方法比使用 deleteDocuments( new
   * MatchAllDocsQuery() )。然而,这种方法也有不同的语义
   * 与 {@link #deleteDocuments(Query...)} 相比,因为内部
   * 清除数据结构以及所有段信息
   * 强行删除的抗病毒语义,如忽略规范被重置或
   * doc 值类型被清除。基本上调用 {@link #deleteAll()} 是
   * 相当于创建一个新的 {@link IndexWriter}
   * {@link OpenMode#CREATE} 删除查询仅将文档标记为
   * 删除。
   * </p>
   *
   * <p>
   * 注意:此方法将强制中止所有正在进行的合并。如果其他
   * 线程正在运行 {@link #forceMerge},{@link #addIndexes(CodecReader[])}
   * 或 {@link #forceMergeDeletes} 方法,他们可能会收到
   * {@link MergePolicy.MergeAbortedException}s。
   *
   * @return <a href="#sequence_number">序列号</a>
   * 对于这个操作
   */
  @SuppressWarnings("try")
  public long deleteAll() throws IOException {
    ensureOpen();
    // Remove any buffered docs
    boolean success = false;
 
     /*
      * 我们首先中止并丢弃内存中的所有内容
      * 并保持线程状态锁定,lockAndAbortAll 操作
     
      * 所以我们中止内存结构中的所有内容
      * 在中止之前删除全局字段编号确定它就像一个新的索引。
      */
    try {
      synchronized (fullFlushLock) {
        try (Closeable finalizer = docWriter.lockAndAbortAll()) {
          processEvents(false);
          synchronized (this) {
            try {
              // Abort any running merges
              try {
                abortMerges();
                assert merges.areEnabled() == false : "merges should be disabled - who enabled them?";
                assert mergingSegments.isEmpty() : "found merging segments but merges are disabled: " + mergingSegments;
              } finally {
              // abortMerges 禁用所有合并,我们需要在此处重新启用它们以确保
                 // IW 可以正常运行。 abortMerges() 中的异常对于 IW 可能是致命的,但只是为了确定让我们重新启用合并。
                merges.enable();
              }
              adjustPendingNumDocs(-segmentInfos.totalMaxDoc());
              // Remove all segments
              segmentInfos.clear();
              // Ask deleter to locate unreferenced files & remove them:
              deleter.checkpoint(segmentInfos, false);
			/* 不要在这里刷新删除器,因为可能有
                * 是开放的并发索引请求
                * 调用 DW#abort() 后目录中的文件
                * 如果我们这样做,这些索引请求可能会遇到 FNF 异常。
                * 我们将逐步删除文件...
                */
               // 不要费心在我们的 segmentInfos 中保存任何更改
              readerPool.dropAll();
              // Mark that the index has changed
              changeCount.incrementAndGet();
              segmentInfos.changed();
              globalFieldNumberMap.clear();
              success = true;
              long seqNo = docWriter.getNextSequenceNumber();
              return seqNo;
            } finally {
              if (success == false) {

                if (infoStream.isEnabled("IW")) {
                  infoStream.message("IW", "hit exception during deleteAll");
                }
              }
            }
          }
        }
      }
    } catch (VirtualMachineError tragedy) {
      tragicEvent(tragedy, "deleteAll");
      throw tragedy;
    }
  }

/** 中止正在运行的合并。 使用这个时要小心
    * 方法:当你中止一个长时间运行的合并时,你就输了
    * 很多工作必须在以后重做。 */
  private synchronized void abortMerges() throws IOException {
    merges.disable();
    // Abort all pending & running merges:
    IOUtils.applyToAll(pendingMerges, merge -> {
      if (infoStream.isEnabled("IW")) {
        infoStream.message("IW", "now abort pending merge " + segString(merge.segments));
      }
      abortOneMerge(merge);
      mergeFinish(merge);
    });
    pendingMerges.clear();

    for (final MergePolicy.OneMerge merge : runningMerges) {
      if (infoStream.isEnabled("IW")) {
        infoStream.message("IW", "now abort running merge " + segString(merge.segments));
      }
      merge.setAborted();
    }
// 我们在这里等待让所有合并停止。 它不应该
     // 需要很长时间,因为他们会定期检查是否
     // 他们被中止了。
    while (runningMerges.size() + runningAddIndexesMerges.size() != 0) {

      if (infoStream.isEnabled("IW")) {
        infoStream.message("IW", "now wait for " + runningMerges.size()
            + " running merge/s to abort; currently running addIndexes: " + runningAddIndexesMerges.size());
      }

      doWait();
    }

    notifyAll();
    if (infoStream.isEnabled("IW")) {
      infoStream.message("IW", "all running merges have aborted");
    }
  }

/**
    * 等待任何当前未完成的合并完成。
    *
    * <p>保证在调用此方法之前开始任何合并
    * 将在此方法完成后完成。</p>
    */
  void waitForMerges() throws IOException {

  // 给合并调度程序最后一次运行的机会,以防万一
     // 任何挂起的合并都在等待。 我们无法抓住信息战的锁
     // 当进入合并时,因为它可能导致死锁。
    mergeScheduler.merge(mergeSource, MergeTrigger.CLOSING);

    synchronized (this) {
      ensureOpen(false);
      if (infoStream.isEnabled("IW")) {
        infoStream.message("IW", "waitForMerges");
      }

      while (pendingMerges.size() > 0 || runningMerges.size() > 0) {
        doWait();
      }

      // sanity check
      assert 0 == mergingSegments.size();

      if (infoStream.isEnabled("IW")) {
        infoStream.message("IW", "waitForMerges done");
      }
    }
  }

/**
    * 每当 SegmentInfos 更新和
    * 引用的索引文件(正确)存在于
    * 索引目录。
    */
  private synchronized void checkpoint() throws IOException {
    changed();
    deleter.checkpoint(segmentInfos, false);
  }
/** 检查点与 IndexFileDeleter,所以它知道
    * 新文件,并增加changeCount,依此类推
    * close/commit 我们会写一个新的segments文件,但是
    * 不会碰撞 segmentInfos.version。 */
  private synchronized void checkpointNoSIS() throws IOException {
    changeCount.incrementAndGet();
    deleter.checkpoint(segmentInfos, false);
  }

/** 如果任何索引状态已更改,则在内部调用。 */
  private synchronized void changed() {
    changeCount.incrementAndGet();
    segmentInfos.changed();
  }

  private synchronized long publishFrozenUpdates(FrozenBufferedUpdates packet) {
    assert packet != null && packet.any();
    long nextGen = bufferedUpdatesStream.push(packet);
// 将此作为一个事件执行,以便当我们不持有 DocumentsWriterFlushQueue.purgeLock 时它在堆栈中应用更高:
    eventQueue.add(w -> {
      try {
      // 我们在这里调用 tryApply 因为我们不想阻塞如果刷新或刷新已经应用
         //数据包。 无论如何刷新都会重试这个数据包以确保所有这些数据包都被应用
        tryApply(packet);
      } catch (Throwable t) {
        try {
          w.onTragicEvent(t, "applyUpdatesPacket");
        } catch (Throwable t1) {
          t.addSuppressed(t1);
        }
        throw t;
      }
      w.flushDeletesCount.incrementAndGet();
    });
    return nextGen;
  }

 /**
    * 原子地添加segment私有删除包并发布flush
    * 将 SegmentInfo 分段到索引编写器。
    */
  private synchronized void publishFlushedSegment(SegmentCommitInfo newSegment, FieldInfos fieldInfos,
                                                  FrozenBufferedUpdates packet, FrozenBufferedUpdates globalPacket,
                                                  Sorter.DocMap sortMap) throws IOException {
    boolean published = false;
    try {
      // Lock order IW -> BDS
      ensureOpen(false);

      if (infoStream.isEnabled("IW")) {
        infoStream.message("IW", "publishFlushedSegment " + newSegment);
      }

      if (globalPacket != null && globalPacket.any()) {
        publishFrozenUpdates(globalPacket);
      }

     // 发布段必须在 IW -> BDS 上同步以确保
       // 没有合并会删除段。 私人删除包
      final long nextGen;
      if (packet != null && packet.any()) {
        nextGen = publishFrozenUpdates(packet);
      } else {
      // 因为我们没有要应用的删除包,所以我们可以得到一个新的
         // 立即生成
        nextGen = bufferedUpdatesStream.getNextGen();
        // No deletes/updates here, so marked finished immediately:
        bufferedUpdatesStream.finishedSegment(nextGen);
      }
      if (infoStream.isEnabled("IW")) {
        infoStream.message("IW", "publish sets newSegment delGen=" + nextGen + " seg=" + segString(newSegment));
      }
      newSegment.setBufferedDeletesGen(nextGen);
      segmentInfos.add(newSegment);
      published = true;
      checkpoint();
      if (packet != null && packet.any() && sortMap != null) {
      // TODO:我们在持有 IW 的监视器锁的同时执行这个繁重的操作,这不是很好,
         // 但它仅适用于使用排序索引和更新文档值的情况:
        ReadersAndUpdates rld = getPooledInstance(newSegment, true);
        rld.sortMap = sortMap;
       // 不要释放这个 ReadersAndUpdates 我们需要坚持那个 sortMap
      }
      FieldInfo fieldInfo = fieldInfos.fieldInfo(config.softDeletesField); // 如果不存在软删除,将返回 null
       // 这是一个极端情况,文档使用软删除来删除它们自己。 这用于
       // 构建删除墓碑等。在这种情况下,我们在这个新刷新的片段中没有看到对 DV 的任何更新。
       // 如果我们看到了更新,则更新代码检查该段是否已完全删除。
      boolean hasInitialSoftDeleted = (fieldInfo != null
          && fieldInfo.getDocValuesGen() == -1
          && fieldInfo.getDocValuesType() != DocValuesType.NONE);
      final boolean isFullyHardDeleted = newSegment.getDelCount() == newSegment.info.maxDoc();
      // 我们要么有一个完全硬删除的片段,要么一个或多个文档被软删除。 在这两种情况下,我们都需要
       // 去检查它们是否被完全删除。 这有一个很好的副作用,我们现在有了准确的数字
       // 用于在我们刷新到磁盘后立即进行软删除。
       if (hasInitialSoftDeleted || isFullyHardDeleted){
         // 此操作仅在需要时才真正执行,如果未配置软删除,则仅执行
         // 如果我们删除了这个新刷新的段中的所有文档。
        ReadersAndUpdates rld = getPooledInstance(newSegment, true);
        try {
          if (isFullyDeleted(rld)) {
            dropDeletedSegment(newSegment);
            checkpoint();
          }
        } finally {
          release(rld);
        }
      }

    } finally {
      if (published == false) {
        adjustPendingNumDocs(-newSegment.info.maxDoc());
      }
      flushCount.incrementAndGet();
      doAfterFlush();
    }

  }

  private synchronized void resetMergeExceptions() {
    mergeExceptions.clear();
    mergeGen++;
  }

  private void noDupDirs(Directory... dirs) {
    HashSet<Directory> dups = new HashSet<>();
    for(int i=0;i<dirs.length;i++) {
      if (dups.contains(dirs[i]))
        throw new IllegalArgumentException("Directory " + dirs[i] + " appears more than once");
      if (dirs[i] == directoryOrig)
        throw new IllegalArgumentException("Cannot add directory to itself");
      dups.add(dirs[i]);
    }
  }

/** 获取所有目录的写锁; 确定
    * 与调用 {@link IOUtils#close} 相匹配
    * 最后条款。 */
  private List<Lock> acquireWriteLocks(Directory... dirs) throws IOException {
    List<Lock> locks = new ArrayList<>(dirs.length);
    for(int i=0;i<dirs.length;i++) {
      boolean success = false;
      try {
        Lock lock = dirs[i].obtainLock(WRITE_LOCK_NAME);
        locks.add(lock);
        success = true;
      } finally {
        if (success == false) {
          // Release all previously acquired locks:
          // TODO: addSuppressed? it could be many...
          IOUtils.closeWhileHandlingException(locks);
        }
      }
    }
    return locks;
  }

/**
   * 将索引数组中的所有段添加到该索引中。
   *
   * <p>这可用于并行化批量索引。一个大文件
   * 集合可以分解为子集合。每个子集都可以
   * 在不同的线程、进程或机器上并行索引。这
   * 然后可以通过合并子集合索引来创建完整索引
   * 用这种方法。
   *
   * <p>
   * <b>注意:</b> 此方法获取写锁
   * 每个目录,确保没有{@code IndexWriter}
   * 当前已打开或在此期间尝试打开
   * 跑步。
   *
   * <p>此方法是事务性的异常如何
   * 处理:它不会提交新的segments_N 文件,直到
   * 添加所有索引。这意味着如果一个异常
   * 发生(例如磁盘已满),然后没有索引
   * 将被添加,或者它们都将被添加。
   *
   * <p>注意这需要临时的空闲空间
   * {@link Directory} 高达所有输入索引总和的 2 倍
   *(包括起始索引)。如果读者/搜索者
   * 针对起始索引打开,然后是临时的
   * 所需的可用空间会因大小而增加
   * 起始索引(详见 {@link #forceMerge(int)})。
   *
   * <p>这要求该索引不在要添加的索引中。
   *
   * <p>所有添加的索引必须由相同的
   * Lucene 版本作为此索引。
   *
   * @return <a href="#sequence_number">序列号</a>
   * 对于这个操作
   *
   * @throws CorruptIndexException 如果索引损坏
   * @throws IOException 如果存在低级 IO 错误
   * @throws IllegalArgumentException 如果 addIndexes 会导致
   * 索引超过 {@link #MAX_DOCS},或者如果indoming
   * 索引排序与该索引的索引排序不匹配
   */
  public long addIndexes(Directory... dirs) throws IOException {
    ensureOpen();

    noDupDirs(dirs);

    List<Lock> locks = acquireWriteLocks(dirs);

    Sort indexSort = config.getIndexSort();

    boolean successTop = false;

    long seqNo;

    try {
      if (infoStream.isEnabled("IW")) {
        infoStream.message("IW", "flush at addIndexes(Directory...)");
      }

      flush(false, true);

      List<SegmentCommitInfo> infos = new ArrayList<>();

      // long so we can detect int overflow:
      long totalMaxDoc = 0;
      List<SegmentInfos> commits = new ArrayList<>(dirs.length);
      for (Directory dir : dirs) {
        if (infoStream.isEnabled("IW")) {
          infoStream.message("IW", "addIndexes: process directory " + dir);
        }
        SegmentInfos sis = SegmentInfos.readLatestCommit(dir); // read infos from dir
        if (segmentInfos.getIndexCreatedVersionMajor() != sis.getIndexCreatedVersionMajor()) {
          throw new IllegalArgumentException("Cannot use addIndexes(Directory) with indexes that have been created "
              + "by a different Lucene version. The current index was generated by Lucene "
              + segmentInfos.getIndexCreatedVersionMajor()
              + " while one of the directories contains an index that was generated with Lucene "
              + sis.getIndexCreatedVersionMajor());
        }
        totalMaxDoc += sis.totalMaxDoc();
        commits.add(sis);
      }

      // Best-effort up front check:
      testReserveDocs(totalMaxDoc);
        
      boolean success = false;
      try {
        for (SegmentInfos sis : commits) {
          for (SegmentCommitInfo info : sis) {
            assert !infos.contains(info): "dup info dir=" + info.info.dir + " name=" + info.info.name;

            Sort segmentIndexSort = info.info.getIndexSort();

            if (indexSort != null && (segmentIndexSort == null || isCongruentSort(indexSort, segmentIndexSort) == false)) {
              throw new IllegalArgumentException("cannot change index sort from " + segmentIndexSort + " to " + indexSort);
            }

            String newSegName = newSegmentName();

            if (infoStream.isEnabled("IW")) {
              infoStream.message("IW", "addIndexes: process segment origName=" + info.info.name + " newName=" + newSegName + " info=" + info);
            }

            IOContext context = new IOContext(new FlushInfo(info.info.maxDoc(), info.sizeInBytes()));

            FieldInfos fis = readFieldInfos(info);
            for(FieldInfo fi : fis) {
            // 如果任何传入字段具有非法架构更改,这将引发异常:
              globalFieldNumberMap.addOrGet(fi.name, fi.number, fi.getIndexOptions(), fi.getDocValuesType(), fi.getPointDimensionCount(), fi.getPointIndexDimensionCount(), fi.getPointNumBytes(), fi.isSoftDeletesField());
            }
            infos.add(copySegmentAsIs(info, newSegName, context));
          }
        }
        success = true;
      } finally {
        if (!success) {
          for(SegmentCommitInfo sipc : infos) {
            // Safe: these files must exist
            deleteNewFiles(sipc.files());
          }
        }
      }

      synchronized (this) {
        success = false;
        try {
          ensureOpen();
// 现在保留文档,就在我们更新 SIS 之前:
          reserveDocs(totalMaxDoc);

          seqNo = docWriter.getNextSequenceNumber();

          success = true;
        } finally {
          if (!success) {
            for(SegmentCommitInfo sipc : infos) {
              // Safe: these files must exist
              deleteNewFiles(sipc.files());
            }
          }
        }
        segmentInfos.addAll(infos);
        checkpoint();
      }

      successTop = true;

    } catch (VirtualMachineError tragedy) {
      tragicEvent(tragedy, "addIndexes(Directory...)");
      throw tragedy;
    } finally {
      if (successTop) {
        IOUtils.close(locks);
      } else {
        IOUtils.closeWhileHandlingException(locks);
      }
    }
    maybeMerge();

    return seqNo;
  }

  private void validateMergeReader(CodecReader leaf) {
    LeafMetaData segmentMeta = leaf.getMetaData();
    if (segmentInfos.getIndexCreatedVersionMajor() != segmentMeta.getCreatedVersionMajor()) {
      throw new IllegalArgumentException("Cannot merge a segment that has been created with major version "
          + segmentMeta.getCreatedVersionMajor() + " into this index which has been created by major version "
          + segmentInfos.getIndexCreatedVersionMajor());
    }

    if (segmentInfos.getIndexCreatedVersionMajor() >= 7 && segmentMeta.getMinVersion() == null) {
      throw new IllegalStateException("Indexes created on or after Lucene 7 must record the created version major, but " + leaf + " hides it");
    }

    Sort leafIndexSort = segmentMeta.getSort();
    if (config.getIndexSort() != null &&
          (leafIndexSort == null || isCongruentSort(config.getIndexSort(), leafIndexSort) == false)) {
      throw new IllegalArgumentException("cannot change index sort from " + leafIndexSort + " to " + config.getIndexSort());
    }
  }

/**
   * 将提供的索引合并到该索引中。
   *
   * <p>
   * 提供的 IndexReaders 未关闭。
   *
   * <p>
   * 有关事务语义的详细信息,请参阅 {@link #addIndexes},临时
   * 目录中所需的可用空间,以及异常上的非 CFS 段。
   *
   * <p>
   * <b>注意:</b> 空段被这个方法删除,而不是添加到这个
   * 指数。
   *
   * <p>
   * <b>注意:</b> 这将所有给定的 {@link LeafReader} 合并为一个
   * 合并。如果你打算合并大量的读者,可能会更好
   * 多次调用此方法,每次使用一小组读者。
   * 原则上,如果您使用带有 {@code mergeFactor} 的合并策略或
   * {@code maxMergeAtOnce} 参数,你应该一次传递那么多读者
   * 称呼。
   *
   * <p>
   * <b>注意:</b> 此方法不会调用或使用 {@link MergeScheduler},
   * 因此任何自定义带宽限制目前都被忽略。
   *
   * @return <a href="#sequence_number">序列号</a>
   * 对于这个操作
   *
   * @throws CorruptIndexException
   * 如果索引损坏
   * @throws IOException
   * 如果存在低级 IO 错误
   * @throws IllegalArgumentException
   * 如果 addIndexes 会导致索引超过 {@link #MAX_DOCS}
   */
  public long addIndexes(CodecReader... readers) throws IOException {
    ensureOpen();

    // long so we can detect int overflow:
    long numDocs = 0;
    long seqNo;
    try {
      if (infoStream.isEnabled("IW")) {
        infoStream.message("IW", "flush at addIndexes(CodecReader...)");
      }
      flush(false, true);

      String mergedName = newSegmentName();
      int numSoftDeleted = 0;
      for (CodecReader leaf : readers) {
        numDocs += leaf.numDocs();
        validateMergeReader(leaf);
        if (softDeletesEnabled) {
            Bits liveDocs = leaf.getLiveDocs();
            numSoftDeleted += PendingSoftDeletes.countSoftDeletes(
            DocValuesFieldExistsQuery.getDocValuesDocIdSetIterator(config.getSoftDeletesField(), leaf), liveDocs);
        }
      }
      
      // Best-effort up front check:
      testReserveDocs(numDocs);

      final IOContext context = new IOContext(new MergeInfo(Math.toIntExact(numDocs), -1, false, UNBOUNDED_MAX_MERGE_SEGMENTS));
// TODO:我们应该以某种方式修复这个合并,所以它是
       // 可中止,以便 IW.close(false) 能够停止它
      TrackingDirectoryWrapper trackingDir = new TrackingDirectoryWrapper(directory);
      Codec codec = config.getCodec();
     // 我们暂时将最小版本设置为 null,稍后由 SegmentMerger 设置
      SegmentInfo info = new SegmentInfo(directoryOrig, Version.LATEST, null, mergedName, -1,
                                         false, codec, Collections.emptyMap(), StringHelper.randomId(), Collections.emptyMap(), config.getIndexSort());

      SegmentMerger merger = new SegmentMerger(Arrays.asList(readers), info, infoStream, trackingDir,
                                               globalFieldNumberMap, 
                                               context);

      if (!merger.shouldMerge()) {
        return docWriter.getNextSequenceNumber();
      }

      synchronized (this) {
        ensureOpen();
        assert merges.areEnabled();
        runningAddIndexesMerges.add(merger);
      }
      try {
        merger.merge();  // merge 'em
      } finally {
        synchronized (this) {
          runningAddIndexesMerges.remove(merger);
          notifyAll();
        }
      }
      SegmentCommitInfo infoPerCommit = new SegmentCommitInfo(info, 0, numSoftDeleted, -1L, -1L, -1L, StringHelper.randomId());

      info.setFiles(new HashSet<>(trackingDir.getCreatedFiles()));
      trackingDir.clearCreatedFiles();
                                         
      setDiagnostics(info, SOURCE_ADDINDEXES_READERS);

      final MergePolicy mergePolicy = config.getMergePolicy();
      boolean useCompoundFile;
      synchronized(this) { // Guard segmentInfos
        if (merges.areEnabled() == false) {
          // Safe: these files must exist
          deleteNewFiles(infoPerCommit.files());

          return docWriter.getNextSequenceNumber();
        }
        ensureOpen();
        useCompoundFile = mergePolicy.useCompoundFile(segmentInfos, infoPerCommit, this);
      }

      // Now create the compound file if needed
      if (useCompoundFile) {
        Collection<String> filesToDelete = infoPerCommit.files();
        TrackingDirectoryWrapper trackingCFSDir = new TrackingDirectoryWrapper(directory);
       // 待办事项:与合并不同,例外的是我们不会在这里狙击任何垃圾 cfs 文件?
         // createCompoundFile 尝试清理,但它可能并不总是能够...
        try {
          createCompoundFile(infoStream, trackingCFSDir, info, context, this::deleteNewFiles);
        } finally {
          // delete new non cfs files directly: they were never
          // registered with IFD
          deleteNewFiles(filesToDelete);
        }
        info.setUseCompoundFile(true);
      }

     // 让编解码器写入 SegmentInfo。 之后必须这样做
       // 创建 CFS 以便 1) .si 不会被混入 CFS,
       // 和 2) .si 反映了 useCompoundFile=true 的变化
       // 多于:
      codec.segmentInfoFormat().write(trackingDir, info, context);

      info.addFiles(trackingDir.getCreatedFiles());

      // Register the new segment
      synchronized(this) {
        if (merges.areEnabled() == false) {
          // Safe: these files must exist
          deleteNewFiles(infoPerCommit.files());

          return docWriter.getNextSequenceNumber();
        }
        ensureOpen();

        // Now reserve the docs, just before we update SIS:
        reserveDocs(numDocs);
      
        segmentInfos.add(infoPerCommit);
        seqNo = docWriter.getNextSequenceNumber();
        checkpoint();
      }
    } catch (VirtualMachineError tragedy) {
      tragicEvent(tragedy, "addIndexes(CodecReader...)");
      throw tragedy;
    }
    maybeMerge();

    return seqNo;
  }

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值