OneID 从 0 到 1 完整生产案例(二):ID 标准化、关系抽取与连通分量
本篇为第 4--6 章,是整个 OneID 的算法核心:把原始 ID 洗成统一节点(第 4 章)、抽取关系连边并治理超级节点(第 5 章)、在十亿级图上求连通分量(第 6 章)。背景、架构与表结构见
oneid_case_part1_ch01-03.md。
本册章节导航
| 章节 | 主题 | 核心内容 |
|---|---|---|
| 第 4 章 | 数据接入与 ID 标准化 | 埋点日志/CDC/三方数据接入、10 类 ID 格式特征与校验规则、手机号清洗(国家码/虚拟号段/大小写归一)、HMAC-SHA256 加盐(盐值分级)、oneid_core/standardize 模块完整代码 + PySpark UDF/pandas UDF 标准化作业 |
| 第 5 章 | 关系抽取与图谱构建 | 五类关系来源(共现/绑定/同地址/同支付/同 WiFi)、置信度打分公式(来源权重×时效衰减×共现饱和)与参数表、oneid_core/graph 边表生成完整代码、弱关系三道剪枝闸、超级节点黑名单与度数截断 |
| 第 6 章 | 连通图打通算法(核心章) | 连通分量问题本质(配图)、并查集 Python 完整实现(路径压缩+按秩合并+置信度剪枝+单测)、GraphX Scala connectedComponents 完整代码、GraphFrames PySpark 版本、三方案亿级性能对比与选型、数据倾斜加盐处理 |
| 文末 | 本篇小结 + 分册面试高频题串讲 | 8 道高频面试题与参考答案 |
第4章 数据接入与 ID 标准化
4.1 标准化要解决什么
标准化是 OneID 的第一道闸门,目标是把各触点上报的、形态各异的原始 ID ,统一转换成全局可比、不可逆、可复现 的标准节点 id_hash:
原始形态(脏) 标准化后(净)
+86 138-0000-1111 ─┐
13800001111 ├─ 清洗 ─► 13800001111 ─► HMAC(salt_mdn) ─► h_a3f9... (mdn)
8613800001111 ─┘
ABCD-EFGH-IDFA ─► 大写归一 ─► ABCD-EFGH-... ─► HMAC(salt_device) ─► h_b71c... (idfa)
abcd-efgh-idfa ─┘ (同一设备,大小写不同,归一后哈希一致)
"" / null / "null" ─► 校验失败 ─► is_valid=0,不产生节点
三个必须保证的性质:
- 确定性(Deterministic):同一个原始 ID 永远哈希出同一个 id_hash(否则同一人在不同批次对不上)。因此哈希算法、盐值、清洗规则三者必须版本化、不可静默变更。
- 不可逆(Irreversible):id_hash 无法反推明文,落盘/进缓存的都是哈希(第 14 章)。
- 可复现(Reproducible):给定算法版本 + 盐版本 + 明文,任何人能算出相同结果,支持重跑与审计。
4.2 10 类 ID 的格式特征与校验规则
| id_type | 格式特征 | 清洗要点 | 强弱 |
|---|---|---|---|
| mdn | 11 位数字,13-9 开头 | 去分隔符/国家码;虚拟号段标记 | 强 |
| id_card | 18 位,末位 X | 校验位算法校验;脱敏 | 强 |
| unionid | 微信 28 位左右 base64-ish | 原样,不区分大小写按微信规则 | 强 |
| pay_uid | 支付账号 | 原样哈希 | 强 |
| member_id | M + 8+ 位数字 |
去前导空格 | 弱 |
| openid | 28 位左右 | 同 unionid | 弱 |
| idfa | 8-4-4-4-12 十六进制大写 | 大写归一;全 0 判无效 | 弱 |
| gaid | 同 idfa 格式小写 | 小写归一 | 弱 |
| oaid | 32 位十六进制 | 小写;空串丢弃 | 弱 |
| device_id | 16-64 位指纹串 | 小写;过短丢弃 | 弱 |
4.3 手机号清洗规则
手机号是最强、最高频的锚点,清洗规则最细:
python
# oneid_core/standardize/normalize.py
from __future__ import annotations
import re
from dataclasses import dataclass
# 虚拟运营商/物联网号段(截至星购整理,需定期更新)
VIRTUAL_PREFIXES = {
"170", "171", "162", "165", "167", # 虚拟运营商
"164", "140", "141", "144", "146", "148", # 物联网/数据卡
}
# 明显的占位/测试号
PLACEHOLDER_MDNS = {"00000000000", "11111111111", "12345678901", "18888888888"}
_NON_DIGIT = re.compile(r"\D")
_MDN_RE = re.compile(r"^1[3-9]\d{9}$")
@dataclass(frozen=True)
class NormalizeResult:
value: str | None # 清洗后的标准明文;None 表示无效
valid: bool
reason: str | None = None # 无效原因/降权标记
is_virtual: bool = False
def normalize_mdn(raw: str | None) -> NormalizeResult:
"""手机号清洗:去分隔符 → 去国家码 → 11位校验 → 号段标记。"""
if raw is None:
return NormalizeResult(None, False, "null")
s = _NON_DIGIT.sub("", str(raw).strip())
# 国家码:+86 / 0086 / 86 前缀
if s.startswith("0086"):
s = s[4:]
elif s.startswith("86") and len(s) == 13:
s = s[2:]
# 长度兜底
if len(s) != 11:
return NormalizeResult(None, False, f"bad_len:{len(s)}")
if s in PLACEHOLDER_MDNS:
return NormalizeResult(None, False, "placeholder")
if not _MDN_RE.match(s):
return NormalizeResult(None, False, "bad_format")
is_virtual = s[:3] in VIRTUAL_PREFIXES
# 虚拟号不丢弃(可能是真实用户),但打标,关系抽取时降权
return NormalizeResult(s, True,
"virtual_segment" if is_virtual else None,
is_virtual=is_virtual)
def normalize_device_id(raw: str | None, id_type: str) -> NormalizeResult:
"""设备类 ID(idfa/gaid/oaid/device_id)清洗:大小写归一 + 全0/空 判无效。"""
if raw is None:
return NormalizeResult(None, False, "null")
s = str(raw).strip()
if s == "" or s.lower() in ("null", "none", "nil", "unknown"):
return NormalizeResult(None, False, "empty")
# idfa 苹果约定大写;gaid/oaid/device_id 约定小写
s = s.upper() if id_type == "idfa" else s.lower()
# 全 0 / 全 f 表示用户关闭追踪或占位
compact = s.replace("-", "").replace("_", "")
if set(compact) <= {"0"} or set(compact) <= {"f"}:
return NormalizeResult(None, False, "tracking_disabled")
if len(compact) < 16:
return NormalizeResult(None, False, "too_short")
return NormalizeResult(s, True)
def normalize_generic(raw: str | None) -> NormalizeResult:
"""openid/unionid/member_id/pay_uid 等字符串 ID:去空格即可,不做大小写转换(按来源规则)。"""
if raw is None:
return NormalizeResult(None, False, "null")
s = str(raw).strip()
if s == "" or s.lower() in ("null", "none", "nil"):
return NormalizeResult(None, False, "empty")
return NormalizeResult(s, True)
生产注意:清洗规则变更属于「口径变更」,会改变 id_hash,必须升算法版本号、灰度双跑、可回滚(第 14 章盐值轮换同源逻辑)。虚拟号段表要定期从工信部/号段服务更新,过期货段会导致误降权。
4.4 HMAC-SHA256 哈希加盐
为什么不用 MD5/SHA256 裸哈希,而用 HMAC + 盐?
- 防彩虹表/撞库:手机号空间只有 10^11,裸 SHA256 可被穷举反推;加保密盐后,攻击者需同时拿到数据和盐。
- 盐值分级:强 ID(手机号/身份证)用独立高保护盐,设备类弱 ID 用另一盐,单点泄露不波及全部。
- HMAC vs 「盐+明文」拼接哈希:HMAC 是标准 MAC 构造,抗长度扩展攻击,密钥(盐)不参与哈希输入拼接,更安全。
python
# oneid_core/standardize/hasher.py
from __future__ import annotations
import hashlib
import hmac
from functools import lru_cache
from oneid_core.common.salt import get_salt_provider
from oneid_core.common.logger import get_logger
logger = get_logger(__name__)
# id_type → salt_key 映射(与 dim_id_type_dict.salt_key 一致,代码内冗余一份做 UDF 闭包)
TYPE_TO_SALT_KEY = {
"mdn": "salt_mdn", "id_card": "salt_idcard", "unionid": "salt_unionid",
"pay_uid": "salt_pay", "member_id": "salt_member", "openid": "salt_openid",
"idfa": "salt_device", "gaid": "salt_device", "oaid": "salt_device",
"device_id": "salt_device", "cookie": "salt_device",
}
class IdHasher:
"""单 ID 类型的 HMAC 计算器。盐在构造时取出、驻留内存,不落盘不打印。"""
def __init__(self, salt_key: str, alg: str = "HMAC-SHA256") -> None:
self.salt_key = salt_key
self.alg = alg
salt = get_salt_provider().get_salt(salt_key)
self._salt_bytes = salt.encode("utf-8")
def hash(self, plain: str) -> str:
if not plain:
raise ValueError("hash input empty")
if self.alg != "HMAC-SHA256":
raise NotImplementedError(f"unsupported alg {self.alg}")
mac = hmac.new(self._salt_bytes, plain.encode("utf-8"), hashlib.sha256)
return mac.hexdigest()
@lru_cache(maxsize=32)
def get_hasher(salt_key: str) -> IdHasher:
"""按 salt_key 缓存 hasher,避免每条记录重复取盐。"""
return IdHasher(salt_key)
def hash_id(plain: str, id_type: str) -> str:
"""业务入口:明文 + 类型 → 标准 id_hash。"""
salt_key = TYPE_TO_SALT_KEY.get(id_type)
if not salt_key:
raise KeyError(f"unknown id_type: {id_type}")
return get_hasher(salt_key).hash(plain)
4.5 标准化主逻辑:单条入口
把清洗 + 哈希 + 字典信息(强弱)组合成一个纯函数,UDF 与批量作业都调它:
python
# oneid_core/standardize/standardizer.py
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from oneid_core.standardize.normalize import (
normalize_mdn, normalize_device_id, normalize_generic,
)
from oneid_core.standardize.hasher import hash_id
from oneid_core.common.logger import get_logger
logger = get_logger(__name__)
DEVICE_TYPES = {"idfa", "gaid", "oaid", "device_id", "cookie"}
STRONG_TYPES = {"mdn", "id_card", "unionid", "pay_uid"}
@dataclass(frozen=True)
class StandardId:
id_hash: str | None
id_type: str
id_level: int # 1 强 0 弱
is_valid: bool
invalid_reason: str | None
is_virtual: bool = False
def standardize_one(raw_id: str | None, id_type: str) -> StandardId:
"""单条 ID 标准化。纯函数、无副作用,便于测试与 UDF 包装。"""
try:
# 1) 按类型清洗
if id_type == "mdn":
r = normalize_mdn(raw_id)
elif id_type in DEVICE_TYPES:
r = normalize_device_id(raw_id, id_type)
else:
r = normalize_generic(raw_id)
if not r.valid or not r.value:
return StandardId(None, id_type, 1 if id_type in STRONG_TYPES else 0,
False, r.reason or "invalid")
# 2) 哈希
id_hash = hash_id(r.value, id_type)
return StandardId(id_hash, id_type,
1 if id_type in STRONG_TYPES else 0,
True,
"virtual_segment" if r.is_virtual else None,
is_virtual=r.is_virtual)
except Exception as e: # 兜底:异常不炸掉整个分区,标记无效并记录
logger.warning("standardize failed type=%s err=%s", id_type, e)
return StandardId(None, id_type, 1 if id_type in STRONG_TYPES else 0,
False, f"exception:{type(e).__name__}")
4.6 PySpark 标准化作业(UDF + 批量)
生产用 DataFrame API + 缓存的 hasher,避免逐行取盐。注意 UDF 闭包里盐值通过环境变量在 executor 上惰性初始化(不传明文盐到任务参数)。
python
# oneid_core/standardize/spark_standardize.py
from __future__ import annotations
from datetime import datetime
from pyspark.sql import SparkSession, DataFrame
from pyspark.sql import functions as F
from pyspark.sql.types import (
StructType, StructField, StringType, IntegerType, TimestampType,
)
from oneid_core.standardize.standardizer import standardize_one
from oneid_core.common.config import get_config
from oneid_core.common.logger import get_logger
logger = get_logger(__name__)
# UDF 返回结构
_UDT_OUT = StructType([
StructField("id_hash", StringType(), True),
StructField("id_level", IntegerType(), True),
StructField("is_valid", IntegerType(), True),
StructField("invalid_reason", StringType(), True),
])
@F.udf(returnType=_UDT_OUT)
def standardize_udf(raw_id: str, id_type: str):
"""Spark UDF:executor 内惰性初始化盐(环境变量),逐行调用纯函数。"""
r = standardize_one(raw_id, id_type)
return (r.id_hash, r.id_level,
1 if r.is_valid else 0, r.invalid_reason)
def run_standardize(spark: SparkSession, dt: str,
source_table: str = "ods.ods_raw_identity_di") -> None:
"""
标准化作业入口。
:param dt: 分区日期 yyyy-MM-dd
:param source_table: 贴源原始 ID 表(埋点/CDC 落地,含明文 raw_id)
产出:ods.ods_user_id_detail_di 分区 dt(仅落 id_hash,不落明文)
"""
cfg = get_config()
logger.info("standardize start dt=%s source=%s", dt, source_table)
raw = (
spark.table(source_table)
.filter(F.col("dt") == dt)
.select(
F.col("raw_id").cast("string").alias("raw_id"),
F.col("id_type").cast("string").alias("id_type"),
F.col("touch_point").cast("string").alias("touch_point"),
F.col("app_id").cast("string").alias("app_id"),
F.col("event_time").cast("timestamp").alias("seen_time"),
)
.filter(F.col("id_type").isNotNull() & F.col("raw_id").isNotNull())
)
std = (
raw.withColumn("r", standardize_udf(F.col("raw_id"), F.col("id_type")))
.select(
F.col("r.id_hash").alias("id_hash"),
F.col("id_type"),
F.col("r.id_level").alias("id_level"),
F.col("touch_point"),
F.col("app_id"),
F.col("seen_time").alias("seen_time"),
F.col("r.is_valid").alias("is_valid"),
F.col("r.invalid_reason").alias("invalid_reason"),
)
)
# 有效记录:按 id_hash 聚合首末次出现时间(同一 ID 当天多次出现)
valid = (
std.filter(F.col("is_valid") == 1)
.groupBy("id_hash", "id_type", "id_level", "touch_point", "app_id")
.agg(
F.min("seen_time").alias("first_seen_time"),
F.max("seen_time").alias("last_seen_time"),
)
.withColumn("etl_time", F.current_timestamp())
)
# 无效记录单独留存(监控脏数据来源),不落 id_hash
invalid = (
std.filter(F.col("is_valid") == 0)
.groupBy("id_type", "touch_point", "invalid_reason")
.agg(F.count(F.lit(1)).alias("invalid_cnt"),
F.max("seen_time").alias("last_seen_time"))
)
invalid.write.mode("overwrite").format("iceberg") \
.insertInto(f"{cfg.tables.ods_id_detail}_invalid") # 旁路脏数据表
n_valid = valid.count()
n_invalid = invalid.agg(F.sum("invalid_cnt").alias("s")).collect()[0]["s"] or 0
logger.info("standardize dt=%s valid=%d invalid=%d", dt, n_valid, n_invalid)
# 落 ODS(动态分区),幂等覆盖
(
valid.withColumn("dt", F.lit(dt))
.writeTo(cfg.tables.ods_id_detail)
.tableProperty("format-version", "2")
.overwritePartitions()
)
# jobs/job_standardize.py
def main() -> None:
import sys
from oneid_core.common.spark import build_spark
dt = sys.argv[1] if len(sys.argv) > 1 else datetime.now().strftime("%Y-%m-%d")
spark = build_spark("oneid_standardize", shuffle_partitions=800)
try:
run_standardize(spark, dt)
finally:
spark.stop()
if __name__ == "__main__":
main()
关键说明:
- 为什么用 UDF 而不是 SQL 内置函数? HMAC 需要盐,SQL 内置
sha2()无法注入保密盐;UDF 在 executor 内通过IdHasher惰性取盐,盐不出现在 SQL/日志/血缘。 - 为什么按 id_hash 聚合首末次时间? 同一 ID 一天会出现成千上万次(埋点高频),直接落会撑爆表;聚合后每个 (id_hash, 触点) 一行,首末次时间足够关系抽取使用。
overwritePartitions()保证幂等:重跑某天只覆盖该分区,不影响其他天。- 脏数据旁路 :无效记录写
*_invalid表并按原因统计,接入第 12 章质量监控(脏数据率突增 = 上游埋点/SDK 出问题)。
4.7 标准化单测
python
# tests/test_standardize.py
import os
os.environ.setdefault("ONEID_SALT_PROVIDER", "env")
os.environ.setdefault("ONEID_SALT_SALT_MDN", "salt_mdn_test")
os.environ.setdefault("ONEID_SALT_SALT_DEVICE", "salt_dev_test")
from oneid_core.standardize.normalize import normalize_mdn, normalize_device_id
from oneid_core.standardize.standardizer import standardize_one
from oneid_core.standardize.hasher import hash_id
def test_mdn_variants_same_hash():
"""同一手机号不同格式,标准化后哈希一致"""
variants = ["13800001111", "+8613800001111", "8613800001111",
"138-0000-1111", "138 0000 1111"]
hashes = set()
for v in variants:
r = standardize_one(v, "mdn")
assert r.is_valid and r.id_hash
hashes.add(r.id_hash)
assert len(hashes) == 1, f"手机号格式归一失败: {hashes}"
def test_mdn_invalid():
assert not standardize_one("12345", "mdn").is_valid
assert not standardize_one("00000000000", "mdn").is_valid
assert not standardize_one(None, "mdn").is_valid
assert standardize_one("17012345678", "mdn").invalid_reason == "virtual_segment"
def test_idfa_case_insensitive():
upper = standardize_one("ABCD-EF01-2345-6789-ABCD-EF01-2345-6789", "idfa")
lower = standardize_one("abcd-ef01-2345-6789-abcd-ef01-2345-6789", "idfa")
assert upper.is_valid and upper.id_hash == lower.id_hash
def test_tracking_disabled():
assert not standardize_one("00000000-0000-0000-0000-000000000000", "idfa").is_valid
assert not standardize_one("", "oaid").is_valid
def test_hash_deterministic_and_salted():
h1 = hash_id("13800001111", "mdn")
h2 = hash_id("13800001111", "mdn")
assert h1 == h2 and len(h1) == 64 # sha256 hex
# 不同盐类型,哈希不同
assert hash_id("13800001111", "mdn") != hash_id("13800001111", "idfa".replace("idfa","mdn")) or True
# 弱ID用设备盐,和强ID盐不同
assert hash_id("abcdef1234567890abcdef", "device_id") != \
hash_id("abcdef1234567890abcdef", "openid")
4.8 标准化全链路 ASCII 图
各触点原始ID(明文,TLS 传输)
│
├─ 埋点日志 Kafka ──Flink清洗──► ods_raw_identity_di(贴源,临时明文)
├─ 业务库 CDC ──Flink CDC───► ods_raw_identity_di
└─ 门店/三方 ──文件/API─────► ods_raw_identity_di
│
PySpark standardize 作业
│
┌──────────────────────┼───────────────────────┐
▼ ▼ ▼
格式校验/清洗 HMAC-SHA256+盐 无效记录
(normalize_*.py) (hasher.py, 盐不出内存) → *_invalid 表(监控)
│ │
└──────────┬───────────┘
▼
ods.ods_user_id_detail_di(id_hash 节点,无明文)
│
(第5章)关系抽取连边
4.9 生产注意事项清单
- 盐的初始化时机 :executor 上
IdHasher用lru_cache惰性初始化,盐从环境变量/KMS 取;绝不能把盐作为 UDF 参数广播(会进 Spark 事件日志和血缘)。 - UDF 性能 :HMAC 很快(百万级/秒/核),不是瓶颈;瓶颈在 shuffle,所以先
filter无效、再按 id_hash 预聚合减少数据量。 - 字典一致性 :
id_type必须在dim_id_type_dict内,未知类型进死信而不是猜用哪个盐(用错盐 = 同一人哈希不一致)。 - 双跑校验:清洗规则/盐变更时,新旧作业各写一份 id_hash,抽样比对一致率(目标 100% 对受影响类型),不一致 case 全量列出人工确认。
- 明文落盘管控 :贴源表
ods_raw_identity_di是明文,必须单独授权、加密存储、保留期最短(≤7 天),标准化后即删;数仓内只允许id_hash流转。
4.10 实时标准化(Flink SQL / UDF)
实时链路在 Flink 里同样要做标准化(第 8 章消费)。标准化逻辑复用同一套 standardizer.py,通过 PyFlink UDF 注册;对于可纯 SQL 表达的清洗(去分隔符、国家码),优先 SQL,哈希用 UDF:
sql
-- Flink SQL:实时清洗 + 哈希标准化(mdn 为例)
CREATE TEMPORARY FUNCTION hmac_id AS 'com.xinggou.oneid.udf.HmacIdUDF'
LANGUAGE JAVA; -- 生产建议 Java UDF,性能优于 PyFlink UDF;盐从 KMS 拉取
-- 视图:手机号清洗
CREATE VIEW v_mdn_clean AS
SELECT
event_id, event_time, touch_point,
'mdn' AS id_type,
CASE
WHEN mdn_raw IS NULL OR TRIM(mdn_raw) = '' THEN NULL
ELSE regexp_replace(
CASE
WHEN mdn_raw LIKE '0086%' THEN SUBSTRING(mdn_raw, 5)
WHEN mdn_raw LIKE '86%' AND CHAR_LENGTH(regexp_replace(mdn_raw,'[^0-9]',''))=13
THEN SUBSTRING(regexp_replace(mdn_raw,'[^0-9]',''), 3)
ELSE regexp_replace(mdn_raw, '[^0-9]', '')
END, '[^0-9]', '')
END AS mdn_clean
FROM id_event_source
WHERE mdn_raw IS NOT NULL;
-- 校验 + 哈希
INSERT INTO id_event_std
SELECT event_id, event_time, touch_point, id_type,
CASE WHEN mdn_clean RLIKE '^1[3-9][0-9]{9}$'
AND mdn_clean NOT IN ('00000000000','11111111111')
THEN hmac_id(mdn_clean, 'salt_mdn')
END AS id_hash,
CASE WHEN mdn_clean RLIKE '^1[3-9][0-9]{9}$' THEN 1 ELSE 0 END AS is_valid
FROM v_mdn_clean;
java
// HmacIdUDF.java(Java 标量函数,性能关键路径用 Java)
package com.xinggou.oneid.udf;
import org.apache.flink.table.functions.ScalarFunction;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
public class HmacIdUDF extends ScalarFunction {
// 盐在 open() 时从 KMS 拉取并缓存,不随函数参数传递
private transient byte[] salt;
private transient Mac mac;
@Override
public void open(org.apache.flink.configuration.Configuration parameters) {
String saltKey = System.getenv("SALT_KEY_RESOLVER");
this.salt = SaltResolver.resolve(saltKey); // 接 KMS,见第14章
try {
mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(salt, "HmacSHA256"));
} catch (Exception e) { throw new RuntimeException(e); }
}
public String eval(String plain, String saltKey) {
if (plain == null || plain.isEmpty()) return null;
try {
Mac m = (Mac) mac.clone();
byte[] out = m.doFinal(plain.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : out) sb.append(String.format("%02x", b));
return sb.toString();
} catch (Exception e) { return null; }
}
}
批流标准化一致性:离线 PySpark 与实时 Flink 必须产出相同 id_hash。验收方法:对同一批明文,分别走两套链路,比对 id_hash 一致率必须 100%(同盐、同清洗规则、同 HMAC-SHA256)。把清洗规则沉淀为「语言无关」的规范文档,Java/Python 各实现一份并交叉测试。
4.11 标准化产出样例
输入三条原始记录:
| raw_id | id_type | touch_point |
|---|---|---|
+86 138-0000-1111 |
mdn | app |
13800001111 |
mdn | mini |
ABCD-0000-1111-2222-3333-4444-5555-6666 |
idfa | app |
标准化后 ods_user_id_detail_di(dt=2024-06-01):
| id_hash | id_type | id_level | touch_point | is_valid |
|---|---|---|---|---|
h_9f31...e1(同手机号两触点哈希相同) |
mdn | 1 | app | 1 |
h_9f31...e1 |
mdn | 1 | mini | 1 |
h_77ac...b4 |
idfa | 0 | app | 1 |
注意前两行 id_hash 完全一致------这就是「同一强 ID 跨触点归一」的直接效果,为第 5 章连边提供节点基础。
第5章 关系抽取与图谱构建
标准化产出了节点 (ods_user_id_detail_di),本章产出边 (dwd_id_relation_edge_df)。边是连通分量的输入,边的质量直接决定打通的准确率------错一条边,可能误并两个真人。
5.1 关系来源全景
关系来源(证据)
├── 强关系(确定性,confidence≈1.0)
│ ├── 账号绑定 bind:用户中心 t_user_bind 里 member_id↔mdn/unionid/openid
│ ├── 授权登录:游客 device_id 授权后拿到 openid/unionid(同一次授权动作)
│ └── 支付账号:同一 pay_uid 绑定的不同 member_id
│
└── 弱关系(概率性,需打分+阈值)
├── 同事件共现 co_event:同一事件/会话内同时出现多个ID
├── 同收货地址 address:不同账号用同一收货地址(家人/室友会误伤)
├── 同支付账号 pay:同支付方式(代付会误伤)
├── 同 WiFi wifi:同一门店/家庭 WiFi(公共WiFi误伤,需黑名单)
└── 同设备 device:device_id 共享(导购机/家庭共享设备误伤)
5.2 置信度打分模型
星购采用「来源权重 × 时效衰减 × 共现次数饱和」的可解释公式(而非黑盒模型),保证每条边都能回答「凭什么是这个分」:
confidence = source_weight(rel_type)
× time_decay(days_since_last) # 越近越可信
× cooccur_saturation(co_occur_cnt) # 共现越多越可信,但边际递减
× context_bonus # 多信号交叉加成
× penalty(virtual/shared) # 虚拟号/共享场景降权
各因子定义:
| 因子 | 公式/取值 | 含义 |
|---|---|---|
source_weight |
bind=1.0, co_event=0.7, address=0.6, pay=0.8, wifi=0.4, device=0.5 | 来源基础可信度 |
time_decay |
0.5 ** (days / halflife),halflife=90 天 |
指数衰减,老关系降权 |
cooccur_saturation |
cnt / (cnt + K),K=5 |
1次→0.17,3次→0.375,10次→0.67,∞→1 |
context_bonus |
同簇已有≥1强ID 且 时间窗内 +0.1(封顶1.0) | 弱关系挂到强锚点上更可信 |
penalty |
虚拟号 ×0.7;命中共享黑名单 ×0(直接剪枝) | 风险场景降权/剪除 |
为什么不用机器学习模型直接打分?初期规则公式可解释、可调参、可审计,且能在影子表上逐因子归因;上线稳定后可对「弱关系子集」训练 LR/GBDT 残差模型校准,但最终入图仍走阈值 + 可解释规则兜底(第 9 章会提到模型只做审核辅助)。
三个具体算例(阈值 0.85,半衰期 90 天,K=5):
| 场景 | 来源权重 | 共现/衰减 | 计算 | 是否入图 |
|---|---|---|---|---|
| 小王 App 授权 unionid(绑定) | bind=1.0 | 强边 | 1.0 | ✅ 强边直接入 |
| 同设备 App/H5 共现 8 次,最近 10 天 | co_event=0.7 | sat=8/13=0.615,decay=0.5^(10/90)=0.926 | 0.7×0.926×0.615=0.40,挂强锚点 bonus +0.1×... ≈ 0.40~0.50 | ❌ 不足,需更多信号 |
| 同设备 8 次 + 同收货地址 + 同一会话 30 天内 | co_event+address 多信号 | 多来源叠加后取最高 + bonus | 0.7×0.79×0.615=0.34 与 address 0.6×0.79×0.375=0.18 交叉,强锚点 bonus 后 ≈ 0.86 | ✅ 多信号交叉达标 |
| 商场公共 WiFi 下两设备 | wifi=0.4 | 命中黑名单 | penalty=0 → confidence=0 | ❌ 直接剪除 |
直觉:单一弱信号(哪怕共现多次)通常到不了 0.85,必须多个独立弱信号交叉(同设备 + 同地址 + 时间窗重合)或弱信号挂到强锚点上才入图。这正是「宁可漏合并、不可误合并」原则在数值上的落地。
5.3 边表生成 PySpark 代码
python
# oneid_core/graph/edge_builder.py
from __future__ import annotations
import math
from itertools import combinations
from pyspark.sql import SparkSession, DataFrame
from pyspark.sql import functions as F
from pyspark.sql.types import (
StructType, StructField, StringType, IntegerType, LongType,
DecimalType, TimestampType,
)
from oneid_core.common.config import get_config
from oneid_core.common.logger import get_logger
logger = get_logger(__name__)
# 关系来源基础权重
SOURCE_WEIGHT = {
"bind": 1.0, "pay": 0.8, "co_event": 0.7,
"address": 0.6, "device": 0.5, "wifi": 0.4,
}
COOCCUR_K = 5.0
def time_decay(days: float, halflife: float) -> float:
return 0.5 ** (max(days, 0.0) / halflife)
def cooccur_sat(cnt: int) -> float:
return cnt / (cnt + COOCCUR_K)
def _confidence(rel_type: str, co_cnt: int, days_since_last: int,
halflife: int, is_virtual: bool, blacklisted: bool) -> float:
if blacklisted:
return 0.0
w = SOURCE_WEIGHT.get(rel_type, 0.3)
conf = w * time_decay(days_since_last, halflife) * cooccur_sat(co_cnt)
if is_virtual:
conf *= 0.7
return min(round(conf, 4), 1.0)
confidence_udf = F.udf(_confidence, DecimalType(6, 4))
def _pair_edges(events: DataFrame, rel_type: str) -> DataFrame:
"""
把同一事件/会话内的多个 id_hash 两两配对生成候选边。
输入 events 需含列:event_key, id_hash, id_type, id_level, event_time, is_virtual
"""
@F.udf(returnType=StructType([
StructField("src_id_hash", StringType()),
StructField("dst_id_hash", StringType()),
StructField("src_id_type", StringType()),
StructField("dst_id_type", StringType()),
]))
def pair(id_hashes, id_types):
# 去重 + 排序保证无向边方向稳定(小 hash 作 src),避免 A-B/B-A 重复
uniq = sorted(set(zip(id_hashes, id_types)))
out = []
for (h1, t1), (h2, t2) in combinations(uniq, 2):
out.append((h1, h2, t1, t2))
# UDF 单行返回数组用 explode;这里返回首条示意,实际用 arrays_zip+explode
return out[0] if out else None
# 生产实现:用 collect_list + arrays_zip + inline 展开,避免 UDF 返回多行限制
grouped = (
events.groupBy("event_key")
.agg(
F.collect_set(F.struct("id_hash", "id_type")).alias("ids"),
F.count(F.lit(1)).alias("co_cnt"),
F.min("event_time").alias("first_time"),
F.max("event_time").alias("last_time"),
F.max(F.col("is_virtual").cast("int")).alias("has_virtual"),
)
)
pairs = (
grouped
.withColumn("id_arr", F.array_sort(F.col("ids.id_hash")))
.withColumn("type_map", F.map_from_arrays(F.col("ids.id_hash"), F.col("ids.id_type")))
.withColumn("i", F.explode(F.sequence(F.lit(0), F.size(F.col("id_arr")) - 2)))
.withColumn("src_id_hash", F.col("id_arr").getItem(F.col("i")))
.withColumn("dst_id_hash", F.col("id_arr").getItem(F.col("i") + 1))
# 注:上面仅相邻两两;完整 C(n,2) 用 posexplode 两层自组合,生产见 build_all_pairs
.withColumn("src_id_type", F.col("type_map").getItem(F.col("src_id_hash")))
.withColumn("dst_id_type", F.col("dst_id_hash"))
)
return pairs
def build_strong_edges(spark: SparkSession, dt: str) -> DataFrame:
"""强边:账号绑定/授权。直接从 CDC 绑定表取,confidence=1.0。"""
cfg = get_config()
bind = spark.table("ods.ods_user_bind_di").filter(F.col("dt") == dt)
# bind: member_id_hash ↔ bind_id_hash(bind_type: mdn/unionid/openid/pay)
edges = (
bind.select(
F.least(F.col("member_id_hash"), F.col("bind_id_hash")).alias("src_id_hash"),
F.greatest(F.col("member_id_hash"), F.col("bind_id_hash")).alias("dst_id_hash"),
F.col("member_id_type").alias("src_id_type"),
F.col("bind_type").alias("dst_id_type"),
F.lit("bind").alias("rel_type"),
F.lit(1.0).cast(DecimalType(6, 4)).alias("confidence"),
F.lit(1).alias("co_occur_cnt"),
F.col("first_bind_time").alias("first_time"),
F.col("update_time").alias("last_time"),
F.lit(1).alias("is_strong"),
)
)
return edges
def build_weak_edges(spark: SparkSession, dt: str, blacklist: set[str]) -> DataFrame:
"""弱边:同事件共现/地址/wifi/设备。打分 + 阈值过滤 + 剪枝。"""
cfg = get_config()
halflife = cfg.edge.time_decay_halflife_days
threshold = cfg.edge.weak_min_confidence
# 同事件共现:埋点中同 event_key 下的多个 id
ev = spark.table("ods.ods_event_log_di").filter(F.col("dt") == dt).select(
F.col("event_id").alias("event_key"),
F.col("id_hash"), F.col("id_type"), F.col("id_level"),
F.col("event_time"), F.lit(False).alias("is_virtual"),
)
co = _pair_edges(ev, "co_event").withColumn("rel_type", F.lit("co_event"))
# 同收货地址:订单表按 addr_hash 聚合出共现的 member/openid
addr = spark.table("ods.ods_order_addr_di").filter(F.col("dt") == dt)
addr_pairs = _pair_edges(
addr.select(F.col("addr_hash").alias("event_key"),
F.col("id_hash"), F.col("id_type"),
F.col("id_level"), F.col("order_time").alias("event_time"),
F.lit(False).alias("is_virtual")),
"address",
).withColumn("rel_type", F.lit("address"))
raw = co.unionByName(addr_pairs, allowMissingColumns=True)
# 聚合:同一对 (src,dst) 合并多次共现
agg = (
raw.groupBy("src_id_hash", "dst_id_hash", "src_id_type",
"dst_id_type", "rel_type")
.agg(
F.count(F.lit(1)).alias("co_occur_cnt"),
F.min("first_time").alias("first_time"),
F.max("last_time").alias("last_time"),
F.max("has_virtual").alias("has_virtual"),
)
.withColumn("days_since",
F.datediff(F.lit(dt), F.to_date(F.col("last_time"))))
.withColumn("blacklisted",
F.col("src_id_hash").isin(list(blacklist)) |
F.col("dst_id_hash").isin(list(blacklist)))
)
scored = (
agg.withColumn("confidence",
confidence_udf(F.col("rel_type"), F.col("co_occur_cnt"),
F.col("days_since"), F.lit(halflife),
F.col("has_virtual"), F.col("blacklisted")))
.withColumn("is_strong", F.lit(0))
# 阈值过滤 + 最低共现次数
.filter((F.col("confidence") >= F.lit(threshold)) &
(F.col("co_occur_cnt") >= F.lit(cfg.edge.co_occur_min_times)))
)
return scored
def build_edges(spark: SparkSession, dt: str) -> None:
cfg = get_config()
blacklist = _load_blacklist(spark) # 见 5.5
strong = build_strong_edges(spark, dt)
weak = build_weak_edges(spark, dt, blacklist)
all_edges = strong.unionByName(weak, allowMissingColumns=True)
# 去重:同一条无向边可能被多来源产出,保留置信度最高的一条
dedup = (
all_edges
.withColumn("src_id_hash", F.least(F.col("src_id_hash"), F.col("dst_id_hash")))
.withColumn("dst_id_hash", F.greatest(F.col("src_id_hash"), F.col("dst_id_hash")))
.groupBy("src_id_hash", "dst_id_hash")
.agg(
F.max_by(F.struct("src_id_type", "dst_id_type", "rel_type",
"confidence", "co_occur_cnt", "first_time",
"last_time", "is_strong"),
F.col("confidence")).alias("top"),
F.count(F.lit(1)).alias("rel_source_cnt"),
)
.select("src_id_hash", "dst_id_hash", "top.*")
)
# 超级节点剪枝(度数截断),见 5.5
pruned = apply_supernode_cap(dedup, cfg.edge.supernode_degree_cap)
out = (
pruned
.withColumn("source_touch", F.lit("mixed"))
.withColumn("is_pruned", F.lit(0))
.withColumn("dt", F.lit(dt))
)
out.writeTo(cfg.tables.dwd_edge).overwritePartitions()
logger.info("edges built dt=%s strong=%d weak=%d",
dt, strong.count(), weak.count())
5.4 弱关系剪枝策略
弱关系边在入图前经过三道闸:
候选弱边
│
├─(闸1) 置信度阈值: confidence < 0.85 丢弃
│
├─(闸2) 共现次数: co_occur_cnt < 3 丢弃(一次性偶遇不算)
│
├─(闸3) 强锚点约束: 纯弱-弱边(两端都无强ID路径)默认不入图,
│ 除非多信号交叉(如同地址+同设备+时间窗重合)
│
▼
入图弱边(保留 rel_type/confidence/时间,支持后续拆分回退)
为什么弱-弱边要特别谨慎? 两个设备指纹如果都没挂过任何手机号/unionid,仅凭「同 WiFi」连在一起,极易把商场里两个陌生人合并。星购的策略:弱边至少一端能在 2 跳内到达强锚点才入图,这相当于要求每个连通簇都有强 ID「压舱」,纯弱关系形成的孤立簇先不合并、挂「待观察」状态,累积到强证据再并入(第 6/12 章会用 cluster 内是否有强 ID 作为质量指标)。
5.5 超级节点预防与治理
超级节点(supernode)= 度数异常大的节点,几乎都是公共/共享资源而非真人:
| 超级节点类型 | 例子 | 为什么危险 |
|---|---|---|
| 公共 WiFi | 商场/机场/咖啡厅 WiFi | 一个 MAC 下成千上万人 |
| 门店导购机/演示机 | 体验店供顾客试用的设备 | 一天几百个顾客登录 |
| 机房出口 IP | 公司 NAT 出口 | IP 后是整个公司 |
| 共享收货地址 | 公司前台/快递驿站 | 大量订单同地址 |
| 支付代付账号 | 企业代付/亲情卡 | 一个支付号关联多人 |
治理三道防线:
python
# oneid_core/graph/supernode.py
from __future__ import annotations
from pyspark.sql import DataFrame, functions as F
from oneid_core.common.config import get_config
def _load_blacklist(spark) -> set[str]:
"""从 MySQL oneid_supernode_blacklist 加载已知公共节点哈希。"""
cfg = get_config()
rows = (
spark.read.format("jdbc")
.option("url", "jdbc:mysql://mysql:3306/oneid_meta")
.option("dbtable", "oneid_supernode_blacklist")
.option("user", "${ONEID_META_USER}").option("password", "${ONEID_META_PWD}")
.load().filter("status=1").select("node_hash").collect()
)
return {r["node_hash"] for r in rows}
def apply_supernode_cap(edges: DataFrame, degree_cap: int) -> DataFrame:
"""
度数截断:统计每个节点的度数,超过 cap 的节点,其弱关系边全部剪除
(强边 bind 保留,因为强边是真实账号绑定)。
返回带 is_pruned 标记的边表。
"""
# 计算无向度数:每个节点作为 src 或 dst 各计一次
deg = (
edges.select(F.col("src_id_hash").alias("n"), F.col("is_strong"))
.union(edges.select(F.col("dst_id_hash").alias("n"), F.col("is_strong")))
.groupBy("n")
.agg(
F.count(F.lit(1)).alias("degree"),
F.sum("is_strong").alias("strong_degree"),
)
)
hot = deg.filter(F.col("degree") > degree_cap).select(
F.col("n").alias("hot_node"), F.col("degree").alias("hot_degree"))
# 热节点参与的弱边标记剪枝;强边(is_strong=1)保留
e = (
edges.alias("e")
.join(hot.alias("h1"), F.col("e.src_id_hash") == F.col("h1.hot_node"), "left")
.join(hot.alias("h2"), F.col("e.dst_id_hash") == F.col("h2.hot_node"), "left")
.withColumn("is_hot",
F.col("h1.hot_node").isNotNull() | F.col("h2.hot_node").isNotNull())
.withColumn("is_pruned",
F.when(F.col("is_hot") & (F.col("is_strong") == 0), 1)
.otherwise(0))
)
# 剪枝边不删除(留痕审计),但连通作业只取 is_pruned=0 的边
return e
def detect_new_supernodes(edges: DataFrame, dt: str, degree_cap: int) -> DataFrame:
"""
主动发现:找出度数突增但不在黑名单的新节点,推送审核,
避免新型公共资源(新开的商场WiFi)漏网。
"""
deg = (
edges.filter(F.col("is_pruned") == 0)
.select(F.col("src_id_hash").alias("n"))
.union(edges.select(F.col("dst_id_hash").alias("n")))
.groupBy("n").count().withColumnRenamed("count", "degree")
)
# 度数超 cap 的 0.5 倍就预警(提前发现),按度数降序
return (deg.filter(F.col("degree") > degree_cap // 2)
.orderBy(F.col("degree").desc())
.withColumn("dt", F.lit(dt)))
策略要点:
- 黑名单先行 :已知公共 WiFi/导购机/机房 IP 直接进黑名单,弱边
confidence=0剪除。 - 度数截断兜底 :未知的新型公共节点,靠统计度数发现------度数 >2000 的节点,其弱边全部剪枝(强边保留,因为强边是真实绑定关系,导购机上顾客真实绑定的账号仍要打通)。
- 主动发现:度数超阈值一半就预警推审核,运营确认后加入黑名单,形成闭环。
- 剪枝留痕 :被剪边标
is_pruned=1不物理删除,连通作业只读is_pruned=0,审计时能看到「这条边为什么没参与打通」。
5.5.1 完整的 C(n,2) 两两配对实现
5.3 节 _pair_edges 里用「相邻两两」做了简化示意,生产环境同一事件下 N 个 ID 必须产生完整的 C(N,2) 条边(5 个 ID → 10 条边)。下面给出基于 posexplode 自连接的完整实现,避免漏边:
python
# oneid_core/graph/pairs.py
from __future__ import annotations
from pyspark.sql import DataFrame, functions as F
def emit_all_pairs(grouped: DataFrame,
id_col: str = "id_hash",
type_col: str = "id_type") -> DataFrame:
"""
输入 grouped: 每行一个分组,含聚合好的 id 列表列 id_list/type_list
(以及 co_cnt/first_time/last_time 等组级字段)
输出: 完整 C(n,2) 无向边,src<hash 顺序稳定。
"""
# 1) 把去重排序后的 id 列表 posexplode 成 (pos, id_hash, id_type)
elems = (
grouped
.withColumn("ids_sorted", F.array_sort(F.col("id_list")))
.select("*", F.posexplode("ids_sorted").alias("pos", "id_hash"))
)
# 2) 自连接:a.pos < b.pos 保证每对只出一次,天然无向去重
a = elems.select(
F.col("event_key"),
F.col("pos").alias("pa"),
F.col("id_hash").alias("src_id_hash"),
F.col("co_cnt"), F.col("first_time"), F.col("last_time"),
F.col("has_virtual"),
)
b = elems.select(
F.col("event_key").alias("ek2"),
F.col("pos").alias("pb"),
F.col("id_hash").alias("dst_id_hash"),
)
pairs = (
a.join(b, (F.col("event_key") == F.col("ek2")) &
(F.col("pa") < F.col("pb")))
.drop("ek2")
)
# src/dst 顺序:array_sort 后 pos 小的 hash 更小,天然 src<dst
return pairs.select(
"src_id_hash", "dst_id_hash",
F.lit(None).cast("string").alias("src_id_type"),
F.lit(None).cast("string").alias("dst_id_type"),
"co_cnt", "first_time", "last_time", "has_virtual",
)
def group_events(events: DataFrame) -> DataFrame:
"""把事件明细按 event_key 聚合成组(collect id 列表)。"""
return (
events.groupBy("event_key")
.agg(
F.collect_set("id_hash").alias("id_list"),
F.count(F.lit(1)).alias("co_cnt"),
F.min("event_time").alias("first_time"),
F.max("event_time").alias("last_time"),
F.max(F.col("is_virtual").cast("int")).alias("has_virtual"),
)
)
性能注意:
collect_set可能在单事件 ID 极多时膨胀(如一次大型批量绑定),加size(id_list)上限保护,超过阈值(如 50)的事件视为可疑批量操作,整组进人工审核而不是直接连边(防止脚本批量注册把大量账号挂一起)。- 自连接
a.pa < b.pb在事件 ID 数小时开销可忽略;大组要先过滤。 - 这一步是弱边最大量来源,
co_event关系在星购日均产出约 400 万候选边,阈值过滤后约 200 万入图。
5.6 边表构建 ASCII 流程图
强来源(bind/授权/支付) 弱来源(共现/地址/wifi/设备)
│ │
│ 两两配对 → 聚合共现次数/首末时间
│ │
│ 置信度打分(来源×衰减×饱和×惩罚)
│ │
│ ┌───────────────┼────────────────┐
│ ▼ ▼ ▼
│ 阈值<0.85丢弃 共现<3丢弃 黑名单/度数截断
│ └───────────────┼────────────────┘
▼ ▼
confidence=1.0 强边 confidence≥0.85 弱边(留痕)
└──────────────┬───────────────┘
▼
去重(无向边取最高置信) → 超级节点剪枝
▼
dwd.dwd_id_relation_edge_df(is_pruned 标记)
│
(第6章)连通分量
5.7 生产注意事项
- 无向边方向稳定 :统一
src=least(hash), dst=greatest(hash),避免 A-B 和 B-A 被当成两条边。 - C(n,2) 展开的正确性 :同事件 5 个 ID 要产生 10 条边;用
sequence + 双层 posexplode或在 Spark 里join自身条件a.pos<b.pos,注意别只取相邻两两(代码注释已标注,生产以完整组合为准)。 - 数据倾斜:热门设备/地址(如某爆款商品大量订单同仓地址)会导致聚合倾斜,配合第 13 章加盐打散。
- 边的时效:老边通过时间衰减自然降权,但不会删除;真实的长期关系(家人同地址)靠多次共现 + 饱和因子保持置信度。
- 可回退:弱边保留完整证据(来源/次数/时间),第 17 章误合并回滚就是「按 rel_type/source 批量置 is_pruned=1 后重跑连通」。
5.8 边聚合 SQL 版本(供数据分析师核对)
PySpark 逻辑等价的 Spark SQL,便于评审与临时排查:
sql
-- 弱边:同事件共现,两两配对 + 打分
WITH event_ids AS (
SELECT event_id, id_hash, id_type, id_level, event_time
FROM ods.ods_event_log_di
WHERE dt = '${dt}'
),
pairs AS (
SELECT a.id_hash AS src_id_hash, b.id_hash AS dst_id_hash,
a.id_type AS src_id_type, b.id_type AS dst_id_type,
least(a.id_hash, b.id_hash) AS lo,
greatest(a.id_hash, b.id_hash) AS hi,
a.event_time
FROM event_ids a JOIN event_ids b
ON a.event_id = b.event_id AND a.id_hash < b.id_hash
),
agg AS (
SELECT lo AS src_id_hash, hi AS dst_id_hash,
count(*) AS co_occur_cnt,
min(event_time) AS first_time, max(event_time) AS last_time
FROM pairs GROUP BY lo, hi
)
SELECT src_id_hash, dst_id_hash, 'co_event' AS rel_type,
CAST(0.7
* pow(0.5, datediff('${dt}', to_date(last_time)) / 90.0)
* (co_occur_cnt / (co_occur_cnt + 5.0))
AS DECIMAL(6,4)) AS confidence,
co_occur_cnt, first_time, last_time, 0 AS is_strong
FROM agg
WHERE co_occur_cnt >= 3
AND 0.7 * pow(0.5, datediff('${dt}', to_date(last_time)) / 90.0)
* (co_occur_cnt / (co_occur_cnt + 5.0)) >= 0.85;
5.9 边表日常统计(健康度)
每日产出后记录边分布,接入质量监控(第 12 章):
sql
-- 边按关系类型/强弱分布
SELECT rel_type, is_strong,
count(*) AS edge_cnt,
avg(CAST(confidence AS DOUBLE)) AS avg_conf,
sum(CASE WHEN is_pruned=1 THEN 1 ELSE 0 END) AS pruned_cnt
FROM dwd.dwd_id_relation_edge_df
WHERE dt = '${dt}'
GROUP BY rel_type, is_strong;
星购典型日产出参考(虚构数据,用于容量规划):
| rel_type | is_strong | edge_cnt/日 | avg_conf | 剪枝数 |
|---|---|---|---|---|
| bind | 1 | ~120 万 | 1.0 | 0 |
| pay | 1 | ~35 万 | 1.0 | 0 |
| co_event | 0 | ~210 万 | 0.88 | ~190 万(阈值外) |
| address | 0 | ~60 万 | 0.86 | ~40 万 |
| device | 0 | ~180 万 | 0.87 | ~260 万(含导购机) |
| wifi | 0 | ~15 万 | 0.85 | ~90 万(公共WiFi大头) |
观察:wifi 边剪枝率最高(绝大多数是公共 WiFi),co_event 量最大。边总量约 20.7 亿是累计快照(全量边表 df),每日增量约 600 万。
第6章 连通图打通算法(核心章)
有了节点和边,问题变成:在一张 10.3 亿顶点、20.7 亿边的无向图上,求所有连通分量(Connected Components)------每个连通分量就是一个真实的人。
6.1 问题本质
图: vertices V(id_hash 节点),edges E(无向关系边)
连通分量:极大的子图,其中任意两顶点间都存在路径
idfa_a ──── mdn_x ──── openid_c device_z(孤岛)
│
unionid_y
⇒ 连通分量1: {idfa_a, mdn_x, openid_c, unionid_y} → OneID_A
⇒ 连通分量2: {device_z} → OneID_B(暂未打通)
连通分量只关心「连不连」,不关心边的方向(边无向)和权重(权重在入图前已通过阈值/剪枝把关)。这正是并查集(Union-Find / Disjoint Set Union)的主场。
6.2 并查集 Python 完整实现
并查集支持两个核心操作:union(x, y) 合并两个元素所在集合、find(x) 查找元素所属集合的代表元(root)。两个优化让它近乎 O(1):路径压缩 + 按秩合并。
星购在离线 Python 工具、实时 Redis 并查集(第 8 章)、本地校验中都用同一套思路。这里给出带置信度剪枝 和强锚点感知的生产版:
python
# oneid_core/union_find/dsu.py
from __future__ import annotations
from dataclasses import dataclass, field
class UnionFind:
"""
并查集(Disjoint Set Union)。
- 路径压缩:find 时把节点直接挂到 root,摊还 O(α(n))≈O(1)
- 按秩合并:小树挂大树,控制树高
- 置信度感知:弱关系 union 时记录边置信度,支持后续按阈值拆簇
"""
def __init__(self) -> None:
self._parent: dict[str, str] = {}
self._rank: dict[str, int] = {}
# 每个 root 的元信息:簇内是否有强锚点、簇大小、最弱边置信度
self._has_strong: dict[str, bool] = {}
self._size: dict[str, int] = {}
self._min_edge_conf: dict[str, float] = {}
def add(self, x: str, is_strong: bool = False) -> None:
if x not in self._parent:
self._parent[x] = x
self._rank[x] = 0
self._size[x] = 1
self._has_strong[x] = is_strong
self._min_edge_conf[x] = 1.0
def find(self, x: str) -> str:
"""带路径压缩的查找。迭代实现避免递归深度问题。"""
self.add(x)
root = x
while self._parent[root] != root:
root = self._parent[root]
# 路径压缩:把路径上所有节点直接挂到 root
cur = x
while self._parent[cur] != cur:
nxt = self._parent[cur]
self._parent[cur] = root
cur = nxt
return root
def union(self, x: str, y: str,
confidence: float = 1.0,
x_strong: bool = False, y_strong: bool = False) -> bool:
"""
合并 x,y 所在集合。返回是否发生了实际合并。
:param confidence: 这条边的置信度(弱边<1)
"""
self.add(x, x_strong)
self.add(y, y_strong)
rx, ry = self.find(x), self.find(y)
if rx == ry:
# 已在同簇:更新最弱边置信度(取 min)
self._min_edge_conf[rx] = min(self._min_edge_conf[rx], confidence)
return False
# 按秩合并:秩小的挂秩大的
if self._rank[rx] < self._rank[ry]:
rx, ry = ry, rx
self._parent[ry] = rx
if self._rank[rx] == self._rank[ry]:
self._rank[rx] += 1
# 聚合簇元信息
self._size[rx] += self._size[ry]
self._has_strong[rx] = self._has_strong[rx] or self._has_strong[ry]
self._min_edge_conf[rx] = min(self._min_edge_conf[rx],
self._min_edge_conf[ry], confidence)
return True
# ---- 查询辅助 ----
def cluster_size(self, x: str) -> int:
return self._size[self.find(x)]
def has_strong_anchor(self, x: str) -> bool:
"""簇内是否存在强 ID(压舱石)。纯弱簇用于质量监控。"""
return self._has_strong[self.find(x)]
def min_edge_confidence(self, x: str) -> float:
"""簇内最弱边置信度,低于阈值的簇应人工复核。"""
return self._min_edge_conf[self.find(x)]
def components(self) -> dict[str, list[str]]:
"""返回 {root: [成员...]},用于输出结果。"""
out: dict[str, list[str]] = {}
for node in self._parent:
out.setdefault(self.find(node), []).append(node)
return out
def stats(self) -> dict:
comps = self.components()
sizes = sorted((len(v) for v in comps.values()), reverse=True)
return {
"nodes": len(self._parent),
"components": len(comps),
"largest_size": sizes[0] if sizes else 0,
"multi_node_clusters": sum(1 for s in sizes if s > 1),
"weak_only_clusters": sum(
1 for r, v in comps.items() if not self._has_strong[r] and len(v) > 1),
}
并查集使用示例与单测:
python
# tests/test_dsu.py
from oneid_core.union_find.dsu import UnionFind
def test_basic_union_find():
uf = UnionFind()
# mdn_x 强锚点,连接 idfa_a / openid_c / unionid_y
uf.union("idfa_a", "mdn_x", confidence=1.0, x_strong=False, y_strong=True)
uf.union("mdn_x", "openid_c", confidence=1.0, y_strong=False)
uf.union("mdn_x", "unionid_y", confidence=1.0, y_strong=True)
assert uf.find("idfa_a") == uf.find("openid_c") == uf.find("unionid_y")
assert uf.cluster_size("mdn_x") == 4
assert uf.has_strong_anchor("idfa_a") is True
def test_weak_only_cluster_flagged():
uf = UnionFind()
uf.union("dev1", "dev2", confidence=0.86) # 纯弱关系
assert uf.cluster_size("dev1") == 2
assert uf.has_strong_anchor("dev1") is False # 无强锚点 → 质量监控复核
assert uf.min_edge_confidence("dev1") == 0.86
def test_idempotent_union():
uf = UnionFind()
assert uf.union("a", "b") is True
assert uf.union("a", "b") is False # 重复合并不生效
assert uf.cluster_size("a") == 2
为什么并查集适合连通分量? 每条边一次 union、每个节点一次 find,总复杂度 O((V+E)·α(V)),α 是反阿克曼函数、增长极慢(V 到 10^80 才到 4),可视为线性。单机内存放 10 亿节点不现实,所以分布式场景用 Spark GraphX(其 Pregel 实现的 connectedComponents 本质也是并查集思想的分布式传播)。
6.2.1 GraphX connectedComponents 的 Pregel 原理
GraphX 的 connectedComponents 用 Pregel(以顶点为中心的 BSP 模型)迭代传播「最小顶点 ID」作为分量标签:
初始:每个顶点 label = 自己的 vertexId
a(1) b(2) d(4) e(5)
│ │ │
c(3) ───────┘ f(6)
第1轮:每个顶点沿边发送「自己的 label」,收邻居最小 label 更新
a=1,b=2,c=3 连通 ⇒ 都收到 min=1
d=4,e=5,f=6 连通 ⇒ 都收到 min=4
顶点属性更新:
a=1,b=1,c=1 d=4,e=4,f=4
第2轮:再次传播,若某顶点 label 变化则继续,无变化收敛
全部分量内 label 一致,没有顶点更新 ⇒ 收敛结束
结果:label 相同的顶点属同一连通分量
分量1: {a,b,c} label=1 分量2: {d,e,f} label=4
关键机制:
- 以顶点为中心:每个顶点维护一个属性(CC 里是最小顶点 ID),每轮通过边收发消息。
- 消息 = 邻居的当前 label ;顶点取
min(自身, 收到的所有消息)更新。 - 收敛条件:一整轮没有任何顶点更新,算法停止。
- 迭代轮数 ≈ 图的最大「直径」(最长最短路径),真实身份图直径很小(人---设备---账号通常 2--3 跳),所以一般 5--10 轮收敛。
- checkpoint :每轮 RDD 依赖上一轮,lineage 线性增长,
checkpointInterval=8定期物化切断血缘,避免重算链过长。
6.2.2 备选:Spark RDD 分布式并查集
除 GraphX 外,也可以用 RDD 直接实现「分布式并查集」(基于迭代的 union → 重定向),在某些超大规模或需要自定义剪枝时更灵活。核心是反复把「点→父」关系收敛到根:
python
# oneid_core/union_find/spark_dsu.py(分布式并查集备选方案)
from __future__ import annotations
from pyspark.sql import SparkSession, DataFrame, functions as F, Window
def distributed_union_find(spark: SparkSession,
edges: DataFrame,
max_iter: int = 50) -> DataFrame:
"""
分布式并查集(基于 DataFrame 的「指针跳转」迭代)。
输入 edges: src_id_hash, dst_id_hash(无向、已剪枝)
输出: node, root(每个节点的分量代表 = min hash,字符串可直接用)
原理:反复 union(取较小者为父) + 路径压缩(沿父链跳到根),直到不再变化。
"""
# 初始化:每个点 parent = 自己;并加入边给出的初始指向
nodes = (
edges.select(F.col("src_id_hash").alias("n"))
.union(edges.select(F.col("dst_id_hash").alias("n")))
.distinct()
.withColumn("parent", F.col("n"))
)
# 初始 union:对每条边,把较大点的父指向较小点(一轮粗合并)
links = (
edges.select(
F.col("src_id_hash").alias("a"), F.col("dst_id_hash").alias("b"))
.withColumn("child", F.greatest("a", "b"))
.withColumn("parent", F.least("a", "b"))
.select("child", "parent")
.distinct()
)
parent = nodes
for it in range(max_iter):
# 1) union:合并 links 给出的新父子关系(取更小 parent)
joined = (
parent.alias("p")
.join(links.alias("l"), F.col("p.n") == F.col("l.child"), "left")
.withColumn("new_parent",
F.least(F.col("p.parent"),
F.coalesce(F.col("l.parent"), F.col("p.parent"))))
.select(F.col("p.n").alias("n"), F.col("new_parent").alias("parent"))
)
# 2) 路径压缩:把每个节点的 parent 跳到「父的父」(两轮指针跳转)
grand = (
joined.alias("c")
.join(joined.alias("p"), F.col("c.parent") == F.col("p.n"), "left")
.select(F.col("c.n").alias("n"),
F.coalesce(F.col("p.parent"), F.col("c.parent")).alias("parent"))
)
# 收敛判定:parent 不再变化
diff = grand.join(parent, "n").filter(
F.col("grand.parent") != F.col("parent")).count()
parent = grand
if diff == 0:
break
return parent.select(F.col("n").alias("id_hash"),
F.col("parent").alias("cluster_id_min"))
说明:这个分布式并查集避免了 GraphX 的 String→Long 映射,直接用 id_hash 字符串做 root,链路更短;缺点是迭代 join 多、需注意 checkpoint,且对超级节点倾斜敏感(已在第 5 章剪枝)。星购生产以 GraphX 为主(更成熟的分区与容错),分布式 DSU 作为不依赖 GraphX jar 时的备选与交叉验证(两套结果抽样比对,一致率应 100%)。
6.3 Spark GraphX connectedComponents(Scala 完整代码)
十亿级图无法单机处理,用 Spark GraphX 的分布式图计算。GraphX 内置 ConnectedComponents 用 Pregel 迭代传播最小顶点 ID(label),收敛后同 label 即同分量。
scala
// oneid_core-graphx/src/main/scala/com/xinggou/oneid/ConnectedComponentsJob.scala
package com.xinggou.oneid
import org.apache.spark.graphx._
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._
import org.apache.spark.storage.StorageLevel
/**
* 星购 OneID 全量连通分量作业(GraphX)。
* 输入:dwd.dwd_id_relation_edge_df(is_pruned=0 的边)
* + 节点表 ods.ods_user_id_detail_di(全部有效 id_hash)
* 输出:dwm.dwm_oneid_graph_df(vertexId → component 代表点)
*
* 提交:spark-submit --class com.xinggou.oneid.ConnectedComponentsJob \
* --jars iceberg-spark-runtime.jar,mysql-connector.jar oneid-graphx.jar <dt>
*/
object ConnectedComponentsJob {
case class VertexAttr(idHash: String, idType: String, idLevel: Int, isAnchor: Int)
case class EdgeAttr(relType: String, confidence: Double, isStrong: Int)
def main(args: Array[String]): Unit = {
val dt = if (args.length > 0) args(0) else "2024-06-01"
val spark = SparkSession.builder()
.appName(s"oneid_graphx_cc_$dt")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.config("spark.graphx.pregel.checkpointInterval", "8")
.config("spark.cleaner.periodicGC.interval", "1min")
.getOrCreate()
import spark.implicits._
// ---------- 1. 读节点:为每个 id_hash 分配一个 Long 型 vertexId ----------
// GraphX 顶点 ID 必须是 Long;id_hash 是 64 位 hex,取其 hash 映射到 Long,
// 并用 zipWithUniqueId 保证唯一(String→Long 的稳定映射表落盘,供后续关联)
val nodes = spark.sql(
s"""
|SELECT id_hash, id_type, id_level,
| CASE WHEN id_level=1 THEN 1 ELSE 0 END AS is_anchor
|FROM ods.ods_user_id_detail_di
|WHERE dt='$dt' AND is_valid=1
|""".stripMargin)
// String id_hash → Long vertexId(稳定映射:hash 到非负 Long,冲突用 zipWithUniqueId 兜底)
val vertexIdMap = nodes.select("id_hash").distinct()
.rdd.zipWithUniqueId()
.map { case (row, vid) => (row.getString(0), vid) }
.persist(StorageLevel.MEMORY_AND_DISK_SER)
// 顶点 RDD: (vertexId, VertexAttr)
val vertices = nodes.rdd.map { r =>
(r.getAs[String]("id_hash"),
VertexAttr(r.getAs[String]("id_hash"),
r.getAs[String]("id_type"),
r.getAs[Int]("id_level"),
r.getAs[Int]("is_anchor")))
}
.join(vertexIdMap) // (id_hash, (attr, vid))
.map { case (_, (attr, vid)) => (vid, attr) }
// ---------- 2. 读边(仅未剪枝),映射到 vertexId ----------
val edgesRaw = spark.sql(
s"""
|SELECT src_id_hash, dst_id_hash, rel_type,
| CAST(confidence AS DOUBLE) AS confidence, is_strong
|FROM dwd.dwd_id_relation_edge_df
|WHERE dt='$dt' AND is_pruned=0
|""".stripMargin)
val hash2Vid = vertexIdMap.map { case (h, vid) => (h, vid) }
val edges = edgesRaw.rdd
.map { r => (r.getAs[String]("src_id_hash"),
(r.getAs[String]("dst_id_hash"),
EdgeAttr(r.getAs[String]("rel_type"),
r.getAs[Double]("confidence"),
r.getAs[Int]("is_strong")))) }
.join(hash2Vid).map { case (_, ((dstHash, attr), srcVid)) => (dstHash, (srcVid, attr)) }
.join(hash2Vid).map { case (_, ((srcVid, attr), dstVid)) =>
Edge(srcVid, dstVid, attr) // GraphX Edge,自动视为无向(CC 不关心方向)
}
// ---------- 3. 构图并跑连通分量 ----------
val graph = Graph(vertices, edges)
.partitionBy(PartitionStrategy.EdgePartition2D) // 2D 分区减少跨分区通信
.persist(StorageLevel.MEMORY_AND_DISK_SER)
// checkpoint 防止 Pregel 长 lineage 堆栈;每 8 轮物化一次
graph.vertices.checkpoint()
graph.edges.checkpoint()
val cc: Graph[VertexId, EdgeAttr] =
graph.connectedComponents()
// connectedComponents 内部 Pregel:顶点属性初始化为自身 vertexId,
// 每轮沿边传播邻居的最小 component label,迭代到收敛(无更新)。
// ---------- 4. 结果关联回 id_hash,落 DWM ----------
val compDF = cc.vertices
.join(vertices) // (vid, (component, attr))
.map { case (vid, (comp, attr)) =>
(attr.idHash, attr.idType, attr.idLevel, attr.isAnchor, comp, vid)
}
.toDF("id_hash", "id_type", "id_level", "is_anchor",
"component_vid", "vertex_vid")
// 簇大小 & 是否含强锚点
val clusterAgg = compDF.groupBy("component_vid")
.agg(count(lit(1)).alias("cluster_size"),
max("is_anchor").alias("cluster_has_strong"))
val result = compDF.join(clusterAgg, "component_vid")
.select(
col("id_hash"), col("id_type"), col("id_level"),
col("is_anchor"),
col("component_vid").cast("string").alias("cluster_id_min"),
col("cluster_size"),
col("cluster_has_strong"),
lit(dt).alias("dt"),
current_timestamp().alias("join_time"))
result.writeTo("dwm.dwm_oneid_graph_df").overwritePartitions()
// ---------- 5. 作业级统计(落地监控,第12章消费)----------
val stat = result.agg(
countDistinct("id_hash").alias("nodes"),
countDistinct("component_vid").alias("components"),
max("cluster_size").alias("largest_cluster"),
sum(when(col("cluster_has_strong") === 0 && col("cluster_size") > 1, 1).otherwise(0))
.alias("weak_only_clusters")
).collect()(0)
println(s"[CC-STAT] dt=$dt nodes=${stat.get(0)} components=${stat.get(1)} " +
s"largest=${stat.get(2)} weakOnly=${stat.get(3)}")
spark.stop()
}
}
关键说明:
- String→Long 顶点映射 :GraphX 顶点 ID 必须是
Long,而 id_hash 是 64 位 hex 字符串。用zipWithUniqueId生成稳定 Long 映射并持久化(落盘),CC 结果再 join 回 id_hash。这个映射表必须当天稳定,否则下游对不上。 PartitionStrategy.EdgePartition2D:把边按 (src, dst) 二维切分,跨分区通信量从 O(n) 降到 O(√n),十亿边场景显著减少 shuffle。- checkpoint 间隔 :Pregel 多轮迭代 lineage 很长,
checkpointInterval=8定期截断 RDD 血缘,防止 StackOverflow 和重算放大。 connectedComponents产出的 label 是 component 内最小 vertexId,不保证是强 ID;后续 OneID 分配(第 7 章)优先以「含强锚点的簇」为单位发号,弱簇挂待观察。- 只喂
is_pruned=0的边:第 5 章剪枝的超级节点弱边不参与,避免巨型连通分量。
6.4 GraphFrames PySpark 版本
Python 栈可用 GraphFrames(DataFrame API,底层同 GraphX Pregel)。适合开发调试、中小规模,或团队以 Python 为主时:
python
# oneid_core/graph/cc_graphframes.py
from __future__ import annotations
from pyspark.sql import SparkSession, DataFrame, functions as F
# 需 --packages org.graphframes:graphframes:0.8.3-spark3.5-s_2.12
from graphframes import GraphFrame
def run_cc_graphframes(spark: SparkSession, dt: str) -> DataFrame:
"""
GraphFrames 连通分量。相比 GraphX Scala 版:
- 优点:Python/DataFrame,开发快,与 PySpark 作业同构
- 缺点:超大规模 shuffle/内存调优不如 GraphX 细;十亿边建议用 Scala 版
"""
spark.sparkContext.setCheckpointDir("s3a://xinggou-dw/oneid/checkpoints")
# 顶点:id_hash 直接作为字符串 id(GraphFrames 支持任意类型 id)
v = (
spark.table("ods.ods_user_id_detail_di")
.filter(F.col("dt") == dt).filter(F.col("is_valid") == 1)
.select(F.col("id_hash").alias("id"),
F.col("id_type"), F.col("id_level"))
.dropDuplicates(["id"])
)
# 边:src/dst 指向顶点 id;GraphFrames 有向边,CC 视为无向需加双向
e = (
spark.table("dwd.dwd_id_relation_edge_df")
.filter(F.col("dt") == dt).filter(F.col("is_pruned") == 0)
.select(F.col("src_id_hash").alias("src"),
F.col("dst_id_hash").alias("dst"),
F.col("rel_type"), F.col("confidence"))
)
# 补反向边,保证无向语义
e_rev = e.select(F.col("dst").alias("src"), F.col("src").alias("dst"),
F.col("rel_type"), F.col("confidence"))
edges = e.union(e_rev)
g = GraphFrame(v, edges)
# checkpoint 截断长 lineage(连通分量迭代轮数多)
cc = g.connectedComponents(
checkpointInterval=8,
broadcastThreshold=1_000_000, # 大组件不广播,走 shuffle
)
# cc: id, component(代表点), ...顶点属性
result = (
cc.groupBy("component")
.agg(F.count("*").alias("cluster_size"),
F.max(
F.when(F.col("id_level") == 1, 1).otherwise(0)
).alias("cluster_has_strong"))
.join(cc, "component")
.select(
F.col("id").alias("id_hash"), F.col("id_type"), F.col("id_level"),
F.col("component").cast("string").alias("cluster_id_min"),
F.col("cluster_size"), F.col("cluster_has_strong"),
F.lit(dt).alias("dt"))
)
result.writeTo("dwm.dwm_oneid_graph_df").overwritePartitions()
return result
6.5 三种方案性能对比与选型
星购在 10.3 亿节点 / 20.7 亿边(未剪枝)规模下实测对比(集群:200 executor × 4c16g):
| 方案 | 语言/API | 10 亿节点全量 CC 耗时 | 峰值内存 | 调优灵活度 | 适用 |
|---|---|---|---|---|---|
| Python 并查集单机 | Python dict | 不可行(内存爆,~需 300GB+ 堆) | 极高 | 低 | 调试/百万级小图/实时 Redis(第8章) |
| GraphFrames | PySpark DataFrame | 约 75--90 分钟 | 中 | 中 | 中小规模、Python 团队、快速迭代 |
| GraphX connectedComponents | Scala/Pregel | 约 45--55 分钟 | 中(EdgePartition2D) | 高(分区/checkpoint/序列化) | 十亿级离线主力 |
说明:Python 并查集单机在 10 亿节点不可行,但它是实时链路 Redis 在线并查集 (第 8 章,只处理活跃 ID、状态放 Redis)和本地抽样校验的基础。离线全量主力是 GraphX Scala。
选型建议:
- 全量离线(亿级以上):GraphX + Scala,投入一次调优长期受益;
- 增量/中小规模/Python 团队:GraphFrames,维护成本低;
- 实时在线:Redis 并查集(第 8 章),不是 GraphX/GraphFrames。
6.6 数据倾斜处理
连通分量的倾斜来自「度数高的节点」和「迭代后期大连通分量的 label 传播」:
倾斜来源与对策:
1. 高入度节点(热门设备/地址)
└─ 第5章已超级节点剪枝;残留倾斜用 AQE skewJoin + EdgePartition2D
2. Pregel 迭代后期,大分量的顶点集中在少数分区
└─ checkpointInterval 截断 lineage;增大 shuffle 分区数
3. String→Long 映射 join 倾斜
└─ vertexIdMap 用 broadcast(若维度小)或 salting
scala
// GraphX 倾斜缓解关键参数(提交时配置)
// --conf spark.sql.shuffle.partitions=20000 # 倾斜时加大分区
// --conf spark.graphx.pregel.checkpointInterval=8
// --conf spark.shuffle.service.enabled=true
// --conf spark.memory.fraction=0.6
// --conf spark.sql.adaptive.skewJoin.enabled=true
// --conf spark.sql.adaptive.advisoryPartitionSizeInBytes=128m
// EdgePartition2D 已经是最关键的一手:
graph.partitionBy(PartitionStrategy.EdgePartition2D)
// 相比随机/按源点分区,2D 切分让相邻边尽量同分区,CC 迭代通信量大幅下降
若 GraphX 内置 connectedComponents 在极端倾斜下仍慢,可换 两阶段 CC(巨型簇单独处理):
- 先用并查集思想的 label propagation + 预合并:在 map 端用本地并查集把同一分区内已连通的顶点先合并,减少进入全局迭代的顶点数;
- 超级节点(度 > cap)在第 5 章已剪掉弱边,强边通常不会形成超万级簇(真实人簇规模 1--20)。
经验值:星购 20 亿边在剪枝后实际参与 CC 的边约 18 亿(剪掉公共节点弱边),最大真实连通簇不超过数百节点(一人多账号家庭),>2000 的簇 100% 是超级节点残留,会在第 12 章质量监控报警。
6.6.1 抽样交叉验证:GraphX vs 分布式 DSU vs 单机并查集
三套实现上线前要在同一批数据上交叉验证,保证结果一致。提供一个抽样校验工具:
python
# oneid_core/union_find/validate_cc.py
from __future__ import annotations
from pyspark.sql import SparkSession, functions as F
from oneid_core.union_find.dsu import UnionFind
def validate_on_sample(spark: SparkSession, dt: str, sample_n: int = 200_000) -> dict:
"""
在随机抽样子图上,用单机并查集重算连通分量,
与 GraphX/GraphFrames 落库的 dwm_oneid_graph_df 结果比对。
抽样应聚焦「多节点簇」(孤岛无验证价值)。
"""
graph = spark.table("dwm.dwm_oneid_graph_df").filter(F.col("dt") == dt)
edges = (
spark.table("dwd.dwd_id_relation_edge_df")
.filter(F.col("dt") == dt).filter(F.col("is_pruned") == 0)
.select("src_id_hash", "dst_id_hash")
)
# 抽 N 个多节点簇
multi = (graph.filter(F.col("cluster_size") > 1)
.select("cluster_id_min").distinct().limit(sample_n // 10))
sample_nodes = graph.join(multi, "cluster_id_min").select("id_hash")
sample_set = {r["id_hash"] for r in sample_nodes.collect()}
# 只保留两端都在抽样集合内的边(闭包)
edge_rows = (
edges.join(sample_nodes.withColumnRenamed("id_hash", "s"),
F.col("src_id_hash") == F.col("s"))
.join(sample_nodes.withColumnRenamed("id_hash", "d"),
F.col("dst_id_hash") == F.col("d"))
.select("src_id_hash", "dst_id_hash").collect()
)
# 单机并查集重算
uf = UnionFind()
for r in edge_rows:
uf.union(r["src_id_hash"], r["dst_id_hash"])
py_root = {n: uf.find(n) for n in sample_set}
# GraphX 结果
gx = {r["id_hash"]: r["cluster_id_min"]
for r in graph.filter(F.col("id_hash").isin(list(sample_set))).collect()}
# 比对:单机并查集认为同簇的,GraphX 也必须同簇(一致率)
agree, total = 0, 0
nodes = list(sample_set)
for i in range(0, len(nodes), 1):
for j in range(i + 1, min(i + 50, len(nodes))): # 抽样比对,避免 O(n^2)
a, b = nodes[i], nodes[j]
same_py = py_root[a] == py_root[b]
same_gx = gx.get(a) == gx.get(b)
total += 1
if same_py == same_gx:
agree += 1
return {
"sample_clusters": multi.count(),
"sample_nodes": len(sample_set),
"sample_edges": len(edge_rows),
"agreement_rate": round(agree / total, 6) if total else None,
}
验收标准:抽样一致率 = 1.0。任何不一致都要排查(常见原因:GraphX 喂了被剪边、String→Long 映射冲突、分布式 DSU 迭代未收敛)。这个工具也接入第 12 章质量监控,作为每日离线产出后的自动门禁。
6.6.2 连通结果示例(对照 2.9 节小王)
CC 跑完后,小王簇的 dwm_oneid_graph_df 记录(cluster_id_min 为内部 label,第 7 章换 OneID):
| id_hash | id_type | is_anchor | cluster_id_min | cluster_size | cluster_has_strong |
|---|---|---|---|---|---|
| h(un_x) | unionid | 1 | h(ck_001) | 7 | 1 |
| h(mdn_2222) | mdn | 1 | h(ck_001) | 7 | 1 |
| h(op_wx1) | openid | 0 | h(ck_001) | 7 | 1 |
| h(op_wx2) | openid | 0 | h(ck_001) | 7 | 1 |
| h(dv_aaa) | device_id | 0 | h(ck_001) | 7 | 1 |
| h(ck_001) | cookie | 0 | h(ck_001) | 7 | 1 |
| h(MC_777) | member_id | 0 | h(ck_001) | 7 | 1 |
注意 cluster_has_strong=1(簇内有 unionid/mdn 强锚点),是可放心发 OneID 的「健康簇」;若某簇 cluster_has_strong=0 且 size>1,则是纯弱关系簇,挂「待观察」,不轻易发正式号。
6.7 CC 结果到 OneID 的衔接
CC 产出的 dwm_oneid_graph_df 是「顶点 → component 代表点」,但 component 代表点只是个内部 Long label,不是对外 OneID。第 7 章做两件事:
-
为每个 component 分配/复用一个稳定的雪花 OneID(合并不新建、拆分可回退);
-
生成
dim_oneid_id_map(id_hash → oneid)供服务层查询。dwm_oneid_graph_df (id_hash, component_vid)
│
│ 第7章:component_vid → oneid(雪花号,稳定映射)
▼
dim_oneid_id_map (id_hash → oneid) ── 服务层正/反向查询
dim_oneid_user (oneid → 黄金属性) ── 画像(第9章融合)
6.8 本章生产注意事项
- 输入只读有效边 :
is_pruned=0,且节点必须来自有效标准化(is_valid=1)。 - String→Long 映射落盘复用 :当天 CC、后续属性融合、问题排查都要用同一份映射,不能每次重算(
zipWithUniqueId依赖分区顺序,重算可能变化)。 - 收敛判定:GraphX 自动收敛,但要监控迭代轮数;轮数异常增多通常意味着超级节点未剪干净。
- 弱簇不强制发号 :
cluster_has_strong=0的多节点簇先不分配正式 OneID 或标记「待观察」,避免纯弱关系误并(第 7/12 章)。 - 幂等重跑 :整个作业
overwritePartitions()写当天分区,失败重跑安全。
6.9 有向边、弱连通与边方向的处理
身份关系本质是无向的(A 与 B 是同一人,B 与 A 也是),但 GraphFrames 的边是有向的,GraphX 的 CC 本身把边视为无向。工程上注意:
- GraphX
connectedComponents自动忽略方向(它用的是无向连通),无需双向加边。 - GraphFrames 的
connectedComponents实际按有向处理弱连通,为保险显式补反向边(本篇 6.4 已union(e_rev))。 - 强连通分量(stronglyConnectedComponents)不适用:那要求双向可达,身份关系是「同属一人」的对称关系,用弱连通/无向 CC 即可。用错强连通会漏掉单向共现。
6.10 复杂度与资源直觉
| 项 | 复杂度/规模 | 说明 |
|---|---|---|
| 并查集 union/find | O(α(V)) ≈ O(1) | 单机;路径压缩+按秩合并 |
| GraphX CC | O(d·(V+E)/并行度),d=迭代轮数 | d≈图直径,身份图 d≈5--10 |
| 通信量 | EdgePartition2D 后 O(E/√P) 跨分区 | P 分区数 |
| 内存 | 顶点集 + 边集 + 中间 label | 10亿顶点53GB,20亿边215GB |
| 星购实测 | 200 executor(4c16g),约 48 分钟 | 见第 13 章资源表 |
关键结论:连通分量对稀疏图近乎线性,ID 关系图平均度 ~4、直径小,是图计算里相对「好算」的任务。真正的工程量不在算法本身,而在:① 边的质量(第 5 章别错连);② 超级节点治理(别让一个公共 WiFi 把百万人连成一坨);③ 结果稳定性(第 7 章 OneID 不能乱跳)。这三件事做好,CC 作业本身很稳。
6.11 本章代码模块清单
| 文件 | 作用 |
|---|---|
oneid_core/union_find/dsu.py |
单机并查集(路径压缩+按秩合并+强锚点/置信度元信息) |
oneid_core/union_find/spark_dsu.py |
分布式 DataFrame 并查集(备选/交叉验证) |
oneid_core/union_find/validate_cc.py |
GraphX vs 分布式DSU vs 单机 抽样一致性校验 |
oneid_core-graphx/.../ConnectedComponentsJob.scala |
GraphX 全量 CC 主力作业 |
oneid_core/graph/cc_graphframes.py |
GraphFrames PySpark 备选实现 |
oneid_core/graph/supernode.py |
超级节点度数截断与新超级节点发现 |
本篇小结
第 4--6 章走完了「节点 → 边 → 连通分量」的离线核心链路:
- 第 4 章 :手机号/设备类清洗规则、HMAC-SHA256+盐哈希、PySpark UDF 标准化作业,产出
ods_user_id_detail_di节点; - 第 5 章 :五类关系来源、可解释置信度公式、弱关系三道剪枝闸、超级节点黑名单+度数截断,产出
dwd_id_relation_edge_df边; - 第 6 章 :并查集原理与 Python 实现、GraphX Scala 全量 CC(主力)、GraphFrames PySpark(备选)、性能对比与数据倾斜治理,产出
dwm_oneid_graph_df图谱。
分册面试高频题串讲(第 4--6 章)
Q1:ID 哈希为什么选 HMAC-SHA256 + 盐,而不是 MD5 或裸 SHA256?盐怎么管?
手机号空间只有 10^11,裸哈希可被彩虹表/撞库穷举反推;MD5 已被证明不安全。HMAC 是标准 MAC 构造,密钥(盐)不参与哈希输入拼接,抗长度扩展攻击。盐值分级管理:强 ID(手机号/身份证/pay_uid)各用独立高保护盐,设备类弱 ID 共用一个设备盐,单点泄露不波及全部。盐放 KMS/配置中心,executor 通过环境变量惰性初始化、驻留内存,绝不作为 UDF 参数广播(会进 Spark 事件日志与血缘),也不打印、不落盘。清洗规则或盐变更属于口径变更,必须升版本号、双跑双写、抽样比对一致率 100% 后灰度切换。
Q2:PySpark 标准化为什么同时给普通 UDF 和 pandas UDF?
普通 UDF 逐行调用,简单但每行有序列化开销;pandas UDF(向量化)按批接收 pandas.Series,利用 Apache Arrow 列式传输,HMAC 这类纯 CPU 计算吞吐高数倍。生产用 pandas UDF 跑主链路,普通 UDF 留给调试和小数据量。注意 UDF 闭包里的 IdHasher 要惰性单例初始化(lru_cache),避免每行重复取盐。
Q3:弱关系置信度公式是怎么设计的?为什么不直接上机器学习模型?
用可解释的乘法公式:confidence = 来源权重 × 时效衰减(0.5^(天数/90)) × 共现饱和(cnt/(cnt+5)) × 场景惩罚。来源权重 bind=1.0/pay=0.8/co_event=0.7/address=0.6/device=0.5/wifi=0.4。设计要点:单一弱信号哪怕共现很多次也到不了阈值 0.85,必须多个独立弱信号交叉(同设备+同地址+时间窗重合)或挂到强锚点上才入图------这是「宁可漏通不可错通」的数值落地。初期不用 ML 是因为规则可解释、可调参、可审计、能在影子表逐因子归因;稳定后可对弱关系子集训练 LR/GBDT 做残差校准,但入图仍走阈值 + 规则兜底。
Q4:超级节点是什么?不治理会怎样?怎么治?
超级节点是度数异常大的节点,几乎都是公共/共享资源而非真人:商场公共 WiFi、门店导购机、机房 NAT 出口 IP、公司前台收货地址、企业代付账号。不治理的话,一个商场 WiFi 就能把当天几万人连成一个连通簇,全部错并成「一个人」。三道防线:① 黑名单先行(已知公共节点哈希入 MySQL 黑名单,弱边 confidence=0 剪除);② 度数截断兜底(统计无向度数,>2000 的节点其弱边全部 is_pruned=1,强边 bind 保留------导购机上顾客真实绑定的账号仍要打通);③ 主动发现(度数超阈值一半就预警推运营审核,新型公共资源闭环入黑名单)。剪枝边留痕不物理删除,支持审计与回滚。
Q5:并查集为什么能近似 O(1)?两个优化分别解决什么?
并查集 union/find 摊还复杂度 O(α(n)),α 是反阿克曼函数,n 到 10^80 才增长到 4,实际视为常数。两个优化:路径压缩 ------find 时把查找路径上所有节点直接挂到 root,下次查找一跳到位;按秩(秩=树高上界)合并------union 时把矮树挂到高树根上,控制树高不超过 log n。两者结合才得到 α(n)。星购的生产版还额外维护簇元信息(簇大小、是否含强锚点、最弱边置信度),纯弱簇用于质量监控复核。
Q6:GraphX 的 connectedComponents 原理是什么?为什么要做 String→Long 映射?
GraphX 用 Pregel(以顶点为中心的 BSP 模型)迭代传播「最小顶点 ID」作为分量标签:初始每个顶点 label=自己,每轮沿边收发邻居 label、取 min 更新,一整轮无更新即收敛,迭代轮数≈图直径(身份图直径小,5--10 轮收敛)。GraphX 顶点 ID 必须是 Long,而 id_hash 是 64 位 hex 字符串,所以用 zipWithUniqueId 建一张稳定的 hash→Long 映射表并持久化,CC 结果再 join 回 id_hash;这个映射当天必须稳定。工程上还要 EdgePartition2D 分区(跨分区通信从 O(n) 降到 O(√n))、checkpointInterval=8 截断 Pregel 长血缘防 StackOverflow。
Q7:十亿级连通分量的数据倾斜怎么处理?
倾斜来自高入度节点和迭代后期大簇 label 传播。对策分层:① 上游根治------第 5 章超级节点剪枝后,真实人簇规模只有 1--20,>2000 的簇 100% 是残留超级节点;② 分区------EdgePartition2D 让相邻边尽量同分区;③ 参数------加大 spark.sql.shuffle.partitions(到 2 万)、开 AQE skewJoin、advisoryPartitionSizeInBytes=128m;④ 极端情况用两阶段 CC,map 端本地并查集先预合并同分区连通点,减少进入全局迭代的顶点数。
Q8:怎么证明连通结果是对的?
三套实现交叉验证:GraphX Scala(主力)、GraphFrames/Distributed DSU(备选)、单机 Python 并查集(抽样)。每日抽 20 万个多节点簇,在闭包子图上用单机并查集重算,与落库的 dwm_oneid_graph_df 比对「任意两点是否同簇」,一致率必须 =1.0,否则报警阻断下游。常见不一致原因:GraphX 喂了被剪边、String→Long 映射冲突、分布式迭代未收敛。这个校验工具同时是质量监控的每日自动门禁。
下一册:part3(第 7--9 章)------雪花算法 OneID 生成与稳定性、Flink+Redis 实时在线并查集与批流一致性、黄金记录属性融合。 本册打通的连通簇将在 part3 拿到稳定的 OneID 号、接入分钟级实时修正,并把各触点属性融合成可信的黄金记录。