Hadoop核心源码剖析系列(三)-元数据管理

点击上方“数据与智能”,“星标或置顶公众号”

第一时间获取好内容

作者 | 吴邪   大数据4年从业经验,目前就职于广州一家互联网公司,负责大数据基础平台自研、离线计算&实时计算研究

编辑 | auroral-L

前面的两篇文章《Hadoop核心源码剖析系列(一)》和《Hadoop核心源码剖析系列(二)》主要是剖析了NameNode和DataNode的初始化流程,包括注册和心跳机制,从中可以知道整个初始化流程主要做了哪些动作,让大家从源码的层面了解HDFS核心组件初始化的实现。有了前面初始化的基础,有助于我们下面更深入的学习其他核心知识。

 

在HDFS中我们先将集群数据分为两种:元数据和用户数据,元数据主要是指整个集群的信息,包括数据目录信息、节点信息、数据块位置存储信息、集群通信信息以及心跳信息等等,最重要的用途就是方便集群管理,快速地找到正确有效的信息。而用户数据顾名思义主要是指外部写入到HDFS集群中的业务数据,与业务场景息息相关,存储用户数据是HDFS集群的核心功能。可以看出,从HDFS本身出发,元数据的管理是HDFS对外提供服务极其重要的一环。

 

双缓存+分段加锁   

下面先用简单的代码模拟一下HDFS写数据的模型。

 

/**
 *
 *  面向对象编程,构建元数据对象
 *
 */
public class FSEditLog {
    private long txid=0L;
    private DoubleBuffer editLogBuffer=new DoubleBuffer();
    //当前是否正在往磁盘里面刷写数据
    private volatile Boolean isSyncRunning = false;
    private volatile Boolean isWaitSync = false;


    private volatile Long syncMaxTxid = 0L;
    /**
     * 一个线程 就会有自己一个ThreadLocal的副本
     */
    private ThreadLocal<Long> localTxid=new ThreadLocal<Long>();


    public static void main(String[] args) {
        final  FSEditLog fsEditLog = new FSEditLog();
        //模拟多线程操作
        for (int i =0; i < 50; i++){
            new Thread(new Runnable() {
                public void run() {


                    for (int j = 0; j < 1000; j++) {
                        fsEditLog.editLog("hdfs metadata");
                    }


                }
            }).start();


        }
    }


    /**
     * 写元数据日志方法
     *
     * @param content
     */
    public void editLog(String content){//mkdir /data
        /**
         * 在这里实现分段加锁,为了保证线程安全,操作的顺序性
         *保证元数据id的唯一性且自动递增
         * 线程1,线程2, 线程3
         */
        synchronized (this){
            //线程1
            //日志的ID号,元数据信息的ID号。
            txid++; 
            /**
             * 每个线程都会有自己的一个副本。
             * 线程1,1
             * 线程2,2
             * 线程3,3
             */
            localTxid.set(txid);
            EditLog log = new EditLog(txid, content);
            //往内存里面写数据
            editLogBuffer.write(log);
        } //释放锁


        /**
         * 内存1:
         * 线程1,1 元数据1
         * 线程2,2 元数据2
         * 线程3,3 元数据3
         */
        logSync();
    }


    private  void logSync(){
      
        synchronized (this){
            //当前是否正在往磁盘写数据,默认是false
            if(isSyncRunning){
                //当前的元数据信息的编号就是:2
              
                long txid = localTxid.get();
                // 2 <= 3
                //4 <= 3
                //5 <= 3
                if(txid <= syncMaxTxid){
                    return;
                }
                
                if(isWaitSync){
                    //直接返回
                    return;
                }
                //重新赋值
                isWaitSync = true;


                while(isSyncRunning){
                    try {
                         //释放锁
                        /**
                         * 1) 时间到了
                         * 2)被唤醒了
                         */
                        wait(2000);
                    }catch (Exception e){
                        e.printStackTrace();
                    }
                }
                //重新赋值
                isWaitSync = false;
            }


            //交换内存
            //真正的源码里面是有各种判断的。
            //频繁的交换内存,也是很影响性能的。
            editLogBuffer.setReadyToSync();


            if(editLogBuffer.currentBuffer.size() > 0) {
                //获取当前 内存2(正在往磁盘上面写数据的那个内存)
                //里面元数据日志日志编号最大的是多少
                syncMaxTxid = editLogBuffer.getSyncMaxTxid();


            }


            //说明接下来就要往磁盘上面写元数据日志信息了。
            isSyncRunning = true;
        } //释放锁


        //往磁盘上面写数据(这个操作是很耗费时间的)
        /**
         * 不加锁
         * 在最耗费时间的这段代码上面是没有加锁
         * 
         */
        editLogBuffer.flush(); //这个地方写完了。


        synchronized (this) {
            //状态恢复
            isSyncRunning = false;
            //唤醒当前wait的线程。
            notify();
        }


    }




    /**
     * 使用了面向对象的思想,把一条日志看成一个对象。
     * 日志信息,或者就是我们说的元数据信息。
     */
    class EditLog{ //
        //日志的编号,递增,唯一。
        long txid;
        //日志内容
        String content;


        //构造函数
        public EditLog(long txid,String content){
            this.txid = txid;
            this.content = content;
        }


        //方便我们打印日志
        @Override
        public String toString() {
            return "EditLog{" +
                    "txid=" + txid +
                    ", content='" + content + '\'' +
                    '}';
        }
    }


    /**
     * 双缓冲算法
     */
    class DoubleBuffer{


        //内存1
        LinkedList<EditLog> currentBuffer = new LinkedList<EditLog>();
        //内存2
        LinkedList<EditLog> syncBuffer= new LinkedList<EditLog>();


        /**
         * 把数据写到当前内存
         * @param log
         */
        public void write(EditLog log){
            currentBuffer.add(log);
        }


        /**
         * 两个内存交换数据
         */
        public void setReadyToSync(){
            LinkedList<EditLog> tmp= currentBuffer;
            currentBuffer = syncBuffer;
            syncBuffer = tmp;
        }


        /**
         * 获取当前正在刷磁盘的内存里的ID最大的值。
         * @return
         */
        public Long getSyncMaxTxid(){
            return syncBuffer.getLast().txid;
        }


        /**
         * 就是把数据刷写到磁盘上面
         * 为了演示效果,所以我们打印出来
         */
        public void flush(){
            for(EditLog log:syncBuffer){
                System.out.println("存入磁盘日志信息:"+log);
            }
            //清空内存
            syncBuffer.clear();
        }
    }
}


HDFS很巧妙运用了双缓存机制和分段加锁的方式实现了快速写入数据流量,包括现在外面很多公司进行二次开发的Hadoop,有很大部分优化是将重点放在降低锁粒度上,可以极大提高HDFS数据读写性能。

   元数据管理   

在开始剖析源码之前我们有必要先了解FileSystem这个类,FileSystem是分布式文件系统的抽象类,是Hadoop提供给用户的接口类,封装了大量的操作API,比如目录创建、删除、文件读写等等,也是我们解析元数据管理要聚焦的重点,会围绕它进行展开,以点破面,我们可以针对FileSystem中的某一个元数据管理方法进行剖析,如写数据进行解析,其他方法同理。

对于一个文件系统来说,我们写数据的第一件事情就是先创建目录:

/**
 * Call {@link #mkdirs(Path, FsPermission)} with default permission.
 * 找到这个方法的实现类,FileSystem类有指明是 DistributedFileSystem
 */
public boolean mkdirs(Path f) throws IOException {
  //重要,执行创建目录
  return mkdirs(f, FsPermission.getDirDefault());
}


/**
 * Create a directory and its parent directories.
 *
 * See {@link FsPermission#applyUMask(FsPermission)} for details of how
 * the permission is applied.
 *
 * @param f           The path to create
 * @param permission  The permission.  See FsPermission#applyUMask for 
 *                    details about how this is used to calculate the
 *                    effective permission.
 */
@Override
public boolean mkdirs(Path f, FsPermission permission) throws IOException {
  return mkdirsInternal(f, permission, true);
}


private boolean mkdirsInternal(Path f, final FsPermission permission,
    final boolean createParent) throws IOException {
  statistics.incrementWriteOps(1);
  Path absF = fixRelativePart(f);
  return new FileSystemLinkResolver<Boolean>() {
    @Override
    public Boolean doCall(final Path p)
        throws IOException, UnresolvedLinkException {
    //关键代码,获取目录路径以及权限
      return dfs.mkdirs(getPathName(p), permission, createParent);
    }
    @Override
    public Boolean next(final FileSystem fs, final Path p)
        throws IOException {
      // FileSystem doesn't have a non-recursive mkdir() method
      // Best we can do is error out
      if (!createParent) {
        throw new IOException("FileSystem does not support non-recursive"
            + "mkdir");
      }
      return fs.mkdirs(p, permission);
    }
  }.resolve(this, absF);
}

接下来我们只需要深入了解dfs.mkdirs(...)这个方法的具体实现。

/**
 *
 * Create a directory (or hierarchy of directories) with the given name and
 * permission.
 *
 * @param src
 *            The path of the directory being created
 * @param permission
 *            The permission of the directory being created. If permission ==
 *            null, use {@link FsPermission#getDefault()}.
 * @param createParent
 *            create missing parent directory if true
 * 
 * @return True if the operation success.
 * 
 * @see ClientProtocol#mkdirs(String, FsPermission, boolean)
 */
public boolean mkdirs(String src, FsPermission permission, boolean createParent) throws IOException {
   if (permission == null) {
      permission = FsPermission.getDefault();
   }
   //检查目录权限,这个不是我们关注的重点,可以先略过
   FsPermission masked = permission.applyUMask(dfsClientConf.uMask);
   //一眼看下来,眼里只有它
   return primitiveMkdir(src, masked, createParent);
}


/**
 * Same {{@link #mkdirs(String, FsPermission, boolean)} except that the
 * permissions has already been masked against umask.
 */
public boolean primitiveMkdir(String src, FsPermission absPermission) throws IOException {
   return primitiveMkdir(src, absPermission, true);
}


/**
 * Same {{@link #mkdirs(String, FsPermission, boolean)} except that the
 * permissions has already been masked against umask.
 */
public boolean primitiveMkdir(String src, FsPermission absPermission, boolean createParent) throws IOException {
   checkOpen();
   if (absPermission == null) {
      absPermission = FsPermission.getDefault().applyUMask(dfsClientConf.uMask);
   }


   if (LOG.isDebugEnabled()) {
      LOG.debug(src + ": masked=" + absPermission);
   }
   TraceScope scope = Trace.startSpan("mkdir", traceSampler);
   try {
     
   //Hadoop的RPC设计,调用NameNodeRPC服务端的代码
  //从这里我们也可以和HDFS架构中的NameNode功能对应起来,最终进行元数据管理的是由NameNode实现的
      return namenode.mkdirs(src, absPermission, createParent);
   } catch (RemoteException re) {
      throw re.unwrapRemoteException(AccessControlException.class, InvalidPathException.class,
            FileAlreadyExistsException.class, FileNotFoundException.class, ParentNotDirectoryException.class,
            SafeModeException.class, NSQuotaExceededException.class, DSQuotaExceededException.class,
            UnresolvedPathException.class, SnapshotAccessControlException.class);
   } finally {
      scope.close();
   }
}

如果对RPC比较熟悉的小伙伴应该知道接下来我们应该去前面说到的RPC服务端NameNodeRPCServer去找到mkdirs(...)实现方法,顺便验证一下我们的思路是否是正确的,如果能找到mkdirs()方法,则思路没错,反之则说明我们走偏了。

@Override 
public boolean mkdirs(String src, FsPermission masked, boolean createParent)
    throws IOException {
    //检查NameNode是否启动
  checkNNStartup();
  if(stateChangeLog.isDebugEnabled()) {
    stateChangeLog.debug("*DIR* NameNode.mkdirs: " + src);
  }
  if (!checkPathLength(src)) {
    throw new IOException("mkdirs: Pathname too long.  Limit " 
                          + MAX_PATH_LENGTH + " characters, " + MAX_PATH_DEPTH + " levels.");
  }
  //调用FSNameSystem创建目录的方法
  return namesystem.mkdirs(src,
      new PermissionStatus(getRemoteUser().getShortUserName(),
          null, masked), createParent);
}


/**
 * Create all the necessary directories
 */
boolean mkdirs(String src, PermissionStatus permissions,
    boolean createParent) throws IOException {
  HdfsFileStatus auditStat = null;
  checkOperation(OperationCategory.WRITE);
  //加写锁
  writeLock();
  try {
    checkOperation(OperationCategory.WRITE);
    //检查NameNode是否处于安全模式,如果NameNode处于安全模式下,我们是无法创建目录的
    checkNameNodeSafeMode("Cannot create directory " + src);
    //创建目录
    auditStat = FSDirMkdirOp.mkdirs(this, src, permissions, createParent);
  } catch (AccessControlException e) {
    logAuditEvent(false, "mkdirs", src);
    throw e;
  } finally {
    //释放锁
    writeUnlock();
  }
  //不管创建目录成不成功都要将数据日志持久化
  getEditLog().logSync();
  logAuditEvent(true, "mkdirs", src, null, auditStat);
  return true;
}

说到目录,我们自然而然会想到目录树结构,我们可能创建一级目录,也可能创建多级目录,HDFS的目录树结构和Linux的一样。

static HdfsFileStatus mkdirs(FSNamesystem fsn, String src,
    PermissionStatus permissions, boolean createParent) throws IOException {
  //HDFS是如何管理目录树的
  /**
   *  FSDirectory 目录树
   *
   *  hadoop  fs  -ls /
   *
   *  hdfs dfs -ls /
   *
   * 
   *
   */
  FSDirectory fsd = fsn.getFSDirectory();
  if(NameNode.stateChangeLog.isDebugEnabled()) {
    NameNode.stateChangeLog.debug("DIR* NameSystem.mkdirs: " + src);
  }
  if (!DFSUtil.isValidName(src)) {
    throw new InvalidPathException(src);
  }
  FSPermissionChecker pc = fsd.getPermissionChecker();
  byte[][] pathComponents = FSDirectory.getPathComponentsForReservedPath(src);
  fsd.writeLock();
  try {
    /**
     * hadoop fs -mkdir /user/hive/warehouse/data/test
     * fsSystem.mkdirs(new Path("/user/hive/warehouse/data/test"))
     *
     */
   //解析要创建目录的路径 /user/hive/warehouse/data/test
    src = fsd.resolvePath(pc, src, pathComponents);
    INodesInPath iip = fsd.getINodesInPath4Write(src);
    if (fsd.isPermissionEnabled()) {
      fsd.checkTraverse(pc, iip);
    }
    //  /user/hive/warehouse
    //  /user/hive/warehouse/data/test
    //找到最后一个node,即最后一级
    /**
     * 比如我们现在已经存在的目录是 /user/hive/warehouse
     * 我们需要创建的目录是:/user/hive/warehouse/data/test
     * 首先找到最后一个INode,其实就是warehouse 这个INode
     */
    final INode lastINode = iip.getLastINode();
    if (lastINode != null && lastINode.isFile()) {
      throw new FileAlreadyExistsException("Path is not a directory: " + src);
    }


    INodesInPath existing = lastINode != null ? iip : iip.getExistingINodes();
    if (lastINode == null) {
      if (fsd.isPermissionEnabled()) {
        fsd.checkAncestorAccess(pc, iip, FsAction.WRITE);
      }


      if (!createParent) {
        fsd.verifyParentDir(iip, src);
      }


      // validate that we have enough inodes. This is, at best, a
      // heuristic because the mkdirs() operation might need to
      // create multiple inodes.
      fsn.checkFsObjectLimit();
      /**
       * 已存在:/user/hive/warehouse
       * 要创建:/user/hive/warehouse/data/mytable
       * 需要创建的目录 /data/mytable
       * 计算要创建的目录结构与已存在的目录结构的差值
       */
      List<String> nonExisting = iip.getPath(existing.length(),
          iip.length() - existing.length());
      int length = nonExisting.size();
      //需要创建多级目录执行这里的逻辑
      if (length > 1) {
        List<String> ancestors = nonExisting.subList(0, length - 1);
        // Ensure that the user can traversal the path by adding implicit
        // u+wx permission to all ancestor directories
        existing = createChildrenDirectories(fsd, existing, ancestors,
            addImplicitUwx(permissions, permissions));
        if (existing == null) {
          throw new IOException("Failed to create directory: " + src);
        }
      }
      //如果只需要创建一个目录就执行下面的判断
      if ((existing = createChildrenDirectories(fsd, existing,
          nonExisting.subList(length - 1, length), permissions)) == null) {
        throw new IOException("Failed to create directory: " + src);
      }
    }
    return fsd.getAuditFileInfo(existing);
  } finally {
    fsd.writeUnlock();
  }
}

创建完目录后将元数据进行持久化,元数据的持久化分为两种,一种是存于内存中,一种是存于磁盘,对应HDFS中的类分别为FSDirectory和FSNameSystem,先写内存再刷磁盘,这里是我们要特别关注的地方,双缓存发生的地方就在下面代码中。

private static INodesInPath createSingleDirectory(FSDirectory fsd,
    INodesInPath existing, String localName, PermissionStatus perm)
    throws IOException {
  assert fsd.hasWriteLock();
  //更新文件目录树,这棵目录树是存在于内存中的,由FSNameSystem管理的
  //更新内存里面的数据
  existing = unprotectedMkdir(fsd, fsd.allocateNewInodeId(), existing,
      localName.getBytes(Charsets.UTF_8), perm, null, now());
  if (existing == null) {
    return null;
  }


  final INode newNode = existing.getLastINode();
  // Directory creation also count towards FilesCreated
  // to match count of FilesDeleted metric.
  NameNode.getNameNodeMetrics().incrFilesCreated();


  String cur = existing.getPath();
  //把元数据信息记录到磁盘上(但是一开始先写到内存)
  //往磁盘上面记录元数据日志
  fsd.getEditLog().logMkDir(cur, newNode);
  if (NameNode.stateChangeLog.isDebugEnabled()) {
    NameNode.stateChangeLog.debug("mkdirs: created directory " + cur);
  }
  return existing;
}


/** 
 * Add create directory record to edit log
 */
public void logMkDir(String path, INode newNode) {
  PermissionStatus permissions = newNode.getPermissionStatus();
  
  //创建日志对象
  MkdirOp op = MkdirOp.getInstance(cache.get())
    .setInodeId(newNode.getId())
    .setPath(path)
    .setTimestamp(newNode.getModificationTime())
    .setPermissionStatus(permissions);
  AclFeature f = newNode.getAclFeature();
  if (f != null) {
    op.setAclEntries(AclStorage.readINodeLogicalAcl(newNode));
  }
  XAttrFeature x = newNode.getXAttrFeature();
  if (x != null) {
    op.setXAttrs(x.getXAttrs());
  }
  //记录日志
  logEdit(op);
}
/**
 * Write an operation to the edit log. Do not sync to persistent
 * store yet.
 */
void logEdit(final FSEditLogOp op) {
  //采用了synchronized关键字和事务保证线程安全
  synchronized (this) {
    assert isOpenForWrite() :
      "bad state: " + state;
    
    // wait if an automatic sync is scheduled
    //一开始不需要等待
    waitIfAutoSyncScheduled();
    //最重要的就是生成了全局唯一的事务ID(日志)


    //开启事务,获取当前的独一无二的事务ID,里面调用了ThreadLocal
    long start = beginTransaction();
    op.setTransactionId(txid);


    try {
     /**
      * 1)  namenode editlog 文件缓冲里面
      * 2) journalnode的内存缓冲
      * 有兴趣的可以关注以下两个类的write方法
      * JournalSetOutputStream
      * QuorumOutputStream
      */
     //把元数据写入到内存缓冲区、write() 方法可以好好研究一下,逻辑比较复杂,里面不仅实现了将元数据写到本地磁盘中,同时还将元数据写到journalnode,并且涉及将fsimage定期checkpoint同步提交到active namenode磁盘,内容比较繁琐,本文不方便展开,感兴趣的同学可以自行阅读。
      editLogStream.write(op);
    } catch (IOException ex) {
      // All journals failed, it is handled in logSync.
    } finally {
      op.reset();
    }


    //结束事务
    endTransaction(start);
    
    // check if it is time to schedule an automatic sync
    // 看当前的内存大小是否 >= 512kb = true
    //这个条件决定了,两个内存是否交换数据
    //如果当前的内存写满了,512kb >= 512 kb 我们这儿就会返回true
    // !ture = false
    // !false =true
    if (!shouldForceSync()) {
      return;
    }
   //如果到这儿就说明 当前的那个缓冲区存满了
    isAutoSyncScheduled = true;
  } 
  //交换内存,把数据持久化到磁盘
  logSync(); 
}

交换内存,将数据刷写到磁盘,清空内存进行交换。

public void logSync() {
  long syncStart = 0;


  // Fetch the transactionId of this thread.获取线程的事务id
  long mytxid = myTransactionId.get().txid;
  
  boolean sync = false;
  try {
    EditLogOutputStream logStream = null;
    synchronized (this) {
      try {
        printStatistics(false);
        //比较当前线程的事务ID和正在同步的事务ID,如果已经在刷磁盘了,当前线程就不用刷写磁盘了
        while (mytxid > synctxid && isSyncRunning) {
          try {
            //释放锁,等待唤醒
            wait(1000);
          } catch (InterruptedException ie) {
          }
        }        
        // If this transaction was already flushed, then nothing to do 
        //如果该事务已经被刷新,则无需操作,直接返回      
        if (mytxid <= synctxid) {
          numTransactionsBatchedInSync++;
          if (metrics != null) {
            // Metrics is non-null only when used inside name node
            metrics.incrTransactionsBatchedInSync();
          }
          return;
        }
   
        // now, this thread will do the sync
        syncStart = txid;
        isSyncRunning = true;
        sync = true;


        // swap buffers 交换内存
        try {
          if (journalSet.isEmpty()) {
            throw new IOException("No journals available to flush");
          }
          //交换内存缓冲,有兴趣的同学可以点进去看一下setReadyToFlush方法
          /*
          *public void setReadyToFlush() {
              assert isFlushed() : "previous data not flushed yet";
              TxnBuffer tmp = bufReady;
              bufReady = bufCurrent;
              bufCurrent = tmp;
            }
          *
          */
          editLogStream.setReadyToFlush();
        } catch (IOException e) {
          final String msg =
              "Could not sync enough journals to persistent storage " +
              "due to " + e.getMessage() + ". " +
              "Unsynced transactions: " + (txid - synctxid);
          LOG.fatal(msg, new Exception());
          synchronized(journalSetLock) {
            IOUtils.cleanup(LOG, journalSet);
          }
          terminate(1, msg);
        }
      } finally {
        // Prevent RuntimeException from blocking other log edit write
        //恢复标志位 和 唤醒等待的线程
        doneWithAutoSyncScheduling();
      }
      //editLogStream may become null,
      //so store a local variable for flush.
      logStream = editLogStream;
    }
    
    // do the sync
    long start = monotonicNow();
    try {
      if (logStream != null) {
       //真正把数据写到磁盘
       //默默的在刷写磁盘就可以。      
       /**
        * 內存一: 服務于namenode的内存
        * 內存二: 服務于journalnode的内存
        */
       
        logStream.flush();
      }
    } catch (IOException ex) {
      synchronized (this) {
        //打印日志
        final String msg =
            "Could not sync enough journals to persistent storage. "
            + "Unsynced transactions: " + (txid - synctxid);
        //如果我们的程序里面发生了fatal 级别日志,这个错误
        //就是灾难型的错误。
        LOG.fatal(msg, new Exception());
        synchronized(journalSetLock) {
          IOUtils.cleanup(LOG, journalSet);
        }
        //执行这段代码
        terminate(1, msg);
      }
    }
    long elapsed = monotonicNow() - start;
    // Metrics non-null only when used inside name node
    if (metrics != null) { 
      metrics.addSync(elapsed);
    }
    
  } finally {
    // Prevent RuntimeException from blocking other log edit sync 
    synchronized (this) {
      if (sync) {
        synctxid = syncStart;
        //持久化数据到磁盘之后恢复标志位
        isSyncRunning = false;
      }
      //同时唤醒线程,通知该事务已经完成,可以进行下一次刷写
      this.notifyAll();
   }
  }
}

本文主要剖析了HDFS元数据管理流程,以上传文件为例,以点破面,引申出HDFS底层架构双缓存+分段加锁两个体现HDFS性能效率的优秀设计思想。这篇文章主要针对双缓存方案进行展开,涉及到了线程安全、高并发、NIO、流对拷相关的知识,这也是我们在学习分布式技术中必不可少的技能包。HDFS的双缓存和分段加锁同样适用于我们工作中的代码设计,降低锁粒度是提高效率的有效手段。


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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值