数据分区(高级)


这章最后我们讨论的Spark另一个特性,怎样控制数据集在节点之间分区。在分布式程序中,通信是很昂贵的,减少网络流量可以大大提高性能,Spark可以控制RDD分区来减少通信。分区也不是适用于所有的应用,例如如果一个RDD只扫描一次,那么就没有预先进行分区。


Spark分区在所有的key/value的RDDs中都是可用的,并可以根据每个key的功能将元素进行分组。虽然Spark并没有明确地控制每个key在哪个节点上(部分原因是即使节点出现故障也可以使用),但是它可以保证一组key会出现在一些节点上。例如,你可以使用hash分区将一个RDD分成100个分区,将hash值的模为100的key放在相同的节点上。或者可以将RDD范围划分为按键的排序范围,以使具有相同范围内的键的元素显示在同一个节点上。


一个简单的例子,现在有一个用户信息的大表在内存中,RDD中是UserID和UserInfo对,其中UserInfo包含用户订阅的主题列表。应用程序定期将此表与一个较小的文件组合,表示在过去五分钟内发生的事件 - 例如,在五分钟内点击了网站上的链接的用户的(UserID,LinkInfo)对表。例如,我们可能希望计算有多少用户访问了一个不是其中一个订阅主题的链接,我们可以使用Spark的join操作,该操作可以用每个UserID作为key分组UserInfo和LinkInfo对。我们的应用可能像是下面这样:

Example 4-22. Scala simple application 

 // Initialization code; we load the user info from a Hadoop SequenceFile on HDFS. 

 // This distributes elements of userData by the HDFS block where they are found, 

 // and doesn't provide Spark with any way of knowing in which partition a 

 // particular UserID is located. 

 val sc = new SparkContext(...) 

 val userData = sc.sequenceFile[UserID, UserInfo]("hdfs://...").persist() 



 // Function called periodically to process a logfile of events in the past 5 minutes; 

 // we assume that this is a SequenceFile containing (UserID, LinkInfo) pairs. 



 def processNewLogs(logFileName: String) { 

  val events = sc.sequenceFile[UserID, LinkInfo](logFileName) 

  val joined = userData.join(events)// RDD of (UserID, (UserInfo, LinkInfo)) pairs 

  val offTopicVisits = joined.filter { 

  case (userId, (userInfo, linkInfo)) => // Expand the tuple into its components 

  !userInfo.topics.contains(linkInfo.topic) 

  }.count() 

  println("Number of visits to non-subscribed topics: " + offTopicVisits) 

 }



上面代码运行正常,但是效率会很低。这是因为join()操作会在每次调用processNewLogs()时调用,这是因为它不知道数据集中key是怎么分区的。默认情况下,此操作将散列两个数据集的所有键,通过网络将具有相同密钥散列的元素发送到同一台机器,然后使用该机器上的相同key的元素连接在一起。因为我们期望userData表比每隔五分钟看到的事件的小日志要大得多,所以浪费了大量的工作:每次调用时,userData表都会在网络上进行散列和混洗,尽管它不会改变。


解决这个问题很简单:只需要在程序开始时,在userData上使用partitionBy()转换成hash分区。

Example 4-23. Scala custom partitioner 

 val sc = new SparkContext(...) 

 val userData = sc.sequenceFile[UserID, UserInfo]("hdfs://...") 

  .partitionBy(new HashPartitioner(100)) // Create 100 partitions 

  .persist()



processNewLogs()方法可以保持不变:事件RDD对processNewLogs()是本地的,并且在此方法中仅使用一次,因此在为事件指定分区时没有任何优势。因为当建立userData时我们调用partitionBy(),Spark会知道使用的是hash分区。特别是,当我们调用用户Data.join(events)时,Spark将只会将事件RDD随机播放,并将每个特定UserID的事件发送到包含相应哈希分区userData的机器。 结果是通过网络传送的数据少得多,程序运行速度明显更快。


注意partitionBy()是转换,所以它通常是返回一个新的RDD-他不会改变原来的RDD。RDDs一旦创建就不会改变。


在使用partitionBy()转换后没有持久化RDD,那么在随后使用RDD的过程中会重复分区数据。如果没有持久化,使用分区的RDD会导致重新评估RDDs整个结构。这将抵消掉partitionBy()的优点,在网络上重新分区混洗数据,就像没有指定分区一样。


事实上,很多其他Spark操作都会知道RDD分区信息,很多的操作像是join()都会利用这些信息。例如,sortByKey()和groupByKey()都会导致范围分区和hash分区。另一方面,像map()这样的操作会导致新的RDD忘记父分区的信息,因为像这样的操作理论上可以修改每个记录的关键字。