大数据技术之_19_Spark学习_05_Spark GraphX 应用解析 + Spark GraphX 概述、解析 + 计算模式 + Pregel API + 图算法参考代码 + PageRank

第1章 Spark GraphX 概述

1.1 什么是 Spark GraphX


  Spark GraphX 是一个分布式图处理框架,它是基于 Spark 平台提供对图计算和图挖掘简洁易用的而丰富的接口,极大的方便了对分布式图处理的需求。那么什么是图,都计算些什么?众所周知社交网络中人与人之间有很多关系链,例如 Twitter、Facebook、微博和微信等,数据中出现网状结构关系都需要图计算。
  GraphX 是一个新的 Spark API,它用于图和分布式图(graph-parallel)的计算。GraphX 通过引入弹性分布式属性图(Resilient Distributed Property Graph): 顶点和边均有属性的有向多重图,来扩展Spark RDD。为了支持图计算,GraphX 开发了一组基本的功能操作以及一个优化过的 Pregel API。另外,GraphX 也包含了一个快速增长的图算法和图 builders 的集合,用以简化图分析任务。
  从社交网络到语言建模,不断增长的数据规模以及图形数据的重要性已经推动了许多新的分布式图系统的发展。通过限制计算类型以及引入新的技术来切分和分配图,这些系统可以高效地执行复杂的图形算法,比一般的分布式数据计算(data-parallel,如 spark、MapReduce)快很多。

  分布式图(graph-parallel)计算和分布式数据(data-parallel)计算类似,分布式数据计算采用了一种 record-centric(以记录为中心)的集合视图,而分布式图计算采用了一种 vertex-centric(以顶点为中心)的图视图。分布式数据计算通过同时处理独立的数据来获得并发的目的,分布式图计算则是通过对图数据进行分区(即切分)来获得并发的目的。更准确的说,分布式图计算递归地定义特征的转换函数(这种转换函数作用于邻居特征),通过并发地执行这些转换函数来获得并发的目的。

  分布式图计算比分布式数据计算更适合图的处理,但是在典型的图处理流水线中,它并不能很好地处理所有操作。例如,虽然分布式图系统可以很好的计算 PageRank 等算法,但是它们不适合从不同的数据源构建图或者跨过多个图计算特征。更准确的说,分布式图系统提供的更窄的计算视图无法处理那些构建和转换图结构以及跨越多个图的需求。分布式图系统中无法提供的这些操作需要数据在图本体之上移动并且需要一个图层面而不是单独的顶点或边层面的计算视图。例如,我们可能想限制我们的分析到几个子图上,然后比较结果。 这不仅需要改变图结构,还需要跨多个图计算。

  我们如何处理数据取决于我们的目标,有时同一原始数据可能会处理成许多不同表和图的视图,并且图和表之间经常需要能够相互移动。如下图所示:

  所以我们的图流水线必须通过组合 graph-parallel 和 data- parallel 来实现。但是这种组合必然会导致大量的数据移动以及数据复制,同时这样的系统也非常复杂。例如,在传统的图计算流水线中,在 Table View 视图下,可能需要 Spark 或者 Hadoop 的支持,在 Graph View 这种视图下,可能需要 Prege 或者 GraphLab 的支持。也就是把图和表分在不同的系统中分别处理。不同系统之间数据的移动和通信会成为很大的负担。
  GraphX 项目将 graph-parallel 和 data-parallel 统一到一个系统中,并提供了一个唯一的组合 API。GraphX 允许用户把数据当做一个图和一个集合(RDD),而不需要数据移动或者复制。也就是说 GraphX 统一了 Graph View 和 Table View,可以非常轻松的做 pipeline 操作。

1.2 弹性分布式属性图


  GraphX 的核心抽象是弹性分布式属性图,它是一个有向多重图,带有连接到每个顶点和边的用户定义的对象。有向多重图中多个并行的边共享相同的源和目的顶点。支持并行边的能力简化了建模场景,相同的顶点可能存在多种关系(例如 co-worker 和 friend)。 每个顶点用一个唯一的 64 位长的标识符(VertexID)作为 key。GraphX 并没有对顶点标识强加任何排序。同样,边拥有相应的源和目的顶点标识符。

  属性图扩展了 Spark RDD 的抽象,有 Table 和 Graph 两种视图,但是只需要一份物理存储。两种视图都有自己独有的操作符,从而使我们同时获得了操作的灵活性和执行的高效率。属性图以 vertex(VD) 和 edge(ED) 类型作为参数类型,这些类型分别是顶点和边相关联的对象的类型。

  在某些情况下,在同样的图中,我们可能希望拥有不同属性类型的顶点。这可以通过继承完成。例如,将用户和产品建模成一个二分图,我们可以用如下方式:

class VertexProperty()
case class UserProperty(val name: String) extends VertexProperty
case class ProductProperty(val name: String, val price: Double) extends VertexProperty
// The graph might then have the type:
var graph: Graph[VertexProperty, String] = null

  和 RDD 一样,属性图是不可变的、分布式的、容错的。图的值或者结构的改变需要生成一个新的图来实现。注意,原始图中不受影响的部分都可以在新图中重用,用来减少存储的成本。执行者使用一系列顶点分区方法来对图进行分区。如 RDD 一样,图的每个分区可以在发生故障的情况下被重新创建在不同的机器上。
  逻辑上,属性图对应于一对类型化的集合(RDD),这个集合包含每一个顶点和边的属性。因此,图的类中包含访问图中顶点和边的成员变量。

class Graph[VD, ED] {
   
  val vertices: VertexRDD[VD]
  val edges: EdgeRDD[ED]
}

VertexRDD[VD] 和 EdgeRDD[ED] 类是 RDD[(VertexID, VD)] 和 RDD[Edge[ED]] 的继承和优化版本。VertexRDD[VD] 和 EdgeRDD[ED] 都提供了额外的图计算功能并提供内部优化功能。如下图所示:

源码如下:

abstract class VertexRDD[VD](sc: SparkContext, deps: Seq[Dependency[_]]) extends RDD[(VertexId, VD)](sc, deps)

abstract class EdgeRDD[ED](sc: SparkContext, deps: Seq[Dependency[_]]) extends RDD[Edge[ED]](sc, deps)

  GraphX 的底层设计有以下几个关键点:
  • 对 Graph 视图的所有操作,最终都会转换成其关联的 Table 视图的 RDD 操作来完成。这样对一个图的计算,最终在逻辑上,等价于一系列 RDD 的转换过程。因此,Graph 最终具备了 RDD 的3个关键特性:Immutable、Distributed和Fault-Tolerant,其中最关键的是 Immutable(不变性)。逻辑上,所有图的转换和操作都产生了一个新图;物理上,GraphX 会有一定程度的不变顶点和边的复用优化,对用户透明。
  • 两种视图底层共用的物理数据,由 RDD[Vertex-Partition] 和 RDD[EdgePartition] 这两个 RDD 组成。点和边实际都不是以表 Collection[tuple] 的形式存储的,而是由 VertexPartition/EdgePartition 在内部存储一个带索引结构的分片数据块,以加速不同视图下的遍历速度。不变的索引结构在 RDD 转换过程中是共用的,降低了计算和存储开销。
  • 图的分布式存储采用点分割模式,而且使用 partitionBy 方法,由用户指定不同的划分策略(PartitionStrategy)。划分策略会将边分配到各个 EdgePartition,顶点分配到各个 VertexPartition,EdgePartition 也会缓存本地边关联点的 Ghost 副本。划分策略的不同会影响到所需要缓存的 Ghost 副本数量,以及每个 EdgePartition 分配的边的均衡程度,需要根据图的结构特征选取最佳策略。目前有 EdgePartition2d、EdgePartition1d、RandomVertexCut 和 CanonicalRandomVertexCut 这四种策略。

1.3 运行图计算程序

假设我们想构造一个包括不同合作者的属性图。顶点属性可能包含用户名和职业。我们可以用描述合作者之间关系的字符串标注边缘。

Step1、开始的第一步是引入 Spark 和 GraphX 到你的项目中,如下面所示:

import org.apache.spark.graphx.{
   Edge, Graph, VertexId}
import org.apache.spark.rdd.RDD
import org.apache.spark.{
   SparkConf, SparkContext}

Step2、如果你没有用到 Spark shell,你还将需要 SparkContext。
所得的图形将具有类型签名:val userGraph: Graph[(String, String), String]
有很多方式从一个原始文件、RDD 构造一个属性图。最一般的方法是利用 Graph object。下面的代码从 RDD 集合生成属性图。

    // 创建 SparkConf() 并设置 App 名称
    val conf = new SparkConf().setMaster("local[3]").setAppName("WC")
    // 创建 SparkContext,该对象是提交 spark App 的入口
    val sc = new SparkContext(conf)

    // Create an RDD for the vertices (顶点),这里的顶点属性是 一个二元组
    val users: RDD[(VertexId, (String, String))] =
      sc.parallelize(Array((3L, ("rxin", "student")), (7L, ("jgonzal", "postdoc")),
        (5L, ("franklin", "prof")), (2L, ("istoica", "prof"))))

    // Create an RDD for edges (边),这里的边属性是 String 类型
    val relationships: RDD[Edge[String]] =
      sc.parallelize(Array(Edge(3L, 7L, "collab"), Edge(5L, 3L, "advisor"),
        Edge(2L, 5L, "colleague"), Edge(5L, 7L, "pi")))

    val defaultUser = ("John Doe", "Missing") // 缺省属性
    // Build the initial Graph (图)
    val graph = Graph(users, relationships, defaultUser)

Step3、在上面的例子中,我们用到了 Edge 样本类。边有一个 srcId 和 dstId 分别对应于源和目标顶点的标示符。另外,Edge 类有一个 attr 成员用来存储边属性。
我们可以分别用 graph.vertices 和 graph.edges 成员将一个图解构为相应的顶点和边。

    // Count all users which are postdocs (过滤图上的所有顶点,统计顶点的属性的第二个值是 postdoc 的个数)
    val verticesCount = graph.vertices.filter {
    case (id, (name, pos)) => pos == "postdoc" }.count
    println(verticesCount)

    // Count all the edges where src > dst (统计图上满足边的 源顶点ID > 目标顶点ID 的个数)
    val edgeCount = graph.edges.filter(e => e.srcId > e.dstId).count
    println(edgeCount)

注意:graph.vertices 返回一个 VertexRDD[(String, String)],它继承于 RDD[(VertexID, (String, String))]。所以我们可以用 scala 的 case 表达式解构这个元组。另一方面,graph.edges 返回一个包含 Edge[String] 对象的 EdgeRDD。我们也可以用到 case 类的类型构造器,如下例所示。

    graph.edges.filter {
    case Edge(src, dst, prop) => src > dst }.count

Step4、除了属性图的顶点和边视图,GraphX 也包含了一个三元组视图,三元视图逻辑上将顶点和边的属性保存为一个 RDD[EdgeTriplet[VD, ED]],它包含 EdgeTriplet 类的实例。可以通过下面的 Sql 表达式表示这个连接。

SELECT
	src.id ,
	dst.id ,
	src.attr ,
	e.attr ,
	dst.attr
FROM
	edges AS e
LEFT JOIN vertices AS src ,
 vertices AS dst ON e.srcId = src.Id
AND e.dstId = dst.Id

或者通过下面的图来表示:

Step5、EdgeTriplet 类继承于 Edge 类,并且加入了 srcAttr 和 dstAttr 成员,这两个成员分别包含源和目的的属性。我们可以用一个三元组视图渲染字符串集合用来描述用户之间的关系。

val graph: Graph[(String, String), String] // Constructed from above
// Use the triplets view to create an RDD of facts.
val facts: RDD[String] = graph.triplets.map(triplet => triplet.srcAttr._1 + " is the " + triplet.attr + " of " + triplet.dstAttr._1)
facts.collect.foreach(println(_))

第2章 Spark GraphX 解析

2.1 存储模式

2.1.1 图存储模式

PowerGraph 巨型图的存储总体上有边分割点分割两种存储方式。
  1)边分割(Edge-Cut):每个顶点都存储一次,但有的边会被打断分到两台机器上。这样做的好处是节省存储空间;坏处是对图进行基于边的计算时,对于一条两个顶点被分到不同机器上的边来说,要跨机器通信传输数据,内网通信流量大。
  2)点分割(Vertex-Cut):每条边只存储一次,都只会出现在一台机器上。邻居多的点会被复制到多台机器上,增加了存储开销,同时会引发数据同步问题。好处是可以大幅减少内网通信量。

虽然两种方法互有利弊,但现在是点分割占上风,各种分布式图计算框架都将自己底层的存储形式变成了点分割。主要原因有以下两个。
  1)磁盘价格下降,存储空间不再是问题,而内网的通信资源没有突破性进展,集群计算时内网带宽是宝贵的,时间比磁盘更珍贵。这点就类似于常见的空间换时间的策略。
  2)在当前的应用场景中,绝大多数网络都是“无尺度网络”,遵循幂律分布,不同点的邻居数量相差非常悬殊。而边分割会使那些多邻居的点所相连的边大多数被分到不同的机器上,这样的数据分布会使得内网带宽更加捉襟见肘,于是边分割存储方式被渐渐抛弃了

2.1.2 GraphX 存储模式

Graphx 借鉴 PowerGraph,使用的是 Vertex-Cut(点分割)方式存储图,用三个 RDD 存储图数据信息:
  VertexTable(id, data):id 为顶点 id, data 为顶点属性
  EdgeTable(pid, src, dst, data):pid 为分区 id ,src 为源顶点 id ,dst 为目的顶点 id,data 为边属性
  RoutingTable(id, pid):id 为顶点 id ,pid 为分区 id
点分割存储实现如下图所示:

GraphX 在进行图分割时,有几种不同的分区(partition)策略,它通过 PartitionStrategy 专门定义这些策略。在 PartitionStrategy 中,总共定义了 EdgePartition2D、EdgePartition1D、RandomVertexCut 以及 CanonicalRandomVertexCut 这四种不同的分区策略。下面分别介绍这几种策略。

RandomVertexCut

case object RandomVertexCut extends PartitionStrategy {
   
  override def getPartition(src: VertexId, dst: VertexId, numParts: PartitionID): PartitionID = {
   
    math.abs((src, dst).hashCode()) % numParts
  }
}

这个方法比较简单,通过取源顶点和目标顶点 id 的哈希值来将边分配到不同的分区。这个方法会产生一个随机的边分割,两个顶点之间相同方向的边会分配到同一个分区。

CanonicalRandomVertexCut

case object CanonicalRandomVertexCut extends PartitionStrategy {
   
  override def getPartition(src: VertexId, dst: VertexId, numParts: PartitionID): PartitionID = {
   
    if (src < dst) {
   
      math.abs((src, dst).hashCode()) % numParts
    } else {
   
      math.abs((dst, src).hashCode()) % numParts
    }
  }
}

这种分割方法和前一种方法没有本质的不同。不同的是,哈希值的产生带有确定的方向(即两个顶点中较小 id 的顶点在前)。两个顶点之间所有的边都会分配到同一个分区,而不管方向如何。

EdgePartition1D

case object EdgePartition1D extends PartitionStrategy {
   
  override def getPartition(src: VertexId, dst: VertexId, numParts: PartitionID): PartitionID = {
   
    val mixingPrime: VertexId = 1125899906842597L
    (math.abs(src * mixingPrime) % numParts).toInt
  }
}

这种方法仅仅根据源顶点 id 来将边分配到不同的分区。有相同源顶点的边会分配到同一分区。

EdgePartition2D


case object EdgePartition2D extends PartitionStrategy {
   
  override def getPartition(src: VertexId, dst: VertexId, numParts: PartitionID): PartitionID = {
   
    val ceilSqrtNumParts: PartitionID = math.ceil(math.sqrt(numParts)).toInt
    val mixingPrime: VertexId = 1125899906842597L
    if (numParts == ceilSqrtNumParts * ceilSqrtNumParts) {
   
      // Use old method for perfect squared to ensure we get same results
      val col: PartitionID = (math.abs(src * mixingPrime) % ceilSqrtNumParts).toInt
      val row: PartitionID = (math.abs(dst * mixingPrime) % ceilSqrtNumParts).toInt
      (col * ceilSqrtNumParts + row) % numParts
    } else {
   
      // Otherwise use new method
      val cols = ceilSqrtNumParts
      val rows = (numParts + cols - 1) / cols
      val lastColRows = numParts - rows * (cols - 1)
      val col = (math.abs(src * mixingPrime) % numParts / rows).toInt
      val row = (math.abs(dst * mixingPrime) % (if (col < cols - 1) rows else lastColRows)).toInt
      col * rows + row
    }
  }
}

这种分割方法同时使用到了源顶点 id 和目的顶点 id。它使用稀疏边连接矩阵的 2 维区分来将边分配到不同的分区,从而保证顶点的备份数不大于 2 * sqrt(numParts) 的限制。这里 numParts 表示分区数。 这个方法的实现分两种情况,即分区数能完全开方和不能完全开方两种情况。当分区数能完全开方时,采用下面的方法:

val col: PartitionID = (math.abs(src * mixingPrime) % ceilSqrtNumParts).toInt
val row: PartitionID = (math.abs(dst * mixingPrime) % ceilSqrtNumParts).toInt
(col * ceilSqrtNumParts + row) % numParts

当分区数不能完全开方时,采用下面的方法。这个方法的最后一列允许拥有不同的行数。

val cols = ceilSqrtNumParts
val rows = (numParts + cols - 1) / cols
//最后一列允许不同的行数
val lastColRows = numParts - rows * (cols - 1)
val col = (math.abs(src * mixingPrime) % numParts / rows).toInt
val row = (math.abs(dst * mixingPrime) % (if (col < cols - 1) rows else lastColRows)).toInt
col * rows + row

下面举个例子来说明该方法。假设我们有一个拥有 12 个顶点的图,要把它切分到 9 台机器。我们可以用下面的稀疏矩阵来表示:

          __________________________________
     v0   | P0 *     | P1       | P2    *  |
     v1   |  ****    |  *       |          |
     v2   |  ******* |      **  |  ****    |
     v3   |  *****   |  *  *    |       *  |
          ----------------------------------
     v4   | P3 *     | P4 ***   | P5 **  * |
     v5   |  *  *    |  *       |          |
     v6   |       *  |      **  |  ****    |
     v7   |  * * *   |  *  *    |       *  |
          ----------------------------------
     v8   | P6   *   | P7    *  | P8  *   *|
     v9   |     *    |  *    *  |          |
     v10  |       *  |      **  |  *  *    |
     v11  | * <-E    |  ***     |       ** |
          ----------------------------------

上面的例子中 * 表示分配到处理器上的边。E 表示连接顶点 v11 和 v1 的边,它被分配到了处理器 P6 上。为了获得边所在的处理器,我们将矩阵切分为 sqrt(numParts) * sqrt(numParts) 块。 注意:上图中与顶点 v11 相连接的边只出现在第一列的块 (P0,P3,P6) 或者最后一行的块 (P6,P7,P8) 中,这保证了 V11 的副本数不会超过 2 * sqrt(numParts) 份,在上例中即副本不能超过 6 份。在上面的例子中,P0 里面存在很多边,这会造成工作的不均衡。为了提高均衡,我们首先用顶点 id 乘以一个大的素数,然后再 shuffle 顶点的位置。乘以一个大的素数本质上不能解决不平衡的问题,只是减少了不平衡的情况发生。

2.2 vertices、edges 以及 triplets

vertices、edges 以及 triplets 是 GraphX 中三个非常重要的概念。我们在前文 GraphX 介绍中对这三个概念有初步的了解。

2.2.1 vertices

在 GraphX 中,vertices 对应着名称为 VertexRDD 的 RDD。这个 RDD 有顶点 id 和顶点属性两个成员变量。它的源码如下所示:

abstract class VertexRDD[VD](sc: SparkContext, deps: Seq[Dependency[_]]) extends RDD[(VertexId, VD)](sc, deps)

从源码中我们可以看到,VertexRDD 继承自 RDD[(VertexId, VD)],这里 VertexId 表示顶点 id,VD 表示顶点所带的属性的类别。这从另一个角度也说明 VertexRDD 拥有顶点 id 和顶点属性。

2.2.2 edges

在 GraphX 中,edges 对应着 EdgeRDD。这个 RDD 拥有三个成员变量,分别是源顶点 id、目标顶点 id以及边属性。它的源码如下所示:

abstract class EdgeRDD[ED](sc: SparkContext, deps: Seq[Dependency[_]]) extends RDD[Edge[ED]](sc, deps)

从源码中我们可以看到,EdgeRDD 继承自 RDD[Edge[ED]],即类型为 Edge[ED] 的 RDD。

2.2.3 triplets

在 GraphX 中,triplets 对应着 EdgeTriplet。它是一个三元组视图,这个视图逻辑上将顶点和边的属性保存为一个 RDD[EdgeTriplet[VD, ED]]。可以通过下面的 Sql 表达式表示这个三元视图的含义:

SELECT
	src.id ,
	dst.id ,
	src.attr ,
	e.attr ,
	dst.attr
FROM
	edges AS e
LEFT JOIN vertices AS src ,
 vertices AS dst ON e.srcId = src.Id
AND e.dstId = dst.Id

同样,也可以通过下面图解的形式来表示它的含义:

EdgeTriplet 的源代码如下所示:

class EdgeTriplet[VD, ED] extends Edge[ED] {
   
  //源顶点属性
  var srcAttr: VD = _ // nullValue[VD]
  //目标顶点属性
  var dstAttr: VD = _ // nullValue[VD]
  protected[spark] def set(other: Edge[ED]): EdgeTriplet[VD, ED] = {
   
    srcId = other.srcId
    dstId = other.dstId
    attr = other.attr
    this
  }
}

EdgeTriplet 类继承自 Edge 类,我们来看看这个父类:

case class Edge[@specialized(Char, Int, Boolean, Byte, Long, Float, Double) ED] (var srcId: VertexId = 0, var dstId: VertexId = 0, var attr: ED = null.asInstanceOf[ED]) extends Serializable

Edge 类中包含源顶点 id,目标顶点 id 以及边的属性。所以从源代码中我们可以知道,triplets 既包含了边属性也包含了源顶点的 id 和属性、目标顶点的 id 和属性。

2.3 图的构建

GraphX 的 Graph 对象是用户操作图的入口。前面的章节我们介绍过,它包含了边(edges)、顶点(vertices)以及 triplets 三部分,并且这三部分都包含相应的属性,可以携带额外的信息。

2.3.1 构建图的方法

构建图的入口方法有两种,分别是根据边构建和根据边的两个顶点构建。

根据边构建图(Graph.fromEdges)

def fromEdges[VD: ClassTag, ED: ClassTag](
       edges: RDD[Edge[ED]],
       defaultValue: VD,
       edgeStorageLevel: StorageLevel = StorageLevel.MEMORY_ONLY,
       vertexStorageLevel: StorageLevel = StorageLevel.MEMORY_ONLY): Graph[VD, ED] = {
   
  GraphImpl(edges, defaultValue, edgeStorageLevel, vertexStorageLevel)
}

根据边的两个顶点数据构建(Graph.fromEdgeTuples)

def fromEdgeTuples[VD: ClassTag](
      rawEdges: RDD[(VertexId, VertexId)],
      defaultValue: VD,
      uniqueEdges: Option[PartitionStrategy] = None,
      edgeStorageLevel: StorageLevel = StorageLevel.MEMORY_ONLY,
      vertexStorageLevel: StorageLevel = StorageLevel.MEMORY_ONLY): Graph[VD, Int] =
{
   
  val edges = rawEdges.map(p => Edge(p._1, p._2, 1))
  val graph = GraphImpl(edges, defaultValue, edgeStorageLevel, vertexStorageLevel)
  uniqueEdges match {
   
    case Some(p) => graph.partitionBy(p).groupEdges((a, b) => a + b)
    case None => graph
  }
}

从上面的代码我们知道,不管是根据边构建图还是根据边的两个顶点数据构建,最终都是使用 GraphImpl 来构建的,即调用了 GraphImpl 的 apply 方法。

2.3.2 构建图的过程

构建图的过程很简单,分为三步,它们分别是 构建边EdgeRDD、构建顶点VertexRDD、生成Graph对象。下面分别介绍这三个步骤:

Step1、构建边 EdgeRDD
从源代码看 构建边EdgeRDD 也分为三步,下图的例子详细说明了这些步骤。

• 1 从文件中加载信息,转换成 tuple 的形式,即 (srcId, dstId)

val rawEdgesRdd: RDD[(Long, Long)] =
  sc.textFile(input).filter(s => s != "0,0").repartition(partitionNum).map {
   
    case line =>
      val ss = line.split(",")
      val src = ss(0).toLong
      val dst = ss(1).toLong
      if (src < dst)
        (src, dst)
      else
        (dst, src)
  }.distinct()

• 2 入口,调用 Graph.fromEdgeTuples(rawEdgesRdd)
源数据为分割的两个点 ID,把源数据映射成 Edge(srcId, dstId, attr) 对象, attr 默认为 1。这样元数据就构建成了 RDD[Edge[ED]],如下面的代码:

val edges = rawEdges.map(p => Edge(p._1, p._2, 1))

• 3 将 RDD[Edge[ED]] 进一步转化成 EdgeRDDImpl[ED, VD]
第二步构建完 RDD[Edge[ED]] 之后,GraphX 通过调用 GraphImpl 的 apply 方法来构建 Graph。

val graph = GraphImpl(edges, defaultValue, edgeStorageLevel, vertexStorageLevel)
def apply[VD: ClassTag, ED: ClassTag](
       edges: RDD[Edge[ED]],
       defaultVertexAttr: VD,
       edgeStorageLevel: StorageLevel,
       vertexStorageLevel: StorageLevel): GraphImpl[VD, ED] = {
   
  fromEdgeRDD(EdgeRDD.fromEdges(edges), defaultVertexAttr, edgeStorageLevel, vertexStorageLevel)
}

在 apply 调用 fromEdgeRDD 之前,代码会调用 EdgeRDD.fromEdges(edges) 将 RDD[Edge[ED]] 转化成 EdgeRDDImpl[ED, VD]。

def fromEdges[ED: ClassTag, VD: ClassTag](edges: RDD[Edge[ED]]): EdgeRDDImpl[ED, VD] = {
   
  val edgePartitions = edges.mapPartitionsWithIndex {
    (pid, iter) =>
    val builder = new EdgePartitionBuilder[ED, VD]
    iter.foreach {
    e =>
      builder.add(e.srcId, e.dstId, e.attr)
    }
    Iterator((pid, builder.toEdgePartition))
  }
  EdgeRDD.fromEdgePartitions(edgePartitions)
}

程序遍历 RDD[Edge[ED]] 的每个分区,并调用 builder.toEdgePartition 对分区内的边作相应的处理。

def toEdgePartition: EdgePartition[ED, VD] = {
   
  val edgeArray = edges.trim().array
  new Sorter(Edge.edgeArraySortDataFormat[ED])
    .sort(edgeArray, 0, edgeArray.length, Edge.lexicographicOrdering)
  val localSrcIds = new Array[Int](edgeArray.size)
  val localDstIds = new Array[Int](edgeArray.size)
  val data = new Array[ED](edgeArray.size)
  val index = new GraphXPrimitiveKeyOpenHashMap[VertexId, Int]
  val global2local = new GraphXPrimitiveKeyOpenHashMap[VertexId, Int]
  val local2global = new PrimitiveVector[VertexId]
  var vertexAttrs = Array.empty[VD]
  //采用列式存储的方式,节省了空间
  if (edgeArray.length > 0) {
   
    index.update(edgeArray(0).srcId, 0)
    var currSrcId: VertexId = edgeArray(0).srcId
    var currLocalId = -1
    var i = 0
    while (i < edgeArray.size) {
   
      val srcId = edgeArray(i).srcId
      val dstId = edgeArray(i).dstId
      localSrcIds(i) = global2local.changeValue(srcId,
        {
    currLocalId += 1; local2global += srcId; currLocalId }, identity)
      localDstIds(i) = global2local.changeValue(dstId,
        {
    currLocalId += 1; local2global += dstId; currLocalId }, identity)
      data(i) = edgeArray(i).attr
      //相同顶点srcId中第一个出现的srcId与其下标
      if (srcId != currSrcId) {
   
        currSrcId = srcId
        index.update(currSrcId, i)
      }
      i += 1
    }
    vertexAttrs = new Array[VD](currLocalId + 1)
  }
  new EdgePartition(
    localSrcIds, localDstIds, data, index, global2local, local2global.trim().array, vertexAttrs,
    None)
}

• toEdgePartition 的第一步就是对边进行排序。
  按照 srcId 从小到大排序。排序是为了遍历时顺序访问,加快访问速度。采用数组而不是 Map,是因为数组是连续的内存单元,具有原子性,避免了 Map 的 hash 问题,访问速度快。
• toEdgePartition 的第二步就是填充 localSrcIds, localDstIds, data, index, global2local, local2global, vertexAttrs。
  数组 localSrcIds, localDstIds 中保存的是通过 global2local.changeValue(srcId/dstId) 转换而成的分区本地索引。可以通过 localSrcIds、localDstIds 数组中保存的索引位从 local2global 中查到具体的 VertexId。
  global2local 是一个简单的,key 值非负的快速 hash map:GraphXPrimitiveKeyOpenHashMap,保存 vertextId 和本地索引的映射关系。global2local 中包含当前 partition 所有 srcId、dstId 与本地索引的映射关系。
• data 就是当前分区的 attr 属性数组。
  我们知道相同的 srcId 可能对应不同的 dstId。按照 srcId 排序之后,相同的 srcId 会出现多行,如上图中的 index desc 部分。index 中记录的是相同 srcId 中第一个出现的 srcId 与其下标。
• local2global 记录的是所有的 VertexId 信息的数组。形如:srcId, dstId, srcId, dstId, srcId, dstId, srcId, dstId。其中会包含相同的 srcId。即:当前分区所有 vertextId 的顺序实际值。
  我们可以通过根据本地下标取 VertexId,也可以根据 VertexId 取本地下标,取相应的属性。

// 根据本地下标取 VertexId
localSrcIds/localDstIds -> index -> local2global -> VertexId
// 根据 VertexId 取本地下标,取属性
VertexId -> global2local -> index -> data -> attr object

构建顶点 VertexRDD
紧接着上面构建边 RDD 的代码,我们看看方法 fromEdgeRDD 的实现。

private def fromEdgeRDD[VD: ClassTag, ED: ClassTag](
         edges: EdgeRDDImpl[ED, VD],
         defaultVertexAttr: VD,
         edgeStorageLevel: StorageLevel,
         vertexStorageLevel: StorageLevel): GraphImpl[VD, ED] = {
   
  val edgesCached = edges.withTargetStorageLevel(edgeStorageLevel).cache()
  val vertices = VertexRDD.fromEdges(edgesCached, edgesCached.partitions.size, defaultVertexAttr)
    .withTargetStorageLevel(vertexStorageLevel)
  fromExistingRDDs(vertices, edgesCached)
}

从上面的代码我们可以知道,GraphX 使用 VertexRDD.fromEdges 构建顶点 VertexRDD,当然我们把边 RDD 作为参数传入。

def fromEdges[VD: ClassTag](
                             edges: EdgeRDD[_], numPartitions: Int, defaultVal: VD): VertexRDD[VD] = {
   
  // 1 创建路由表
  val routingTables = createRoutingTables(edges, new HashPartitioner(numPartitions))
  // 2 根据路由表生成分区对象vertexPartitions
  val vertexPartitions = routingTables.mapPartitions({
    routingTableIter =>
    val routingTable =
      if (routingTableIter.hasNext) routingTableIter.next() else RoutingTablePartition.empty
    Iterator(ShippableVertexPartition(Iterator.empty, routingTable, defaultVal))
  }, preservesPartitioning = true)
  // 3 创建VertexRDDImpl对象
  new VertexRDDImpl(vertexPartitions)
}

构建顶点 VertexRDD 的过程分为三步,如上代码中的注释。它的构建过程如下图所示:

创建路由表
为了能通过点找到边,每个点需要保存点到边的信息,这些信息保存在 RoutingTablePartition 中。

private[graphx] def createRoutingTables(edges: EdgeRDD[_], vertexPartitioner: Partitioner): RDD[RoutingTablePartition] = {
   
  // 将edge partition中的数据转换成RoutingTableMessage类型,
  val vid2pid = edges.partitionsRDD.mapPartitions(_.flatMap(
    Function.tupled(RoutingTablePartition.edgePartitionToMsgs)))
}

上述程序首先将边分区中的数据转换成 RoutingTableMessage 类型,即 tuple(VertexId,Int) 类型。

def edgePartitionToMsgs(pid: PartitionID, edgePartition: EdgePartition[_, _])
: Iterator[RoutingTableMessage] = {
   
  val map = new GraphXPrimitiveKeyOpenHashMap[VertexId, Byte]
  edgePartition.iterator.foreach {
    e =>
    map.changeValue(e.srcId, 0x1, (b: Byte) => (b | 0x1).toByte)
    map.changeValue(e.dstId, 0x2, (b: Byte) => (b | 0x2).toByte)
  }
  map.iterator.map {
    vidAndPosition =>
    val vid = vidAndPosition._1
    val position = vidAndPosition._2
    toMessage(vid, pid, position)
  }
}
//`30-0`比特位表示边分区`ID`,`32-31`比特位表示标志位
private def toMessage(vid: VertexId, pid: PartitionID, position: Byte): RoutingTableMessage = {
   
  val positionUpper2 = position << 30
  val pidLower30 = pid & 0x3FFFFFFF
  (vid, positionUpper2 | pidLower30)
}

根据代码,我们可以知道程序使用 int 的 32-31 比特位表示标志位,即 01: isSrcId ,10: isDstId。30-0 比特位表示边分区 ID。这样做可以节省内存。RoutingTableMessage 表达的信息是:顶点id和它相关联的边的分区id是放在一起的,所以任何时候,我们都可以通过 RoutingTableMessage 找到顶点关联的边。

根据路由表生成分区对象

private[graphx] def createRoutingTables(
                                         edges: EdgeRDD[_], vertexPartitioner: Partitioner): RDD[RoutingTablePartition] = {
   
  // 将edge partition中的数据转换成RoutingTableMessage类型,
  val numEdgePartitions = edges.partitions.size
  vid2pid.partitionBy(vertexPartitioner).mapPartitions(
    iter => Iterator(RoutingTablePartition.fromMsgs(numEdgePartitions, iter)),
    preservesPartitioning = true)
}

我们将第 1 步生成的 vid2pid 按照 HashPartitioner 重新分区。我们看看 RoutingTablePartition.fromMsgs 方法。

def fromMsgs(numEdgePartitions: Int, iter: Iterator[RoutingTableMessage])
: RoutingTablePartition = {
   
  val pid2vid = Array.fill(numEdgePartitions)(new PrimitiveVector[VertexId])
  val srcFlags = Array.fill(numEdgePartitions)(new PrimitiveVector[Boolean])
  val dstFlags = Array.fill(numEdgePartitions)(new PrimitiveVector[Boolean])
  for (msg <- iter) {
   
    val vid = vidFromMessage(msg)
    val pid = pidFromMessage(msg)
    val position = positionFromMessage(msg)
    pid2vid(pid) += vid
    srcFlags(pid) += (position & 0x1) != 0
    dstFlags(pid) += (position & 0x2) != 0
  }
  new RoutingTablePartition(pid2vid.zipWithIndex.map {
   
    case (vids, pid) => (vids.trim().array, toBitSet(srcFlags(pid)), toBitSet(dstFlags(pid)))
  })
}

  该方法从 RoutingTableMessage 获取数据,将 vid, 边pid, isSrcId/isDstId 重新封装到 pid2vid,srcFlags,dstFlags 这三个数据结构中。它们表示当前顶点分区中的点在边分区的分布。想象一下,重新分区后,新分区中的点可能来自于不同的边分区,所以一个点要找到边,就需要先确定边的分区号 pid, 然后在确定的边分区中确定是 srcId 还是 dstId, 这样就找到了边。新分区中保存 vids.trim().array, toBitSet(srcFlags(pid)), toBitSet(dstFlags(pid)) 这样的记录。这里转换为 toBitSet 保存是为了节省空间。
  根据上文生成的 routingTables,重新封装路由表里的数据结构为 ShippableVertexPartition。ShippableVertexPartition 会合并相同重复点的属性 attr 对象,补全缺失的 attr 对象。

def apply[VD: ClassTag](
                         iter: Iterator[(VertexId, VD)], routingTable: RoutingTablePartition, defaultVal: VD,
                         mergeFunc: (VD<
  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值