最近在开发一个ID Mapping业务系统——识别数据上报中社交账号的关联关系,找到系统中哪些社交账号属于现实世界中的同一个人。简单来讲,如果同一条上报数据中出现了两个社交账号(比如一个手机号和一个QQ号),就认为这两个社交账号在现实世界属于同一个人。那么,如何计算这个关联关系呢?
一开始我们解决这个问题的思路很直接:现实世界的每个人在系统中用唯一的UUID标识,每次社交账号(Account)上报,在Redis中记录一个UUID->Account的正向索引,同时记录一个Account->UUID的反向索引。每个UUID下可能有多个社交账号,每个社交账号只会归属于一个UUID。每次收到2个社交账号相关的上报时,先通过Account->UUID的反向索引查到这两个社交账号对应的UUID,如果两个账号分别属于两个不同的UUID,就把这两个UUID合并为一个新的UUID,同时原来归属于这两个UUID的所有社交账号都归属于新的UUID;如果查到一个UUID,那么把这两个账号归属于这个UUID;如果未查到UUID,则新生成一个UUID,最后把新的正向索引和反向索引再写回Redis即可。
当然实际的ID Mapping可能有更复杂的关联关系,并不是两个社交账号在同一条上报数据中出现这么简单的逻辑,这就不在本文讨论的范围内了。
这种方式乍一看也没什么问题,但仔细想来却忽略了一个很基本的问题:这种方式只能不断的将账号关联在一起,而不能解除关联。举例来说,给定A-B、B-C两组社交账号的关联关系,A、B、C应该全部关联在一个UUID下,此时若B点被删除,或B-C关联关系解除,系统无法将A和C解除关联。而这种解除关联的场景在业务系统中也是很常见的,比如在企业的客户管理系统中,往往会记录一个客户的一些社交账号,有时企业会删除客户的一些社交账号,甚至删除某一个客户。
其实这个业务抽象成一个数据结构的问题,就是典型的不相交集问题。每一个社交账号看做一个点,两个社交账号的关联关系看做两点之间的无向边,上面的问题就变成了在一个图里划分不相交集的问题。既然很难处理动态变化的图,就每天批量计算一下某一时刻所有账号的关联关系吧。本文就是要介绍一下如何用Spark的RDD API实现静态图不相交集的计算。
一、问题定义
前言描述了问题的背景,这里再明确定义下本文要解决的问题。
算法输入是一张离线Hive表,每行有两个字段Pi、Pj,表示无向图中节点Pi和Pj之间存在一条边。输出同样得到一张Hive表,每行也是Pi、Pj两个字段,Pj均表示不相交集的根节点,即所有Pj相同的行中Pi的集合加上Pj就构成了与图中其他点不相关联的一个独立集合。如图一所示,对于左侧的输入,计算结果将得到右侧的输出,可以看出P1、P2、P3、P6、P8、P9构成一个独立集合,P4、P5、P7构成另一个独立集合。
图一 算法输入与输出
二、计算过程
1. 使每行数据中Pi > Pj
为了保证迭代过程最终可以收敛,不妨将图中所有边都当做有向边处理,方向都是节点ID较大的节点指向节点ID较小的节点,这样最终计算得到的不相交集必是以集合中ID最小的点为根,即所有节点都指向所在集合中ID最小的点。因此,不妨将原始数据中的每一行当做由Pi指向Pj的有向边,若Pj>Pi,则交换Pi和Pj。如图二所示,这一步修改了第三行和第五行数据。
图二 使Pi > Pj
edge_rdd = edge_rdd.map(row => if (row._1 > row._2) (row._1, row._2) else (row._2. row._1))
2. 保证Pi不重复
第一步处理完之后,原始数据中还会存在一个问题:多条边相交于一个ID较大的节点,这会导致ID较大的节点成为潜在的根节点。解决这个问题需要将局部相交于ID较大节点的边转化为相交于ID最小的节点。比如存在P6->P1和P6->P3两条边,这两条边交于P6,P1、P3、P6组成一个独立集合。需要将这个关联关系转换为P6->P1和P3->P1两条边,即以P1为根节点。前一步的处理已经保证数据中每一行都满足Pi>Pj,因此多条边交于ID较大的节点等价于多行的Pi相同。所以只需要在保证原有关联关系的条件下将表处理为Pi不重复即可。
这一步保证了Pi中的节点不会作为根节点,所有有向边都由叶子节点指向潜在的根节点。
图三 保证Pi不重复
在算法实现上,首先将Pi相同的行聚合在一起,然后输出这个集合中每个点指向集合中ID最小点的有向边。
while(edge_rdd.keys.count() != edge_rdd.keys.distinct.count()) {
edge_rdd = edge_rdd.groupbykey()
.flatMap(row => {
val vertex_list = row._2.toSeq.sorted.distinct
val result = new ArrayBuffer[(String, String)]()
result.append(row => (row._1, vertex_list.head))
vertex_list.tail.foreach(vertex => result.append((vertex, vertex_list.head)))
result
})
}
从图三种可以看出,每次迭代之后,可能会产生新的Pi相同的数据,因此需要用迭代的方式,多次执行以上逻辑,迭代的终止条件就是Pi中的点不重复。
3. 将每一行中的Pj替换为集合中最小的节点ID
最后一步就是算法的核心,通过自关联,将所有叶子节点关联到根节点上。算法原理很好理解:若同时存在Pz->Py的有向边和Py->Px的有向边,就将Pz->Py替换为Pz->Px。
如图四所示,第一次迭代由P8->P2和P2->P1两条边得到P8->P1,由P9->P8和P8->P2两条边得到P9->P2;第二次迭代由P9->P2和P2->P1两条得到P9->P1。
如果我们定义P2与根节点P1的距离是1,P8和根节点P1的距离是2,P9和根节点P1的距离是3,依次类推。该算法每n次迭代可以将距离为2n之内的节点全部关联到根节点上。
图四 将Pj替换为根节点
for (i <- 1 to iterateNum) {
val inverse_edge = edge_rdd.map(row => (row._2, row._1))
val edge_rdd = inverse_edge.leftOuterJoin(edge_rdd).map(row => (row._2._1, if (row._2._2.isDefined) row._2._2.get else row._1))
}
通过以上3个步骤的处理,可以看到原始数据集被划分成了2个不相交集,根节点分别为P1和P4。
三、执行优化
问题也并不是这样顺利的就解决了,将上述逻辑转化为工程代码时还遇到了一些其他问题,下面也分享下遇到的问题,以及采取的优化方案。
1. 推测执行
由于数据量比较大,计算时会使用较大的并发来执行,对于同一个job经常出现大部分task都执行很快,只是在等2-3个task的执行,而观察发现并没有明显的数据倾斜。查阅相关资料了解到这种情况可能是由于各个executor上的运行环境不同(有可能同时运行了其他任务、或者是硬件原因),导致计算时间差异较大,这个问题可以通过推测执行解决。
spark.speculation=true
spark.speculation.interval=100
spark.speculation.multiplier=1.5
2. checkpoint
由于该算法是一个迭代算法,执行流程较长,有时会出现某个executor丢失,使RDD中的一部分丢失,导致整个任务需要重新计算,甚至失败。查阅相关资料后,最终通过checkpoint的方式解决了这个问题。checkpoint算子将RDD写入到HDFS某个目录下,因此也一个Action,所以一般会先执行cache再执行checkpoint。
edge_rdd = edge_rdd.groupbykey().flatMap(row => {...}).cache
edge_rdd.checkpoint()
3. RDD cache释放
代码运行过程中还发现任务会占用很多内存,远比预期大的多,通过查看Spark任务的Storage页,发现其实是迭代的方式导致了“内存泄漏”。
在迭代的过程中,算法对每一次迭代得到的edge_rdd进行了cache,而事实上每次计算出新的edge_rdd后,前一次迭代的cache就没用了。但如果没有手动释放的话,这些RDD的cache在任务终止之前都不会被释放掉,会一直占用着集群内存,导致“内存泄漏”。从图五中可以看出,每次迭代都会生成一个RDD,并cache在内存中,如果迭代次数比较多,这部分内存浪费对集群资源的占用就很可观了。甚至如果新的RDD没有内存可以cache,会导致RDD的重复计算,这样会严重影响任务执行的时间。
图五 没有手动释放RDD,导致“内存泄漏”
这个问题可以通过在每次计算生成新的RDD时手动unpersist上一个RDD来解决,在内存无效时立刻释放掉这部分内存。
val tmp = edge_rdd.groupbykey().flatMap(row => {...}).cache
tmp.checkpoint()
edge_rdd.unpersist()
edge_rdd = tmp
四、执行性能
用上述算法计算业务收集到的社交账号关联关系,数据量在5000万条左右,第二步计算需要7次迭代收敛,第三步计算需要3-4次迭代收敛。程序运行使用16核64G内存的分布式Spark运行环境,迭代过程中partition个数为64,整体运行时间在20分钟左右,基本达到了业务使用的要求。
五、附加完整实现代码
package com.yang.spark.idmapping
import com.yang.spark.utils.SparkUtils
/**
* vertices edges
*/
object IncrementIDMapping {
def main(args: Array[String]): Unit = {
//最大迭代次数
val step3MaxIterateNum = 100
val spark = SparkUtils.initSession(isLocal = false, this.getClass.getSimpleName)
spark.sqlContext.setConf("spark.sql.adaptive.maxNumPostShufflePartitions", "1000")
/*val inputRDD: RDD[(String, String)] = spark.sparkContext.makeRDD(Seq(
("100", "101"),
("100", "105"),
("101", "102"),
("102", "103"),
("104", "105"),
("105", "106"),
("106", "103"),
("107", "108"),
("108", "109"),
("110", "107"),
("111", "112"),
("113", "114"),
("114", "112"),
("115", "116"),
("117", "118"),
("117", "116")
))*/
val inputRDD = spark
.sql(
s"""
|select id, ccid as id2
|from hdp_jinrong_tech_dw.dw_wb_ajk_idmapping_output_idrevtable
|where dt = '20200618'
|union all
|select id1, id2
|from hdp_jinrong_tech_ods.temp_ajk_phone_idmapping_input_data_20200619
""".stripMargin
)
.rdd
/**
* 1. 使每行数据中(id1, id2) 满足 id1 > id2
* 为了保证迭代过程最终可以收敛,不妨将图中所有边都当做有向边处理,方向都是节点ID较大的节点指向节点ID较小的节点,
* 这样最终计算得到的不相交集必是以集合中ID最小的点为根,即所有节点都指向所在集合中ID最小的点。
* 因此,不妨将原始数据中的每一行当做由id1指向id2的有向边,若id2 > id1,则交换id1和id2。
*/
var edgeRDD = inputRDD.repartition(1000)
.map(row => (row.getAs[String]("id1"), row.getAs[String]("id2")))
.map { case (id1, id2) => if (id1 > id2) (id1, id2) else (id2, id1) }
/*println("=================step2: edgeRDD=====================")
edgeRDD.take(20).foreach(println)*/
/**
* 2. 保证id1不重复
* 第一步处理完之后,原始数据中还会存在一个问题:多条边相交于一个ID较大的节点,
* 这会导致ID较大的节点成为潜在的根节点。解决这个问题需要将局部相交于ID较大节点的边转化为相交于ID最小的节点。
* 比如存在P6->P1和P6->P3两条边,这两条边交于P6,P1、P3、P6组成一个独立集合。
* 需要将这个关联关系转换为P6->P1和P3->P1两条边,即以P1为根节点。
* 前一步的处理已经保证数据中每一行都满足id1 > id2,因此多条边交于ID较大的节点等价于多行的id1相同。
* 所以只需要在保证原有关联关系的条件下将表处理为id1不重复即可。
* 这一步保证了id1中的节点不会作为根节点,所有有向边都由叶子节点指向潜在的根节点。
*/
var step2IterateNum = 0
while (edgeRDD.keys.count() != edgeRDD.keys.distinct().count()) {
step2IterateNum = step2IterateNum + 1
println(s"================step2: iterateNum: $step2IterateNum======================")
edgeRDD = edgeRDD.groupByKey(1000)
.flatMap {
case (id, ids) =>
val vertexList = ids.toSeq.sorted
val head = vertexList.head
val result = new scala.collection.mutable.HashSet[(String, String)]
result.add((id, head))
vertexList.tail.foreach(vertex => result.add((vertex, head)))
result
}
}
/*println("================step2: edgeRDD======================")
edgeRDD.take(20).foreach(println)*/
/**
* 3. 将每一行中的id2替换为集合中最小的节点ID
* 第三步就是算法的核心,通过自关联,将所有叶子节点关联到根节点上。
* 算法原理很好理解:若同时存在id3 -> id2的有向边和id2 -> id1的有向边,就将id3 -> id2替换为id3 -> id1。
* 第一次迭代由P8->P2和P2->P1两条边得到P8->P1,由P9->P8和P8->P2两条边得到P9->P2;
* 第二次迭代由P9->P2和P2->P1两条得到P9->P1。
* 如果我们定义P2与根节点P1的距离是1,P8和根节点P1的距离是2,P9和根节点P1的距离是3,依次类推。
* 该算法每n次迭代可以将距离为2^^n之内的节点全部关联到根节点上。
*/
import scala.util.control.Breaks._
breakable {
for (step3IterateNum <- 1 to step3MaxIterateNum) {
println(s"================step3: iterateNum: $step3IterateNum======================")
val reverseEdgeRDD = edgeRDD.map { case (id1, id2) => (id2, id1) }
/*println("================step3: reverseEdgeRDD======================")
reverseEdgeRDD.take(20).foreach(println)*/
val joinRDD = reverseEdgeRDD.leftOuterJoin(edgeRDD, 1000)
/*println("================step3: joinRDD======================")
joinRDD.take(20).foreach(println)*/
val innerCount = joinRDD.filter{case (k, (_, v2)) => v2.isDefined && k != v2.get}.count()
if(0 != innerCount) {
edgeRDD = joinRDD.map { case (k, (v1, v2)) => (v1, if (v2.isDefined) v2.get else k) }
} else {
break
}
}
}
/*println("================step3: edgeRDD======================")
edgeRDD.take(20).foreach(println)*/
import spark.implicits._
val resultDF = edgeRDD.toDF("id1", "id2")
resultDF.createOrReplaceTempView("idmapping_output_temp_view")
spark.sql(
s"""
|insert overwrite table hdp_jinrong_tech_dw.dw_wb_ajk_idmapping_output_idrevtable
|partition (dt = '20200619')
|select *
|from idmapping_output_temp_view
""".stripMargin
)
}
}