1. spark源码学习分享:reduceByKey
来源:互联网 发布:java attach source 编辑:程序博客网 时间:2024/05/16 19:41
零、前置 (已经了解的看官可以跳过第0章)
spark的rdd支持两种类型的操作,分别是transformation和action操作。简单来说,transformation操作就是通过现有的rdd作一些变换之后得到一个新的rdd(例如map操作);action操作则是在rdd上作一些计算,然后将结果返回给drvier(例如reduce操作)。具体哪些操作属于transformation,哪些操作属于action可以参照官方文档(http://spark.apache.org/docs/latest/programming-guide.html)。当spark解析到一个transformation类型的方法时,spark并不会立马执行这个transformation操作,而是会将该transformation操作作用在哪个rdd上记录下来,然后等到解析到action类型的方法时才会一并去执行前面的transformation方法。
默认情况下,每次执行到action类型的方法都会把它所依赖的transformation方法重新执行一遍(哪怕两个action方法依赖了同一个transformation方法)。除非你调用cache或者presist方法将产生的中间rdd缓存起来。
本文将从transformation操作开始,以一个job执行的过程为主线来走读源码。这里选择一个比较有代表性的transformation类型方法——reduceByKey。
reduceByKey函数的作用可以参照官方文档,这里不在赘述。
在阅读这部分源码的过程中可以验证二个问题(答案参照文中标粗的部分):
1、transformation操作究竟会不会立马执行
2、经过transformation操作后生成的rdd和其父rdd的partition个数是什么关系
spark的reduceByKey方法有三种重载形式:
def reduceByKey(partitioner: Partitioner, func: JFunction2[V, V, V]): JavaPairRDD[K, V]
def reduceByKey(func: JFunction2[V, V, V], numPartitions: Int): JavaPairRDD[K, V]
def reduceByKey(func: JFunction2[V, V, V]): JavaPairRDD[K, V]
前两种形式除了允许用户传入聚合函数以外,还允许用户指定partitioner或者指定reduceByKey后生成的rdd的partition个数
def reduceByKey(func: JFunction2[V, V, V]): JavaPairRDD[K, V] = { fromRDD(reduceByKey(defaultPartitioner(rdd), func)) }
一、Partitioner的获取
其中,当用户没有指定partitioner以及partition的个数时,spark会调用defaultPartitioner(rdd)函数去获取一个默认的partitioner。defaultPartitioner的源码如下:
/** * Choose a partitioner to use for a cogroup-like operation between a number of RDDs. * * If any of the RDDs already has a partitioner, choose that one. * * Otherwise, we use a default HashPartitioner. For the number of partitions, if * spark.default.parallelism is set, then we'll use the value from SparkContext * defaultParallelism, otherwise we'll use the max number of upstream partitions. * * Unless spark.default.parallelism is set, the number of partitions will be the * same as the number of partitions in the largest upstream RDD, as this should * be least likely to cause out-of-memory errors. * * We use two method parameters (rdd, others) to enforce callers passing at least 1 RDD. */ def defaultPartitioner(rdd: RDD[_], others: RDD[_]*): Partitioner = { val bySize = (Seq(rdd) ++ others).sortBy(_.partitions.length).reverse for (r <- bySize if r.partitioner.isDefined && r.partitioner.get.numPartitions > 0) { return r.partitioner.get } if (rdd.context.conf.contains("spark.default.parallelism")) { new HashPartitioner(rdd.context.defaultParallelism) } else { new HashPartitioner(bySize.head.partitions.length) } }
该方法的注释中,描述了这个方法的大致逻辑(英语好的看官可以自行看上面的注释):
该方法允许传入两个rdd,调用该方法时候最少需要传入一个rdd。方法首先会将传入的两个rdd合并成一个数组,然后依据rdd中partition的个数进行降序排序。之后,遍历这个数组,从有partitioner的rdd中,挑选出partition个数最多的rdd,将其partitioner返回。如果传入的rdd都没有partitioner,那么久会返回一个HashPartitioner,其中,如果spark配置了spark.default.parallelism参数,则partition的个数为该参数的值。否则,新生成的rdd中partition的个数取与其依赖的父rdd中partition个数的最大值。
再进一步,我们来看看HashPartitioner(HashPartitioner是Partitioner的一个内部类)的划分规则是怎么样的。先上源码:
/** * A [[org.apache.spark.Partitioner]] that implements hash-based partitioning using * Java's `Object.hashCode`. * * Java arrays have hashCodes that are based on the arrays' identities rather than their contents, * so attempting to partition an RDD[Array[_]] or RDD[(Array[_], _)] using a HashPartitioner will * produce an unexpected or incorrect result. */class HashPartitioner(partitions: Int) extends Partitioner { require(partitions >= 0, s"Number of partitions ($partitions) cannot be negative.") def numPartitions: Int = partitions def getPartition(key: Any): Int = key match { case null => 0 case _ => Utils.nonNegativeMod(key.hashCode, numPartitions) } override def equals(other: Any): Boolean = other match { case h: HashPartitioner => h.numPartitions == numPartitions case _ => false } override def hashCode: Int = numPartitions}
HashPartitioner是一个基于Java的Object.hashCode实现(想了解Object.hashCode的实现原理可自行谷歌)的基于hash的partitioner。由于Java arrays的Hash code是基于arrays的标识而不是它的内容,所以如果使用HashPartitioner对RDD[Array[_]]或者RDD[(Array[_],_)]进行partition可能会得到不正确的结果。也就是说,如果rdd中保存的数据类型是arrays,这个时候默认的HashPartitioner是不可用的,用户在调用reduceByKey时需要自行实现一个partitioner,否则方法会抛出异常(具体这块代码在后续会遇到,看官稍安勿躁)。
从源码中可以看出,HashPartitioner的划分规则是根据Utils.nonNegativeMod(key.hashCode, numPartitions)方法而这个方法也很简单粗暴:
def nonNegativeMod(x: Int, mod: Int): Int = { val rawMod = x % mod rawMod + (if (rawMod < 0) mod else 0) }
就是直接用对象的hashCode对numPartition取模。(所以关键的划分规则还是需要了解下Object.hashCode的实现,这里就不再深入,后续有机会再深入去研究一下)
刚开始说了reduceByKey有三种形式,如果用户传入的是聚合方法和partitioner的个数,这时候会依据传入的partitioner个数直接创建出一个HashPartitioner。这里就不在详细描述。
二、构建子RDD
回到最开始的地方,所有的reduceByKey重载最终都会调用到以下的地方:
/** * Merge the values for each key using an associative and commutative reduce function. This will * also perform the merging locally on each mapper before sending results to a reducer, similarly * to a "combiner" in MapReduce. */ def reduceByKey(partitioner: Partitioner, func: (V, V) => V): RDD[(K, V)] = self.withScope { combineByKeyWithClassTag[V]((v: V) => v, func, func, partitioner) }
combineByKeyWithClassTag方法是最终构造子rdd的地方(很多的方法,例如groupByKey,最终都会调用该方法对rdd中的数据进行重新的聚合来得到新的rdd,区别在于传入的createCombiner、mergeValue和mergeCombiners参数有所不同)。这里注解中提到了一个小细节,这里会使用(满足交换律和结合律的?)reduce方法对每一个key的值作merge操作,这里在将结果发送给reducer之前,会现在每一个mapper的本地执行merge操作,类似于MapReduce的combiner。这与官方文档中提到的reduceByKey and aggregateByKey create these structures on the map side, and 'ByKey operations generate these on the reduce side.说法相符,具体的将在后续的部分说明(其实是我还没有把那块看完啦,以后补上)。老规矩,先上源码:
/** * :: Experimental :: * Generic function to combine the elements for each key using a custom set of aggregation * functions. Turns an RDD[(K, V)] into a result of type RDD[(K, C)], for a "combined type" C * Note that V and C can be different -- for example, one might group an RDD of type * (Int, Int) into an RDD of type (Int, Seq[Int]). Users provide three functions: * * - `createCombiner`, which turns a V into a C (e.g., creates a one-element list) * - `mergeValue`, to merge a V into a C (e.g., adds it to the end of a list) * - `mergeCombiners`, to combine two C's into a single one. * * In addition, users can control the partitioning of the output RDD, and whether to perform * map-side aggregation (if a mapper can produce multiple items with the same key). */ @Experimental def combineByKeyWithClassTag[C]( createCombiner: V => C, mergeValue: (C, V) => C, mergeCombiners: (C, C) => C, partitioner: Partitioner, mapSideCombine: Boolean = true, serializer: Serializer = null)(implicit ct: ClassTag[C]): RDD[(K, C)] = self.withScope { require(mergeCombiners != null, "mergeCombiners must be defined") // required as of Spark 0.9.0 if (keyClass.isArray) { ------- 1) if (mapSideCombine) { throw new SparkException("Cannot use map-side combining with array keys.") } if (partitioner.isInstanceOf[HashPartitioner]) { throw new SparkException("Default partitioner cannot partition array keys.") } } val aggregator = new Aggregator[K, V, C]( ------- 2) self.context.clean(createCombiner), self.context.clean(mergeValue), self.context.clean(mergeCombiners)) if (self.partitioner == Some(partitioner)) { ------- 3) self.mapPartitions(iter => { val context = TaskContext.get() new InterruptibleIterator(context, aggregator.combineValuesByKey(iter, context)) }, preservesPartitioning = true) } else { -------- 4) new ShuffledRDD[K, V, C](self, partitioner) .setSerializer(serializer) .setAggregator(aggregator) .setMapSideCombine(mapSideCombine) } }
ps: combineByKeyWithClassTag使用了Experimental的注解进行了修饰,这个注解的含义是该方法是一个实验性的方法,在spark的一些小版本里,被这个注解修饰的方法可能会被修改或者移除。
看到代码中的1)部分,大家有没有种亲切的感觉。在讲Partitioner生成的时候,注释中就提到了,如果rdd中保存的数据类型是arrays,这个时候HashPartitioner是不可用的,这里1)部分就是来作相关的验证。此外,如果rdd中保存的数据类型是arrays,在map端作combine操作也是不允许的。
代码的2)部分,会产生一个聚合器,该聚合器中存储了之前传入的用于对rdd作聚合操作的方法。其中,self.context.clean方法用于将 闭包(closeure)进行序列化,以便将其发送给其他的task(闭包这个概念会在下篇文章中重点介绍)。
接着重点来看3)和4)部分,这里会先判定当前rdd(也就是self变量引用的rdd)的partitioner跟传入的partitioner一不一样(scala中的==类似于调用方法的equal方法)。如果不一样,4)分支的代码会返回一个shuffledRDD对象,并把要作用在rdd上的相关操作,包括partitioner,serializer,aggregator,mapSideCombine一并保存在ShuffledRDD中返回,则该方法就结束了,并没有实际执行rdd得聚合方法。到这里,跟官网上描述的是相同的(All transformations in Spark are lazy, in that they do not compute their results right away. Instead, they just remember the transformations applied to some base dataset)。而如果partitioner相同,那么走3)分支的代码,这里可以看到一个比较不一样的东西,self作了一个mapPartitions操作,最终返回了一个mapPartitionsRDD。也就是说,当子rdd和它所依赖的父rdd使用了相同的partitioner时,就不需要再进行shuffle操作了。这里其实也很好理解,如果父rdd用的和子rdd相同的partitioner,那么父rdd划分出的分片就已经符合子rdd的需求了,这个时候再作shuffle也就木有意义了嘛。(这段需要再求证一下,官网上暂时没有找到相应的描述,也请了解的看官不吝赐教)
在mapPartition方法中,首先从TaskContext获取了当前运行的task的一些信息(比如task是否已经中断,是否运行成功,GC消耗了多少时间,占用了多少内存等)。然后用InterruptibleIterator修饰了每个partition作聚合操作后得到的Iterator,其中InterruptibleIterator是一个修饰类,被该修饰类修饰的Iterator能够在task被杀掉的时候终止。最后剩下的就是aggregator.combineValuesByKey方法了,接下来结合源码来详细分析一下这个方法:
/** * :: DeveloperApi :: * A set of functions used to aggregate data. * * @param createCombiner function to create the initial value of the aggregation. * @param mergeValue function to merge a new value into the aggregation result. * @param mergeCombiners function to merge outputs from multiple mergeValue function. */@DeveloperApicase class Aggregator[K, V, C] ( createCombiner: V => C, mergeValue: (C, V) => C, mergeCombiners: (C, C) => C) { def combineValuesByKey( iter: Iterator[_ <: Product2[K, V]], context: TaskContext): Iterator[(K, C)] = { val combiners = new ExternalAppendOnlyMap[K, V, C](createCombiner, mergeValue, mergeCombiners) combiners.insertAll(iter) updateMetrics(context, combiners) combiners.iterator } def combineCombinersByKey( iter: Iterator[_ <: Product2[K, C]], context: TaskContext): Iterator[(K, C)] = { val combiners = new ExternalAppendOnlyMap[K, C, C](identity, mergeCombiners, mergeCombiners) combiners.insertAll(iter) updateMetrics(context, combiners) combiners.iterator } /** Update task metrics after populating the external map. */ private def updateMetrics(context: TaskContext, map: ExternalAppendOnlyMap[_, _, _]): Unit = { Option(context).foreach { c => c.taskMetrics().incMemoryBytesSpilled(map.memoryBytesSpilled) c.taskMetrics().incDiskBytesSpilled(map.diskBytesSpilled) c.taskMetrics().incPeakExecutionMemory(map.peakMemoryUsedBytes) } }}
这里先看combineCombinersByKey方法,其他的方法用到了再看。combineCombinersByKey方法首先创建一个ExernalAppendOnlyMap对象,然后将当前partition的Iterator放入新创建的ExernalAppendOnlyMap对象,然后调用updataMetrics方法更新TaskContext中的一些信息(消耗的内存空间,磁盘空间等信息),然后将得到的Iterator返回。逻辑很简单,这里比较重要的需要搞清楚两件事:ExternalAppendOnlyMap是个什么?插入Iterator到底怎么插?
首先来看ExternalAppendOnlyMap,先上源码中的官方注释:
/** * :: DeveloperApi :: * An append-only map that spills sorted content to disk when there is insufficient space for it * to grow. * * This map takes two passes over the data: * * (1) Values are merged into combiners, which are sorted and spilled to disk as necessary * (2) Combiners are read from disk and merged together * * The setting of the spill threshold faces the following trade-off: If the spill threshold is * too high, the in-memory map may occupy more memory than is available, resulting in OOM. * However, if the spill threshold is too low, we spill frequently and incur unnecessary disk * writes. This may lead to a performance regression compared to the normal case of using the * non-spilling AppendOnlyMap. */@DeveloperApiclass ExternalAppendOnlyMap[K, V, C](
ExternalAppendOnlyMap是spark的一个容器对象,该对象类似于一个只支持append操作的内容有序的map,该对象会依据存储的数据的大小动态调整所占用的内存。ExternalAppendOnlyMap有一个阈值,当添加对象时,如果当前ExternalAppendOnlyMap实例所占的空间小于阈值,则向全局的shuffle memory申请空间,如果大于阈值,则会移除内存存入磁盘当中。这里,如果阈值设置得过大会有OOM的风险,如果阈值设置的过小则会影响容器性能。(翻译得比较渣,英语好的看官直接看官方注释)
接下来看一下insertAll方法,insertAll是ExternalAppendOnlyMap的一个方法,先上源码:
/** * Insert the given iterator of keys and values into the map. * * When the underlying map needs to grow, check if the global pool of shuffle memory has * enough room for this to happen. If so, allocate the memory required to grow the map; * otherwise, spill the in-memory map to disk. * * The shuffle memory usage of the first trackMemoryThreshold entries is not tracked. */ def insertAll(entries: Iterator[Product2[K, V]]): Unit = { if (currentMap == null) { throw new IllegalStateException( "Cannot insert new elements into a map after calling iterator") } // An update function for the map that we reuse across entries to avoid allocating // a new closure each time var curEntry: Product2[K, V] = null val update: (Boolean, C) => C = (hadVal, oldVal) => { ------- 2) if (hadVal) mergeValue(oldVal, curEntry._2) else createCombiner(curEntry._2) } while (entries.hasNext) { ------- 1) curEntry = entries.next() val estimatedSize = currentMap.estimateSize() if (estimatedSize > _peakMemoryUsedBytes) { _peakMemoryUsedBytes = estimatedSize } if (maybeSpill(currentMap, estimatedSize)) { currentMap = new SizeTrackingAppendOnlyMap[K, C] } currentMap.changeValue(curEntry._1, update) addElementsRead() } }
该方法的作用各位看官可以看方法上的说明注释。这里分析一下逻辑,首先代码的1)部分,遍历Iterator的所有元素,首先获取currentMap当前所占用的内存大小,如果比_peakMemoryUsedBytes的值大,则更新_peakMemoryUsedBytes的值(_peakMemoryUsedBytes记录了当前map所占用的内存的峰值),然后调用maybeSpill方法判断当前的map是否需要被移出(spill)内存放入磁盘中,如果需要被移出磁盘的话,则重新创建一个SizeTrackingAppendOnlyMap,然后调用2)的方法对rdd作聚合操作(reduceByKey的聚合逻辑很简单,createCombiner方法实际就是直接将入参返回,mergeValue方法也就是用户调用reduceByKey时实现的func方法)。最后调用addElementsRead()方法计数(注解中说这里是用来记录record被读取得次数,用来控制内存移出的频率。。。读?难道是读入内存的意思)
tips:讲到这里,这边我们来额外探讨一下关于partition数量对性能的影响问题。不知道各位看官发现 ExternalAppendOnlyMap会被置换到磁盘中 的时候有没有反应过来,涉及到磁盘I/O或网络传输的操作是非常影响性能的。那么这时候我们就要考虑怎么样才能让ExternalAppendOnlyMap不把内存中的记录写到磁盘中去,或者怎么样能尽量少触发写磁盘的操作?这边可以想到的方法有两种:
(1)很显然,如果要插入的record减少,那么就可以减少甚至不触发ExternalAppendOnlyMap的写磁盘操作。那么怎么样让record减少呢?只要我们把partition的数量变多,那么每个partition里record的数量不就减少了嘛。因此,我们可以通过增加partition的个数来提高这一部分的性能(这边要注意的是,并不是增加partition一定会导致record减少,有可能发生数据倾向的问题,这个日后再讨论。此外,partition的数量并不是越多越好,这里涉及到内存和CPU的问题,每一个partition都需要占用额外的空间,另外task的调度本身也有开销)。
(2)第二种能想到的方法就是提高触发的阈值。显然写磁盘触发的条件提高了,那就不会那么容易触发了嘛。一方面,我们可以通过调整配置参数spark.shuffle.spill.numElementsForceSpillThreshold调高numElementsForceSpillThreshold的值;另一方面,我们可以通过调整配置参数spark.shuffle.spill.initialMemoryThreshold提高initialMemoryThreshold的值(也就是myMemoryThreshold的初始值)。同样的,值不能调得太高,否则很容易导致OOM。
参数的调整就全凭大家机器的性能以及经验了。
这里有两块比较重要的代码,maybeSpill方法里判断map是否需要被移出内存的逻辑到底是什么样的?2)中的聚合逻辑是什么样的?
首先来看maybeSpill方法:
/** * Spills the current in-memory collection to disk if needed. Attempts to acquire more * memory before spilling. * * @param collection collection to spill to disk * @param currentMemory estimated size of the collection in bytes * @return true if `collection` was spilled to disk; false otherwise */ protected def maybeSpill(collection: C, currentMemory: Long): Boolean = { var shouldSpill = false if (elementsRead % 32 == 0 && currentMemory >= myMemoryThreshold) { ------ 1) // Claim up to double our current memory from the shuffle memory pool val amountToRequest = 2 * currentMemory - myMemoryThreshold val granted = acquireMemory(amountToRequest) myMemoryThreshold += granted // If we were granted too little memory to grow further (either tryToAcquire returned 0, // or we already had more memory than myMemoryThreshold), spill the current collection shouldSpill = currentMemory >= myMemoryThreshold } shouldSpill = shouldSpill || _elementsRead > numElementsForceSpillThreshold ------ 2) // Actually spill if (shouldSpill) { _spillCount += 1 logSpillage(currentMemory) spill(collection) _elementsRead = 0 _memoryBytesSpilled += currentMemory releaseMemory() } shouldSpill }
1)的if分支需要满足两个条件才会导致map被移出内存存入磁盘中,第一个条件是elementsRead的值必须是32的倍数,前面提到过elementsRead用来控制map被移出的频率,也就是说移出的频率被控制在至少每32次才触发一次(除非当前读入的map元素个数已经超过了必须移出的阈值,也就是2)的逻辑)。另一个条件是currentMemory必须大于等于myMemoryThreshold(可以这么理解,elementsRead控制spill的频率,myMemoryThreshold控制spill的粒度)。接着,如果条件满足的话,会对map的空间进行扩展,扩展的大小为2 * currentMemory - myMemoryThreshold(其实就是扩展为当前map所占内存的两倍,myMemoryThreshold可以理解为你当前已经申请到的内存 )。之后调用acquireMemory方法去向taskMemoryManager申请内存。acquireMemory()方法的返回值是申请到的内存大小(注意,并不是你申请多少就给你多少,有可能内存不够,只能给你一点点),更新一下myMemoryThreshold的值,然后再次判断空间是不是够大,如果不够还是要做移出操作。
2)则是判断从上一次spill后到现在读入内存的元素个数是否已经大于给定的阈值numElementsForceSpillThreshold(这个值可以通过spark.shuffle.spill.numElementsForceSpillThreshold参数配置),如果大于也需要作spill操作。然后接下来的操作就很明了了,如果需要移出,那么记录移出的次数,移出的内存大小,重置_elementsRead,记日志,然后移出,清理内存。
最后来看一下changeValue方法,上源码:
/** * Set the value for key to updateFunc(hadValue, oldValue), where oldValue will be the old value * for key, if any, or null otherwise. Returns the newly updated value. */ def changeValue(key: K, updateFunc: (Boolean, V) => V): V = { assert(!destroyed, destructionMessage) val k = key.asInstanceOf[AnyRef] if (k.eq(null)) { ------ 1) if (!haveNullValue) { incrementSize() } nullValue = updateFunc(haveNullValue, nullValue) haveNullValue = true return nullValue } var pos = rehash(k.hashCode) & mask var i = 1 while (true) { val curKey = data(2 * pos) ------ 2) if (k.eq(curKey) || k.equals(curKey)) { val newValue = updateFunc(true, data(2 * pos + 1).asInstanceOf[V]) data(2 * pos + 1) = newValue.asInstanceOf[AnyRef] return newValue } else if (curKey.eq(null)) { ------ 3) val newValue = updateFunc(false, null.asInstanceOf[V]) data(2 * pos) = k data(2 * pos + 1) = newValue.asInstanceOf[AnyRef] incrementSize() return newValue } else { ------ 4) val delta = i pos = (pos + delta) & mask i += 1 } } null.asInstanceOf[V] // Never reached but needed to keep compiler happy }
该方法1)部分用于处理空元素,当遇到key为null时,就返回一个nullValue,如果之前没有遇到key为null的record,则调用incrementSize将表的size加1(也就是说,如果key为null也算作一个元素)。2)位置data是一个Array类型,保存的是已经插入的key,value值(以key1, value1, key2, value2 ……的格式保存),方法首先会通过hash值计算key在data中的相对位置(也就是相同的key会映射到data中的同一个位置),然后从data中取出相应的key(当hash值为n时,则2n的位置保存的是key,2n+1的位置保存的是value),如果取出的key与当前的curKey相等,则将对应位置的val与当前curKey的value值进行merge。相反,如果当前位置取出的key为null,则说明之前没有与curKey相同的key存入,则直接将curKey对应的curVal存入(这里updateFunc对应的就是insertAll里的update方法,如果忘记了update方法的逻辑可以返回前面再看一下)。
最后4)位置,什么时候会走4)的分支呢?就是你hash计算出的值在对应的位置上有key,当时这个key跟curKey不相等的时候,其实也就hash函数产生碰撞的时候(简单的说就是不同的key被映射到同一个hash值了)。如果产生碰撞,这里的处理方法就是简答地继续访问Array的下一个位置,重新走一遍之前的逻辑,直到找到相同的key或者有空位置可以插入为止。
至此,完整的reduceByKey过程就已经结束了。最后,需要注意的是,mapPartitions这个分支最后会将上面提到的aggregator.combineValuesByKey方法保存在mapPartitionsRDD中返回给用,依旧不会立即执行。至于实际执行这些代码的地方,请关注后续的文章。
- 1. spark源码学习分享:reduceByKey
- Spark源码之reduceByKey与GroupByKey
- 结合Spark源码分析, combineByKey, aggregateByKey, foldByKey, reduceByKey
- 0. spark源码学习分享 前言
- 2. spark源码学习分享:DAGScheduler.runJob
- [spark]groupbykey reducebykey
- spark ReduceByKey操作
- spark--transform算子--reduceByKey
- Spark算子reduceByKey深度解析
- spark reduceByKey的小问题
- Spark算子reduceByKey深度解析
- spark dataframe dataset reducebykey用法
- Spark基础知识学习分享
- Spark基础知识学习分享
- Spark基础知识学习分享
- Spark基础知识学习分享
- Spark基础知识学习分享
- Spark基础知识学习分享
- Nginx配置HTTPS和HTTP
- 要登录到这台远程计算机 必须被授予允许通过终端服务登录的权限
- (POJ 1703) Find them, Catch them【典型并查集:判断在不同的集合】
- ubuntu16.04主题美化和软件推荐
- 现场支援-问题解决记录
- 1. spark源码学习分享:reduceByKey
- android 录像 录制
- python魔法方法
- BZOJ 4137 [FJOI2015]火星商店问题
- c语言中malloc、realloc与calloc 的区别以及联系
- Vim编辑常用快捷键整理
- 文件操作工具类FileUtils
- Maven创建EJB项目HelloWorld使用IDE为eclipse
- jQuery获取尺寸