OneID 从 0 到 1 完整生产案例(二)

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,不产生节点

三个必须保证的性质:

  1. 确定性(Deterministic):同一个原始 ID 永远哈希出同一个 id_hash(否则同一人在不同批次对不上)。因此哈希算法、盐值、清洗规则三者必须版本化、不可静默变更。
  2. 不可逆(Irreversible):id_hash 无法反推明文,落盘/进缓存的都是哈希(第 14 章)。
  3. 可复现(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 生产注意事项清单

  1. 盐的初始化时机 :executor 上 IdHasherlru_cache 惰性初始化,盐从环境变量/KMS 取;绝不能把盐作为 UDF 参数广播(会进 Spark 事件日志和血缘)。
  2. UDF 性能 :HMAC 很快(百万级/秒/核),不是瓶颈;瓶颈在 shuffle,所以先 filter 无效、再按 id_hash 预聚合减少数据量。
  3. 字典一致性id_type 必须在 dim_id_type_dict 内,未知类型进死信而不是猜用哪个盐(用错盐 = 同一人哈希不一致)。
  4. 双跑校验:清洗规则/盐变更时,新旧作业各写一份 id_hash,抽样比对一致率(目标 100% 对受影响类型),不一致 case 全量列出人工确认。
  5. 明文落盘管控 :贴源表 ods_raw_identity_di 是明文,必须单独授权、加密存储、保留期最短(≤7 天),标准化后即删;数仓内只允许 id_hash 流转。

实时链路在 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)))

策略要点

  1. 黑名单先行 :已知公共 WiFi/导购机/机房 IP 直接进黑名单,弱边 confidence=0 剪除。
  2. 度数截断兜底 :未知的新型公共节点,靠统计度数发现------度数 >2000 的节点,其弱边全部剪枝(强边保留,因为强边是真实绑定关系,导购机上顾客真实绑定的账号仍要打通)。
  3. 主动发现:度数超阈值一半就预警推审核,运营确认后加入黑名单,形成闭环。
  4. 剪枝留痕 :被剪边标 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 生产注意事项

  1. 无向边方向稳定 :统一 src=least(hash), dst=greatest(hash),避免 A-B 和 B-A 被当成两条边。
  2. C(n,2) 展开的正确性 :同事件 5 个 ID 要产生 10 条边;用 sequence + 双层 posexplode 或在 Spark 里 join 自身条件 a.pos<b.pos,注意别只取相邻两两(代码注释已标注,生产以完整组合为准)。
  3. 数据倾斜:热门设备/地址(如某爆款商品大量订单同仓地址)会导致聚合倾斜,配合第 13 章加盐打散。
  4. 边的时效:老边通过时间衰减自然降权,但不会删除;真实的长期关系(家人同地址)靠多次共现 + 饱和因子保持置信度。
  5. 可回退:弱边保留完整证据(来源/次数/时间),第 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(巨型簇单独处理)

  1. 先用并查集思想的 label propagation + 预合并:在 map 端用本地并查集把同一分区内已连通的顶点先合并,减少进入全局迭代的顶点数;
  2. 超级节点(度 > 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 章做两件事:

  1. 为每个 component 分配/复用一个稳定的雪花 OneID(合并不新建、拆分可回退);

  2. 生成 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 本章生产注意事项

  1. 输入只读有效边is_pruned=0,且节点必须来自有效标准化(is_valid=1)。
  2. String→Long 映射落盘复用 :当天 CC、后续属性融合、问题排查都要用同一份映射,不能每次重算(zipWithUniqueId 依赖分区顺序,重算可能变化)。
  3. 收敛判定:GraphX 自动收敛,但要监控迭代轮数;轮数异常增多通常意味着超级节点未剪干净。
  4. 弱簇不强制发号cluster_has_strong=0 的多节点簇先不分配正式 OneID 或标记「待观察」,避免纯弱关系误并(第 7/12 章)。
  5. 幂等重跑 :整个作业 overwritePartitions() 写当天分区,失败重跑安全。

6.9 有向边、弱连通与边方向的处理

身份关系本质是无向的(A 与 B 是同一人,B 与 A 也是),但 GraphFrames 的边是有向的,GraphX 的 CC 本身把边视为无向。工程上注意:

  • GraphX connectedComponents 自动忽略方向(它用的是无向连通),无需双向加边。
  • GraphFramesconnectedComponents 实际按有向处理弱连通,为保险显式补反向边(本篇 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 号、接入分钟级实时修正,并把各触点属性融合成可信的黄金记录。

相关推荐
思录Echo2 小时前
光学轮廓仪质检设备厂家怎么选?优可测国产替代方案深度解析
大数据·人工智能
半摆烂日常2 小时前
自建WMS和买成品:三年成本对比
大数据·服务器·数据库·python·深度学习·低代码·numpy
runshui272 小时前
openssl 3.5安装
大数据
临沂GEO2 小时前
芝麻开门GEO|AI数字化新趋势,助力企业线上长效增长
大数据·人工智能·python
invicinble3 小时前
做数字产品的核心内容--数据的设计与展示
大数据·前端
财迅通Ai3 小时前
光智科技的护城河:稀散金属的资源禀赋与产业纵深
大数据·科技·光智科技
hrrrrxeeeee3 小时前
告别功能验收!AI 项目要用效果指标说话
大数据·人工智能
dh2711987794 小时前
AI幻觉与信任赤字:南京GEO服务商如何帮企业重建AI时代的品牌信用
大数据·人工智能
玖石书4 小时前
Git Submodule 完全指南:从添加到日常维护的常规操作全流程
大数据·git·elasticsearch