MapReduce的各个细节你都知道了吗?MapReduce万字解析 Google高效分布式计算框架 MIT 6.824推荐论文 论文精读

说明

本文是阅读MapReduce论文后写的笔记,大部分内容来自于原文的直接翻译,少部分部分补充了自己觉得阅读者可能有疑问的问题,同时对于这些问题自己给出了一些见解,这些见解可能并不一定正确,欢迎大家斧正。
如果你觉得阅读英文的文章很困难,那么阅读本篇笔记来代替阅读原文是你不错的选择。

论文原文链接

MapReduce: Simplified Data Processing on Large Clusters

ABS

MapReduce是一个用于处理和生成大量数据集合的编程模型,开发者可以利用MapReduce进行分布式开发。开发者只需要编写map函数和reduce函数,map函数用来产生中间的key-value对,而reduce函数负责处理key相同的中间key-value对。
MapReduce很符合函数式编程的定义,用户只需要编写两个函数既可以完成相关工作,而函数会在分布式系统上并行执行,这些都是用户不需要关心的。数据如何在分布式系统中分发,分布式系统的冗余处理都是由系统自动完成,即使没有分布式开发经验的开发人员也可以利用该系统开发出功能强大的分布式应用。
注:MapReduce的受众群体是需要是需要利用分布式来开发应用的开发人员,所以用户是指开发人员。

1 INTRO

设计这样一个系统的起因是,Google内部的很多计算需要用到分布式计算,例如处理爬虫爬取的文件、统计页面访问次数等,而在分布式系统中进行并行需要考虑很多复杂问题。而处理分布式中的这些问题会让一个原本简单的问题变得非常复杂(例如原始问题是统计单词的统计次数,但是如果在分布式系统中进行,那么需要考虑的问题包括但不限于:某台主机宕机,如何分发数据)。
而设计MapReduce就只需要考虑原始的问题,中间数据分发的细节,宕机的处理等等这些全部由这个系统自己处理。
作者提到MapReduce这个名字来源于Lisp中的原语mapreduce,同时公司的人员发现他们很多需要借助分布式的工作可以被分解成mapreduce的过程。
本文的贡献:提出了一个简单但是功能强大的接口,该接口具有以下功能:

  • 自动并行计算;
  • 大规模计算的分布式处理;

除了提供接口之外,还提供了接口的高性能实现。

2 Programming Model

MapReduce的输入是一系列的键值对(例如文件名-文件内容),输出也是一系列的键值对(例如单词-出现次数)。用户只需要编写map函数和reduce函数。
map函数:该函数会输出中间键值对,对于所有的输出MapReduce的底层会自动将不同结点上的相同的key的数据(这里实际上还有一个partition的过程后文会详细介绍)发送到同一个reduce函数进行处理。
reduce函数:处理接收到的键值对数据,不同结点上的map函数产生具有相同keyvalue会被拼接成一个列表传递给reduce函数。

2.1 Example

经典例子:统计许多文件组成的文件集合中单词的出现次数。
解决该问题的伪代码如下:

map(String key, String value):
  // key: document name
  // value: document contents
  for each word w in value:
  EmitIntermediate(w, "1");

reduce(String key, Iterator values):
  // key: a word
  // values: a list of counts
  int result = 0;
  for each v in values:
  result += ParseInt(v);
  Emit(AsString(result));

上述伪代码中,map函数每遇到一个单词就将该单词的出现次数增加 1 1 1。而reduce则是将传递过来的中间值进行累加即可的都整个文件集合的单词出现次数。

2.2 Types

可以上面的代码使用的是string作为数据类型,但是在MapReduce的实现中存在着类型转换,其中map函数的输入类型与其输出类型是不同的(来自不同的域,例如上面的例子中,输入是文件名和文件内容而输出单词和对应的出现次数),而中间值的类型与reduce函数的输出类型是相同的(来自同一域,这里可以理解成有相同的取值范围)。
下面的代码可以很好的解释这一点:

map (k1,v1) → list(k2,v2)
reduce (k2,list(v2)) → list(v2)

不同用户不用担心这一点,这些不同结果由MapReduce自动完成相应的类型转换以保证结果的正确性。

2.3 More Examples

说明 1 1 1grepunix/linux下的一个工具用于过滤出输入中满足模式串的内容。
说明 2 2 2Reverse Web-Link Graph是指统计某个target网址能够由哪些source跳转。
说明 3 3 3Term-Vector per Host是指统计每个Host的关键字,如果数据是Host的访问记录,那么关键字可以理解为Host最常访问的某几个网站的网址。
说明 4 4 4Inverted Index表示倒排索引,这里的例子是需要统计每个词语出现在哪些文件中。

  • Distributed Grep:在这个例子中map函数会产生出满足模式串的内容,而reduce函数只需要将这些内容原封不动的进行输出即可。
  • Count of URL Access Frequency:该例子与统计单词个数是类似的,只是单词变成了URL
  • Reverse Web-Link Graphmap函数只需要输出<target, source>键值对,与单词统计不同的是,这里是将source放到列表中(或者以分割符分割的形式进行拼接);reduce则只需要将收到的value中的值全部拼接起来,就可以产生<target, source>的输出表示target可以由source中的网址跳转而来。
  • Term-Vector per Hostmap只需要统计出自己输入的关键字产生<host, term vector>中间值;reduce将这些值全部拼接起来,然后再按照关键程度排序保留最关键的几个值即可。
    • 例如需要统计用户最长访问的 5 5 5个网站,如果可以的话,各个结点的map都需要找到最高频率的五个网站,同时需要将网站的访问频率一同发送,如果有五个结点运行map并且每个节点都找到了各自文件中每个最常访问的 5 5 5个(可能少于 5 5 5个)网站,那么对于reduce处理每个用户的时候,将所有value进行拼接,可能得到多余 5 5 5个网站,这个时候再从这些网站中选取频率最高的 5 5 5个网站作为最终结果即可。
  • Inverted Indexmap函数只需要统计出<word, document ID>,而reduce只需要将获得的<word, list(document ID)>的每个单词的list按照文件的ID进行排序即可。
  • Distributed Sort:这里的内容会在第四部分详细讲解(涉及partition的过程)。

3 Implementation

不同的情况下需要使用不同的MapReduce实现,这里作者根据Google内部商用PC集群提出了一种实现方法。
环境介绍:

  • 每个结点有两个x86处理器,2-4GB的内存;
  • 机器级别的网络速度为100Mbps或者1Gbps(这里应该是指网卡支持的最大速度),但实际的网络速度远比这个小得多;
  • 十万级别的结点个数;
  • 存在一个内部开发的分布式文件系统来管理存放在磁盘上的数据。该文件系统通过将数据进行复制来保证可靠性和可用性。
  • 用户提交jobs给调度系统,调度系统会将每个job细分为多个tasks,每个job由调度系统分发给一系列结点进行执行。

3.1 Execution Overview

系统会自动将所有的输入分成M份,而对于这M份输入的处理,可以分给多个结点进行处理。对于产生的中间值,系统会使用用户提供的partition函数将其分成R份(这里partition函数和R均由用户提供,如果不提供默认的是根据key的一个哈希,即hash(key)%R相同的会被分到同一部分)。
Figure 1展现了整个MapReduce的过程
在这里插入图片描述

Figure 1中的各个步骤描述如下:

  1. 首先会根据用户指定的分片大小将所有输入文件分成若干个分片。用户的程序会在许多的结点上启动。
  2. 众多运行程序的节点中存在一个主节点(图中的Master),主节点将会决定哪些节点执行map函数,那些结点执行reduce工作,实际上当一个结点空闲时间,主节点就会选择安排map或者reduce工作给这个空闲的节点。
  3. 执行map的工作节点会将分配给自己处理的数据在自己本地执行产生的中间键值对会暂时保存在自己的内存中。
  4. 存储在内存中的中间结果会周期性的写入到磁盘中,这些键值对再存入之前会执行用户提供的partition将中间值分成R个部分,每个部分的存储位置会发送给主节点,主节点会将这些信息发送给执行reduce的结点。
  5. 当一个执行reduce的工作结点能够开始工作时,该结点会通过RPC获取到中间键值对,然后按照key进行排序,这一步是为了让相同的key能够连续出现(虽然相同的key会被分到同一个结点进行处理,但是这些数据中可能并不只有一个key所以需要进行一次排序)。当中间结果过多而不能一次性读入内存时,这时会需要进行额外的排序。
  6. 执行reduce的结点会遍历排序后的中间键值对,然后将同一个keyvalue拼接起来作为参数传递给reduce函数,每个结点将自己的最终的结果输出到各自的一个文件中。
  7. 当所有mapreduce执行完成后,用户程序对应的进程会被唤醒,此时会继续执行用户进程的后续部分。

最终会输出R个文件(每一个reduce task会输出一个文件),用户可以将这些文件继续作为另一个MapReduce的输入以执行其他的分布式计算。

3.2 Master Data Structures

主节点会记录每个task的状态,包含:空闲,进行中,完成。同时还会为非空闲的tasks记录每个task各自由那个结点执行。
同时主节点还需要记录由map产生的R个文件的位置和大小。这些信息会按照增量信息的方法传递给执行reduce任务的结点。

3.3 Fault Tolerance

工作结点故障
主节点会周期性的对工作结点进行ping操作。如果对某个工作结点进行ping操作之后的一定时间内没有收到回复(可以理解为一段时间内ping不通),那么主节点会将该工作结点标记为故障状态。为了进行后续的工作,所有由该结点完成的map tasks将会全部被设置为idle状态(即未开始状态),这样设置是为了这些任务能够重新被分配到正常的工作结点上进行执行。当然正在由该节点执行的任务不论是map task还是reduce task都会被设置为idle状态。
为什么完成了的map tasks需要重新执行,而完成了的reduce tasks不需要重新被执行?

这是因为map tasks的输出被保存在工作结点的本地磁盘上,如果一个工作结点宕机了,那么其他工作结点是无法获取这一部分的数据;而reduce tasks的输出保存在一个全局的文件系统上,某个工作结点宕机,之前的reduce tasks的输出依然可以被访问。

那为什么map tasks的输出不保存到一个全局的文件系统上呢?

这里文章中没有给出这一部分的说明,不过我猜测是因为,map tasks的输出主要是作为reduce tasks的输入使用,当reduce tasks完成后这一部分数据就没有太大的价值,所以没有将这一部分数据放到一个共享的文件系统上。

当一个工作结点故障后,执行reduce的结点会从新的结点去获取中间键值对。
这样的设计特性即使有大量的工作结点故障,最终也能够完成分布式任务。
主节点故障
在只有一个主节点的情况下,如果主节点故障(这通常不会发生),那么整个任务会终止,用户可以重新执行该任务。
而如果有多个可以承担主节点工作的结点,当主节点故障了,只需要让另一个可以承担主节点工作的结点担任主节点即可(这些主节点需要保存和主节点的数据结构,例如那些任务还没被完成,而这些数据可能并不会和主节点完全一致,可能会落后几轮,但这并不会影响结果的正确性)
故障时的语义
当用户的函数(mapreduce)对于相同的输入在多次执行能够保障同样的输出,那么MapReduce系统能够保证出现故障的情况下的输出与不出现故障的情况下一样。
保证map tasksreduce tasks的输出的提交过程是原子性的,是上述特性的实现方式。当一个map task执行完成之后,会向主结点发送中间输出文件的名字,主结点需要记录这些文件及其位置等信息,如果主节点又收到了一个已经完成的map task的完成信息的时候,主节点会忽略这些信息。
为什么主节点会收到重复的完成信息?

一是主节点发送了ping之后没有收到响应信息,但是实际上工作结点没有宕机,那么后续可能会受到新结点发送的某个map task的完成信息和这个“宕机”结点的完成信息。
二是可能出现straggler的情况,3.6 Backup Tasks部分会详细介绍。

如果有多个节点执行同一个reduce task那么对于输出文件的原子操作会保证最后只有一个输出文件。
为什么会有多个结点执行同一个reduce task

一是可能当主节点ping之后没有收到响应信息,但是这个时候工作结点并没有宕机,此时主节点将该工作结点的reduce task给其他工作结点执行,这样就会产生两从输出文件;
二是可能出现straggler的情况,3.6 Backup Tasks部分会详细介绍。

对于非确定性的程序(指对于相同的输出多次运行后的输出可能会不同),某个特定的reduce task会得到与串行运行相同的输出,而其余的reduce task输出则不一定能够保证。

3.4 Locality

输入文件会被拆分成若干份大小为64 MB的块,对于每一个块通常会有三个工作结点会对其进行保存(也就是一个块同时存在三个结点上)。这样的话,当给某个快分配map task进行处理的时候,会优先让这个任务在拥有这个块的结点上处理(也就是三个结点中的某一个结点),这样就不不需要进行文件的传输。而如果没有这样的节点(意味着这三个节点可能正在执行其他的任务),那么会优先将任务分配给距离含有数据的结点更近的结点(这里通常是指处于同一个子网下的两个结点)这样在进行数据传输的时候可以不需要太多的网络带宽。
上述的描述可以看出,同一个块的三份数据应该保存在不同的子网中这样才能是效率尽可能的提升。
向不同的结点上面复制三份数据不也会消耗大量的带宽吗,既然是这样为什么这样做反而能提升性能?

一开始的分发过程是发生在系统开始计算之前,这样的过程是统筹安排的,实际上对于一个确定的带宽发送时间应该是确定的。但是如果一开始并不将数据发送给各个机器,而是在执行过程中才一次次的获取数据,那么可以出现更严重的情况是,某些节点明明完成了任务,但是因为迟迟不能获取数据而导致这些计算资源没有能够被利用起来,从而很容易导致整体的速度下降(这在结点个数增多的情况下会更加明显)。

3.4 Task Granularity

根据前面的讨论可以发现会有Mmap tasksRreduce tasks
往往会将map job分成远多于结点个数的map tasks这样有助于动态的负载均衡。
同时我们可以根据复杂度来决定MR的大小,可以知道主节点需要做 O ( M + R ) O(M+R) O(M+R)次的调度,而存储状态的空间复杂度为 O ( M R ) O(MR) O(MR)(由前面可以知道主节点需要存储每个map task产生的R个文件的信息,所以是两者相乘)。
同时通常会选择 R R R的大小是工作结点的几倍,例如文中提到了的一组选择是: M = 200 , 000   R = 5 , 000 M=200,000\ R=5,000 M=200,000 R=5,000工作结点的个数为 2000 2000 2000

3.6 Backup Tasks

straggler:掉队者,指的是在大多数任务完成后,最后几个还在执行任务的结点,通常这些结点的执行速度会变得非常慢。而执行速度变慢的原因有很多,可能磁盘IO速度下降,CPU被其他任务占用,内存不够导致频繁的发生交换操作等。
为了缓解straggler出现的情况,主节点在发现最后还有几个任务执行很慢的情况下,会把这几个任务分配给其他的空闲结点,而主节点会保留最先收到的完成信息,这也解释了为什么可能会有多个结点执行同一个task的情况(3.3中提出的问题)。

4 Refinements

4.1 Partitioning Function

默认情况下的partitioning functionhash(key) mod R但是有时候我们需要根据实际情况来自定义partitioning function例如对于keyURL的情况,我们可能需要将同一个服务器的URL相关数据输出到同一个输出文件,那么此时我们可以选择hash(hostname(URL)) mod R作为partitioning function

4.2 Ordering Guarantees

保证处理中间键值对的时候是按照key的升序顺序这里,这样能够保证输出文件是有序的。这样做可能会方便用户后续的处理,例如查找某个数据(在有序而数据中进行查找的耗时要比无序的数据少)。

4.3 Combiner Function

combiner function可以理解就是在执行map tasks完成后,在该结点上执行一次reduce函数以达到减少网络带宽使用。以word counting为例,由于二八原理的存在,一个map task的中间输出可能会有成百上千条<the, 1>(因为the很常用),而如果不对这些数据进行combiner function那么需要传送成百上千条<the, 1>而如果做了一次combiner function也就是本地做一次reduce如果the在本次任务的输入中出现了 100 100 100次,那么传递数据就会变成<the, 100>传递的数据量大大减少,从而达到节约网络带宽的目的。

4.4 Input and Output Types

除了使用提供的text类型作为输入输出类型之外,用户可以通过实现reader来自定义输入类型,当然输出文件的类型也可以自定义。

4.5 Side-effects

用户可能需要输出其他的文件来方便后续的操作,需要注意的是MapReduce并不能保证这些文件输出的原子性,所以这一部分文件的原子性和幂等性(指多次执行输出相同)应该由用户来保证。

4.6 Skipping Bad records

MapReduce的一种容错机制,在处理某些tasks的时候,这些tasks读取的数据可能存在问题而导致程序崩溃。为了能够让整个程序继续执行,当一次执行出现崩溃(一般是指收到段错误或者总线错误)此时执行任务的结点会发送任务的索引并告知主节点该任务执行失败,如果主节点收到某个任务超过一次(这通常意味着该任务在不同的主机上均出现了错误),那么主节点会跳过该任务,不需要处理这一部分的数据。
这么做的合理性?

需要进行分布式计算的数据量往往非常巨大,而一个task的数据粒度相比所有数据是非常小的,在这种情况下丢弃一些tasks不会导致最终结果的准确性(这是大数据的特性之一)。

4.7 Local Execution

这一部分是为了用户调试所设计的。如果直接在实际环境上进行调试,由于任务的有哪一个结点执行是动态的,进行调试难度较大。而MapReduce提供了本地调试的库,用户可以指定各个任务由哪些结点执行。

4.8 Status Information

提供了一个性能检测的页面,可以看到各种各样的详细信息。

4.9 Counters

提供了计数器,计数器信息是在heartbeat过程中的响应信息携带的,会周期性的发送给主节点,主节点会将计数器的信息显示在性能监控页面上,同时主节点会自动去除任务重复执行而导致计数器增长的数据。
例如要统计大写单词的数量,则可以使用下面的伪代码:

Counter* uppercase;
uppercase = GetCounter("uppercase");

map(String name, String contents):
for each word w in contents:
  if (IsCapitalized(w)):
    uppercase->Increment();
  EmitIntermediate(w, "1");

5 Performance

5.1 Cluster Configuration

大约 1800 1800 1800个结点。每个节点及其有两块启用超线程的 2 G H z 2GHz 2GHzCPU 4 G B 4GB 4GB的内存,两块 160 G B 160GB 160GB的机械硬盘。 1 G b p s 1Gbps 1Gbps的以太网连接,网络拓扑是一个两层的树形结构,根节点的带宽有 100 − 200 G b p s 100-200Gbps 100200Gbps,所有结点都处于同一域名下,任意两个结点之间的来回通信时间(指不携带数据的情况)不超过一毫秒。 4 G B 4GB 4GB的内存有 1 − 1.5 G B 1-1.5GB 11.5GB用于其他应用程序。

5.2 Grep

在该项任务中需要扫描 1 0 10 10^{10} 1010 100 B 100 B 100B的记录,查找目标字符串(目标字符串出现在 92 , 337 92,337 92,337条记录中)。输入文件被分成了 64 M B 64MB 64MB大小的块(这意味着 M = 15 , 000 M=15,000 M=15,000)将所有的输出输出到一个文件中(意味着 R = 1 R=1 R=1)。
下面的Figure 2展示了随着计算的进行处理数据的速度,横轴代表工作时间,竖轴代表当前时间下处理数据的速度。整个计算过程花了 150 150 150秒左右。
在这里插入图片描述

5.3 Sort

一共有 1 0 10 10^{10} 1010条数据需要排序,每条数据的大小为 100 B 100 B 100B
我们首先了解MapReduce是如何实现分布式排序的,我们的map函数需要提取输入文件的每一项记录的用于排序的值作为中间键值对的key而每一项记录作为中间键值对的value这样在map函数执行完成后, 4.1   P a r t i t i o n i n g   F u n c t i o n 4.1\ Partitioning\ Function 4.1 Partitioning Function部分介绍了会进行分组,分组后 4.2   O r d e r i n g   G u a r a n t e e n s 4.2\ Ordering\ Guaranteens 4.2 Ordering Guaranteens中介绍了会按照key进行升序的排序(排序的过程是在reduce结点拿到了数据后进行也就是执行reduce的结点进行key的排序),这样map的中间输出就是局部有序的。我们的reduce函数不需要做任何其余的处理,只需要将受到的数据输出到输出文件中,这样每一个输出文件就是有序的。
需要注意的是由于GFS的实现机制为了保障可用性和可靠性,输出文件会被输出两次,也就是最终需要输出 2   T B 2\ TB 2 TB的数据。
Grep任务一样将输入分成大小为 64 M B 64MB 64MB的块( M = 15 , 000 M=15,000 M=15,000),设置 R = 4 , 000 R=4,000 R=4,000
Figure 1的左边三张图分别展示了:map的速率,传递中间值的速率,reduce的执行速率。
从图片中可以看到处理的最大速率比Grep任务要少,这是因为Sort的中间文件是与输入文件大小等价的,而grep的中间文件包含的内容是已经匹配的模式串,该文件的大小是远小于输入文件大小的。同时可以看到在中间数据传递完成后reduce并没有立即开始写文件而是有一个延迟,这一部分的时间实际上是在对key进行排序(这也能证明key的排序是在reduce方进行的)。
在这里插入图片描述

5.4 Effect of Backup Tasks

backup tasks 3.6 3.6 3.6部分介绍的,Figure 3中的中间(竖着)三张图展示了关闭backup taskssort的各个部分的执行时长。

5.5 Machine Failures

这一部分故意通过kill命令在几分钟内随机终止 200 200 200个进程(这意味着结点仍然是可用的,只是任务失败了),右边(竖着)三张图展现了这样做sort的各部分执行时间,可以看到在处理过程中出现了负数速率,这是因为这部分被终止的任务必须要重新执行(速率的计算应该是通过上一时刻的数据量和当前时刻的剩余数据量之差除以时间间隔,由于任务需要重新执行所以当前的数据量增大了,导致速率出现了负数)。

6 Experience

作者团队在完成了MapReduce的初次实现与优化后,发现该模型适用于很多领域:

  • 大规模的机器学习训练;
  • Google NewsFroogle Products的聚类问题;
  • 提取搜索关键字中最常被搜索的词(用于流行词统计);
  • 提取能够用于新实验的网页属性;
  • 大规模图计算。
    在这里插入图片描述

Figure 4展示了基于MapReduce的应用数量的增长情况。
在这里插入图片描述

Table 1展示了Google统计的一部分MapReduce的使用情况数据。

6.1 Large-Scale Indexing

MapReduce应用到大规模的索引系统上(指的是搜索引擎的索引,例如使用谷歌搜索后,实际上是一个分布式爬虫,现在需要将各个爬虫的文件中,索引出符合用户关键字的信息)有以下好处:

  • 代码编写更加简单,容易理解。因为很多细节的实现都被隐藏了;
  • 性能足够好,同时当任务发生改变的时候,能够更快的部署;
  • 更容易操作,因为中间的故障处理等都是由底层自动完成的。

7 Related Work

这一部分介绍了一些相关系统。
许多系统已经实现了分布式计算,但是这些方法实现的分布式计算并没有隐藏实现系统,例如故障处理等需要用户参与这一部分,这导致开发成本的上升。
一类同步编程与提供一些原语用于实现分布式编程的系统与MapReduce的不同在于MapReduce充分利用了固定的编程模式形式(用户指定mapreduce函数)以及让差错处理对用户透明。
本地优化( 3.4   L o c a l i t y 3.4\ Locality 3.4 Locality)的灵感来自于active disks
backup tasksCharlotte System中的渴望调度机制类似。
MapReduce中的key-sortNOW-Sort中的操作类似。
River是另一个分布式计算系统,该系统通过精心选择进行通信的结点来提高效率,MapReduce则是通过固定编程模式来实现分布式计算的简洁性。
BAD-FS不同于MapReduce,该系统是做广域网下的分布式计算。即使是这样,两个系统之间也有亮点类似的地方:1)通过重复执行任务来解决结点故障问题;2)通过locality机制减少数据的发送。
TACC是一个为了简化高可用网络服务架构的系统。该系统与MapReduce的相似点是通过重复执行任务来处理结点故障的情况。

8 Conclusions

MapReduce已经在Google中的很多任务进行的使用。这些成功可以归结于它具有的以下特点:

  • 容易上手:没有分布式编程经验的人也能够很容易地使用MapReduce因为其中并行等细节全部是透明的。
  • 大量的问题可以被MapReduce解决。
  • 已经实现了用于大规模分布式的MapReduce系统。

能够成功实现MapReduce并能够广泛使用的原因:

  • 限制编程模式使其容易进行并行计算。
  • 该系统利用一些机制来节约网络带宽。
  • 使用机制减缓了stragller的情况。
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值