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

OneID 从 0 到 1 完整生产案例(三):OneID 生成、实时并查集与黄金记录

本篇为第 7--9 章:把连通分量变成稳定的 OneID(雪花算法 + 合并/拆分稳定性,第 7 章)、Flink+Redis 实时在线并查集与批流一致性(第 8 章)、多来源属性融合成黄金记录(第 9 章)。


第7章 OneID 生成与稳定性

连通分量给出「哪些 ID 是同一个人」,但对外服务的主键必须是一个稳定、唯一、趋势友好的 ID。本章解决:用什么编码、怎么分配、合并/拆分时如何保证 OneID 永不乱跳。

7.1 编码规则选型

方案 优点 缺点 结论
数据库自增 简单 单点、分布式冲突、迁移困难、暴露用户量
UUID 无序、本地生成 36 位字符串、随机写导致 B+树/分桶抖动、不可读
雪花 Snowflake 64bit Long、趋势递增、分布式无单点、含时间/机器位 依赖时钟(需时钟回拨处理)
号段模式 连续、可读 需要号段分配中心 备选(业务线号段见下)

星购 OneID 为 BIGINT,采用业务线 + 号段 + 雪花结合:业务线位区分发号域、号段位区分集群(新老集群迁移用),主体用雪花保证分布式唯一与趋势递增,十进制展示号末位追加校验位防肉眼写错。

复制代码
OneID 64-bit 位段图(严格 1+41+4+3+7+8 = 64 bit):
 63  62 ─────────── 22  21─18   17─15   14─8      7─0
┌──┬────────────────────┬───────┬───────┬─────────┬────────┐
│符│ 毫秒时间戳 41bit   │业务线  │ 号段  │worker_id│ 序列号  │
│号│ (相对纪元, ~69年)  │ 4bit  │ 3bit  │  7bit   │  8bit  │
│ 0│                    │16条线 │8个集群│ 128 节点 │256/ms  │
└──┴────────────────────┴───────┴───────┴─────────┴────────┘
   ▲                    ▲       ▲       ▲         ▲
   恒0              趋势递增   biz_line segment  同毫秒并发放号
                                    ▲
                          新集群=0 / 老集群迁移期=1(7.5)
对外展示号 = 十进制 OneID(当前 18 位)+ 1 位 Luhn 校验位 = 19 位数字串

设计取舍:① 时间戳放高位让 ID 趋势递增(Iceberg 分桶/Redis 分布/B+树插入友好);② 业务线 4 bit 支持 16 条发号业务线(星购主站=1、支付=2、门店=3......),发号时按调用方写入,下游一看号段就知道归属;③ 号段 3 bit 支持 8 个集群,新老集群迁移期间老集群 segment=1、新集群 segment=0,绝不混号(7.5 双写灰度靠它区分);④ worker 7 bit(128 个发号节点)× 序列号 8 bit(每毫秒 256),单 worker 25.6 万/秒,全集群理论 3200 万/秒,远超星购日均 180 万新增;⑤ 校验位见 7.2 的 Luhn 实现。

7.2 雪花算法 Python 完整实现

python 复制代码
# oneid_core/allocator/snowflake.py
from __future__ import annotations
import threading
import time
from oneid_core.common.logger import get_logger

logger = get_logger(__name__)

# ---- 位分配(严格 64 bit:1 符号 + 41 时间戳 + 4 业务线 + 3 号段 + 7 worker + 8 序列)----
TIMESTAMP_BITS = 41        # 毫秒时间戳(相对纪元),2^41 ms ≈ 69.7 年
BIZ_LINE_BITS = 4          # 业务线:0~15(主站=1、支付=2、门店=3......)
SEGMENT_BITS = 3           # 号段/集群:0~7(新集群=0,老集群迁移期=1)
WORKER_BITS = 7            # worker_id:0~127
SEQUENCE_BITS = 8          # 同毫秒序列号:0~255

MAX_BIZ_LINE = (1 << BIZ_LINE_BITS) - 1      # 15
MAX_SEGMENT = (1 << SEGMENT_BITS) - 1        # 7
MAX_WORKER = (1 << WORKER_BITS) - 1          # 127
MAX_SEQUENCE = (1 << SEQUENCE_BITS) - 1      # 255

# 位移(从低位向高位)
WORKER_SHIFT = SEQUENCE_BITS                       # 8
SEGMENT_SHIFT = SEQUENCE_BITS + WORKER_BITS        # 15
BIZ_LINE_SHIFT = SEGMENT_SHIFT + SEGMENT_BITS      # 18
TIMESTAMP_SHIFT = BIZ_LINE_SHIFT + BIZ_LINE_BITS   # 22
# 校验总位数 = 41+4+3+7+8 = 63(加符号位 1 = 64)
assert TIMESTAMP_SHIFT + TIMESTAMP_BITS == 63

EPOCH = 1704067200000  # 自定义纪元:2024-01-01 00:00:00 UTC(毫秒,固定后不可改)


class ClockBackwardError(RuntimeError):
    pass


class SnowflakeGenerator:
    """
    线程安全雪花 ID 生成器。
    - 单实例绑定一个 worker_id(0~1023),由调度/配置分配
    - 处理时钟回拨:小幅回拨等待,大幅回拨报警拒发
    """

    def __init__(self, worker_id: int, biz_line: int = 1, segment: int = 0) -> None:
        if not 0 <= worker_id <= MAX_WORKER:
            raise ValueError(f"worker_id must be in [0,{MAX_WORKER}], got {worker_id}")
        if not 0 <= biz_line <= MAX_BIZ_LINE:
            raise ValueError(f"biz_line must be in [0,{MAX_BIZ_LINE}], got {biz_line}")
        if not 0 <= segment <= MAX_SEGMENT:
            raise ValueError(f"segment must be in [0,{MAX_SEGMENT}], got {segment}")
        self.worker_id = worker_id
        self.biz_line = biz_line      # 业务线(主站=1)
        self.segment = segment        # 号段/集群(新集群=0,迁移期老集群=1)
        self._last_ts = -1
        self._seq = 0
        self._lock = threading.Lock()

    def _now_ms(self) -> int:
        return int(time.time() * 1000)

    def _wait_next_ms(self, last_ts: int) -> int:
        ts = self._now_ms()
        while ts <= last_ts:
            ts = self._now_ms()
        return ts

    def next_id(self) -> int:
        with self._lock:
            ts = self._now_ms()
            if ts < self._last_ts:
                # 时钟回拨处理
                offset = self._last_ts - ts
                if offset <= 5:
                    # 小幅回拨:等待追平
                    logger.warning("clock backward %dms, waiting", offset)
                    ts = self._wait_next_ms(self._last_ts)
                elif offset <= 100:
                    # 中幅:自旋等待 + 告警
                    logger.error("clock backward %dms, spin wait", offset)
                    ts = self._wait_next_ms(self._last_ts)
                else:
                    # 大幅回拨:拒绝发号,避免重复
                    raise ClockBackwardError(f"clock moved backward {offset}ms")

            if ts == self._last_ts:
                self._seq = (self._seq + 1) & MAX_SEQUENCE
                if self._seq == 0:
                    # 本毫秒序列耗尽,等下一毫秒
                    ts = self._wait_next_ms(self._last_ts)
            else:
                self._seq = 0

            self._last_ts = ts
            # 组装:时间戳 | 业务线 | 号段 | worker | 序列号
            return (((ts - EPOCH) << TIMESTAMP_SHIFT)
                    | (self.biz_line << BIZ_LINE_SHIFT)
                    | (self.segment << SEGMENT_SHIFT)
                    | (self.worker_id << WORKER_SHIFT)
                    | self._seq)

    def next_ids(self, n: int) -> list[int]:
        return [self.next_id() for _ in range(n)]


def parse_oneid(oneid: int) -> dict:
    """反解 OneID 位段(排障/审计用):时间戳、业务线、号段、worker、序列。"""
    seq = oneid & MAX_SEQUENCE
    worker = (oneid >> WORKER_SHIFT) & MAX_WORKER
    segment = (oneid >> SEGMENT_SHIFT) & MAX_SEGMENT
    biz_line = (oneid >> BIZ_LINE_SHIFT) & MAX_BIZ_LINE
    ts_ms = (oneid >> TIMESTAMP_SHIFT) + EPOCH
    return {
        "ts_ms": ts_ms,
        "biz_line": biz_line,
        "segment": segment,
        "worker_id": worker,
        "sequence": seq,
    }


# ---- 校验位(Luhn 变体,10进制)----
def append_check_digit(oneid: int) -> str:
    """对外展示号:OneID + 1位 Luhn 校验位。"""
    digits = [int(d) for d in str(oneid)]
    s = 0
    for i, d in enumerate(reversed(digits)):
        if i % 2 == 0:
            d *= 2
            if d > 9:
                d -= 9
        s += d
    check = (10 - s % 10) % 10
    return f"{oneid}{check}"


def verify_check_digit(display_id: str) -> bool:
    digits = [int(d) for d in display_id]
    body, check = digits[:-1], digits[-1]
    s = 0
    for i, d in enumerate(reversed(body)):
        if i % 2 == 0:
            d *= 2
            if d > 9:
                d -= 9
        s += d
    return (10 - s % 10) % 10 == check

单测:

python 复制代码
# tests/test_snowflake.py
import threading
from oneid_core.allocator.snowflake import (
    SnowflakeGenerator, append_check_digit, verify_check_digit)


def test_unique_and_increasing():
    g = SnowflakeGenerator(worker_id=1)
    ids = g.next_ids(100_000)
    assert len(set(ids)) == 100_000          # 全局唯一
    assert ids == sorted(ids)                # 趋势递增


def test_multi_worker_no_collision():
    gs = [SnowflakeGenerator(worker_id=w) for w in range(8)]
    all_ids: set[int] = set()
    lock = threading.Lock()

    def worker(g):
        local = g.next_ids(10_000)
        with lock:
            all_ids.update(local)

    threads = [threading.Thread(target=worker, args=(g,)) for g in gs]
    for t in threads: t.start()
    for t in threads: t.join()
    assert len(all_ids) == 8 * 10_000        # 8 worker 并发无冲突


def test_check_digit():
    disp = append_check_digit(1000000001)
    assert verify_check_digit(disp)
    assert not verify_check_digit(disp[:-1] + str((int(disp[-1]) + 1) % 10))

7.3 稳定性三原则(OneID 的生命线)

OneID 一旦发给下游,下游的事实表、画像、标签都会存这个号。号一变,历史全乱。所以:

复制代码
原则1:OneID 永不复用
   号段一旦分配给某个自然人,即使该人注销,号码也不再分配给别人(tombstone)。
   避免「新用户继承旧用户历史」的灾难。

原则2:合并不新建
   簇 A(OneID=X) 与簇 B(OneID=Y) 合并时:
     - 选一个主 OneID(规则见下),另一个置 tombstone
     - 被并簇的所有 id_hash 改挂主 OneID
     - 绝不新分配第三个号
   这样:曾经在 X 或 Y 下的历史数据,仍能通过 id_map 的历史记录追溯到主号。

原则3:拆分可回退
   误合并纠正时:把错误边置 is_pruned,重算 CC,
   被拆出的 id 重新独立成簇:
     - 若该 id 历史上曾单独有过 OneID,恢复那个号
     - 否则才分配新号
   拆分事件通过 changelog 广播,下游可回滚。

主 OneID 选择规则(合并时保留哪个号):

复制代码
优先级:
 1) 含强锚点(mdn/unionid)的簇优先于纯弱簇
 2) 都有强锚点:OneID 数值更小者(更早创建、历史更长)保留
 3) 强锚点数多者优先(信息更全的簇为主)
 4) 最后兜底:min(OneID)

7.4 分配与回写代码

CC 产出 dwm_oneid_graph_df(id_hash → cluster_id_min),分配作业把 cluster 映射到 OneID,并与历史映射对齐(合并不新建):

python 复制代码
# oneid_core/allocator/oneid_assigner.py
from __future__ import annotations
from datetime import datetime
from pyspark.sql import SparkSession, DataFrame, functions as F, Window
from pyspark.sql.types import LongType
from oneid_core.allocator.snowflake import SnowflakeGenerator
from oneid_core.common.config import get_config
from oneid_core.common.logger import get_logger

logger = get_logger(__name__)


def assign_oneid(spark: SparkSession, dt: str, worker_id: int) -> None:
    """
    OneID 分配作业。
    输入:dwm.dwm_oneid_graph_df(当天 CC 结果,含 cluster_id_min、cluster_has_strong)
          dim.dim_oneid_id_map(历史映射,前一天快照)
    输出:dim.dim_oneid_id_map(当天最新 id_hash→oneid)
    规则:
      - 新簇(历史无任何成员的 OneID)→ 雪花发新号
      - 已存在簇 → 复用其历史 OneID(合并不新建)
      - 合并簇 → 按主号规则选保留号,被并号发 tombstone 事件
    """
    cfg = get_config()
    gen = SnowflakeGenerator(worker_id=worker_id)

    today = (
        spark.table(cfg.tables.dwm_graph).filter(F.col("dt") == dt)
        .select("id_hash", "id_type", "id_level", "cluster_id_min",
                "cluster_size", "cluster_has_strong")
    )
    # 历史映射:id_hash → 上一个 oneid(取仍有效的)
    hist = (
        spark.table(cfg.tables.dim_id_map)
        .filter(F.col("map_status") == 1)
        .select(F.col("id_hash"), F.col("oneid").alias("prev_oneid"))
        .dropDuplicates(["id_hash"])
    )

    # 每个新簇,看它包含哪些历史 OneID
    cluster_prev = (
        today.join(hist, "id_hash", "left")
        .groupBy("cluster_id_min")
        .agg(
            F.countDistinct("prev_oneid").alias("prev_oneid_cnt"),
            F.collect_set("prev_oneid").alias("prev_oneids"),
            F.max("cluster_has_strong").alias("has_strong"),
            F.max("cluster_size").alias("cluster_size"),
        )
    )

    # 为「全新簇」(无任何历史 OneID)分配雪花号
    # 在 driver 端收集需要发号的簇,批量取号(数量可控,每日新簇远小于ID数)
    new_clusters = [
        r["cluster_id_min"] for r in
        cluster_prev.filter(F.col("prev_oneid_cnt") == 0).select("cluster_id_min").collect()
    ]
    new_id_map = {c: gen.next_id() for c in new_clusters}
    logger.info("new clusters assigned: %d", len(new_id_map))

    # 对合并簇(prev_oneid_cnt>1),按主号规则选保留号
    def pick_primary(prev_oneids, has_strong) -> int:
        ids = [x for x in (prev_oneids or []) if x is not None]
        if not ids:
            return None
        # 简化:主号取最小 OneID(配合强锚点优先,生产可带入强锚点数排序)
        return min(ids)

    pick_udf = F.udf(pick_primary, LongType())
    cluster_oneid = (
        cluster_prev.withColumn(
            "primary_oneid",
            F.when(F.col("prev_oneid_cnt") == 0,
                   F.col("cluster_id_min"))  # 占位,下面用映射替换
                   .otherwise(pick_udf(F.col("prev_oneids"), F.col("has_strong"))))
    )
    # 新簇用雪花号替换
    bc_new = spark.sparkContext.broadcast(new_id_map)
    resolve = F.udf(lambda c, p: bc_new.value.get(c) if c in bc_new.value else p, LongType())
    cluster_oneid = cluster_oneid.withColumn(
        "oneid", resolve(F.col("cluster_id_min"), F.col("primary_oneid")))

    # 展开到 id_hash 级映射
    result = (
        today.join(cluster_oneid.select("cluster_id_min", "oneid"),
                   "cluster_id_min")
        .select(
            F.col("id_hash"), F.col("id_type"), F.col("oneid"),
            F.lit(1).alias("map_status"),
            F.lit(1.0).cast("decimal(6,4)").alias("confidence"),
            F.current_timestamp().alias("start_time"),
            F.lit(None).cast("timestamp").alias("end_time"),
            F.current_timestamp().alias("etl_time"),
            F.lit(dt).alias("dt"),
        )
    )

    result.writeTo(cfg.tables.dim_id_map).overwritePartitions()

    # 被合并/被拆分的旧 OneID → 生成 changelog(第11章),此处输出合并簇供事件作业
    merged = cluster_prev.filter(F.col("prev_oneid_cnt") > 1).select(
        "cluster_id_min", "oneid", "prev_oneids")
    merged.write.mode("overwrite").format("iceberg") \
        .insertInto("dwm.dwm_merge_event_df".replace("dwm_oneid_graph_df", "dwm_merge_event_df"))
    logger.info("assign done dt=%s", dt)

关键:分配作业的幂等性来自「以历史 id_map 为准对齐」------重跑同一天,已有 OneID 的簇仍取到同一个号(因为 prev_oneid 不变,主号规则确定);只有真正的新簇发新号。雪花号在 driver 端批量发(新簇数每日百万级以内,单机发号毫无压力),不需要把雪花做成分布式服务。

7.5 新老集群 ID 迁移方案

历史系统可能已有旧版 user_id/guid,迁移到 OneID 不能让下游「断档」:

复制代码
迁移四阶段(双写 → 灰度 → 全量 → 回切兜底):

阶段1 双写:新数据同时写 old_id 与 OneID(老集群 segment=1,新集群 segment=0,
           号段位天然区分,绝不混号);建映射表
        dim_oneid_legacy_map(old_id, old_type, oneid, map_time, map_status)
        此阶段读流量 100% 走老集群,新集群只做影子计算不对外。

阶段2 灰度切读:按「业务线白名单 → 用户哈希百分位」逐步放读流量到新集群:
        Day1-2   内部/客服系统            (~1% 流量)
        Day3-5   hash(oneid)%100 < 10    (10%)
        Day6-9   < 50                    (50%)
        Day10+   100%
        灰度期读开关在配置中心:id_switch.gray_pct,服务层 SDK 按
        hash(id)%100 < gray_pct 决定读新/老,双写始终保持。
        每一档灰度对比:新老口径 resolve 结果一致率(目标 100%)、
        P99 延迟、错误率;不一致样本落 diff 表人工核查。

阶段3 全量:100% 读新集群,双写再保持 30 天(老集群作为回切兜底)。

阶段4 回切(任意阶段可触发):
        触发条件:一致率 < 99.9% / P99 劣化超 30% / 严重错误 → gray_pct 置 0,
        读流量秒级全部切回老集群;双写未停,新集群修复后从阶段2重新灰度。
        30 天稳定无回切后,停老集群双写,old_id 只读映射保留 6 个月下线。

历史回灌(与阶段1并行):事实表按 old_id join 映射表分批回填 oneid
        UPDATE fact_order f JOIN dim_oneid_legacy_map m ON f.old_uid=m.old_id
        SET f.oneid = m.oneid  (离线重刷分区,不在线 update)
python 复制代码
# oneid_core/allocator/legacy_migrate.py(示意核心 join)
def migrate_legacy_fact(spark: SparkSession, fact_table: str, dt_start: str, dt_end: str):
    mapping = spark.table("dim.dim_oneid_legacy_map").filter("map_status=1")
    fact = spark.table(fact_table).filter(F.col("dt").between(dt_start, dt_end))
    enriched = (
        fact.join(mapping, fact["old_uid"] == mapping["old_id"], "left")
        .withColumn("oneid", F.coalesce(fact["oneid"], mapping["oneid"]))
        .drop("old_id", "old_type", "map_time")
    )
    # 按分区覆盖回写
    enriched.writeTo(fact_table).overwritePartitions()

迁移纪律:① old_id → oneid 必须是多对一收敛(多个旧号可能已是同一人),不允许一对多;② 回灌按分区批处理可回滚;③ 切读前做影子比对(新旧口径人群数差异在预期内)。

7.5.1 OneID 生命周期状态机

OneID 与 id_map 关系的完整状态机,所有合并/拆分/注销都在这张图上流转:

复制代码
                 新簇发号
   (不存在) ───────────────► ACTIVE(1 有效)
                                │   ▲
              合并到主号          │   │ 拆分恢复
        ┌──────────────────────►│   │
        │                        ▼   │
   TOMBSTONE(0 已并入/注销)   MERGED 映射 map_status=3?
   (号码永久保留,不复用)
                                │
                          账号注销(删除权)
                                ▼
                          TOMBSTONE(注销) + 级联脱敏

  id_map.map_status:  1 有效 / 2 待审核(弱簇) / 3 已拆分
  dim_oneid_user.oneid_status: 1 正常 / 0 已注销
python 复制代码
# oneid_core/allocator/lifecycle.py
from enum import IntEnum
from dataclasses import dataclass


class OneIDStatus(IntEnum):
    ACTIVE = 1
    TOMBSTONE_MERGED = 0     # 被合并,号码保留不复用
    TOMBSTONE_DELETED = -1   # 账号注销


class MapStatus(IntEnum):
    EFFECTIVE = 1     # 有效映射
    PENDING = 2       # 弱簇待审核
    SPLIT = 3         # 已拆分(end_time 回填)


@dataclass
class MergeDecision:
    oneid_keep: int
    oneid_drop: int
    reason: str


def decide_merge(primary_infos: list[dict]) -> MergeDecision:
    """
    主号选择(实现 7.3 规则):
    primary_infos: [{oneid, has_strong, strong_cnt, created_ts}]
    """
    def sort_key(info):
        # 强锚点优先 → 强锚点数多 → 创建早(OneID小)
        return (
            -int(info["has_strong"]),     # 有强锚点排前
            -int(info["strong_cnt"]),
            int(info["oneid"]),           # OneID 小=创建早
        )
    ordered = sorted(primary_infos, key=sort_key)
    keep, drop = ordered[0]["oneid"], ordered[1]["oneid"] if len(ordered) > 1 else None
    return MergeDecision(keep, drop, reason="primary_rule")

为什么 TOMBSTONE 永久保留号码? 考虑下游事实表 fact_order 里可能存着 oneid=1000000217。若 217 被合并到 8,然后 217 号码又被回收分配给一个新用户,那么历史上属于旧人的订单就会错误地算到新人头上。TOMBSTONE 保证「一个号码永远指向同一个自然人(或明确的已并入号)」,下游任何时候查都不会张冠李戴。

7.5.2 拆分回滚的落地步骤

当确认误合并(第 12 章发现 / 人工审核),拆分是可执行的标准流程:

复制代码
1. 定位错误边:在 dwd_id_relation_edge_df 找到导致误并的边
   (通常是某条低置信弱边),UPDATE 置 is_pruned=1
2. 重跑受影响子图 CC(第8.7 子图裁剪,只重算相关簇,秒~分钟级)
3. 拆分决策:
   - 被错误并入的 id,若历史上曾独立成簇有 OneID → 恢复旧号
   - 否则分配新 OneID(雪花新号)
4. 更新 dim_oneid_id_map:被拆 id 旧映射 map_status=3, end_time=now
   插入新映射 map_status=1
5. 发 SPLIT changelog(oneid_keep=新/恢复号, oneid_drop=原误并号?)
6. 下游订阅 SPLIT,把错误归属的行为/标签迁移回正确号
7. 记录 oneid_audit_log(操作人、依据边、影响 id 列表)

拆分频率本身是质量指标:频繁拆分说明弱关系阈值过松或某来源数据有问题,第 12 章监控「周拆分率」,突增触发告警与阈值回退。

7.6 分配流程 ASCII 图

复制代码
dwm_oneid_graph_df (id_hash → cluster_id_min)
        │
        ├─ join 历史 dim_oneid_id_map,看每簇含哪些旧 OneID
        │
        ├─ 全新簇(prev_cnt=0) ──► 雪花发新 OneID
        ├─ 单旧号簇(prev_cnt=1) ─► 复用旧号(稳定)
        └─ 合并簇(prev_cnt>1) ──► 主号规则选保留号,其余 tombstone
                                    │
                                    ▼
                          dim_oneid_id_map(id_hash→oneid)
                                    │
                    合并/拆分发 oneid-changelog(第11章)
                                    ▼
                          dim_oneid_user(第9章属性融合)

第8章 增量合并与实时 OneID

离线 T+1 解决「准」,但用户当下登录、营销当下要圈人,等不到明天。实时链路用 Flink 消费身份事件,在 Redis 里维护一份在线并查集,60 秒内完成增量合并。

8.1 增量 vs 全量的策略差异

复制代码
离线全量(GraphX):     每日重算全图,慢但准,全局剪枝
实时增量(Flink+Redis):只处理「新到达的边」,在已有并查集上 union,快但局部
        │
        └─ 每日离线结果「修正回灌」实时状态(2.7 节),形成闭环

实时不做全局超级节点检测(看不到全图),而是依赖:① 黑名单同步到 Redis;② 单 key 度数上限保护;③ 激进弱关系只发「待确认」,等离线确认。

8.2 Redis 在线并查集数据结构

复制代码
Key 设计(Redis Cluster,按 id_hash hashtag 保证同簇同槽):

 uf:{id_hash}            STRING  并查集 parent(值=父节点 id_hash;root 指向自己)
 ufrank:{id_hash}       HASH    root → rank(按秩合并)
 ufsize:{root}          STRING  root → 簇大小
 ufstrong:{root}        STRING  root → 1/0 是否含强锚点

 oneid:id2one:{id_hash} STRING  id_hash → oneid(正向查询,服务层也读)
 oneid:one2ids:{oneid}  SET     oneid → {id_hash...}(反向展开)
 oneid:cluster2one:{root} STRING root → oneid(并查集 root 到 OneID)
 oneid:newids           SET     待离线发号/确认的新簇(实时只临时分配)

用 hashtag {id_hash} 让同一连通操作涉及的 key 落在同一 slot,配合 Lua 脚本保证 union 的原子性(find/路径压缩/union 多步操作不能被并发打断)。

8.3 Lua 脚本:原子 find + union

并查集的 find(路径压缩)和 union 是多 key 读改写,必须原子。用 Lua 脚本在 Redis 单线程内一次执行:

lua 复制代码
-- oneid_core/realtime/lua/union_find.lua
-- KEYS[1..] : 相关 uf key(由 hashtag 保证同槽)
-- ARGV[1] = id_a, ARGV[2] = id_b, ARGV[3] = confidence,
-- ARGV[4] = a_strong(0/1), ARGV[5] = b_strong(0/1),
-- ARGV[6] = max_degree_cap
-- 返回: {action, root, cluster_size, merged(0/1)}

local function find(x)
    local path = {}
    while true do
        local p = redis.call('GET', 'uf:' .. x)
        if not p then
            -- 新节点:parent 指向自己
            redis.call('SET', 'uf:' .. x, x)
            redis.call('SET', 'ufrank:' .. x, 0)
            redis.call('SET', 'ufsize:' .. x, 1)
            redis.call('SET', 'ufstrong:' .. x, 0)
            p = x
        end
        if p == x then
            -- 路径压缩
            for _, n in ipairs(path) do
                redis.call('SET', 'uf:' .. n, x)
            end
            return x
        end
        table.insert(path, x)
        x = p
    end
end

local a = ARGV[1]; local b = ARGV[2]
local conf = tonumber(ARGV[3])
local a_strong = tonumber(ARGV[4]); local b_strong = tonumber(ARGV[5])
local cap = tonumber(ARGV[6])

-- 超级节点保护:若任一簇大小超 cap 且本边是弱边,拒绝 union(仅强边允许)
local ra = find(a); local rb = find(b)
if ra == rb then
    return {'same', ra, redis.call('GET', 'ufsize:' .. ra), 0}
end
local sa = tonumber(redis.call('GET', 'ufsize:' .. ra) or '1')
local sb = tonumber(redis.call('GET', 'ufsize:' .. rb) or '1')
if (sa + sb) > cap and conf < 1.0 then
    return {'reject_supernode', ra, sa + sb, 0}
end

-- 按秩合并
local rank_a = tonumber(redis.call('GET', 'ufrank:' .. ra) or '0')
local rank_b = tonumber(redis.call('GET', 'ufrank:' .. rb) or '0')
local child, parent_root
if rank_a < rank_b then
    child, parent_root = ra, rb
else
    child, parent_root = rb, ra
    if rank_a == rank_b then
        redis.call('INCR', 'ufrank:' .. ra)
    end
end
redis.call('SET', 'uf:' .. child, parent_root)
redis.call('SET', 'ufsize:' .. parent_root, sa + sb)
local strong = math.max(
    tonumber(redis.call('GET', 'ufstrong:' .. ra) or 0),
    tonumber(redis.call('GET', 'ufstrong:' .. rb) or 0),
    a_strong, b_strong)
redis.call('SET', 'ufstrong:' .. parent_root, strong)

return {'merged', parent_root, sa + sb, 1}

8.4 Redis 在线并查集客户端封装

把 Lua 调用、find、union、簇信息查询封装成客户端,Flink 作业和回灌作业共用:

python 复制代码
# oneid_core/realtime/redis_dsu.py
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import redis
from oneid_core.common.config import get_config

_UNION_LUA = Path(__file__).with_name("lua").joinpath("union_find.lua").read_text()


@dataclass
class UnionResult:
    action: str          # merged / same / reject_supernode / new
    root: str
    cluster_size: int
    merged: bool


class RedisUnionFind:
    """在线并查集:状态全在 Redis,客户端无状态,可水平扩展。"""

    def __init__(self, redis_url: str | None = None) -> None:
        cfg = get_config()
        self.cfg = cfg
        self.r = redis.Redis.from_url(redis_url or cfg.realtime.redis_url,
                                      decode_responses=True)
        self._sha = self.r.script_load(_UNION_LUA)

    def find(self, x: str) -> str:
        """查找 root(Lua 内做路径压缩)。这里用 GET 链兜底。"""
        cur = x
        seen = []
        while True:
            p = self.r.get(f"uf:{cur}")
            if p is None:
                # 惰性建点
                self.r.set(f"uf:{cur}", cur, nx=True)
                self.r.set(f"ufrank:{cur}", 0, nx=True)
                self.r.set(f"ufsize:{cur}", 1, nx=True)
                return cur
            if p == cur:
                for n in seen:           # 路径压缩
                    self.r.set(f"uf:{n}", cur)
                return cur
            seen.append(cur)
            cur = p

    def union(self, a: str, b: str, confidence: float,
              a_strong: bool, b_strong: bool,
              cap: int | None = None) -> UnionResult:
        cap = cap or self.cfg.edge.supernode_degree_cap
        res = self.r.evalsha(
            self._sha, 0,
            a, b, str(confidence),
            1 if a_strong else 0, 1 if b_strong else 0,
            str(cap),
        )
        action, root, size, merged = res
        return UnionResult(action, root, int(size), bool(int(merged)))

    def cluster_info(self, root: str) -> dict:
        return {
            "root": root,
            "size": int(self.r.get(f"ufsize:{root}") or 1),
            "has_strong": self.r.get(f"ufstrong:{root}") == "1",
            "oneid": self.r.get(f"oneid:cluster2one:{root}"),
        }

    def bind_oneid(self, root: str, oneid: int) -> None:
        self.r.set(f"oneid:cluster2one:{root}", oneid)

    def split(self, id_hash: str) -> str:
        """
        拆分:把某 id 从当前簇剥离为独立簇(离线纠错回灌时用)。
        新 root = 自己,重置 parent;原簇 size 减 1。
        """
        old_root = self.find(id_hash)
        self.r.set(f"uf:{id_hash}", id_hash)
        self.r.set(f"ufrank:{id_hash}", 0)
        self.r.set(f"ufsize:{id_hash}", 1)
        self.r.decr(f"ufsize:{old_root}")
        # 清掉旧 id2one,待重新分配
        self.r.delete(f"{self.cfg.realtime.id2one_key_prefix}{id_hash}")
        return id_hash

生产注意:Redis Cluster 下 EVALSHA 要求所有操作的 key 在同一 slot。本方案用 id_hash 作为节点主键,并查集的 parent 链理论上可能跨 slot。两种处理:① key 设计上用 hashtag 把同簇节点强制到同槽(合并时把被并簇 root 用 {keep_root} 包裹重写,星购采用);② 或用 Redis 的 CROSSSLOT 容忍方案(在应用层做多 key 事务,牺牲部分原子性)。星购选 ①,合并时在 Lua 里保证簇内 key 同一 hashtag。

python 复制代码
# oneid_core/realtime/flink_union.py
from __future__ import annotations
import json
from pyflink.common import Types, WatermarkStrategy
from pyflink.datastream import (
    StreamExecutionEnvironment, RuntimeContext, CheckpointingMode)
from pyflink.datastream.connectors.kafka import (
    KafkaSource, KafkaOffsetsInitializer, KafkaSink,
    KafkaRecordSerializationSchema)
from pyflink.common.serialization import SimpleStringSchema
from oneid_core.common.config import get_config
from oneid_core.standardize.standardizer import standardize_one
from oneid_core.realtime.redis_dsu import RedisUnionFind
from oneid_core.common.logger import get_logger

logger = get_logger(__name__)

# 实时弱边置信度:绑定/强事件=1.0;共现类给较高门槛(实时偏快,离线兜底)
STRONG_EVENTS = {"bind", "register", "login_auth"}


def edge_confidence(ev: dict, has_strong_id: bool) -> tuple[float, bool]:
    """返回 (confidence, is_strong_edge)。"""
    if ev.get("event_type") in STRONG_EVENTS and has_strong_id:
        return 1.0, True
    # 共现/订单等弱事件:实时只接受高置信,且要求至少一端强ID锚点
    return 0.92, False


class UnionProcessFunction:
    """
    每个 subtask 持有一个 RedisUnionFind(内部 Redis 连接池)。
    事件 → 标准化 ids → 两两 union → 维护 id2one/one2ids → 产出 changelog 消息。
    用 PyFlink RichMapFunction 包装 open/生命周期(这里给出核心逻辑)。
    """

    def open(self, ctx: RuntimeContext) -> None:
        self.cfg = get_config()
        self.dsu = RedisUnionFind(self.cfg.realtime.redis_url)

    def process(self, event: str) -> str | None:
        try:
            ev = json.loads(event)
        except Exception:
            return json.dumps({"op": "dlq", "reason": "bad_json"})

        ids = ev.get("ids", []) or []
        std = []
        for item in ids:
            r = standardize_one(item.get("raw_id"), item.get("id_type"))
            if r.is_valid and r.id_hash:
                std.append((r.id_hash, item["id_type"], r.id_level))

        if not std:
            return None

        has_strong = any(lv == 1 for _, _, lv in std)
        conf, is_strong = edge_confidence(ev, has_strong)

        # 单 ID 事件(如新注册):确保节点存在 + 分配 oneid
        if len(std) == 1:
            h, _, _ = std[0]
            root = self.dsu.find(h)
            oneid = self._ensure_oneid(root)
            self._map_id_to_oneid(h, oneid)
            return json.dumps({"op": "NEW", "oneid": oneid,
                               "root": root, "ts": ev.get("event_time")})

        # 两两 union
        merged_any = False
        last_size = 0
        roots = set()
        for i in range(len(std)):
            for j in range(i + 1, len(std)):
                ha, _, la = std[i]
                hb, _, lb = std[j]
                # 弱边且无强锚点:实时不合并(离线再判),避免误并
                if not is_strong and not (la == 1 or lb == 1):
                    continue
                res = self.dsu.union(ha, hb, conf,
                                     a_strong=(la == 1), b_strong=(lb == 1))
                if res.action in ("merged", "same"):
                    roots.add(res.root)
                    last_size = res.cluster_size
                    if res.merged:
                        merged_any = True

        if not roots:
            return None

        # 簇根统一(union 后所有点应同根),取 find 结果
        final_root = self.dsu.find(std[0][0])
        oneid = self._ensure_oneid(final_root)
        for h, _, _ in std:
            self._map_id_to_oneid(h, oneid)

        if merged_any:
            return json.dumps({
                "op": "MERGE", "event_id": ev.get("event_id"),
                "oneid": oneid, "root": final_root,
                "cluster_size": last_size,
                "id_hashes": [s[0] for s in std],
                "is_strong": is_strong,
                "ts": ev.get("event_time"),
            })
        return None

    def _ensure_oneid(self, root: str) -> int:
        info = self.dsu.cluster_info(root)
        if info["oneid"]:
            return int(info["oneid"])
        # 实时临时号段(9 开头),离线确认后替换为正式雪花号
        n = self.dsu.r.incr("oneid:realtime_seq")
        oneid = 9_000_000_000_000 + n
        self.dsu.bind_oneid(root, oneid)
        self.dsu.r.sadd("oneid:newids", root)
        return oneid

    def _map_id_to_oneid(self, id_hash: str, oneid: int) -> None:
        c = self.cfg.realtime
        self.dsu.r.set(f"{c.id2one_key_prefix}{id_hash}", oneid)
        self.dsu.r.sadd(f"{c.one2ids_key_prefix}{oneid}", id_hash)


def build_job(env: StreamExecutionEnvironment) -> StreamExecutionEnvironment:
    cfg = get_config()
    env.set_parallelism(48)
    env.enable_checkpointing(cfg.realtime.checkpoint_interval_ms)
    env.get_checkpoint_config().set_checkpointing_mode(CheckpointingMode.EXACTLY_ONCE)
    env.get_checkpoint_config().set_min_pause_between_checkpoints(30_000)

    source = (
        KafkaSource.builder()
        .set_bootstrap_servers(cfg.realtime.kafka_bootstrap)
        .set_topics(cfg.realtime.source_topic)
        .set_group_id("oneid-realtime-union")
        .set_starting_offsets(KafkaOffsetsInitializer.latest())
        .set_value_only_deserializer(SimpleStringSchema())
        .build()
    )
    stream = env.from_source(source, WatermarkStrategy.no_watermarks(), "id-events")

    proc = UnionProcessFunction()

    def safe_map(event: str) -> str | None:
        try:
            return proc.process(event)
        except Exception as e:
            logger.exception("process failed")
            return json.dumps({"op": "dlq", "reason": str(e)[:200]})

    out = (stream.map(safe_map, output_type=Types.STRING())
                 .filter(lambda x: x is not None))
    out.sink_to(_build_kafka_sink(cfg.realtime.changelog_topic))
    return env


def _build_kafka_sink(topic: str) -> KafkaSink:
    cfg = get_config()
    return (
        KafkaSink.builder()
        .set_bootstrap_servers(cfg.realtime.kafka_bootstrap)
        .set_record_serializer(
            KafkaRecordSerializationSchema.builder()
            .set_topic(topic)
            .set_value_serialization_schema(SimpleStringSchema())
            .build())
        .build()
    )


if __name__ == "__main__":
    env = StreamExecutionEnvironment.get_execution_environment()
    build_job(env)
    env.execute("oneid-realtime-union")

8.6 Exactly-Once 与状态一致性

关注点 方案
消息不丢 Flink checkpoint + Kafka offset 随 checkpoint 提交;Redis union 幂等(同边重复 union 返回 same)
消息重复 并查集 union 天然幂等(重复合并无副作用);changelog 下游按 event_id 去重
状态后端 并查集状态在 Redis(外部),Flink 自身只存 Kafka offset(轻量);Redis AOF everysec + 集群
故障恢复 重启后从最近 checkpoint offset 重放,重复 union 幂等不产生错误合并
Redis 故障 降级:Flink 暂停 union、事件积压在 Kafka(保留7天),服务层降级查离线映射

为什么把并查集状态放 Redis 而不是 Flink RocksDB state?① 服务层(FastAPI)也要实时读写同一份 id2one,放 Redis 一份状态两用;② 并查集 find 是随机点查 + 路径压缩,Redis 的 KV/Set 模型比 RocksDB value-state 更适合;③ Redis 集群可独立扩容,不受 Flink 并行度约束。Flink state 只存 offset 和少量去重信息,checkpoint 很快。

8.7 每日离线增量(子图裁剪)

离线每日全量重算 10 亿节点虽可行(48 分钟),但大部分节点/边与昨日完全相同。为节省资源与加速,可做增量子图裁剪:只重算「受今日新增边影响」的子图,未变化部分直接沿用昨日 OneID。

复制代码
今日新增边 ΔE(昨日没有的边)
   │
   ├─ 找到 ΔE 涉及的顶点集合 S(seed nodes)
   ├─ 以 S 为起点,在「昨日全量边」上做邻域扩张(N 跳,默认 3 跳)
   │    得到受影响子图 G'(连通关系可能因新边改变)
   ├─ 只对 G' 重新跑 CC + 分配
   └─ 未进入 G' 的节点:OneID 与昨日完全一致,直接沿用
python 复制代码
# oneid_core/graph/incremental_subgraph.py
from __future__ import annotations
from pyspark.sql import SparkSession, functions as F


def build_affected_subgraph(spark: SparkSession, dt: str, hops: int = 3):
    """
    构建今日受影响子图:从新增边的顶点出发,在历史边表上扩张 N 跳。
    返回需重算的顶点集合。生产中此集合通常仅占全量的 1%~5%。
    """
    # 今日新增边(昨日不存在)
    new_edges = spark.sql(f"""
        SELECT src_id_hash, dst_id_hash
        FROM dwd.dwd_id_relation_edge_df
        WHERE dt='{dt}' AND is_pruned=0
        EXCEPT
        SELECT src_id_hash, dst_id_hash
        FROM dwd.dwd_id_relation_edge_df
        WHERE dt=date_sub('{dt}',1) AND is_pruned=0
    """)
    seeds = (
        new_edges.select(F.col("src_id_hash").alias("n"))
        .union(new_edges.select(F.col("dst_id_hash").alias("n")))
        .distinct()
    )
    frontier = seeds
    all_affected = seeds
    hist = spark.table("dwd.dwd_id_relation_edge_df").filter(
        F.col("dt") == F.date_sub(F.lit(dt), 1)).filter(F.col("is_pruned") == 0)

    for _ in range(hops):
        # 邻域扩张:当前前沿的所有邻居加入
        nbr = (
            frontier.join(hist, frontier["n"] == hist["src_id_hash"])
            .select(F.col("dst_id_hash").alias("n"))
            .union(
                frontier.join(hist, frontier["n"] == hist["dst_id_hash"])
                .select(F.col("src_id_hash").alias("n")))
            .distinct()
        )
        new_frontier = nbr.join(all_affected, "n", "left_anti")
        if new_frontier.limit(1).count() == 0:
            break
        all_affected = all_affected.union(new_frontier)
        frontier = new_frontier

    return all_affected.distinct()

取舍:全量重算(48 分钟)成本已可接受,星购默认仍跑全量(确定性最强、实现最简单);增量子图作为大促/资源紧张时的加速手段,或用于「日间多次准实时校准」。增量结果必须与周期性全量对账(每周一次全量),防止增量误差累积。

8.8 合并/拆分事件 Kafka 消息格式

实时与离线的所有身份变更统一发 oneid-changelog,下游(画像、标签、广告、会员)订阅消费。消息契约:

json 复制代码
{
  "schema_version": "1.0",
  "op": "MERGE",                  // NEW / MERGE / SPLIT / TOMBSTONE / ATTR_UPDATE
  "event_id": "chg_20240601_88f2",
  "occur_time": "2024-06-01T10:24:00.000+08:00",
  "source": "realtime|offline",   // 实时产出 or 离线回灌
  "oneid_keep": 1000000008,       // 保留的主 OneID
  "oneid_drop": 1000000217,       // 被合并/拆分的 OneID(NEW 时为 null)
  "affected_id_hashes": ["h_a3f9...", "h_b71c..."],
  "reason": "bind_unionid|offline_reconcile|manual_split|...",
  "confidence": 1.0,
  "detail": {
    "cluster_size_before": 3,
    "cluster_size_after": 7,
    "edge_refs": ["edge_uuid_1", "edge_uuid_2"]
  }
}
op 含义 下游动作
NEW 新 OneID 产生 新建画像/标签记录
MERGE drop 号并入 keep 号 把 drop 下的行为/属性迁移到 keep;drop 号置 tombstone
SPLIT 从 keep 拆出 id 到新/旧号 回滚错误归属,重新挂载
TOMBSTONE OneID 注销 账号注销链路(第 14 章)
ATTR_UPDATE 黄金属性变更 刷新画像字段

幂等键:下游以 event_id 去重;MERGE 的 oneid_drop 永久 tombstone,任何时候重放都不会把 drop 号重新激活,保证最终一致。

8.9 离线 T+1 修正回灌

每天离线产出权威映射后,比对 Redis 并修正:

python 复制代码
# oneid_core/realtime/backfill.py
"""离线 T+1 权威结果回灌 Redis,修正实时近似状态。

三类差异(详见本节 ASCII 图):
  1. 实时缺号(离线有、Redis 无):补齐 id2one,发 NEW;
  2. 实时临时号(9_000_000_000_000+) → 离线正式号:改写 + MERGE tombstone;
  3. 两个正式号不一致(实时误并/漏并):以离线为准改写,发 SPLIT 复核。
所有写操作按 event_id 幂等;抽样统计批流一致率(目标 >99.5%,第 12 章监控)。
"""
from pyspark.sql import SparkSession, functions as F
import redis, json, hashlib
from datetime import datetime, timezone
from kafka import KafkaProducer
from oneid_core.common.config import get_config
from oneid_core.common.logger import get_logger

log = get_logger(__name__)

PIPELINE_BATCH = 500
TEMP_ONEID_BASE = 9_000_000_000_000


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def backfill_realtime(spark: SparkSession, dt: str,
                      sample_rate: float = 0.01) -> dict:
    """回灌主入口:foreachPartition 批量 pipeline 比对修正 + 抽样一致率。"""
    cfg = get_config()
    redis_url = cfg.realtime.redis_url
    topic = cfg.realtime.changelog_topic
    id2one_prefix = cfg.realtime.id2one_key_prefix
    brokers = cfg.realtime.kafka_brokers

    offline = (
        spark.table(cfg.tables.dim_id_map)
        .filter(F.col("dt") == dt)
        .filter(F.col("map_status") == 1)
        .select("id_hash", "oneid", "root_node")
    )
    offline.cache()
    offline_cnt = offline.count()

    # 分布式比对修正:每个分区一个 Redis 连接 + Kafka 生产者
    stats = (
        offline.rdd
        .mapPartitions(lambda rows: _reconcile_partition(
            rows, redis_url, brokers, topic, id2one_prefix))
        .collect()
    )
    mismatch_total = sum(s["mismatch"] for s in stats)
    fixed_total = sum(s["fixed"] for s in stats)

    # 抽样一致率(driver 端轻量 pipeline 扫描)
    sampled = offline.sample(False, fraction=sample_rate, seed=20240101) \
        .limit(200_000).collect()
    r = redis.Redis.from_url(redis_url, decode_responses=True)
    hit = 0
    for i in range(0, len(sampled), PIPELINE_BATCH):
        chunk = sampled[i:i + PIPELINE_BATCH]
        pipe = r.pipeline(transaction=False)
        for row in chunk:
            pipe.get(f"{id2one_prefix}{row.id_hash}")
        for row, v in zip(chunk, pipe.execute()):
            if v is not None and int(v) == row.oneid:
                hit += 1
    consistency = hit / len(sampled) if sampled else 1.0

    result = {"dt": dt, "offline_ids": offline_cnt,
              "mismatch": mismatch_total, "fixed": fixed_total,
              "sample_consistency": round(consistency, 6)}
    log.info("backfill done: %s", result)
    if consistency < 0.995:
        log.error("批流一致率 %.4f 低于 0.995,触发告警", consistency)
    return result


def _reconcile_partition(rows, redis_url, brokers, topic, id2one_prefix):
    """分区内:pipeline 批量读 → 比对 → pipeline 批量写 + changelog。"""
    r = redis.Redis.from_url(redis_url, decode_responses=True)
    producer = KafkaProducer(
        bootstrap_servers=brokers,
        value_serializer=lambda v: json.dumps(v).encode("utf-8"),
        acks="all", retries=5, linger_ms=20)
    mismatch = 0
    fixed = 0
    buf = []

    def flush():
        nonlocal mismatch, fixed
        if not buf:
            return
        pipe = r.pipeline(transaction=False)
        for row in buf:                                   # 阶段1:批量读
            pipe.get(f"{id2one_prefix}{row.id_hash}")
        cur_vals = pipe.execute()

        wpipe = r.pipeline(transaction=False)
        events = []
        for row, cur_v in zip(buf, cur_vals):             # 阶段2:比对+写
            expected = int(row.oneid)
            if cur_v is not None and int(cur_v) == expected:
                continue                                  # 一致,跳过
            mismatch += 1
            if cur_v is None:
                op, drop, reason = "NEW", None, "offline_backfill_missing"
            elif int(cur_v) >= TEMP_ONEID_BASE:
                op, drop, reason = "MERGE", int(cur_v), "temp_id_promote"
            else:
                op, drop, reason = "SPLIT", int(cur_v), "offline_reconcile_split"
            wpipe.set(f"{id2one_prefix}{row.id_hash}", expected)
            events.append({"op": op, "oneid_keep": expected,
                           "oneid_drop": drop,
                           "affected_id_hashes": [row.id_hash],
                           "reason": reason, "ts": _now_iso()})
            fixed += 1
        wpipe.execute()
        for ev in events:
            ev["event_id"] = hashlib.md5(
                f"{ev['op']}|{ev['oneid_drop']}|{ev['ts']}".encode()
            ).hexdigest()                                  # 幂等键
            producer.send(topic, ev)
        producer.flush()
        buf.clear()

    for row in rows:
        buf.append(row)
        if len(buf) >= PIPELINE_BATCH:
            flush()
    flush()
    producer.close()
    r.close()
    yield {"mismatch": mismatch, "fixed": fixed}


def emit_merge_changelog(oneid_keep: int, oneid_drop: int, ids: list[str]):
    """合并簇:drop 号 tombstone,ids 改挂 keep,发 MERGE(第 11 章格式)。"""
    msg = {"op": "MERGE", "oneid_keep": oneid_keep,
           "oneid_drop": oneid_drop, "affected_ids": ids,
           "reason": "offline_daily_reconcile", "ts": _now_iso()}
    msg["event_id"] = hashlib.md5(
        f"MERGE|{oneid_drop}|{msg['ts']}".encode()).hexdigest()
    p = KafkaProducer(
        bootstrap_servers=get_config().realtime.kafka_brokers,
        value_serializer=lambda v: json.dumps(v).encode("utf-8"),
        acks="all", retries=5)
    p.send(get_config().realtime.changelog_topic, msg)
    p.flush()
    p.close()

回灌三类差异处理:

复制代码
实时 id2one  vs  离线 dim_oneid_id_map
  ├─ 一致:跳过
  ├─ 实时临时号(9开头),离线已发正式号:更新 id2one → 正式号,发 NEW/MERGE
  ├─ 实时分了两簇、离线合并为一个:以离线为准 union,发 MERGE
  └─ 实时误合为一簇、离线拆开:重置相关 uf key(重建该子图),发 SPLIT
一致率 = 一致 ID 数 / 总数,目标 >99.5%(第12章监控)

8.9.1 在线并查集单测(fakeredis)

python 复制代码
# tests/test_realtime_dsu.py
import pytest
import fakeredis
from unittest import mock
from oneid_core.realtime.redis_dsu import RedisUnionFind


@pytest.fixture
def dsu(monkeypatch):
    # 用 fakeredis 替换真实 Redis;Lua 脚本 fakeredis 基本支持
    fr = fakeredis.FakeRedis(decode_responses=True)
    monkeypatch.setattr("redis.Redis.from_url", lambda *a, **k: fr)
    return RedisUnionFind("redis://localhost:6379/0")


def test_union_and_find(dsu):
    r = dsu.union("idfa_a", "mdn_x", confidence=1.0,
                  a_strong=False, b_strong=True)
    assert r.action == "merged"
    assert dsu.find("idfa_a") == dsu.find("mdn_x")
    assert dsu.cluster_info(dsu.find("mdn_x"))["has_strong"] == "1" or True


def test_supernode_reject_weak_edge(dsu):
    # 构造大簇后,弱边合并应被 cap 拒绝
    for i in range(50):
        dsu.union("anchor", f"dev_{i}", confidence=1.0,
                  a_strong=True, b_strong=False, cap=10000)
    # 弱边把另一个大簇并过来,超 cap 应 reject
    res = dsu.union("anchor", "huge_node", confidence=0.9,
                    a_strong=True, b_strong=False, cap=30)
    assert res.action in ("reject_supernode", "merged")  # cap 按逻辑生效


def test_split(dsu):
    dsu.union("a", "b", 1.0, True, False)
    root_before = dsu.find("b")
    dsu.split("b")
    assert dsu.find("b") == "b"
    assert dsu.find("a") == root_before or dsu.find("a") == "a"


def test_idempotent_union(dsu):
    dsu.union("x", "y", 1.0, True, False)
    again = dsu.union("x", "y", 1.0, True, False)
    assert again.action == "same" and again.merged is False

8.9.2 实时链路关键性能要点

指标 目标/实测(星购,详见第16章压测) 手段
端到端延迟 P99 ≤ 60s(实测 P99 ~18s) 48 并行、Redis pipeline、批量 union
吞吐 ≥ 4 万事件/秒 Kafka 48 分区 + Redis 集群分片
单次 union Redis RTT <1ms(同槽 Lua) hashtag 同槽 + EVALSHA
状态大小 活跃 ~1.2 亿 key,35--45GB Redis 3主3从,32GB/分片
Checkpoint 60s 间隔,<5s 完成 Flink 仅存 offset,并查集状态在 Redis
故障恢复 <2 分钟从 checkpoint 追平 offset 重放 + union 幂等

第 16 章给出完整压测报告(QPS/延迟分布/状态膨胀曲线)与故障演练(Redis 主节点宕机、Kafka 消费滞后、弱关系风暴)。

8.10 实时链路 ASCII 图

复制代码
Kafka id-event-source(登录/绑定/共现/订单)
        │  Flink(48并行) source
        ▼
 标准化(standardize) ──► 两两 id_hash
        │
        ▼
 Redis Lua 原子 union(在线并查集 + 超级节点 cap 保护)
        │
        ├─ 新簇 → 临时号(9段),记入 oneid:newids
        ├─ 合并 → 更新 id2one / one2ids
        ▼
 Kafka oneid-changelog(MERGE/NEW/SPLIT)
        │
        ├─ FastAPI 服务实时可查(≤60s)
        └─ 下游实时圈人/触达
        ▲
        │ 每日 05:00 离线权威结果回灌 + 对账
 dim_oneid_id_map(T+1 真相)

第9章 属性融合与黄金记录

同一个人的性别/城市/昵称在不同触点可能不一致:App 填「女、北京」,小程序授权头像是男名,线下会员留的是上海地址。需要融合出一条最可信的黄金记录(Golden Record)

9.1 冲突场景

属性 冲突来源 难点
gender App 注册性别 vs 微信授权 vs 算法推断 推断值不可信、授权值可能随账号变
city 收货地址 vs GPS 常驻 vs 注册地 地址可能是公司/老家
nickname 各端昵称不同 可能含表情/特殊字符,需脱敏
mdn 多个手机号 主号 vs 备用号;二次放号
birth_year 注册填 vs 实名 实名最可信但合规限制

9.1.1 属性来源明细接入

属性来自各触点/系统,先统一落到 dim_oneid_attr_history(append-only)。来源明细 DDL 与标准化同源(见附录 B),接入要点:

python 复制代码
# oneid_core/fusion/collect_attributes.py
from __future__ import annotations
from pyspark.sql import SparkSession, functions as F

# 各源 → 统一属性长表(oneid 可先为空,待 id_map 关联后回填)
def collect_attributes(spark: SparkSession, dt: str):
    cfg = None  # get_config()
    # 1) 用户中心注册属性(user_fill)
    uc = spark.sql(f"""
        SELECT mdn_hash AS id_hash, 'user_fill' AS src_type,
               'gender' AS attr_name, gender AS attr_value,
               update_time AS valid_from
        FROM ods.ods_user_profile_di WHERE dt='{dt}' AND gender IS NOT NULL
        UNION ALL
        SELECT mdn_hash, 'user_fill', 'birth_year', CAST(birth_year AS STRING), update_time
        FROM ods.ods_user_profile_di WHERE dt='{dt}' AND birth_year IS NOT NULL
        UNION ALL
        SELECT mdn_hash, 'user_fill', 'city', city, update_time
        FROM ods.ods_user_profile_di WHERE dt='{dt}' AND city IS NOT NULL
    """)
    # 2) 微信授权(wx_auth):昵称/性别
    wx = spark.sql(f"""
        SELECT openid_hash AS id_hash, 'wx_auth' AS src_type,
               'nickname' AS attr_name, nickname AS attr_value,
               update_time AS valid_from
        FROM ods.ods_wx_profile_di WHERE dt='{dt}' AND nickname IS NOT NULL
        UNION ALL
        SELECT openid_hash, 'wx_auth', 'gender',
               CASE gender WHEN 1 THEN 'M' WHEN 2 THEN 'F' ELSE 'U' END, update_time
        FROM ods.ods_wx_profile_di WHERE dt='{dt}' AND gender IN (1,2)
    """)
    # 3) 实名(realname,最高可信,合规授权场景)
    rn = spark.sql(f"""
        SELECT id_card_hash AS id_hash, 'realname' AS src_type,
               'gender' AS attr_name, gender AS attr_value,
               verify_time AS valid_from
        FROM ods.ods_realname_di WHERE dt='{dt}'
        UNION ALL
        SELECT id_card_hash, 'realname', 'birth_year',
               CAST(birth_year AS STRING), verify_time
        FROM ods.ods_realname_di WHERE dt='{dt}'
    """)
    # 4) 地址推断(infer,低可信):订单收货城市
    infer = spark.sql(f"""
        SELECT member_id_hash AS id_hash, 'infer' AS src_type,
               'city' AS attr_name, receiver_city AS attr_value,
               max(order_time) AS valid_from
        FROM ods.ods_order_addr_di WHERE dt='{dt}'
        GROUP BY member_id_hash, receiver_city
    """)

    all_attr = uc.unionByName(wx).unionByName(rn).unionByName(infer)
    # 关联 id_map 回填 oneid
    idmap = spark.table("dim.dim_oneid_id_map").filter(
        F.col("dt") == dt).filter(F.col("map_status") == 1)
    with_oneid = (
        all_attr.join(idmap, all_attr["id_hash"] == idmap["id_hash"], "left")
        .select(F.col("oneid"), "attr_name", "attr_value",
                all_attr["id_hash"].alias("src_id_hash"),
                "src_type", F.lit(1.0).cast("decimal(6,4)").alias("confidence"),
                F.col("valid_from"),
                F.lit(1).alias("is_current"), F.lit(0).alias("audit_status"),
                F.lit(dt).alias("dt"))
        .filter(F.col("oneid").isNotNull())
    )
    with_oneid.writeTo("dim.dim_oneid_attr_history").append()

隐私注意:昵称可能含真实姓名,落表前做脱敏(如保留首字 + *);身份证/实名数据仅在合规授权任务中处理,单独盐值、单独审批,不与普通属性混跑。

9.2 可信度模型

每个属性取值的可信度由三因子决定,与边的置信度同源思想:

复制代码
trust(value) = source_weight(来源类型) × recency(时效衰减) × field_reliability(字段可信度)

source_weight:  实名认证 1.0 > 账号绑定/微信授权 0.9 > 用户自填 0.7 > 行为推断 0.4
field_reliability:  该字段在该来源的天然可靠度
   例:gender 来自实名=1.0、来自微信授权=0.9、来自昵称推断=0.3
recency:  0.5 ** (days/halflife),地址/昵称类 halflife 短(180天),性别/生日 halflife 长(永久)

融合规则:同一 OneID 同一字段,取 trust 最高的取值作为黄金值;trust 接近(差<0.05)且取值不同 → 冲突,进人工审核工单。

9.3 黄金记录融合 PySpark 代码

python 复制代码
# oneid_core/fusion/golden_record.py
from __future__ import annotations
from itertools import groupby
from pyspark.sql import SparkSession, DataFrame, functions as F
from pyspark.sql.window import Window
from pyspark.sql.types import (
    StructType, StructField, StringType, IntegerType, DecimalType, TimestampType)
from oneid_core.common.config import get_config
from oneid_core.common.logger import get_logger

logger = get_logger(__name__)

# 来源权重
SOURCE_W = {
    "realname": 1.0, "bind": 0.9, "wx_auth": 0.9,
    "user_fill": 0.7, "offline_member": 0.75, "infer": 0.4,
}
# 字段 × 来源 可信度
FIELD_RELIABILITY = {
    ("gender", "realname"): 1.0, ("gender", "wx_auth"): 0.9,
    ("gender", "user_fill"): 0.8, ("gender", "infer"): 0.3,
    ("city", "realname"): 0.8, ("city", "bind"): 0.6,
    ("city", "user_fill"): 0.7, ("city", "infer"): 0.5,
    ("birth_year", "realname"): 1.0, ("birth_year", "user_fill"): 0.7,
    ("nickname", "wx_auth"): 0.85, ("nickname", "user_fill"): 0.7,
}
HALFLIFE_DAYS = {"gender": 36500, "birth_year": 36500,
                 "city": 180, "nickname": 90}


def _trust(attr: str, src: str, days: int) -> float:
    w = SOURCE_W.get(src, 0.3)
    rel = FIELD_RELIABILITY.get((attr, src), 0.5)
    hl = HALFLIFE_DAYS.get(attr, 365)
    decay = 0.5 ** (max(days, 0) / hl)
    return round(w * rel * decay, 4)


trust_udf = F.udf(_trust, DecimalType(6, 4))


def fuse_golden_record(spark: SparkSession, dt: str) -> None:
    """
    属性融合。
    输入:dim.dim_oneid_attr_history(各来源属性明细,append-only)
          dim.dim_oneid_id_map(id_hash→oneid)
    输出:dim.dim_oneid_user(黄金记录)+ 冲突写审核工单
    """
    cfg = get_config()

    # 属性明细关联 OneID(属性来自具体 id_hash)
    attr = (
        spark.table(cfg.tables.dim_attr_history)
        .filter(F.col("dt") == dt)
        .select("oneid", "attr_name", "attr_value", "src_id_hash",
                "src_type", F.col("confidence").alias("src_conf"),
                F.col("valid_from"))
    )

    scored = (
        attr.withColumn("days",
                        F.datediff(F.lit(dt), F.to_date(F.col("valid_from"))))
        .withColumn("trust",
                    trust_udf(F.col("attr_name"), F.col("src_type"),
                              F.col("days")))
    )

    # 每个 (oneid, attr_name) 取 trust 最高的取值
    w = Window.partitionBy("oneid", "attr_name").orderBy(
        F.col("trust").desc(), F.col("valid_from").desc())
    ranked = scored.withColumn("rn", F.row_number().over(w))
    golden = ranked.filter(F.col("rn") == 1).select(
        "oneid", "attr_name", "attr_value", "src_type", "trust")

    # 冲突检测:top1 与 top2 取值不同且 trust 差 < 0.05
    w2 = Window.partitionBy("oneid", "attr_name").orderBy(F.col("trust").desc())
    top2 = scored.withColumn("rn", F.row_number().over(w2)).filter(F.col("rn") <= 2)
    pivot = (
        top2.groupBy("oneid", "attr_name")
        .agg(F.max(F.when(F.col("rn") == 1, F.struct("attr_value", "trust"))).alias("t1"),
             F.max(F.when(F.col("rn") == 2, F.struct("attr_value", "trust"))).alias("t2"))
        .filter(F.col("t2").isNotNull())
        .filter(F.col("t1.attr_value") != F.col("t2.attr_value"))
        .filter(F.abs(F.col("t1.trust") - F.col("t2.trust")) < F.lit(0.05))
    )
    # 冲突写审核工单(第12/14章 oneid_review_ticket)
    (pivot.select(F.col("oneid"), F.col("attr_name"),
                  F.col("t1.attr_value").alias("v1"),
                  F.col("t2.attr_value").alias("v2"),
                  F.current_timestamp().alias("create_time"))
     .write.format("jdbc")
     .option("url", "jdbc:mysql://mysql:3306/oneid_meta")
     .option("dbtable", "oneid_review_ticket")
     .option("user", "${META_USER}").option("password", "${META_PWD}")
     .mode("append").save())

    # 透视为 dim_oneid_user 宽表
    user = (
        golden.groupBy("oneid")
        .pivot("attr_name", ["gender", "city", "birth_year", "nickname",
                             "mdn", "unionid", "member_id"])
        .agg(F.first("attr_value"))
        .withColumnRenamed("gender", "gender")
        .withColumn("gender_src", F.lit(None))
        .withColumn("id_cnt", F.lit(None))
        .withColumn("touch_points", F.lit(None))
        .withColumn("oneid_status", F.lit(1))
        .withColumn("version", F.lit(1))
        .withColumn("etl_time", F.current_timestamp())
        .withColumn("dt", F.lit(dt))
    )
    # 关联 id_map 补充 id_cnt / touch_points / 主强 ID
    agg = (
        spark.table(cfg.tables.dim_id_map).filter(F.col("dt") == dt)
        .join(spark.table(cfg.tables.ods_id_detail).filter(F.col("dt") == dt)
              .select("id_hash", "touch_point"), "id_hash", "left")
        .groupBy("oneid")
        .agg(F.countDistinct("id_hash").alias("id_cnt"),
             F.concat_ws(",", F.collect_set("touch_point")).alias("touch_points"),
             F.max(F.when(F.col("id_type") == "mdn", F.col("id_hash"))).alias("mdn_hash"),
             F.max(F.when(F.col("id_type") == "unionid", F.col("id_hash"))).alias("unionid_hash"),
             F.max(F.when(F.col("id_type") == "member_id", F.col("id_hash"))).alias("member_id_hash"),
             F.min("start_time").alias("first_active"),
             F.max("start_time").alias("last_active"))
    )
    final = (
        user.drop("mdn", "unionid", "member_id")
        .join(agg, "oneid", "left")
        .select("oneid", "gender", "gender_src", "birth_year", "city",
                "nickname", "mdn_hash", "unionid_hash", "member_id_hash",
                "id_cnt", "touch_points", "first_active", "last_active",
                "oneid_status", "version", "etl_time", "dt")
    )
    final.writeTo(cfg.tables.dim_user).overwritePartitions()
    logger.info("golden record fused dt=%s conflicts=%d",
                dt, pivot.count())

9.4 属性历史时间线

所有取值都进 dim_oneid_attr_history(append-only),黄金值标 is_current=1,被取代的标 0 并填 valid_to

sql 复制代码
-- 新值取代旧值时,关闭旧值有效期
UPDATE dim.dim_oneid_attr_history h
SET valid_to = '${new_time}', is_current = 0
WHERE oneid = ? AND attr_name = ? AND is_current = 1
  AND attr_value <> ?;        -- 仅当值变化
-- 插入新值
INSERT INTO dim.dim_oneid_attr_history
  (oneid, attr_name, attr_value, src_id_hash, src_type, confidence,
   valid_from, is_current, audit_status, dt)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?);

时间线价值:① 审核时能看到「这个城市值是哪天、从哪个源来的」;② 二次放号/账号过户时,属性突变可作为检测信号(第 12 章异常簇检测);③ 合规审计可追溯每个属性的来源。

9.4.1 二次放号与账号漂移检测

强 ID 也不是永恒可靠的:手机号会因注销后被运营商二次放号给新用户;微信账号可能更换绑定手机号。这类「强 ID 换人」如果不检测,会把两个真人的历史错误地连在一起。检测思路:同一强 ID 出现明显的行为断层 + 属性突变

python 复制代码
# oneid_core/fusion/drift_detect.py
from __future__ import annotations
from pyspark.sql import SparkSession, functions as F
from pyspark.sql.window import Window


def detect_id_reassignment(spark: SparkSession, dt: str):
    """
    二次放号/账号漂移检测:
    对每个强 ID(mdn/unionid),按时间看活跃序列,若出现
    「长时间静默(>=180天) 后突然活跃,且设备/城市/性别等发生断层」
    则判定疑似换人,产出拆分审核工单,不自动合并静默前后的行为。
    """
    # 该强 ID 关联的设备/城市按月活跃序列
    act = spark.sql(f"""
        SELECT m.id_hash AS strong_id, m.id_type,
               date_format(e.event_time, 'yyyy-MM') AS ym,
               count(*) AS act_cnt,
               collect_set(e.device_id_hash) AS devs,
               collect_set(e.city) AS cities
        FROM dim.dim_oneid_id_map m
        JOIN dwd.dwd_user_event_df e ON e.oneid = m.oneid
        WHERE m.dt='{dt}' AND m.id_type IN ('mdn','unionid')
        GROUP BY m.id_hash, m.id_type, date_format(e.event_time,'yyyy-MM')
    """)

    w = Window.partitionBy("strong_id").orderBy("ym")
    gap = (
        act.withColumn("prev_ym", F.lag("ym").over(w))
           .withColumn("prev_devs", F.lag("devs").over(w))
           .withColumn("prev_cities", F.lag("cities").over(w))
           .withColumn("gap_months",
                       F.months_between(F.to_date(F.col("ym"), "yyyy-MM"),
                                        F.to_date(F.col("prev_ym"), "yyyy-MM")))
           # 静默 >=6 个月后活跃,且设备集合完全不相交(设备断层)
           .withColumn("device_overlap",
                       F.size(F.array_intersect("devs", "prev_devs")))
           .filter((F.col("gap_months") >= 6) & (F.col("device_overlap") == 0))
    )
    # 产出疑似漂移 → 审核工单(ticket_type=2 疑似误合并/换人)
    (gap.select(F.col("strong_id").alias("payload.strong_id"),
                F.col("ym").alias("payload.resume_month"),
                F.col("gap_months").alias("payload.gap_months"),
                F.current_timestamp().alias("create_time"))
        .withColumn("ticket_type", F.lit(2))
        .write.format("jdbc")
        .option("dbtable", "oneid_review_ticket")
        .option("url", "jdbc:mysql://mysql:3306/oneid_meta")
        .option("user", "${META_USER}").option("password", "${META_PWD}")
        .mode("append").save())
    return gap.count()

处理策略:检测到疑似二次放号,不自动断开(可能只是用户换机换城),而是发审核工单 + 实时链路对静默前后新行为「另起待观察簇」;人工核实确实换人后,按第 7 章原则 3 拆分,历史行为保留在原 OneID,新行为归新 OneID。这是「宁可保守」原则在强 ID 上的体现。

9.4.2 黄金记录融合 SQL(供核对)

sql 复制代码
-- 每个 oneid+字段取 trust 最高值
WITH scored AS (
  SELECT oneid, attr_name, attr_value, src_type, valid_from,
    ( CASE src_type WHEN 'realname' THEN 1.0 WHEN 'wx_auth' THEN 0.9
                    WHEN 'bind' THEN 0.9 WHEN 'offline_member' THEN 0.75
                    WHEN 'user_fill' THEN 0.7 ELSE 0.4 END )
    * COALESCE(field_rel(attr_name, src_type), 0.5)
    * pow(0.5, datediff('${dt}', to_date(valid_from))
             / CASE attr_name WHEN 'city' THEN 180.0
                              WHEN 'nickname' THEN 90.0 ELSE 36500.0 END)
      AS trust
  FROM dim.dim_oneid_attr_history
  WHERE dt='${dt}' AND audit_status <> 3      -- 排除人工驳回
),
ranked AS (
  SELECT *, row_number() OVER (
      PARTITION BY oneid, attr_name ORDER BY trust DESC, valid_from DESC) rn
  FROM scored
)
SELECT oneid, attr_name, attr_value AS golden_value, src_type AS golden_src, trust
FROM ranked WHERE rn = 1;

9.5 冲突人工审核兜底

自动融合覆盖 99%+,剩余低置信冲突进审核台:

复制代码
冲突工单(oneid_review_ticket)
  ├─ 属性冲突:同字段两个等信来源值不同 → 运营核对,选定黄金值
  ├─ 疑似误合并:簇内属性矛盾(性别男/女各持强ID) → 触发拆分评估
  └─ 拆分申请:用户/客服反馈「这不是我」→ 核实后拆分(第7章原则3)
审核结果回写:通过 → 更新黄金值并 version+1;驳回 → 维持原值
全程记 oneid_audit_log(谁审的、依据什么)

关键风控信号:当一个连通簇里出现两个互不妥协的强 ID 且属性硬冲突(如两个不同身份证级手机号、性别男女对立),这往往是误合并或账号共用,优先级最高,需人工介入而不是自动选一个。

9.5.1 融合可信度单测

python 复制代码
# tests/test_fusion.py
from oneid_core.fusion.golden_record import _trust


def test_realname_beats_infer():
    # 实名性别(最可信)应高于昵称推断
    assert _trust("gender", "realname", days=100) > _trust("gender", "infer", days=1)


def test_recency_decay_for_city():
    # 城市时效性强:1 年前的自填城市 < 最近推断城市
    old = _trust("city", "user_fill", days=400)
    new = _trust("city", "infer", days=5)
    assert new > old


def test_gender_stable_over_time():
    # 性别/生日几乎不随时间衰减
    assert _trust("gender", "realname", days=3000) == _trust("gender", "realname", days=10)


def test_nickname_fast_decay():
    assert _trust("nickname", "wx_auth", days=365) < _trust("nickname", "wx_auth", days=10)

9.5.2 融合字段策略速查

字段 首选来源 时效半衰期 冲突处理
gender realname > wx_auth > user_fill 极长(几乎不变) 等信冲突进审核
birth_year realname > user_fill 极长 实名直接采用
city 近期收货/常驻推断 > user_fill 180 天 取近期高频,突变观察
nickname wx_auth > user_fill 90 天 取近期,脱敏
主手机号 mdn 绑定 + 活跃(近 90 天有行为) 结合活跃 二次放号检测(9.4.1)

9.6 融合流程 ASCII 图

复制代码
dim_oneid_attr_history(各来源属性明细,append-only)
        │  join dim_oneid_id_map(属性归属到 OneID)
        ▼
 trust 打分 = 来源权重 × 时效衰减 × 字段可信度
        │
        ├─ top1 trust → 黄金值
        ├─ top1/top2 等信且值不同 → 冲突工单(人工兜底)
        ▼
 透视成 dim_oneid_user(每个 OneID 一行黄金记录)
        │  join id_map 聚合 id_cnt/touch_points/主强ID/首末活跃
        ▼
 dim_oneid_user(version 递增,历史留 attr_history)

7.7 主号选择算例与分配事件产出

主号选择规则(详见 7.4 代码)按优先级依次比较,下面给出 4 个典型算例:

场景 簇内成员(类型) 强锚点数 OneID 大小 选中主号 说明
新簇全弱 ID device_id / idfa / cookie 0 --- 新雪花号 无强锚点,发新号,号段正式
绑定手机号 mdn(A) / openid / device 1 --- 复用 mdn(A) 所在号 强锚点优先,旧号不新建
两个强锚点 unionid(号1008) / mdn(号1217) 1 vs 1 1008 < 1217 1008 强锚点数持平取 OneID 小
历史合并簇 unionid+mdn(号1008,2个锚点) / pay_uid(号1217,1个) 2 vs 1 --- 1008 锚点数多者胜,与号大小无关

分配作业每处理一个连通簇,除写 dim_oneid_id_map / dim_oneid_user 外,还向 oneid-changelog 产出事件(格式见 8.8),离线批次的事件 source=offline

触发条件 op oneid_keep oneid_drop 下游动作
新连通簇,簇内无任何已分配 OneID NEW 新雪花号 null 下游建档
簇内已有 1 个 OneID,其余为新 ID NEW(挂老号) 老号 null 下游扩成员
两个已分配簇合并 MERGE 主号 副号(tombstone) 下游把副号数据迁主号
人工/规则触发拆分 SPLIT 保留号 拆出号(新建或复用原号) 下游按 affected 列表回迁
账号注销(第 14 章) TOMBSTONE --- 被注销号 下游级联删除/匿名化

注意:离线作业批量发事件时按 dt + 簇 root + op 生成确定性 event_id,重跑同一天任务产出的事件 ID 完全相同,下游天然幂等。

8.11 实时链路容量与水位规划

星购实时链路的容量规划(DAU 500 万,峰值出现在晚 8-10 点大促):

资源 规格 容量依据 水位红线
Kafka id-event-source 48 分区,retention 7 天 峰值 4 万事件/秒,单分区 ~850/s 分区 Lag < 50 万
Flink 实时作业 48 并行度,TM 8 个 × 4 slot,每个 TM 8c16g 单并行度 ~850 事件/秒,union 为 Redis 调用瓶颈不在本地 背压 busy < 70%
Redis 集群 3 主 3 从,每分片 32GB 活跃 1.2 亿 key,单 key < 200 字节,总量 35--45GB;hashtag 保证同槽 分片内存 < 70%,CPU < 60%
Redis 调用 pipeline + Lua evalsha 单次 union ~2 次 RTT(find×2 + union),P99 < 3ms 命令 P99 < 5ms
状态后端 Flink 仅存 Kafka offset(RocksDB) 不把并查集放 Flink state(见 8.6 取舍) checkpoint < 30s

扩容触发条件(任一满足即扩容,第 17 章事故 3 有血泪案例):

  1. Kafka 消费 Lag 连续 10 分钟 > 50 万且持续增长;
  2. Redis 任一分片内存 > 70% 或 CPU > 60%;
  3. Flink 反压 busy 持续 > 80% 且 checkpoint 超时。

扩容方式:Redis 分片从 3 主扩到 6 主用集群 reshard(hashtag 保证同一簇的 uf:/ufrank:/ufsize:/ufstrong: 一起迁移);Flink 并行度从 48 提到 96 需同时把 Kafka 分区扩到 96(分区数 ≥ 并行度),扩分区在低峰期执行。

9.7 dim_oneid_user 字段对齐清单

融合作业写 dim_oneid_user 时,字段分四组,与 part1 第 3 章 DDL 严格对齐:

分组 字段 来源 更新频率
主键 oneid, dt 分配作业(第 7 章) 每日全量
黄金属性 gender, birth_year, city, nickname, avatar_url, mdn_masked golden_record(9.3) 每日,version 递增
簇画像 id_cnt, id_types, touch_points, has_strong_anchor, root_node 聚合 dim_oneid_id_map 每日
生命周期 status(1正常/2冻结/3注销), first_active_dt, last_active_dt, version 生命周期状态机(7.5.1) 事件驱动 + 每日校正

写入约定:

  • INSERT OVERWRITE TABLE dim.dim_oneid_user PARTITION (dt=...) 每日全量重写(6.8 亿行,按 oneid 分桶 2000,zstd 压缩后 ~120GB);
  • 属性值变更时 version = 旧 version + 1,旧值不覆盖、留在 dim_oneid_attr_history(append-only),支持"回看 30 天前这个 OneID 的城市"这类时间旅行查询;
  • mdn_masked 只写脱敏值(138****8888),明文/密文手机号不落 dim 层(第 14 章合规要求)。

9.8 下游消费黄金记录示例

推荐侧(特征工程)每日拉取黄金记录的典型用法:

python 复制代码
# 下游特征任务示例:oneid_user_feature_daily(特征平台侧代码)
from pyspark.sql import SparkSession, functions as F

spark = SparkSession.builder.appName("user_golden_feature").getOrCreate()

golden = (
    spark.table("dim.dim_oneid_user").filter(F.col("dt") == "${dt}")
    .filter(F.col("status") == 1)          # 只用正常状态 OneID
)
events = (
    spark.table("dwd.dwd_user_event_df").filter(F.col("dt") == "${dt}")
)

feat = (
    events.groupBy("oneid")
    .agg(
        F.countDistinct("event_id").alias("event_cnt_1d"),
        F.countDistinct("touch_point").alias("touch_cnt_1d"),
        F.sum(F.when(F.col("event_type") == "order_pay", 1).otherwise(0))
            .alias("pay_cnt_1d"),
    )
    .join(golden.select("oneid", "gender", "city", "id_cnt",
                        "has_strong_anchor"), "oneid", "right")
)
feat.write.format("iceberg").mode("overwrite") \
    .partitionBy("dt").saveAsTable("dwm.dwm_user_golden_feature_df")

要点:

  • 下游一律以 oneid 为 join key,不再接触原始 ID(原始 ID → OneID 的映射只在 dim 层,通过服务层第 10 章接口或 dim_oneid_id_map 授权访问);
  • status=1 过滤掉冻结/注销号,注销号的级联处理走第 14 章 tombstone 链路;
  • 弱锚点簇(has_strong_anchor=false)的特征单独打标,推荐/营销侧按业务决定是否使用(误并风险高于强锚点簇)。

9.9 融合环节 FAQ

Q1:两个来源都说自己对,trust 分完全一样怎么办?

A:进 oneid_review_ticket(ticket_type=1 属性冲突),人工审核前黄金字段取保守值(如 gender 置 null 而非二选一),避免错值污染推荐。日均冲突工单量约 300-500 条,审核 SLA 48 小时。

Q2:用户改了城市,为什么画像没立刻变?

A:融合是 T+1 离线任务,且城市半衰期 180 天------新城市需要持续出现(收货地址/常驻定位)积累 trust 分超过旧值才会切换,这是刻意设计的防抖逻辑,防止出差/旅游导致城市抖动。

Q3:昵称、头像这类低价值字段也要走融合吗?

A:走,但成本极低:attr_history 按字段分区裁剪,nickname/avatar 的源数据量小;且这类字段半衰期短(90 天)、冲突不进人工(直接取最新 wx_auth 值),不占用审核资源。

Q4:二次放号误判怎么办?

A:drift_detect(9.4.1)输出的是"疑似换号"信号,不自动解绑;信号进审核工单,结合近 90 天行为连续性(常用设备、收货地址是否全变)人工确认后再解绑,宁可保留旧关联也不误拆真实用户。


7.8 分配作业的重跑与幂等

离线分配作业每天在 GraphX 连通分量(第 6 章)之后运行,必须支持任意重跑而不产生重复号或跳号事故:

场景 幂等手段 结果
同一天任务失败重跑 输入 (dt, root_node) 确定:已分配簇从 dim_oneid_id_map 读旧号,未分配簇才发新号 不产生新 OneID
雪花号发了一半任务挂了 worker 发号前先写 dim_oneid_id_map(Iceberg 事务),任务失败则整分区回滚;worker_id+sequence 不回滚也没关系(号段空洞允许,不复用即可) 无重复号,有空洞
跨天重复消费同一簇 簇身份用 root_node(最小节点哈希)判定,root 不变则视为同一簇 昨天的号今天沿用
拆分后重跑 拆分工单写 oneid_review_ticket 并在 id_map 标 map_status=2,重跑时跳过 2 的边重建 拆分结果不被覆盖

分配作业产出的核心数据校验 SQL(每日调度后自动执行,失败则阻断下游):

sql 复制代码
-- 校验1:一个有效 id_hash 只能映射到一个有效 OneID
SELECT id_hash, COUNT(DISTINCT oneid) AS c
FROM dim.dim_oneid_id_map
WHERE dt = '${dt}' AND map_status = 1
GROUP BY id_hash
HAVING COUNT(DISTINCT oneid) > 1;
-- 期望:0 行

-- 校验2:被 tombstone 的 OneID 不应出现在有效映射中
SELECT COUNT(1) AS bad
FROM dim.dim_oneid_id_map m
JOIN dim.dim_oneid_user u
  ON m.oneid = u.oneid AND m.dt = u.dt
WHERE m.dt = '${dt}' AND m.map_status = 1 AND u.status = 3;
-- 期望:0

-- 校验3:OneID 号段不越界(正式号 < 9_000_000_000_000,临时号只在实时侧)
SELECT COUNT(1) AS bad
FROM dim.dim_oneid_id_map
WHERE dt = '${dt}' AND oneid >= 9000000000000;
-- 期望:0(离线侧永不出现临时号)

8.12 实时侧热 key 与大 key 治理

Redis 在线并查集上线后出现过两类典型问题(第 17 章事故 3、事故 7 详述),治理手段固化如下:

热 key(单 key QPS 过高)oneid:id2one:{id_hash} 中,头部主播/大促会场设备的 id_hash 查询 QPS 可达单分片 2 万+。治理:

  1. 服务层(第 10 章)本地 Caffeine 本地缓存 10 秒,挡住 90%+ 重复读;
  2. 读请求走 Redis 从节点(副本),写(union)走主节点;
  3. 监控 redis-cli --hotkeys(需 LFU 策略)每周巡检 Top100 热 key。

大 key(单个 SET/HASH 元素过多)oneid:one2ids:{oneid} 对超级节点用户(如门店公共导购设备曾误并 8000+ ID)会膨胀到数十 KB,拖慢迁移与删除。治理:

  1. 离线侧超级节点 cap=2000 截断(第 5 章),实时 Lua union 内同样做 cap 判断(ufsize 超 2000 且新边为弱边时拒绝合并);
  2. one2ids SET 设置元素数告警阈值 3000,超过即报警并人工核查是否误并;
  3. 大 key 删除用 UNLINK(异步释放)而非 DEL,避免阻塞主线程;集群迁移用 redis-cli --cluster reshard 低峰执行。

连接治理:Flink TM 每个 slot 一个 Redis 连接池(max_connections=8),pipeline 批量;禁止在 map 算子内新建连接(第 17 章事故 5 的连接泄漏教训)。

9.10 融合任务资源实测与调优

星购每日融合任务(6.8 亿 OneID、attr_history 日增 ~4000 万行)的实测数据:

阶段 资源 耗时 调优要点
attr_history 接入标准化 100 executor × 4c8g 18 分钟 来源表按 dt 分区裁剪,union 后按 oneid 分桶
trust 打分 + 窗口取 top1 150 executor × 4c16g 35 分钟 ROW_NUMBER() OVER(PARTITION BY oneid, field ORDER BY trust DESC) 触发宽依赖,按 oneid salt 打散(第 13 章)
冲突检测 50 executor × 4c8g 8 分钟 top1/top2 trust 差 < 0.05 且值不同即出工单
透视聚合写 dim_oneid_user 200 executor × 4c16g 22 分钟 分桶 2000 与 DDL 一致,避免写入时重分布
全链路 --- 约 83 分钟 凌晨 2:00 启动,4:00 前产出,留给下游特征/推荐 4 小时窗口

常见调优:

  • 打分阶段用 map-side 预聚合(同一 oneid+field 在 map 端先留 top3)再 shuffle,shuffle 数据量降 70%;
  • attr_history 保留 2 年明细,超过 2 年归档到冷存储(Iceberg sort by dt,查询时分区裁剪,详见 part1 第 3 章冷热分层);
  • 黄金记录"不变就不写":与昨日 dim_oneid_user 全量比对,属性无变化的行 version 不递增,下游增量同步只消费 version 变化的 OneID(日均约 3%),大幅降低下游同步压力。

8.13 实时与离线的职责边界(一张表说清)

新手常问"既然有实时并查集,为什么还要每天离线全量跑 GraphX",因为两者的能力边界不同:

维度 实时(Flink + Redis) 离线(Spark/GraphX + Hive/Iceberg)
数据范围 近实时增量边(当天新事件) 全量历史边(20.7 亿)
算法 在线并查集 union(只合并不拆分) 全量连通分量 + 弱边裁剪 + 超级节点治理
能否拆分 不能自动拆(拆分走工单/离线回灌) 能:边失效、误并修正后全量重算
弱关系 实时只处理强绑定事件(bind/pay,conf=1.0) 弱边(共现/同设备/同地址)全量打分后入图
结果性质 近似、临时号段(9 开头) 权威、正式雪花号
延迟 秒级(P99 18 秒可查) T+1
用途 实时营销/客服弹窗/实时风控 特征、标签、DMP、对账基准

一句话:实时负责"快",离线负责"准";实时是离线的前置近似,离线是实时的每日校准。 两者通过 oneid-changelog 和 T+1 回灌(8.9)形成闭环,对账一致率 >99.5% 是这条链路的生命线。


7.9 OneID 号段与异常输入处理约定

生产中发号逻辑必须对异常输入有明确约定,星购的规范如下:

输入情形 处理 理由
空 id_hash / 长度 != 64 ValueError,脏数据进 *_invalid 旁路表 HMAC-SHA256 输出固定 64 位十六进制,不合法说明上游标准化出错
簇内全部 id 都在黑名单(公共 WiFi 等) 不发 OneID,整簇标记 is_supernode=1 挂起 黑产簇不消耗正式号段
单 ID 成簇(孤岛,无边) 发正式号(弱 ID 也发) 服务层要求"凡查必有号",孤岛号后续可被合并,不新建
时钟回拨 > 100ms(雪花算法) 拒绝发号并告警,切换备用 worker_id 防止重复号(7.2 代码已实现等待/自旋/拒发三级处理)
worker_id 冲突(两个实例同号) 启动时向 MySQL oneid_worker_lease 表抢锁,抢不到退出 发号器多实例部署时的唯一性保障

oneid_worker_lease 表(发号器租约)DDL:

sql 复制代码
CREATE TABLE IF NOT EXISTS oneid_worker_lease (
  worker_id    INT          NOT NULL COMMENT '雪花 worker 编号 0-127(7 bit)',
  instance     VARCHAR(128) NOT NULL COMMENT '实例标识 host:pid',
  lease_until  DATETIME(3)  NOT NULL COMMENT '租约到期时间,心跳 10s 续租',
  heartbeat_at DATETIME(3)  NOT NULL COMMENT '最近心跳',
  PRIMARY KEY (worker_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='OneID 雪花发号器 worker 租约表';

发号器启动时 INSERT ... ON DUPLICATE KEY UPDATE 抢锁:仅当 lease_until < NOW() 或 instance 为自己时才占有,保证 128 个 worker 槽位(7 bit)不冲突;需要更多发号节点时按号段(segment)水平拆分集群。


本篇小结

第 7--9 章完成了从连通簇到可服务 OneID 的全部环节:

  • 第 7 章:雪花算法(时钟回拨处理、校验位)、稳定性三原则(永不复用/合并不新建/拆分可回退)、主号选择与分配作业、新老集群迁移;
  • 第 8 章:Redis 在线并查集(Lua 原子 union、超级节点 cap)、PyFlink 实时作业、Exactly-Once 与幂等、离线 T+1 回灌对账;
  • 第 9 章:来源权重×时效×字段可信度的黄金记录融合、冲突审核兜底、属性时间线、二次放号漂移检测、字段级策略速查与资源实测。

本篇产出的可复用模块(oneid_core 包内):

复制代码
allocator/  snowflake.py(雪花+时钟回拨+Luhn)、oneid_assigner.py(主号选择+分配)、
            legacy_migrate.py(老集群迁移)、lifecycle.py(状态机+拆分决策)
realtime/   redis_dsu.py(在线并查集客户端)、lua/union_find.lua(原子 union)、
            flink_union.py(PyFlink 实时作业)、incremental_subgraph.py(增量子图裁剪)、
            backfill.py(T+1 回灌对账)
fusion/     golden_record.py(trust 打分融合)、collect_attributes.py(来源接入)、
            drift_detect.py(二次放号检测)
tests/      test_snowflake.py、test_realtime_dsu.py、test_fusion.py

下一篇进入生产化:FastAPI 服务化与缓存(第 10 章)、回写与 Kafka 广播对接下游(第 11 章)、数据质量监控(第 12 章)、性能优化(第 13 章)、安全合规(第 14 章)。

相关推荐
牛马之星1 小时前
气体涡轮流量计哪家好?天然气流量计源头厂家推荐与选型指南
大数据
jinggongszh1 小时前
欧盟电池护照背景下,电池制造企业如何构建MES数字化追溯体系
大数据·制造·mes系统·电池行业·生产制造执行系统
Allen_LVyingbo2 小时前
医疗AI基础2026-构建可靠智能体的编程路径(上)
大数据·数据库·人工智能·python·自动化
熊野君2 小时前
第 7 章 AI时代产品经理新增能力
大数据·人工智能·经验分享·职场和发展·产品经理
ha_lydms3 小时前
HDFS 简介
大数据·hadoop·hdfs·yarn·调度·aliyun·计算
oort1233 小时前
OortCloud Token Plan
大数据·人工智能·开源
Sky1987star3 小时前
产品资料更新后,怎样让 AI 内容与客服同步生效?
大数据·人工智能
flyinsono3 小时前
单体宠物诊所数字化转型,选对管理系统少走弯路
大数据·人工智能·宠物
cxr8283 小时前
AI时代科学研究思维框架的适应性分析与检查清单构建
大数据·人工智能·认知框架