mapreduce从出现以来,已经成为apache hadoop计算范式的扛鼎之作。它对于符合其设计的各项工作堪称完美:大规模日志处理,etl批处理操作等。
随着hadoop使用范围的不断扩大,人们已经清楚知道mapreduce不是所有计算的最佳框架。hadoop 2将资源管理器yarn作为自己的顶级组件,为其他计算引擎的接入提供了可能性。如impala等非mapreduce架构的引入,使平台具备了支持交互式sql的能力。
今天,apache spark是另一种这样的替代,并且被称为是超越mapreduce的通用计算范例。也许您会好奇:mapreduce一直以来已经这么有用了,怎么能突然被取代看毕竟,还有很多etl这样的工作需要在hadoop上进行,即使该平台目前也已经拥有其他实时功能。
值得庆幸的是,在spark上重新实现mapreduce一样的计算是完全可能的。它们可以被更简单的维护,而且在某些情况下更快速,这要归功于spark优化了刷写数据到磁盘的过程。spark重新实现mapreduce编程范式不过是回归本源。spark模仿了scala的函数式编程风格和api。而mapreduce的想法来自于函数式编程语言lisp。
尽管spark的主要抽象是rdd(弹性分布式数据集),实现了map,reduce等操作,但这些都不是hadoop的mapper或reducer api的直接模拟。这些转变也往往成为开发者从mapper和reducer类平行迁移到spark的绊脚石。
与scala或spark中经典函数语言实现的map和reduce函数相比,原有hadoop提供的mapper和reducer api 更灵活也更复杂。这些区别对于习惯了mapreduce的开发者而言也许并不明显,下列行为是针对hadoop的实现而不是mapreduce的抽象概念:
· mapper和reducer总是使用键值对作为输入输出。
· 每个reducer按照key对value进行reduce。
· 每个mapper和reducer对于每组输入可能产生0个,1个或多个键值对。
· mapper和reducer可能产生任意的keys和values,而不局限于输入的子集和变换。
mapper和reducer对象的生命周期可能横跨多个map和reduce操作。它们支持setup和cleanup方法,在批量记录处理开始之前和结束之后被调用。
本文将简要展示怎样在spark中重现以上过程,您将发现不需要逐字翻译mapper和reducer!
作为元组的键值对
假定我们需要计算大文本中每一行的长度,并且报告每个长度的行数。在hadoopmapreduce中,我们首先使用一个mapper,生成为以行的长度作为key,1作为value的键值对。
public class linelengthmapper extends
mapper
@override
protected void map(longwritable linenumber, text line, context context)
throws ioexception, interruptedexception {
context.write(new intwritable(line.getlength()), new intwritable(1));
}
}
值得注意的是mappers和reducers只对键值对进行操作。所以由textinputformat提供输入给linelengthmapper,实际上也是以文本中位置为key(很少这么用,但是总是需要有东西作为key),文本行为值的键值对。
与之对应的spark实现:
lines.map(line => (line.length, 1))
spark中,输入只是string构成的rdd,而不是key-value键值对。spark中对key-value键值对的表示是一个scala的元组,用(a,b)这样的语法来创建。上面的map操作的结果是(int,int)元组的rdd。当一个rdd包含很多元组,它获得了多个方法,如reducebykey,这对再现mapreduce行为将是至关重要的。
reduce
reduce()与reducebykey()
统计行的长度的键值对,需要在reducer中对每种长度作为key,计算其行数的总和作为value。
public class linelengthreducer extends
reducer
@override
protected void reduce(intwritable length, iterable
context context) throws ioexception, interruptedexception {
int sum = 0;
for (intwritable count : counts) {
sum += count.get();
}
context.write(length, new intwritable(sum));
}
}
spark中与上述mapper,reducer对应的实现只要一行代码:
val lengthcounts = lines.map(line => (line.length, 1)).reducebykey(_ + _)
spark的rdd api有个reduce方法,但是它会将所有key-value键值对reduce为单个value。这并不是hadoop mapreduce的行为,spark中与之对应的是reducebykey。
另外,reducer的reduce方法接收多值流,并产生0,1或多个结果。而reducebykey,它接受的是一个将两个值转化为一个值的函数,在这里,就是把两个数字映射到它们的和的简单加法函数。此关联函数可以被调用者用来reduce多个值到一个值。与reducer方法相比,他是一个根据key来reduce value的更简单而更精确的api。
mapper
map() 与 flatmap()
现在,考虑一个统计以大写字母开头的单词的个数的算法。对于每行输入文本,mapper可能产生0个,1个或多个键值对。
public class countuppercasemapper extends
mapper
@override
protected void map(longwritable linenumber, text line, context context)
throws ioexception, interruptedexception {
for (string word : line.tostring().split(" ")) {
if (character.isuppercase(word.charat(0))) {
context.write(new text(word), new intwritable(1));
}
}
}
}
spark对应的写法:
lines.flatmap(
_.split(" ").filter(word => character.isuppercase(word(0))).map(word => (word,1))
)
简单的spark map函数不适用于这种场景,因为map对于每个输入只能产生单个输出,但这个例子中一行需要产生多个输出。所以,和mapperapi支持的相比,spark的map函数语义更简单,应用范围更窄。
spark的解决方案是首先将每行映射为一组输出值,这组值可能为空值或多值。随后会通过flatmap函数被扁平化。数组中的词会被过滤并被转化为函数中的元组。这个例子中,真正模仿mapper行为的是flatmap,而不是map。
groupbykey()
写一个统计次数的reducer是简单的,在spark中,reducebykey可以被用来统计每个单词的总数。比如出于某种原因要求输出文件中每个单词都要显示为大写字母和其数量,在mapreduce中,实现如下:
public class countuppercasereducer extends
reducer
@override
protected void reduce(text word, iterable
throws ioexception, interruptedexception {
int sum = 0;
for (intwritable count : counts) {
sum += count.get();
}
context
.write(new text(word.tostring().touppercase()), new intwritable(sum));
}
}
但是redecebykey不能单独在spark中工作,因为他保留了原来的key。为了在spark中模拟,我们需要一些更像reducer api的操作。我们知道reducer的reduce方法接受一个key和一组值,然后完成一组转换。groupbykey和一个连续的map操作能够达到这样的目标:
groupbykey().map { case (word,ones) => (word.touppercase, ones.sum) }
groupbykey只是将某一个key的所有值收集在一起,并且不提供reduce功能。以此为基础,任何转换都可以作用在key和一系列值上。此处,将key转变为大写字母,将values直接求和。
setup()和cleanup()
在mapreduce中,mapper和reducer可以声明一个setup方法,在处理输入之前执行,来进行分配数据库连接等昂贵资源,同时可以用cleanup函数可以释放资源。
public class setupcleanupmapper extends
mapper
private connection dbconnection;
@override
protected void setup(context context) {
dbconnection = ...;
}
...
@override
protected void cleanup(context context) {
dbconnection.close();
}
}
spark中的map和flatmap方法每次只能在一个input上操作,而且没有提供在转换大批值前后执行代码的方法,看起来,似乎可以直接将setup和cleanup代码放在sparkmap函数调用之前和之后:
val dbconnection = ...
lines.map(... dbconnection.createstatement(...) ...)
dbconnection.close() // wrong!
然而这种方法却不可行,原因在于:
· 它将对象dbconnection放在map函数的闭包中,这需要他是可序列化的(比如,通过java.io.serializable实现)。而数据库连接这种对象一般不能被序列化。
· map是一种转换,而不是操作,并且拖延执行。连接对象不能被及时关闭。
· 即便如此,它也只能关闭driver上的连接,而不是释放被序列化拷贝版本分配的资源连接。
事实上,map和flatmap都不是spark中mapper的最接近的对应函数,spark中mapper的最接近的对应函数是十分重要的mappartitions()方法,这个方法能够不仅完成单值对单值的映射,也能完成一组值对另一组值的映射,很像一个批映射(bulkmap)方法。这意味着mappartitions()方法能够在开始时从本地分配资源,并在批映射结束时释放资源。
添加setup方法是简单的,添加cleanup会更困难,这是由于检测转换完成仍然是困难的。例如,这样是能工作的:
lines.mappartitions { valueiterator =>
val dbconnection = ... // ok
val transformediterator = valueiterator.map(... dbconnection ...)
dbconnection.close() // still wrong! may not have evaluated iterator
transformediterator
}
一个完整的范式应该看起来类似于:
lines.mappartitions { valueiterator =>
if (valueiterator.isempty) {
iterator[...]()
} else {
val dbconnection = ...
valueiterator.map { item =>
val transformeditem = ...
if (!valueiterator.hasnext) {
dbconnection.close()
}
transformeditem
}
}
}
虽然后者代码翻译注定不如前者优雅,但它确实能够完成工作。
flatmappartitions方法并不存在,然而,可以通过调用mappartitions,后面跟一个flatmap(a= > a)的调用达到同样效果。
带有setup和cleanup的reducer对应只需仿照上述代码使用groupbykey后面跟一个mappartition函数。
别急,等一下,还有更多
mapreduce的开发者会指出,还有更多的还没有被提及的api:
· mapreduce支持一种特殊类型的reducer,也称为combiner,可以从mapper中减少洗牌(shuffled)数据大小。
· 它还支持同通过partitioner实现的自定义分区,和通过分组comparator实现的自定义分组。
· context对象授予counter api的访问权限以及它的累积统计。
· reducer在其生命周期内一直能看到已排序好的key 。
· mapreduce有自己的writable序列化方案。
· mapper和reducer可以一次发射多组输出。
· mapreduce有几十个调优参数。
有很多方法可以在spark中实现这些方案,使用类似accumulator的api,类似groupby和在不同的这些方法中加入partitioner参数的方法,java或kryo序列化,缓存和更多。由于篇幅限制,在这篇文章中就不再累赘介绍了。
需要指出的是,mapreduce的概念仍然有用。只不过现在有了一个更强大的实现,并利用函数式语言,更好地匹配其功能性。理解spark rdd api和原来的mapper和reducerapi之间的差异,可以帮助开发者更好地理解所有这些函数的工作原理,以及理解如何利用spark发挥其优势。