IDMapping实现详解

IDMapping的意义

现实存在的问题:

​ 对于一个系统而言,标识用户的方式有很多,用户ID、手机号、身份证号、设备ID等等;那如何确认用户的唯一标识呢?如果只是采用用户ID,那么如果用户没有登录的日志记录就失去价值了;

​ 再对于多个系统而言,如何让多系统中的一个自然人只有一个唯一标识呢?系统之间的id打通可以通过手机号、身份证号等,也就是阿里OneData的OneID思想,通过统一的实体识别和连接,打破数据孤岛,实现数据通融。

解决方案:

构建id映射字典,例如

id(用户id或设备id或等等其他)       guid(全局唯一标识)
    135134111xx                      uid01
    440xxxxxxxxxxxxxxx               uid01
    00-16-EA-AE-3C-40                uid01
    191134111xx                      uid02
    24-16-EA-AE-3C-40                uid03

ID-Mapping,就是将设备 ID(例如手机 MAC、IMEI、IMSI 等),手机号、身份证号、邮箱地址、PC 端的 Cookie,用户名等信息,结合标签体系、知识图谱、机器学习等技术和算法,将各种 ID 都映射到统一的 ID 上。

实现方案

实现思路

由于 ID 识别天然地是一个关联关系问题,也是一个典型的图、图数据库应用场景。

将日志记录中每行记录中每个可以唯一标识用户的字段抽象为顶点顶点同属于一个用户抽象为;由于同一条记录的顶点肯定属于同一用户,因此可以得到如下图。

合并相同的顶点可以得到

不难理解,一个极大无向连通子图就可以代表一个自然人用户,进而构建id映射字典,全局唯一标识id可以用连通子图的最小顶点值表示。

id(用户id或设备id或等等其他)       guid(全局唯一标识)
    00001                            00001
    135134111xx                      00001
    440xxxxxxxxxxxxxxx               00001
    00-16-EA-AE-3C-40                00001
    9569635641825837                 00001
    
    00002                            00002
    450xxxxxxxxxxxxxxx               00002
    26-16-EA-AE-3C-40                00002
    8469635641825837                 00002
    191134111xx                      00002
    
    00003                            00003
    53-16-EA-AE-3C-40                00003

具体实现

对于每日的日志记录,我们都需要对日志记录集成全局唯一标识(guid)信息。考虑到用户可能是第一次访问这个网站,id映射字典并没有该用户的guid,因此需要在数据集成之前,做一次IDMapping,也就是每日都需要IDMapping。

那如何计算当日的id映射字典呢?历史的映射字典+今日的日志记录,进一步说,历史映射字典生成的图+今日日志记录生成的图。

首先考虑今日日志记录生成图。思路如下:

  1. 解析日志数据,取出每个可以唯一标识用户的字段的值作为顶点,同一行日志记录的顶点之间构建边;
  2. 调用Spark GraphX 的Connected Components方法,即连通体算法用id标注图中每个连通体,将连通体中序号最小的顶点的id作为连通体的id。
object TestIDMapping {
  def main(args: Array[String]): Unit = {

    val spark = SparkUtil.getSparkSession(this.getClass.getSimpleName,master = "local[1]")
    // 读取日志文件
    val log: Dataset[String] = spark.read.textFile("G:\\delta_logs\\2020-01-11\\app")
    // 提取可以标识用户的字段值
    val flagsArray: RDD[Array[String]] = extractFlags(log)
    // 将字段值转化为顶点,VertexId为字段值的哈希值,attribute为字段值本身
    // Vertices:由VertexId(Long类型)、attribute(属性描述或距离)构成。如,(3L, ("San Francisco", "CA")),(1L, 10)
    val todayLogVertexes: RDD[(Long, String)] = flagsArray.flatMap(flags => {
      for (flag <- flags) yield (flag.hashCode.toLong, flag)
    })
    // 同一行日志记录的顶点相互构成边(无向)
    // Edges:由srcId(起始节点VertexId)、dstId(终止节点VertexId)、attribute(边的权值)构成。如,Edge(1L, 2L, 20)
    val todayLogEdges: RDD[Edge[Int]] = flagsArray.flatMap(flags => {
      for (i <- 0 to flags.length - 2; j <- i to flags.length - 1)
        yield Edge(flags(i).hashCode.toLong, flags(j).hashCode.toLong, 1)
    })
      .map(edge=>(edge,1))      // 上面生成的边肯定会有重复的
      .reduceByKey(_+_)         // 这里按edge进行聚合,顺便统计一下相同边的数量
      .filter(tp => tp._2 > 2)  // 如果相同边小于等于2,可能是偶然事件,就剔除掉  tp == (edge,count(1))
      .map(tp => tp._1)         // 恢复成edge,此时以及完成了去重和清洗

    // 构建图并调用connectedComponents生成极大连通子图
    val todayConnectedComponent: VertexRDD[VertexId] = Graph(todayLogVertexes, todayLogEdges)
      .connectedComponents()
      .vertices
    // VertexRDD[VertexId] ==> RDD[(flag.hashcode,guid(最小的flag.hashcode))]
    val idMappingDict: RDD[(VertexId, VertexId)] = todayConnectedComponent.map(tp => {
      (tp._2, tp._1)
    })
  }

  /**
   * 抽取可以用于标识用户的字段值
   */
  def extractFlags(logDs: Dataset[String]):RDD[Array[String]] = {
    logDs.rdd.map(logText=>{
      /**
       * 日志内容可标识字段概况
       * {
       *    ... ,
       *    "user":{
       *      "uid" : "",
       *      "account" : "",
       *      "email" : "",
       *      "phoneNbr" : "",
       *      "phone" : {
       *          "imei": "9569635641825837",			  //imei
       *          "mac": "2f-93-d1-e4-5e-35-36",		//mac
       *          "imsi": "1706694213462619",			  //imsi
       *          "deviceId": "",						        //设备id
       *          "uuid": "sarSfmG2p7RFG1z3",			  //uuid
       *          ...
       *      }
       *    }
       *    ...
       * }
       */
      // 解析日志json
      val logJSON = JSON.parseObject(logText)
      // 获取 日志json中 嵌套的 用户json对象
      val userJSON = logJSON.getJSONObject("user")
      // 获取用户json对象中的标识字段
      val uid =       userJSON.getString("uid")       // 用户id
      val account =   userJSON.getString("account")   // 用户账号
      val phoneNbr =  userJSON.getString("phoneNbr")  // 电话号码
        // 获取 用户json对象 中嵌套的 设备json对象
      val phoneJSON = userJSON.getJSONObject("phone")
        val imei =      phoneJSON.getString("imei")
        val mac =       phoneJSON.getString("mac")
        val imsi =      phoneJSON.getString("imsi")
        val deviceId =  phoneJSON.getString("deviceId")
        val uuid =      phoneJSON.getString("uuid")
      Array(uid,account,phoneNbr,imei,mac,imsi,deviceId,uuid)
      }
    )
  }
}

此时已经可以得到当日的id映射字典了,然后开始考虑整合历史的id映射字典。

主要问题:

  • 历史的id映射字典是以什么方式存储的,怎么读回?

    每日的id映射字典以parquet方式存储在HDFS,程序可以直接读回结构化数据。

  • 整合是怎么做到的?

    首先,将历史的id映射字典重新转化为顶点和边,顶点是每个用户的每个标识字段,边是标识字段与guid的连接,如下图所示。

​ 再和今日的顶点和边union成一个更大的图,图里包含了今日的子图和历史的子图。

​ 再重新调用Connected Components方法,生成大图的极大连通子图,就可以实现历史和今日的整合。

​ 但是需要考虑一个问题,由于Connected Components方法生成的guid是连通子图最小的字段值,那如果今日的字段值是最小的,就会改变这个用户的guid,这显然是不合理的,因为历史的guid已经集成到数据中了。因此需要将这种情况的guid保留为历史的guid。具体实现如下:

/**
 * 完整实现生成id映射字典
 */

object TestIDMapping {
  def main(args: Array[String]): Unit = {

    val spark = SparkUtil.getSparkSession(this.getClass.getSimpleName,master = "local[1]")
    // 读取今日日志文件
    val log: Dataset[String] = spark.read.textFile("G:\\delta_logs\\2020-01-11\\app")
    // 提取可以标识用户的字段值
    val flagsArray: RDD[Array[String]] = extractFlags(log)
    // 将字段值转化为顶点,VertexId为字段值的哈希值,attribute为字段值本身
    val todayLogVertexes: RDD[(Long, String)] = flagsArray.flatMap(flags => {
      for (flag <- flags) yield (flag.hashCode.toLong, flag)
    })
    // 同一行日志记录的顶点相互构成边(无向)
    val todayLogEdges: RDD[Edge[Int]] = flagsArray.flatMap(flags => {
      for (i <- 0 to flags.length - 2; j <- i to flags.length - 1)
        yield Edge(flags(i).hashCode.toLong, flags(j).hashCode.toLong, 1)
    })
      .map(edge=>(edge,1))      // 上面生成的边肯定会有重复的
      .reduceByKey(_+_)         // 这里按edge进行聚合,顺便统计一下相同边的数量
      .filter(tp => tp._2 > 2)  // 如果相同边小于等于2,可能是偶然事件,就剔除掉  tp == (edge,count(1))
      .map(tp => tp._1)         // 恢复成edge,此时以及完成了去重和清洗
    
    // 读取历史的id映射字典
    val historyIdMappingDict = spark.read.parquet("data/idmp/2020-02-11")
    // 得到历史字典的所有顶点
    val historyLogVertexes: RDD[(VertexId, String)] = historyIdMappingDict.rdd.map({
      case Row(flagHashCode: VertexId, guid: VertexId) => (flagHashCode, "")
    })
    // 构建历史字典的边
    val historyLogEdges: RDD[Edge[Int]] = historyIdMappingDict.rdd.map(row => {
      Edge(row.getAs[VertexId]("flagHashCode"), row.getAs[VertexId]("guid"), 1)
    })

    // 构建图并调用connectedComponents生成极大连通子图(历史+今天)
    // 注:VertexRDD[VertexId] ==> RDD[(flag.hashcode,guid(最小的flag.hashcode))]
    val unifiedConnectedComponent: VertexRDD[VertexId] =
      Graph(todayLogVertexes.union(historyLogVertexes), todayLogEdges.union(historyLogEdges))
      .connectedComponents()
      .vertices

    // 解决guid历史存在但可能被更换的问题
    // 遍历 unifiedConnectedComponent(统一连通分量) 的 flag,
    // 如果在 历史 guid 中发现存在,
    // 说明该用户已经存在guid了,可以将 该flag所在的统一连通分量的guid 改为 历史的guid

    // 将历史的id映射字典收集到Driver端,然后广播(广播是由于接下来需要在map中使用外部变量)
    val historyIdMappingDict_bc = historyIdMappingDict.rdd.map(row => {
      (row.getAs[VertexId]("flagHashCode"), row.getAs[VertexId]("guid"))
    }).collectAsMap()
    val bc = spark.sparkContext.broadcast(historyIdMappingDict_bc)

    // 将统一连通分量 按guid 分组
    val finalConnectedComponent = unifiedConnectedComponent.map(tp => (tp._2, tp._1))
      .groupByKey() // 得到(guid,Iterator(flag))
      .map(tp => {
        var guid = tp._1
        val flags = tp._2
        val historyIdMappingDict_bc = bc.value
        // 遍历这组flags,看有没有和历史guid一致的,如果有就替换
        var caught = false;
        for (flag <- flags if !caught) {
          if (historyIdMappingDict_bc.get(flag).isDefined) {
            caught = true;
            guid = historyIdMappingDict_bc.get(flag).get
          }
        }
        (guid, flags)
      })
      .flatMap(tp => {
        for (flag <- tp._2) yield (flag, tp._1) // 恢复分组前 (flag,guid)
      })

    // 保存结果
    import spark.implicits._
    finalConnectedComponent.toDF("flagHashCode", "guid")
      .coalesce(1)
      .write.parquet("data/idmp/xxxx")

    spark.close()
  }

  /**
   * 抽取可以用于标识用户的字段值
   */
  def extractFlags(logDs: Dataset[String]):RDD[Array[String]] = {
      ...
  }
}

posted @ 2023-08-28 17:43  nangk  阅读(724)  评论(0编辑  收藏  举报