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。
8.5 PyFlink DataStream 实时并查集作业
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 有血泪案例):
- Kafka 消费 Lag 连续 10 分钟 > 50 万且持续增长;
- Redis 任一分片内存 > 70% 或 CPU > 60%;
- 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 万+。治理:
- 服务层(第 10 章)本地 Caffeine 本地缓存 10 秒,挡住 90%+ 重复读;
- 读请求走 Redis 从节点(副本),写(union)走主节点;
- 监控
redis-cli --hotkeys(需 LFU 策略)每周巡检 Top100 热 key。
大 key(单个 SET/HASH 元素过多) :oneid:one2ids:{oneid} 对超级节点用户(如门店公共导购设备曾误并 8000+ ID)会膨胀到数十 KB,拖慢迁移与删除。治理:
- 离线侧超级节点 cap=2000 截断(第 5 章),实时 Lua union 内同样做 cap 判断(
ufsize超 2000 且新边为弱边时拒绝合并); one2idsSET 设置元素数告警阈值 3000,超过即报警并人工核查是否误并;- 大 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 章)。