背景介绍
移动广告场景中的ID-Mapping
移动广告场景中的广告投放过程一般会涉及多个应用,例如,某款游戏分别在3个应用中投放广告,这3个应用分别是某款社交应用、某款短视频应用和某款游戏社区应用,某个用户可能先后使用一个或多个设备在上述3个应用中浏览了广告,最后下载、安装并激活游戏,在这个过程中,包含游戏在内的4个应用均产生了用户行为,通过收集用户行为数据,我们可以跟踪广告投放效果,并从用户行为序列中挖掘用户兴趣信息,以便后续更好地为用户推荐其感兴趣的广告。
而分析从各个设备、各个应用收集的用户行为数据的一个前提条件是能够对各个来源的数据中的用户进行唯一标识,即ID-Mapping。
不同维度的ID-Mapping
移动广告场景中,用户和设备的关系比较复杂,一个用户可能同时在用多个设备,比如一个用户既在用手机,也在用平板电脑,而一个设备也可能被多个用户在用,比如一个平板电脑被家庭内的多人在用。另外,用户在一个设备上可能在用多个应用,其中,部分应用在用时用户可能使用账号登录,具有登录态,部分应用在用时用户可能未使用账号登录,不具有登录态。对于使用账号登录的应用,我们可以使用账号作为用户唯一标识,但不同应用的账号可能各不相同,有些应用的账号是手机号,有些应用的账号是邮箱,有些应用的账号是微信号。对于未使用账号登录的应用,我们可以尝试获取设备号作为用户唯一标识,而目前存在多种类型的设备号,比如Android系统中常用的IMEI、OAID和iOS系统中常用的IDFA、CAID,但不同的应用拥有的权限不同,获取设备号的机制也不同,像IDFA这类设备号的获取需要用户授权,像OAID、CAID这类设备号则会在一定情况下被重置,存在多个值,因此不同应用获取的设备号也可能各不相同。
针对上述问题,存在以下不同维度的ID-Mapping:
- 设备维度的ID-Mapping,且只考虑设备号,即只使用用户行为数据中的设备号信息,将各个物理设备的多个设备号连通在一起,得到设备维度的唯一标识;
- 账号维度的ID-Mapping,且只考虑账号,即只使用用户行为数据中的账号信息,将各个自然人的多个账号连通在一起,得到账号维度的唯一标识;
- 设备维度的ID-Mapping,同时考虑设备号和账号,即同时使用用户行为数据中的设备号和账号信息,将各个物理设备的多个设备号连通在一起,连通时,引入账号信息作为辅助,得到设备维度的唯一标识;
- 账号维度的ID-Mapping,同时考虑设备号和账号,即同时使用用户行为数据中的设备号和账号信息,将各个自然人的多个账号连通在一起,连通时,引入设备号信息作为辅助,得到账号维度的唯一标识;
上述不同维度的ID-Mapping中,前两个分别是设备维度的ID-Mapping和账号维度的ID-Mapping,若当前移动广告场景中,收集的用户行为数据中用户登录比例较低、收集到的账号较少,则可以考虑采用设备维度的ID-Mapping,反之则可以考虑采用账号维度的ID-Mapping。后两个也分别是设备维度的ID-Mapping和账号维度的ID-Mapping,只是相对前两个,分别又引入账号和设备号信息作为辅助信息,虽然信息更多,但也需要考虑并解决账号和设备号存在多对多关系的问题。
本文下面主要介绍只考虑设备号下的设备维度的ID-Mapping,如有不足之处,请指正。
设备号类型
首先介绍一下设备号类型。以下内容引用自《计算广告中涉及的设备id:oaid、androidid、imei、idfa、caid》。
Android设备号
IMEI
IMEI(International Mobile Equipment Identity):IMEI是手机的国际移动设备识别码,用于唯一标识每台手机设备。IMEI通常用于在移动通信网络中跟踪和管理手机设备,但在计算广告中也可能用于识别用户的设备。目前,IMEI 等设备标识符已经被认定为用户隐私的一部分,在非必要的场景获取甚至频繁获取IMEI,会被认定为违规获取用户信息的行为。从Android 10开始,应用无法获取 IMEI、MAC等设备唯一标识。为了解决这个问题,国家相关部委成立了移动安全联盟MSA,制定了一套《移动智能终端补充设备标识体系》。因为其中最重要的一个标识符是OAID匿名设备标识符,因此也有人把这个体系简称为OAID。
OAID
OAID(Open Advertising ID):OAID是一种由中国移动互联网行业联盟(CAIA)推出的设备标识符,用于在移动应用中识别用户的设备。OAID旨在提供一个更加隐私安全的设备标识符,以保护用户的个人隐私。注意:苹果手机并不支持OAID(Open Advertising ID),因为OAID是由中国移动互联网行业联盟(CAIA)推出的设备标识符,主要用于在中国移动应用中识别用户的设备。苹果手机通常使用IDFA(Identifier for Advertisers)作为广告标识符,用于在iOS设备中唯一标识每台设备。因此,苹果手机上没有OAID这个设备标识符。OAID在终端首次启动时生成。同一设备的OAID相同,因此可以在多个应用之间共享,恢复出厂设置会重置OAID。
IOS设备号
IDFA
IDFA(Identifier for Advertisers):IDFA是苹果公司推出的广告标识符,用于在iOS设备中唯一标识每台设备。IDFA通常用于在iOS应用中识别用户的设备,用于个性化广告投放和统计分析等用途。苹果公司在iOS 14版本(2020年9月16日发布)中引入了App Tracking Transparency(ATT)框架,该框架要求应用在获取并使用IDFA之前必须经过用户的明确授权。这意味着应用需要向用户显示一个弹窗,征得用户的同意后才能访问和使用IDFA进行广告跟踪和定向广告投放等操作。这一变化对于广告行业来说是一个重大的改变,因为用户可以选择拒绝应用跟踪他们的行为,从而影响了广告主和广告平台的精准定向和广告效果追踪。因此,苹果的限制措施对于广告主和广告平台来说是一个挑战,他们需要寻找新的解决方案来适应这一变化。
CAID
CAID(China Advertising ID):CAID是中国广告行业推出的设备标识符,用于在移动应用中识别用户的设备,主要是为了应对苹果公司引入的App Tracking Transparency(ATT)框架而出现的,作为一种替代方案来帮助广告主更好地定位目标用户。CAID的出现旨在提供一个更加准确和可靠的设备标识符,以应对IDFA受限的情况,帮助广告主和广告平台继续进行精准的广告投放和效果追踪。然而,CAID也引发了一些隐私和数据安全方面的争议,因为它可能绕过用户的隐私设置和授权。
问题建模
ID-Mapping
令某个移动广告场景收集到以下10条用户行为数据,其中只保留了设备号信息,且设备号以“设备号类型_序号”的形式表示,并非真实值:
IMEI_1,OAID_1
IMEI_1,OAID_2
OAID_1
OAID_2
IDFA_1,CAID_1
IDFA_2,CAID_2
IDFA_3,CAID_3
CAID_1
CAID_2
CAID_3
将设备号看作图的顶点,若两个设备号同时出现在一条用户行为数据中,则认为两个设备所对应的顶点之间存在一条边,那么可以根据上述的用户行为数据画出如图2所示的无向图,从中可以看出,IMEI_1、OAID_1、OAID_2这三个设备号连通在一起,代表一个物理设备,IDFA_1、CAID_1、CAID_2、CAID_3这四个设备号连通在一起,代表另一个物理设备。连通在一起、代表一个物理设备的多个设备号构成一个子图,该子图即原图的一个连通分量( Connected Component)。ID-Mapping即求解原图中的所有连通分量。
连通分量
这里再给出连通分量的相关定义。
图是一种比较常见的数据结构,其由若干个顶点和连接两点的若干个边构成。若图中的边有方向,则图为有向图,若图中的边无方向,则图为无向图。
对于无向图有以下定义。
- 顶点的连通性:在一个无向图中,若从顶点到顶点有路径相连(当然从到也一定有路径),则称和是连通的。
- 连通图:在一个无向图中,如果图中任意两点都是连通的,那么该图被称作连通图。
- 连通分量:无向图的极大连通子图称为的连通分量(Connected Component),这里的极大是指顶点个数极大。任何连通图的连通分量只有一个,即是其自身,非连通的无向图有多个连通分量。
对于有向图有以下定义。
- 弱连通图:有向图的底图(无向图)是连通图,则是弱连通图。简单来说就是把所有有向边变成无向边,若此时图连通,则是弱连通图。
- 单向连通图:有向图中,任意顶点对至少从一个到另一个是可达的,则是单向连通。
- 强连通图:有向图中,任意顶点对都互相可达。
- 强连通分量:有向图的极大强连通子图,称为强连通分量。对于一个有向图,它不一定是强连通的,但可以分为若干个极大的强连通子图。
本文所介绍的ID-Mapping对应求解无向图中的所有连通分量。
连通分量计算
这里首先介绍单机计算连通分量的三种方法,分别是DFS、BFS和并查集,然后再介绍当图中的顶点和边较多,无法单机计算时,采用分布式计算框架计算连通分量的方法。
单机计算
首先定义Graph类,用于存储图,其中使用邻接表存储图的节点和边,并提供方法用于添加图的节点和边,添加边时,若边有向,则只添加一条边:source->target,若边无向,则添加两条边:source->target, target->source:
package com.magicwt.idmapping;
import org.apache.commons.compress.utils.Lists;
import org.apache.commons.lang3.tuple.Pair;
import java.util.*;
/**
* 图的定义
*
* @param <T>
*/
public class Graph<T> {
/**
* 使用邻接表存储图
*/
private Map<T, List<T>> adj = new LinkedHashMap<>();
/**
* 添加边,若边有向,则只添加一条边:source->target,若边无向,则添加两条边:source->target, target->source
*
* @param source
* @param target
* @param hasDirection
*/
public void addEdge(T source, T target, boolean hasDirection) {
adj.putIfAbsent(source, Lists.newArrayList());
adj.get(source).add(target);
if (!hasDirection) {
adj.putIfAbsent(target, Lists.newArrayList());
adj.get(target).add(source);
}
}
/**
* 获取节点集合
*
* @return
*/
public Set<T> getNodes() {
return adj.keySet();
}
/**
* 获取节点数目
*
* @return
*/
public int getNodeNumber() {
return adj.size();
}
/**
* 获取某个节点的邻居节点
*
* @param node
* @return
*/
public List<T> getNeighbors(T node) {
return adj.get(node);
}
/**
* 获取边集合
*
* @return
*/
public List<Pair<T, T>> getEdges() {
List<Pair<T, T>> edges = new ArrayList<>();
for (Map.Entry<T, List<T>> entry : adj.entrySet()) {
for (T target : entry.getValue()) {
edges.add(Pair.of(entry.getKey(), target));
}
}
return edges;
}
}
定义联通分量算法抽象类ConnectedComponentsAlgo,其中的核心方法connectedComponents用于计算图的连通分量,其返回Pair列表,每个Pair的Right表示一个节点,Left表示这个节点所属的连通分量,连通分量使用该分量中的第一个节点表示:
package com.magicwt.idmapping;
import org.apache.commons.lang3.tuple.Pair;
import java.util.List;
/**
* 联通分量算法抽象类
*
* @param <T>
*/
public abstract class ConnectedComponentsAlgo<T> {
protected Graph<T> graph;
public ConnectedComponentsAlgo(Graph<T> graph) {
this.graph = graph;
}
/**
* 计算图的联通分量,返回Pair列表,每个Pair的Right表示一个节点,Left表示这个节点所属的联通分量,联通分量一般使用该分量中的第一个节点表示
*
* @return
*/
public abstract List<Pair<T, T>> connectedComponents();
}
DFS
定义通过深度优先遍历实现连通分量计算的算法类ConnectedComponentsAlgoDfsImpl,遍历图中未被访问的节点,对于每个节点,采用深度优先遍历,将所有可达的节点加入到同一个连通分量中:
package com.magicwt.idmapping;
import org.apache.commons.lang3.tuple.Pair;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 通过深度优先遍历实现联通分量计算
*
* @param <T>
*/
public class ConnectedComponentsAlgoDfsImpl<T> extends ConnectedComponentsAlgo<T> {
public ConnectedComponentsAlgoDfsImpl(Graph<T> graph) {
super(graph);
}
@Override
public List<Pair<T, T>> connectedComponents() {
List<Pair<T, T>> result = new ArrayList<>();
Map<T, Boolean> visited = graph.getNodes().stream().collect(Collectors.toMap(Function.identity(), k -> false));
for (T node : graph.getNodes()) {
if (visited.get(node)) {
continue;
}
// 遍历图中未被访问的节点,对于每个节点,采用深度优先遍历,将所有可达的节点加入到同一个连通分量中
List<T> component = new ArrayList<>();
dfs(node, visited, component);
result.addAll(component.stream().map(n -> Pair.of(component.get(0), n)).collect(Collectors.toList()));
}
return result;
}
/**
* 深度优先遍历
*
* @param node
* @param visited
* @param component
*/
private void dfs(T node, Map<T, Boolean> visited, List<T> component) {
visited.put(node, true);
component.add(node);
for (T neighbor : graph.getNeighbors(node)) {
if (!visited.get(neighbor)) {
// 对于每个未被访问的邻居节点,递归调用深度优先遍历
dfs(neighbor, visited, component);
}
}
}
}
BFS
定义通过宽度优先遍历实现连通分量计算的算法类ConnectedComponentsAlgoBfsImpl,遍历图中未被访问的节点,对于每个节点,采用宽度优先遍历,将所有可达的节点加入到同一个连通分量中:
package com.magicwt.idmapping;
import org.apache.commons.lang3.tuple.Pair;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 通过宽度优先遍历实现联通分量计算
*
* @param <T>
*/
public class ConnectedComponentsAlgoBfsImpl<T> extends ConnectedComponentsAlgo<T> {
public ConnectedComponentsAlgoBfsImpl(Graph<T> graph) {
super(graph);
}
@Override
public List<Pair<T, T>> connectedComponents() {
List<Pair<T, T>> result = new ArrayList<>();
Map<T, Boolean> visited = graph.getNodes().stream().collect(Collectors.toMap(Function.identity(), k -> false));
for (T node : graph.getNodes()) {
if (visited.get(node)) {
continue;
}
// 遍历图中未被访问的节点,对于每个节点,采用宽度优先遍历,将所有可达的节点加入到同一个连通分量中
List<T> component = new ArrayList<>();
bfs(node, visited, component);
result.addAll(component.stream().map(n -> Pair.of(component.get(0), n)).collect(Collectors.toList()));
}
return result;
}
/**
* 宽度优先遍历
*
* @param node
* @param visited
* @param component
*/
private void bfs(T node, Map<T, Boolean> visited, List<T> component) {
Queue<T> queue = new ArrayDeque<>();
queue.offer(node);
while (!queue.isEmpty()) {
// 以先进先出的方式遍历队列中的节点
node = queue.poll();
visited.put(node, true);
component.add(node);
for (T neighbor : graph.getNeighbors(node)) {
if (!visited.get(neighbor)) {
// 对于每个未被访问的邻居节点,加入队列
queue.offer(neighbor);
}
}
}
}
}
并查集
并查集算法是一个常用的连通分量算法,关于该算法的介绍这里不再赘述,其实现参考自:algs4.cs.princeton.edu/15uf/UF.jav…,其包含以下几个核心方法:
void union(int p, int q),将元素p所属集合和元素q所属集合合并成一个集合;int find(int p),查找元素p所属的集合;boolean connected(int p, int q),判断元素p和元素q是否属于同一个集合。
并查集算法的代码如下:
package com.magicwt.idmapping;
/**
* 并查集数据结构模板
*
* <a href="https://algs4.cs.princeton.edu/15uf/UF.java.html">源代码</>
*/
public class UnionFind {
/**
* parent[i]表示元素i所属的集合
*/
private int[] parent;
/**
* rank[i] = rank of subtree rooted at i (never more than 31)
*/
private byte[] rank;
/**
* 集合个数
*/
private int count;
public UnionFind(int n) {
if (n < 0) {
throw new IllegalArgumentException();
}
count = n;
parent = new int[n];
rank = new byte[n];
for (int i = 0; i < n; i++) {
// 初始时,每个元素都单独属于一个集合
parent[i] = i;
rank[i] = 0;
}
}
/**
* 查找元素p所属的集合
*/
public int find(int p) {
validate(p);
while (p != parent[p]) {
parent[p] = parent[parent[p]];
p = parent[p];
}
return p;
}
/**
* 返回集合个数
*/
public int count() {
return count;
}
/**
* 判断元素p和元素q是否属于同一个集合
*/
public boolean connected(int p, int q) {
return find(p) == find(q);
}
/**
* 将元素p所属集合和元素q所属集合合并成一个集合
*/
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ) {
return;
}
// make root of smaller rank point to root of larger rank
if (rank[rootP] < rank[rootQ]) {
parent[rootP] = rootQ;
} else if (rank[rootP] > rank[rootQ]) {
parent[rootQ] = rootP;
} else {
parent[rootQ] = rootP;
rank[rootP]++;
}
count--;
}
private void validate(int p) {
int n = parent.length;
if (p < 0 || p >= n) {
throw new IllegalArgumentException("index " + p + " is not between 0 and " + (n - 1));
}
}
}
定义通过并查集实现连通分量计算的算法类ConnectedComponentsAlgoUnionFindImpl,对于图中每个边,将边的两个节点通过调用并查集的union方法进行合并,再对于图中每个节点,通过并查集的find方法找到该节点的连通分量:
package com.magicwt.idmapping;
import org.apache.commons.lang3.tuple.Pair;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 通过并查集实现联通分量计算
*
* @param <T>
*/
public class ConnectedComponentsAlgoUnionFindImpl<T> extends ConnectedComponentsAlgo<T> {
public ConnectedComponentsAlgoUnionFindImpl(Graph<T> graph) {
super(graph);
}
@Override
public List<Pair<T, T>> connectedComponents() {
List<Pair<T, T>> result = new ArrayList<>();
Map<T, Integer> node2Idx = new HashMap<>();
Map<Integer, T> idx2Node = new HashMap<>();
// 生成图中节点的索引,索引从0开始,方便后续使用并查集算法
for (T node : graph.getNodes()) {
node2Idx.put(node, node2Idx.size());
idx2Node.put(node2Idx.get(node), node);
}
// 定义并查集
UnionFind unionFind = new UnionFind(graph.getNodeNumber());
// 添加边
for (Pair<T, T> edge : graph.getEdges()) {
// 对于图中每个边,将边的两个节点通过调用并查集的union方法进行合并
unionFind.union(node2Idx.get(edge.getLeft()), node2Idx.get(edge.getRight()));
}
for (T node : graph.getNodes()) {
// 对于图中每个节点,通过并查集的find方法找到该节点的联通分量
result.add(Pair.of(idx2Node.get(unionFind.find(node2Idx.get(node))), node));
}
return result;
}
}
运行结果
定义算法工厂类,通过不同的算法类型传参,生成相应的连通分量计算算法:
package com.magicwt.idmapping;
import com.alibaba.metrics.StringUtils;
public class ConnectedComponentsAlgoFactory {
public static ConnectedComponentsAlgo createConnectedComponentsAlgo(Graph graph, String algoType) {
if (StringUtils.equals(algoType, "BFS")) {
return new ConnectedComponentsAlgoBfsImpl(graph);
} else if (StringUtils.equals(algoType, "DFS")) {
return new ConnectedComponentsAlgoDfsImpl(graph);
} else if (StringUtils.equals(algoType, "UnionFind")) {
return new ConnectedComponentsAlgoUnionFindImpl(graph);
} else {
throw new IllegalArgumentException("aloType must be one of: BFS, DFS or UnionFind");
}
}
}
定义图,并采用某个连通分量计算算法计连通分量:
package com.magicwt.idmapping;
public class ConnectedComponentsAlgoTest {
public static void main(String[] args) {
Graph<String> graph = new Graph<>();
graph.addEdge("IMEI_1", "OAID_1", false);
graph.addEdge("IMEI_1", "OAID_2", false);
graph.addEdge("IDFA_1", "CAID_1", false);
graph.addEdge("IDFA_1", "CAID_2", false);
graph.addEdge("IDFA_1", "CAID_3", false);
ConnectedComponentsAlgo connectedComponentsAlgo = ConnectedComponentsAlgoFactory.createConnectedComponentsAlgo(graph, "UnionFind");
// ConnectedComponentsAlgo connectedComponentsAlgo = ConnectedComponentsAlgoFactory.createConnectedComponentsAlgo(graph, "DFS");
// ConnectedComponentsAlgo connectedComponentsAlgo = ConnectedComponentsAlgoFactory.createConnectedComponentsAlgo(graph, "BFS");
System.out.println(connectedComponentsAlgo.getClass().getCanonicalName() + ": " + connectedComponentsAlgo.connectedComponents());
}
}
基于DFS实现连通分量计算的运行结果:
com.magicwt.idmapping.ConnectedComponentsAlgoDfsImpl: [(IMEI_1,IMEI_1), (IMEI_1,OAID_1), (IMEI_1,OAID_2), (IDFA_1,IDFA_1), (IDFA_1,CAID_1), (IDFA_1,CAID_2), (IDFA_1,CAID_3)]
基于BFS实现连通分量计算的运行结果:
com.magicwt.idmapping.ConnectedComponentsAlgoBfsImpl: [(IMEI_1,IMEI_1), (IMEI_1,OAID_1), (IMEI_1,OAID_2), (IDFA_1,IDFA_1), (IDFA_1,CAID_1), (IDFA_1,CAID_2), (IDFA_1,CAID_3)]
基于并查集实现连通分量计算的运行结果:
com.magicwt.idmapping.ConnectedComponentsAlgoUnionFindImpl: [(IMEI_1,IMEI_1), (IMEI_1,OAID_1), (IMEI_1,OAID_2), (IDFA_1,IDFA_1), (IDFA_1,CAID_1), (IDFA_1,CAID_2), (IDFA_1,CAID_3)]
均与图2所示一致。
分布式计算
以上介绍了如何基于DFS、BFS和并查集在单机下求解图的连通分量,而当图的顶点和边较多时,例如需要计算由数十亿、甚至上百亿顶点和边构成的图的连通分量时,则需要使用分布式图计算框架来实现连通分量的分布式计算,以加速计算过程。Pregel是Google在2009年提出的分布式图计算框架,后续很多相关工作都受到它的思想影响,而GraphX是Apache基金会基于Spark实现的分布式图计算框架,其参考Pregel实现了相似的API,并基于该API实现了包括连通分量在内的多个图算法。以下先介绍Pregel,再介绍如何通过GraphX求解连通分量,并分析GraphX实现连通分量计算的源码。
Pregel
计算模型
Pregel计算的输入是一个有向图,其中每个顶点(Vertex)由一个字符串(Vertex Identifier)进行唯一标识,且每个顶点有一个可修改的用户定义值,每个有向边与其起始顶点和目标顶点相关联,且每个边也有一个可修改的用户定义值。
Pregel计算的过程分多步迭代,每步迭代被称为“Superstep”,每步迭代中各顶点并行计算,每个顶点接收在前一个迭代中发送给它的消息,执行用户定义的函数,修改其自身的用户定义值或其出边的用户定义值,甚至可以改变图的拓扑结构,添加、删除顶点和边,接着向其他顶点发送消息(这些消息将在下一个迭代中被接收),多步迭代直至达到终止条件,最后输出结果图,结果图中顶点和边的值、甚至图的拓扑结构相比原图可能已被修改。
图3表示顶点的状态流转,初始时,图中每个顶点都处于活跃(Active)状态,参与每步迭代计算,每个顶点计算时通过执行VoteToHalt函数(函数中的具体逻辑由各个图算法自行实现)判断顶点是否流转至非活跃(Inactive)状态。顶点流转至非活跃状态意味着该顶点除非被外部触发,否则没有进一步的工作要做,Pregel在后续迭代中不会执行该顶点,除非它收到消息。如果一个顶点被消息重新激活,它必须再次显式地使自己停止。当所有顶点同时处于非活跃状态且没有消息在传输时,算法整体终止。
图4是一个求解有向图中最大顶点的示例,每个顶点包含一个整数,每步迭代中,每个顶点接收在前一个迭代中发送给它的整数,和其保存的当前整数相比较,若接收到的整数更大,则将保存的整数更新至更大值,并将更新后的整数以消息形式发送至与其连通的目标顶点,若在某步迭代中,所有顶点保存的整数均不再更新,则算法终止,每个顶点均保存了最大整数,随机选取某个顶点输出该值即可。
API
Pregel提供了C++ API,用于可以继承Vertex类,重写其中的方法,以实现特定的图算法。
Vertex类的定义如下:
template <typename VertexValue, typename EdgeValue, typename MessageValue>
class Vertex {
public:
virtual void Compute(MessageIterator* msgs) = 0;
const string& vertex_id() const;
int64 superstep() const;
const VertexValue& GetValue();
VertexValue* MutableValue();
OutEdgeIterator GetOutEdgeIterator();
void SendMessageTo(const string& dest_vertex, const MessageValue& message);
void VoteToHalt();
};
Vertex类包含以下方法:
Compute,每次迭代时活跃顶点调用该方法,接收在前一个迭代中发送给它的消息,查询自身及其出边的值,执行相关计算逻辑,修改自身及其出边的值,并向其他顶点发送消息;GetValue,查询顶点自身的值;MutableValue,修改顶点自身的值;GetOutEdgeIterator,获取出边迭代器,可以迭代各出边,并修改出边中的值;SendMessageTo,发送消息至某个顶点;VoteToHalt,流转顶点状态至非活跃状态;vertex_id,顶点的唯一标识;superstep,当前迭代步数;
消息发送
顶点通过发送消息相互通信,每条消息由值和目标顶点的唯一标识符组成,消息值的类型由Vertex类的模板参数指定。
一个顶点在一个迭代中可以发送任意数量的消息。在迭代中发送给顶点的所有消息在迭代中调用 顶点的Compute方法时,通过一个消息迭代器可用。Pregel不保证迭代器中的消息顺序,但保证消息会被传递且不会重复。
消息发送的一种常见模式是对于顶点,遍历其出边,向每条出边的目标顶点发送一条消息,如后续的PageRank算法所示,然而,从发送消息的方法——SendMessageTo的签名可以看出,该方法通过入参“dest_vertex”指定消息发向目标顶点的唯一标识符,而并没有要求目标顶点“dest_vertex”必须是顶点的邻居顶点,也就是说对于顶点,其可以向图中任意其他顶点发送消息,只要该顶点的唯一标识符已知,非邻居顶点的唯一标识符可以从之前收到的消息中获知,也可以通过其他方式获知(例如图的所有顶点已全局已知)。若消息发送时指定的目标顶点“dest_vertex”不存在,则Pregel执行用户定义的处理程序,创建顶点或删除边。
合并器
Pregel是一个分布式并行计算框架,类似MapReduce,其会将图中各顶点按照一定的路由算法划分至不同的分区实例,各分区实例对该分区中的顶点进行并行计算。如果某个分区实例中的某个顶点是另外一个分区实例中的若干个顶点的出边的目标顶点,那么顶点会接受到另一个分区中的多条消息,这些消息的发送会占用一定的网络带宽。为了减少多条消息跨分区实例发送至同一顶点时的带宽占用,Pregel引入了和MapReduce类似的合并器(Combiner)。用户可以继承Combiner类,重写其中的Combine方法,定制合并消息的逻辑,从而使得多条消息在跨分区实例发送至同一顶点前,被合并为一条消息,减少消息发送的带宽占用。例如,若顶点的Compute方法只关注所有消息中的值的总和,而不是每个消息中的值,则可以在合并器的Combine方法中,将发送给顶点的多个消息合并为一个包含各消息中的值的总和的消息。
聚合器
Pregel设计了聚合器(Aggregator)用于全局的通信。用户可以继承Aggregator类,定制聚合逻辑。在迭代中,每个顶点可以向聚合器提供一个值,聚合器按照定制逻辑聚合这些值,并在迭代中将得到的值提供给所有顶点。Pregel包括许多预定义的聚合器,例如min、max或sum操作。
聚合器也可用于全局统计。例如,对每个顶点的出度应用求和聚合器可以得到图中边的总数。
聚合器也可用于全局协调。例如,各顶点的Compute方法在多步迭代时可以先执行一个分支逻辑,直到聚合器确定所有顶点都满足某些条件时,再给各顶点提供一个信号,然后各顶点的Compute方法在后续多步迭代时执行另一个分支逻辑,直至算法终止。
拓扑改变
Pregel允许图算法在计算过程中改变图的拓扑结构,例如,聚类算法可以用单个顶点替换类簇中的多个顶点,最小生成树算法可以删除除树边之外的其他所有边。用户可以在顶点的Compute方法中,类似发送消息,发出添加或删除顶点或边的请求。
多个顶点可能在一个迭代中发出冲突的拓扑改变的请求,例如,两个顶点均发出添加顶点的请求,但对顶点设置不同的初始值。Pregel设计了两种机制来解决冲突:顺序执行和处理器。
顺序执行。与消息一样,拓扑改变的请求在发出请求后的下个迭代中生效。在该迭代内,Pregel按照固定的顺序执行拓扑改变的请求:首先执行删除操作,先删除边再删除顶点,因为删除顶点会级联删除其所有出边,然后执行添加操作,先添加顶点再添加边,最后再调用Compute方法。顺序执行能够尽可能地避免拓扑改变请求的冲突。
处理器。对于顺序执行无法避免的其余冲突则由用户定义的处理器(Handler)解决,例如,若在一个迭代中存在添加相同顶点的多个请求,默认情况下Pregel会随机选择一个请求执行,而用户也可以在继承Vertex类时,通过定义处理器定制冲突解决策略。
Pregel还支持顶点自身的拓扑改变,即顶点添加或删除自己的出边或直接删除自身。
实现
架构
Pregel会将图中各顶点按照一定的路由算法划分至不同的分区,每个分区由一组顶点及其所有出边组成。默认的路由算法是对顶点ID计算哈希值后再按分区数取模得到对应的分区ID,可表示为:
其中,表示分区数。
用户也可以自定义路由算法将相邻的顶点尽可能划分到一个分区,减少跨分区的消息传递,例如,PageRank算法中,图中各顶点表示互联网中的页面,图中各边表示互联网中的页面对另一个页面的链接,而互联网中同一个站点下的页面相互之间的链接较多,因此,可以自定义路由算法将同一个站点下的页面所对应的顶点划分到同一个分区。
Pregel作为一个分布式计算框架,和其他分布式计算框架类似,在架构上也是由多个实例构成集群,而实例类型也包括两类:主节点(Master)和工作节点(Worker),工作节点用于负责所分配的一个或多个分区的计算,而主节点则负责协调所有工作节点。
用户基于Pregel定义的图和实现的图算法的计算过程包括以下几个阶段:
- 用户程序的多个副本在集群上开始执行,其中一个副本充当主节点,它不被分配图的任何分区,但负责协调工作节点。工作节点使用集群管理系统的名称服务来发现主节点的位置,并向主节点发送注册消息。
- 主节点确定图将具有多少个分区,并为每个工作节点分配一个或多个分区。分区数量可以由用户控制。每个工作节点可以对所负责的多个分区进行分区之间的并行计算,从而提高计算性能。每个工作节点维护所负责分区的状态,对分区内的各顶点执行用户定义的
Compute方法,并管理与其他工作节点的消息传递。每个工作节点也都获得所有工作节点的分区分配结果。 - 主节点为每个工作节点分配一部分用户输入。输入的形式是一组记录,其中每个记录包含一定数量的顶点和边。输入的分配与图本身的分区是正交的,通常基于文件边界,也就是说,一个工作节点被分配到的顶点和边可能属于该工作节点负责的分区,也可能不属于该工作节点负责的分区。对于属于当前工作节点负责的分区的顶点和边,工作节点直接加载这些顶点和边,而对于不属于当前工作节点负责的分区的顶点和边,工作节点将这些顶点和边以消息的方式发送至其对应分区所在的工作节点。所有顶点在被加载后,被标记为活跃状态。
- 主节点指示每个工作节点执行一个迭代。在一个迭代中,工作节点为其负责的每个分区各分配一个线程,各分区的线程会遍历当前分区中的活跃顶点,对每个活跃顶点执行其
Compute方法,传入前一个迭代中发送至该顶点的消息。当工作节点完成当前迭代时,其向主节点发送响应,告诉主节点下一个迭代中将有多少个顶点处于活跃状态。只要有任何顶点处于活跃状态或有任何消息在传输,上述主节点指示每个工作节点执行一个迭代的步骤就会重复。 - 算法终止后,主节点指示每个工作节点保存其负责图的分区。
容错
类似其他分布式计算框架,Pregel的容错是通过检查点(Checkpointing)实现的。在迭代开始时,主节点指示工作节点将其负责分区的状态(包括顶点值、边值和传入消息)保存到持久化存储中,而主节点保存聚合器值,生成一个检查点。
主节点通过定期向工作节点发送“Ping”消息来检测工作节点是否发生故障,如果一个工作节点在指定的时间间隔后没有收到“Ping”消息,则该工作节点终止进程,如果主节点没有收到工作节点的响应,则主节点将该工作节点标记为失败。
当一个或多个工作节点发生故障时,主节点分配给这些工作节点的分区的当前状态将丢失,主节点开始基于检查点进行故障恢复。主节点将图的分区重新分配给当前可用的工作节点,这些工作节点从最近可用的检查点中加载其负责的分区状态,假设最近可用的检查点是在迭代开始时生成的,而在故障发生前,各工作节点已执行至迭代,迭代比迭代早若干个迭代,则各工作节点需要从迭代开始重新执行状态丢失的若干个迭代。因此,在检查点生成频率的设置上,需要平衡故障恢复时间和检查点生成成本,生成频率过高(极限情况下,每次迭代均生成检查点),则检查点生成成本过高,生成频率过低(极限情况下,不生成检查点),则故障恢复时间过长。
基于原始的检查点进行故障恢复,需要所有工作节点回退至检查点对应的迭代重新执行,而实际上只有发生故障的工作节点负责的分区丢失最新状态,因此,Pregel进一步提出了“Confined recovery”机制,只对丢失最新状态的分区从最近可用的检查点开始重新执行。具体实现上,各工作节点在图加载和迭代执行时,记录其所负责分区的传出消息,然后,故障恢复、从最近可用检查点开始重新执行的仅限丢失最新状态的分区,这些分区重新执行状态丢失的若干个迭代,每个迭代中,这些分区传入的消息一方面来自未发生故障分区历史记录的传出消息,另一方面来自故障分区重新执行的传出消息。
应用
论文在应用这里介绍了如何使用Pregel实现PageRank、最短路径、二分图匹配和聚类,以下只介绍一下其中的PageRank和最短路径。
PageRank
PageRank算法中,图中各顶点表示互联网中的页面,图中各边表示互联网中的页面对另一个页面的链接。众所周知,PageRank算法是搜索引擎中网页权重计算的基础,其基本思想是网页权重(PageRank)由链接它的网页权重决定,一个网页被越多的网页链接,且链接该网页的网页的权重越高,则该网页的权重越高,因此,PageRank值的计算通常需要进行多次迭代。关于PageRank算法的详细介绍可以阅读谷歌发表的论文《The Anatomy of a Large-Scale Hypertextual Web Search Engine》。使用Pregel实现PageRank算法的代码如下所示:
class PageRankVertex : public Vertex<double, void, double> {
public:
virtual void Compute(MessageIterator* msgs) {
if (superstep() >= 1) {
double sum = 0;
for (; !msgs->Done(); msgs->Next())
sum += msgs->Value();
*MutableValue() = 0.15 / NumVertices() + 0.85 * sum;
}
if (superstep() < 30) {
const int64 n = GetOutEdgeIterator().size();
SendMessageToAllNeighbors(GetValue() / n);
} else {
VoteToHalt();
}
}
};
图中顶点的值表示顶点所对应网页的PageRank值,初始时为:
整体计算会进行30次迭代,除第0次迭代外,其余各次迭代中,各顶点对传入的消息值求和得到sum,并更新自己的PageRank值为:
除最后一次迭代外,其余各次迭代中,各顶点将其当前的PageRank值除以出边数作为消息值向每条出边的目标顶点发送消息。
最后一次迭代,各顶点通过调用VoteToHalt方法终止算法。实际情况下,PageRank算法的迭代会通过聚合器检测各顶点的PageRank值的变化是否满足收敛条件,若满足收敛条件,则终止算法,算法迭代的次数并不限制为30次。
最短路径
最短路径问题是图论中最著名的问题之一,在各种应用中都会出现,并且有几个重要的变体,例如,单源最短路径问题要求找到单个源顶点与图中其他各个顶点之间的最短路径,s-t最短路径问题要求找到给定顶点s和t之间的最短路径,论文列出了使用Pregel求解单源最短路径问题的一个简易可行(非最高效)算法,代码如下所示:
class ShortestPathVertex : public Vertex<int, int, int> {
void Compute(MessageIterator* msgs) {
int mindist = IsSource(vertex_id()) ? 0 : INF;
for (; !msgs->Done(); msgs->Next())
mindist = min(mindist, msgs->Value());
if (mindist < GetValue()) {
*MutableValue() = mindist;
OutEdgeIterator iter = GetOutEdgeIterator();
for (; !iter.Done(); iter.Next())
SendMessageTo(iter.Target(),
mindist + iter.GetValue());
}
VoteToHalt();
}
};
上述算法中,图中各个顶点的值表示该顶点与源顶点的最短路径距离,初始时,除了源顶点与其自身的最短路径距离为0外,其余顶点与源顶点的最短路径距离为INF(一个大于任何可行路径距离的常数)。在每次迭代中,每个顶点首先从传入消息的值中获取最小值,如果最小值小于当前顶点与源顶点的最短路径距离,则当前顶点更新其与源顶点的最短路径距离为上述最小值,并对其每个出边所对应的目标顶点发送消息,消息的值为当前顶点与源顶点的最短路径距离加上当前顶点与目标顶点的边的长度,即在更新当前顶点与源顶点的最短路径距离的基础上,尝试是否需要更新当前顶点的邻居顶点与源顶点的最短路径距离,最后,当前顶点调用VoteToHalt方法进入非活跃状态,除非后续有其他消息传入使其重新流转至活跃状态。在第一次迭代中,只有源顶点向其邻居顶点发送消息,而在后续迭代中,消息的传递以源顶点为中心逐渐向外扩散,直至覆盖所有源顶点可达的顶点后,算法终止。算法终止后,若顶点值仍为INF,则表示从源顶点不可达当前顶点,若顶点值为不为INF,则该值即当前顶点与源顶点的最短路径距离。
由于每次迭代中,顶点只需要传入消息的值的最小值,因此,可以设计合并器,提前将发向某个顶点的多条消息合并为一条消息,合并后单条消息的值即合并前多条消息的值的最小值,从而减少消息传递的带宽占用,合并器的代码如下所示:
class MinIntCombiner : public Combiner<int> {
virtual void Combine(MessageIterator* msgs) {
int mindist = INF;
for (; !msgs->Done(); msgs->Next())
mindist = min(mindist, msgs->Value());
Output("combined_source", mindist);
}
};
Spark GraphX
GraphX是Apache基金会基于Spark实现的分布式图计算框架,其参考Pregel实现了相似的API,并基于该API实现了包括连通分量在内的多个图算法。关于GraphX的详细介绍可以进一步阅读《GraphX 编程指南》,以下直接给出使用GraphX实现连通分量计算的Scala脚本,该脚本可在“Spark Shell”中运行,Spark的版本为“3.5.1”。
// 引入graphx的依赖
import org.apache.spark.graphx._
// 构建顶点,顶点的ID从0开始递增,顶点的属性即设备ID
var vertices = sc.parallelize(Seq((0L, "IMEI_1"), (1L, "OAID_1"), (2L, "OAID_2"), (3L, "IDFA_1"), (4L, "CAID_1"), (5L, "CAID_2"), (6L, "CAID_3")))
// 构建边,边的属性包括源顶点ID,目标顶点ID以及边的ID,边的ID从0开始递增
var edges = sc.parallelize(Seq(Edge(0L, 1L, 0L), Edge(0L, 2L, 1L), Edge(3L, 4L, 2L), Edge(3L, 5L, 3L), Edge(3L, 6L, 4L)))
// 构建图
var graph = Graph(vertices, edges)
// 输出原图
graph.triplets.foreach(println)
// 调用connectedComponents方法求解连通分量
val cc = graph.connectedComponents()
// 输出连通分量
cc.vertices.map(v => (v._2, v._1)).groupByKey().collect().foreach(println)
上述使用Spark GraphX求解连通分量的脚本,其核心是通过调用Graph类的connectedComponents方法实现连通分量的求解,该方法返回一个新图,新图中的顶点与原图中的顶点一一对应,新图中的顶点ID即原图中的顶点ID,但新图中每个顶点包含一个属性,该属性记录该顶点所属连通分量的ID,而连通分量的ID即该连通分量下所有顶点的ID的最小值。
上述使用Spark GraphX求解连通分量的代码在本地模式的“Spark Shell”中的执行过程如图5所示。
上述使用Spark GraphX求解连通分量的代码的执行结果如下所示:
(0,CompactBuffer(0, 1, 2))
(3,CompactBuffer(3, 4, 5, 6))
原图中共有2个连通分量,第一个连通分量ID为0,包含顶点0、1、2,即设备号IMEI_1、OAID_1、OAID_2,第二个连通分量ID为3,包含顶点3、4、5、6,即设备号IDFA_1、CAID_1、CAID_2、CAID_3。
进一步分析使用Spark GraphX求解连通分量的相关源码,调用Graph类的connectedComponents方法,实际上隐式调用了GraphOps类的connectedComponents方法,GraphOps类的connectedComponents方法的源码如下所示:
package org.apache.spark.graphx
import scala.reflect.ClassTag
import scala.util.Random
import org.apache.spark.SparkException
import org.apache.spark.graphx.lib._
import org.apache.spark.ml.linalg.Vector
import org.apache.spark.rdd.RDD
/**
* Contains additional functionality for [[Graph]]. All operations are expressed in terms of the
* efficient GraphX API. This class is implicitly constructed for each Graph object.
*
* @tparam VD the vertex attribute type
* @tparam ED the edge attribute type
*/
class GraphOps[VD: ClassTag, ED: ClassTag](graph: Graph[VD, ED]) extends Serializable {
// 省略connectedComponents方法之前的代码
def connectedComponents(): Graph[VertexId, ED] = {
ConnectedComponents.run(graph)
}
// 省略connectedComponents方法之后的代码
}
其调用了ConnectedComponents类的run方法。ConnectedComponents类的run方法的源码如下所示:
package org.apache.spark.graphx.lib
import scala.reflect.ClassTag
import org.apache.spark.graphx._
/** Connected components algorithm. */
object ConnectedComponents {
/**
* Compute the connected component membership of each vertex and return a graph with the vertex
* value containing the lowest vertex id in the connected component containing that vertex.
*
* @tparam VD the vertex attribute type (discarded in the computation)
* @tparam ED the edge attribute type (preserved in the computation)
* @param graph the graph for which to compute the connected components
* @param maxIterations the maximum number of iterations to run for
* @return a graph with vertex attributes containing the smallest vertex in each
* connected component
*/
def run[VD: ClassTag, ED: ClassTag](graph: Graph[VD, ED],
maxIterations: Int): Graph[VertexId, ED] = {
require(maxIterations > 0, s"Maximum of iterations must be greater than 0," +
s" but got ${maxIterations}")
val ccGraph = graph.mapVertices { case (vid, _) => vid }
def sendMessage(edge: EdgeTriplet[VertexId, ED]): Iterator[(VertexId, VertexId)] = {
if (edge.srcAttr < edge.dstAttr) {
Iterator((edge.dstId, edge.srcAttr))
} else if (edge.srcAttr > edge.dstAttr) {
Iterator((edge.srcId, edge.dstAttr))
} else {
Iterator.empty
}
}
val initialMessage = Long.MaxValue
val pregelGraph = Pregel(ccGraph, initialMessage,
maxIterations, EdgeDirection.Either)(
vprog = (id, attr, msg) => math.min(attr, msg),
sendMsg = sendMessage,
mergeMsg = (a, b) => math.min(a, b))
ccGraph.unpersist()
pregelGraph
} // end of connectedComponents
/**
* Compute the connected component membership of each vertex and return a graph with the vertex
* value containing the lowest vertex id in the connected component containing that vertex.
*
* @tparam VD the vertex attribute type (discarded in the computation)
* @tparam ED the edge attribute type (preserved in the computation)
* @param graph the graph for which to compute the connected components
* @return a graph with vertex attributes containing the smallest vertex in each
* connected component
*/
def run[VD: ClassTag, ED: ClassTag](graph: Graph[VD, ED]): Graph[VertexId, ED] = {
run(graph, Int.MaxValue)
}
}
从中可以看出,其进一步使用了Spark GraphX实现的Pregel计算框架来实现连通分量算法。
拆解其实现过程,首先基于原图定义新图,新图顶点与原图顶点一一对应,新图中的顶点ID即原图中的顶点ID,只新图中每个顶点还包含一个属性,该属性值初始时即顶点ID,源码如下所示:
val ccGraph = graph.mapVertices { case (vid, _) => vid }
然后对于新图,基于Pregel计算框架来实现连通分量算法。从Pregel部分的介绍中我们已知,Pregel计算包括多次迭代,每次迭代中,各顶点接收在前一个迭代中发送给它的消息,执行用户定义的函数,接着向其他顶点发送消息,因此,连通分量算法源码遵循Pregel计算框架,定义了如何合并传入同一顶点的消息(即Pregel中的组合器)、如何处理传入的消息、如何向其他顶点发送消息。
对于连通分量算法如何合并传入同一顶点的消息,其源码如下:
mergeMsg = (a, b) => math.min(a, b))
即对于传入同一顶点的多条消息,只保留消息属性值最小的消息。
对于连通分量算法如何处理传入的消息,其源码如下:
vprog = (id, attr, msg) => math.min(attr, msg)
即对于传入消息的各顶点,比较消息值和顶点属性值,若消息值小于顶点属性值,则将消息值作为最新的顶点属性值。
对于连通分量算法如何向其他顶点发送消息,其源码如下:
def sendMessage(edge: EdgeTriplet[VertexId, ED]): Iterator[(VertexId, VertexId)] = {
if (edge.srcAttr < edge.dstAttr) {
Iterator((edge.dstId, edge.srcAttr))
} else if (edge.srcAttr > edge.dstAttr) {
Iterator((edge.srcId, edge.dstAttr))
} else {
Iterator.empty
}
}
即比较各边两个顶点的属性值大小,属性值较小的顶点向属性值较大的顶点发送消息,且消息值为较小的顶点属性值,若两个顶点的属性值相等,则不发送消息。需要注意的是,连通分量算法的源码中使用“Long.MaxValue”作为第一次迭代时发向所有顶点的消息值。
综上,第一次迭代时,所有顶点在比较消息值和顶点属性值后,均保持原顶点属性值不变,然后各边两个顶点开始比较各自属性值,由属性值较小的顶点向属性值较大的顶点发送消息,后续每次迭代中,各顶点比较消息值和顶点属性值,若消息值小于顶点属性值,则将消息值作为最新的顶点属性值,然后各边两个顶点开始比较各自属性值,由属性值较小的顶点向属性值较大的顶点发送消息,如此进行多次迭代,直至没有消息发出或者到达最大迭代次数。
简单来说,上述连通分量算法就是通过不断比较相邻两个顶点的属性值并将较大属性值更新至较小属性值,最终将各连通分量中的各顶点属性值均更新至顶点所在连通分量中的最小的顶点属性值。
其他优化点
上面介绍了将ID-Mapping转化为连通分量计算,以及如何实现连通分量计算,在此基础上,还可以考虑进行以下几点优化:
- 边权重及过滤,之前只要两个设备在一条用户行为数据中出现,就认为两个设备所对应顶点之间存在一条边,而没有考虑边的权重,可以考虑根据边出现的次数和出现的时间戳来计算边的权重,边出现的次数越多,说明两个顶点关联的越紧密,权重越大,出现的时间越晚,说明是最近的行为,置信度越高,权重越大,然后对边权重设置阈值,对于未达到权重阈值的边进行删除,防止因脏数据引入造成错误的连通。
- 增量计算,之前是对历史全量设备号构成的图进行连通分量计算,而后续存在设备号的新增,可以只对新增设备号以及和新增设备号存在连通的存量设备号构成的图进行连通分量计算,再将连通分量增量计算结果与历史全量计算结果进行合并。
- 引入其他算法,除使用连通分量算法外,还可以考虑使用社区发现算法(如鲁文算法),将所有顶点划分为多个社区,每个社区表示一个物理设备,或是考虑使用图神经网络相关的算法,学习顶点的嵌入表征,再基于嵌入表征进行聚类,每类表示一个物理设备。
参考资料
- 《计算广告中涉及的设备id:oaid、androidid、imei、idfa、caid》
- 《三种方法求图中连通分量的个数(BFS、DFS、并查集)》
- 《Pregel: A System for Large-Scale Graph Processing》
- 《图解图算法 Pregel: 模型简介与实战案例》
- 《图解Spark Graphx基于connectedComponents函数实现连通图底层原理》
- 《数据应用OneID:ID-Mapping Spark GraphX实现》
- 《基于Spark Graphx实现ID-Mapping》
- 《ID-Mapping在心动公司探索实践》
- 《阿里/网易/美团/58用户画像中的ID体系建设》
- 《id-mapping 理解和实现》