OneID 从 0 到 1 完整生产案例(五)
上一册:part4(第 10--14 章)------FastAPI 服务化、下游对接、数据质量、性能优化、安全合规。
本册为落地实战篇:第 15 章把前 14 章的所有模块串成星购电商 5 端打通的端到端生产链路(含调度 DAG、12 周排期、上线效果);第 16 章讲实时 OneID 如何支撑"登录即打通、分钟级圈选、券实时触达",含完整 Flink 代码、压测报告与故障演练;第 17 章复盘 10 个真实风格的生产事故,每个坑都给出现象/原因/修复/预防与可落地的代码或 SQL。
本册导航:
- [第15章 生产案例一:星购电商全渠道 OneID 端到端打通](#第15章 生产案例一:星购电商全渠道 OneID 端到端打通)
- [第16章 生产案例二:实时 OneID 与营销实时触达](#第16章 生产案例二:实时 OneID 与营销实时触达)
- [第17章 踩坑与事故复盘:10 个生产大坑](#第17章 踩坑与事故复盘:10 个生产大坑)
第15章 生产案例一:星购电商全渠道 OneID 端到端打通
前 14 章我们把 OneID 的每个零件都造好了:标准化(第 4 章)、关系抽取与置信度(第 5 章)、GraphX 连通(第 6 章)、雪花分配(第 7 章)、离线增量 + Flink 并查集(第 8、9 章)、黄金记录融合(第 9 章)、FastAPI 服务化(第 10 章)、质量与安全(第 12--14 章)。本章不再讲单点原理,而是回答一个工程问题:这些零件在星购电商(App / 微信小程序 / H5 / 线下门店 / 客服 5 个触点,日活 500 万,ID 总量 10 亿级、关系边 20 亿级)是怎么拼成一条每天稳定产出、可灰度、可回滚、可告警的生产链路的。
15.1 项目背景:打通前的"五个孤岛"
星购在做 OneID 之前,5 个触点各有各的账号体系,数据团队手上是这样一笔烂账:
| 触点 | 主键 | 能拿到的强 ID | 主要弱 ID | 日新增 ID 量 |
|---|---|---|---|---|
| App | app_uid(自建账号) | mdn(手机号)、idfa/oaid | device_id、ip | ~120 万 |
| 微信小程序 | openid + unionid | unionid(绑定手机号后) | openid | ~90 万 |
| H5(浏览器/分享落地页) | 无登录,cookie | 偶发 mdn(下单留资) | cookie、fp 浏览器指纹 | ~150 万 |
| 线下门店(POS/导购) | member_id(会员卡号) | mdn(办卡预留) | 门店设备号、导购工号 | ~8 万 |
| 客服系统(电话/在线) | ticket_id | mdn(来电号码)、unionid(在线客服) | 会话 id | ~20 万 |
打通前,营销和运营的痛点非常具体:
- 同一个人被算成 5 个人:一个用户在 App 浏览、小程序领券、门店核销,三套 ID 互不相认,漏斗各算各的,全渠道转化率被低估近一倍。
- 人群圈选只能圈单端:运营想圈"近 30 天 App 加购但没下单、且小程序领过券"的人,DMP 拼不出跨端 key,只能拆成两个包分别投,预算浪费 30% 以上。
- 归因全靠 last click:门店导购成交的用户,线上广告完全记不上功,市场部和门店部为 ROI 吵架。
- 客服看不到全渠道历史:用户打电话投诉小程序订单,客服要切 3 个系统查,平均处理时长 8 分钟。
项目启动时,我们用第 12 章的连通率口径做了一次基线摸底:以"强 ID 直接/间接可达"为口径,全渠道可识别为同一自然人的 ID 占比只有 42%------剩下 58% 的 ID 是孤零零挂在单端的。这个 42% 就是后面所有效果对比的"before"。
15.2 端到端全景图
整条链路按"接入 → 标准化 → 建边 → 连通 → 分配 → 融合 → 服务 → 质量"八段组织,离线 T+1 全量 + 增量、实时 Flink 秒级合并,两条链路在 dim_oneid_id_map / Redis 处汇合:
星购电商 OneID 端到端全景
┌──────────────────────────────────────────────────────────────────────────────┐
│ 触点: App 小程序 H5 门店POS 客服系统 │
│ 日志: app_uid unionid cookie member_id ticket_id (+ mdn/设备) │
└──────┬──────────┬──────────┬───────────┬────────────┬─────────────────────────┘
│ 埋点SDK上报(HMAC客户端预哈希) / 业务库CDC / 门店与客服T+1批量
▼ ▼ ▼ ▼ ▼
┌────────────────────────── Kafka(binlog/log 主题) ──────────────────────────┐
│ ods.app_event ods.mini_event ods.h5_event ods.pos_member ods.cs_ticket │
└───────┬──────────────────────────────┬──────────────────────────────────────┘
│ 离线(T+1, Spark) │ 实时(Flink 1.18, 秒级)
▼ ▼
[standardize] 清洗/校验/哈希 [realtime] 登录/绑定事件 → 增量并查集
│ ods_user_id_detail_di │ 状态后端 RocksDB + Redis 热查
▼ │
[graph 建边] 共现/绑定/地址/支付/WiFi │
│ dwd_id_relation_edge_df │
▼ │
[graph 连通] GraphX connectedComponents │ ← 实时合并结果回写, 与离线对账
│ dwm_oneid_graph_df │
▼ ▼
[allocator] 雪花算法分配 OneID(强ID锚点不变)
│
├──► [fusion] 黄金记录融合 → dim_oneid_user / dim_oneid_attr_history
├──► dim_oneid_id_map(全量映射,Iceberg)
▼
[serving] FastAPI + Redis(集群) + 本地L1 + 布隆 ◄── Kafka changelog 广播失效
│ /resolve /resolve/batch /expand /profile
▼
下游: 营销触达(实时券) / DMP人群圈选 / 客服360视图 / 推荐特征 / 归因看板
[quality] 连通率/误并率/漏并率/簇分布 监控 + 告警 + 月度抽样标注
八段对应的 oneid_core 包与产出表,和前 14 章完全一致:
| 段 | 包 | 核心产出 | 调度频率 |
|---|---|---|---|
| 接入标准化 | oneid_core/standardize |
ods_user_id_detail_di |
离线 T+1 + 实时 |
| 关系抽取 | oneid_core/graph(建边部分) |
dwd_id_relation_edge_df |
T+1 |
| 连通 | oneid_core/graph(GraphX 作业) |
dwm_oneid_graph_df |
T+1 |
| 分配 | oneid_core/allocator |
dwm_oneid_graph_df.oneid |
T+1 + 实时 |
| 增量合并 | oneid_core/union_find、oneid_core/realtime |
合并 changelog | 实时 |
| 融合 | oneid_core/fusion |
dim_oneid_user、dim_oneid_attr_history |
T+1 |
| 服务 | oneid_core/serving |
Redis + API | 7×24 |
| 质量 | oneid_core/quality |
指标看板/告警 | T+1 + 实时 |
15.3 全链路代码串讲:从一条埋点到一次查询
下面按数据流向,把每一段的入口代码串一遍。每段只讲在生产链路里"起什么作用、关键参数是什么",单点实现细节回看对应章节。
15.3.1 接入与标准化:五端归一到一张明细表
5 个触点的原始日志 schema 各不相同,标准化作业的职责是把它们 union 成统一的 ods_user_id_detail_di。生产里我们用一个可插拔的 source 配置驱动,而不是写 5 套 ETL:
python
# oneid_core/standardize/pipeline.py
"""五端 ID 标准化主作业(PySpark 3.5)。
输入: 各触点 ODS 原始日志(当日分区 dt)
输出: ods.ods_user_id_detail_di(dt, touch_point)
"""
from __future__ import annotations
import logging
from pyspark.sql import DataFrame, SparkSession
from pyspark.sql import functions as F
from oneid_core.standardize.hasher import hmac_hash_id # 第4章: HMAC-SHA256+盐
from oneid_core.standardize.validator import validate_id # 第4章: 格式/虚商号段校验
from oneid_core.standardize.sources import SOURCE_REGISTRY # 触点→适配器注册表
logger = logging.getLogger("oneid.standardize")
# 五端接入登记: 触点 -> (原始表, 适配器名, 参与建边的ID类型白名单)
TOUCHPOINT_SOURCES: dict[str, tuple[str, str, list[str]]] = {
"app": ("ods.ods_app_event_di", "app_adapter", ["mdn", "device_id", "idfa", "oaid"]),
"mini": ("ods.ods_mini_event_di", "mini_adapter", ["unionid", "openid", "mdn"]),
"h5": ("ods.ods_h5_event_di", "h5_adapter", ["cookie", "fp", "mdn"]),
"store": ("ods.ods_pos_member_di", "store_adapter", ["member_id", "mdn"]),
"cs": ("ods.ods_cs_ticket_di", "cs_adapter", ["mdn", "unionid", "ticket_id"]),
}
def build_detail(spark: SparkSession, dt: str) -> DataFrame:
"""读取五端当日数据, 逐个适配器标准化后 union。"""
frames: list[DataFrame] = []
for touch, (table, adapter_name, id_types) in TOUCHPOINT_SOURCES.items():
try:
adapter = SOURCE_REGISTRY[adapter_name]()
df = spark.table(table).where(F.col("dt") == dt)
# 适配器把异构 schema 拍平成长表: (raw_id, id_type, first_seen, last_seen)
long_df = adapter.to_long(df, id_types=id_types)
frames.append(long_df.withColumn("touch_point", F.lit(touch)))
logger.info("standardize source ok: %s rows=%d", touch, long_df.count())
except Exception as exc: # 单端失败不拖垮全链路, 但必须告警
logger.exception("standardize source failed: %s, dt=%s", touch, dt)
raise RuntimeError(f"触点 {touch} 标准化失败, 阻断当日链路") from exc
unioned = frames[0]
for f in frames[1:]:
unioned = unioned.unionByName(f)
# 格式校验 + HMAC 哈希: 落盘只存哈希, 明文 mdn 不出内存
validated = validate_id(unioned) # 产出 is_valid/invalid_reason
hashed = hmac_hash_id(validated, salt_col_by_type=True) # 按 id_type 选盐(第14章)
return (
hashed
.withColumn("etl_time", F.current_timestamp())
.select("raw_id", "id_hash", "id_type", "id_level", "touch_point",
"app_id", "first_seen_time", "last_seen_time",
"is_valid", "invalid_reason", "etl_time")
)
def run(dt: str) -> None:
spark = (SparkSession.builder.appName(f"oneid_standardize_{dt}")
.config("spark.sql.sources.partitionOverwriteMode", "dynamic")
.enableHiveSupport().getOrCreate())
detail = build_detail(spark, dt)
(detail.writeTo("ods.ods_user_id_detail_di")
.overwritePartitions(F.col("dt") == dt)) # Iceberg 动态分区覆盖
logger.info("standardize done dt=%s", dt)
为什么单端失败要"快速失败 + 告警"而不是跳过 :如果门店数据当天没到就跳过,门店会员的 ID 会在当天图里"消失",连通结果可能把本该属于同一个人的门店 member_id 漏连,第二天虽然能补上,但当天的营销人群已经错了。所以生产策略是:核心触点(app/mini/store)失败阻断链路,非核心(h5 的弱指纹)失败告警放行 ,这个分级在适配器里通过 critical: bool 标记。
15.3.2 关系抽取:五种边,置信度说话
标准化产出节点,建边作业在节点之间连边。星购生产上启用 5 类关系,阈值在第 5 章标定过:
python
# oneid_core/graph/edge_build.py
"""关系边抽取(PySpark)。输出 dwd.dwd_id_relation_edge_df。
边类型: bind(账号绑定,强) / co_event(同设备同会话共现) / address(同收货地址+电话)
pay(同支付账号) / wifi(同公共WiFi, 极弱, 默认剪枝)
"""
from __future__ import annotations
from dataclasses import dataclass
from pyspark.sql import DataFrame
from pyspark.sql import functions as F
# 关系类型 -> (基础置信度, 是否强边, 最小共现次数)。阈值由第5章标定、第17章坑①教训复盘
RELATION_RULES: dict[str, "EdgeRule"] = {}
@dataclass(frozen=True)
class EdgeRule:
rel_type: str
base_conf: float # 基础置信度
is_strong: bool
min_co_occur: int
pruned: bool = False # 是否默认剪枝(wifi 边默认不入图)
RELATION_RULES = {
"bind": EdgeRule("bind", 0.98, True, 1),
"pay": EdgeRule("pay", 0.92, True, 1),
"address": EdgeRule("address", 0.85, False, 2), # 同地址+同电话尾号, 需2次
"co_event":EdgeRule("co_event",0.70, False, 3), # 同设备同会话, 需3次共现
"wifi": EdgeRule("wifi", 0.30, False, 99, pruned=True), # 公共WiFi黑产温床, 见坑⑤
}
def build_edges(detail: DataFrame, events: DataFrame) -> DataFrame:
"""汇总各关系抽取器产出的边, 统一打置信度/剪枝标记。"""
edge_frames = [
_bind_edges(detail), # 账号绑定关系(unionid↔mdn 等)
_pay_edges(events), # 同支付账号
_address_edges(events), # 同收货地址
_co_event_edges(events), # 同设备同时间窗共现(加盐防倾斜, 第13章)
]
edges = edge_frames[0]
for f in edge_frames[1:]:
edges = edges.unionByName(f)
# 聚合: 同一对节点的多条边取最高置信度, 累计共现次数
agg = (edges.groupBy("src_id_hash", "dst_id_hash", "src_id_type",
"dst_id_type", "rel_type", "source_touch")
.agg(F.max("confidence").alias("confidence"),
F.sum("co_occur_cnt").alias("co_occur_cnt"),
F.min("first_time").alias("first_time"),
F.max("last_time").alias("last_time")))
rules = F.create_map([x for kv in RELATION_RULES.items()
for x in (F.lit(kv[0]), F.struct(
F.lit(kv[1].base_conf).alias("c"),
F.lit(kv[1].is_strong).alias("s"),
F.lit(kv[1].min_co_occur).alias("m"),
F.lit(kv[1].pruned).alias("p")))])
return (agg
.withColumn("rule", F.col("rules").getItem(F.col("rel_type")))
.withColumn("is_strong", F.col("rule.s").cast("int"))
.withColumn("is_pruned",
F.when(F.col("rule.p"), 1)
.when(F.col("co_occur_cnt") < F.col("rule.m"), 1) # 共现不足剪枝
.otherwise(0))
.drop("rule"))
注意 wifi 边默认 is_pruned=1------这是坑⑤(公共 WiFi 黑产造成超大连通簇)之后立的规矩:弱到 0.30 的边连入图的资格都没有,只在明细表留档。
15.3.3 GraphX 连通:20 亿条边的 connectedComponents
建边后是全链路最重的一步:10 亿节点、20 亿条边跑连通分量。生产用 Scala Spark GraphX(PySpark 调 GraphFrames 在这个量级会在聚合迭代处 OOM,详见第 13 章):
scala
// oneid_core/graph/ConnectedComponentsJob.scala
package oneid_core.graph
import org.apache.spark.graphx._
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.functions._
/** 每日全量连通分量作业。
* 输入: dwd.dwd_id_relation_edge_df (is_pruned=0 的边) + ods 节点
* 输出: dwm.dwm_oneid_graph_df(cluster_id_min = 最小顶点属性)
* 调优: 见第13章------顶点加盐打散、边 src 分桶、numPartitions=2000
*/
object ConnectedComponentsJob {
def main(args: Array[String]): Unit = {
val spark = SparkSession.builder().appName("oneid_cc").enableHiveSupport().getOrCreate()
val dt = args(0)
// 只取未剪枝边; 强边/弱边都参与连通, 但弱边参与的连通簇要过 size 熔断(见坑⑤)
val edgeRows = spark.sql(s"""
SELECT src_id_hash, dst_id_hash, confidence
FROM dwd.dwd_id_relation_edge_df
WHERE dt='$dt' AND is_pruned=0""")
// 节点 RDD: (顶点id=hash的long化, 属性=id_hash 字符串)
val nodes = spark.sql(s"""
SELECT id_hash FROM ods.ods_user_id_detail_di
WHERE dt='$dt' AND is_valid=1""").rdd.map(_.getString(0)).distinct()
val vertexRDD = nodes.map(h => (hashId(h), h))
val edgeRDD = edgeRows.rdd.map { r =>
Edge(hashId(r.getString(0)), hashId(r.getString(1)), r.getAs[java.math.BigDecimal](2))
}
val graph = Graph(vertexRDD, edgeRDD).partitionBy(PartitionStrategy.EdgePartition2D)
// 关键: 连通分量用最小顶点作代表; 但锚点(强ID)优先, 保证 OneID 稳定不跳变(见坑②)
val cc = graph.connectedComponents()
import spark.implicits._
cc.vertices.join(vertexRDD).map { case (vid, (minVid, idHash)) =>
(idHash, minVid.toString)
}.toDF("id_hash", "cluster_id_min")
.withColumn("dt", lit(dt))
.writeTo("dwm.dwm_oneid_graph_df_tmp").overwritePartitions()
// 超级节点熔断: 簇节点数 > 5000 的连通簇进入人工审核, 不直接分配 OneID
spark.sql(s"""
INSERT OVERWRITE TABLE oneid_review.super_cluster_pending PARTITION(dt='$dt')
SELECT cluster_id_min, count(*) AS cluster_size
FROM dwm.dwm_oneid_graph_df_tmp WHERE dt='$dt'
GROUP BY cluster_id_min HAVING count(*) > 5000""")
}
/** 64位 hash 字符串 → 正 long(GraphX VertexId)。 */
private def hashId(h: String): VertexId =
(BigInt(h.substring(0, 15), 16) & Long.MaxValue).toLong
}
为什么连通簇大于 5000 要熔断 :正常自然人的连通簇(同一人的所有设备/账号)节点数中位数是 3、P99 是 47;超过 5000 的簇 99.9% 是黑产/公共设备造成的"脏簇"(坑⑤)。熔断后这批簇进 oneid_review.super_cluster_pending 人工或规则审核,绝不分配 OneID。
15.3.4 OneID 分配:强 ID 锚点 + 雪花算法
连通簇出来后,分配作业给每个簇发 OneID。核心铁律(坑②的教训):OneID 一旦分配给"以强 ID 为锚点的簇"就永不跳变,合并时把弱簇并入强簇、强簇的 OneID 保留:
python
# oneid_core/allocator/assign.py
"""OneID 分配(离线 T+1)。
规则:
1. 每个连通簇选锚点: 簇内 id_level=1 的强ID中, first_seen 最早者为锚;
2. 锚点 id_hash 在上期 dim_oneid_id_map 已有 OneID 的, 沿用(绝不跳变);
3. 新簇用雪花算法发新号;
4. 被合并的弱簇成员写 map_status=3(已拆分), end_time 回填, 保留审计轨迹。
"""
from __future__ import annotations
from pyspark.sql import DataFrame
from pyspark.sql import functions as F
from oneid_core.allocator.snowflake import SnowflakeIssuer # 第7章: 雪花, 含时钟回拨保护
def assign_oneid(graph_df: DataFrame, prev_map: DataFrame, dt: str) -> DataFrame:
# 1) 每簇选锚点: 强ID优先, 同级取最早出现
anchor = (graph_df.where(F.col("id_level") == 1)
.withColumn("rn", F.row_number().over(
__import__("pyspark.sql.window", fromlist=["Window"]).Window
.partitionBy("cluster_id_min").orderBy(F.col("join_time").asc())))
.where(F.col("rn") == 1)
.select(F.col("cluster_id_min"), F.col("id_hash").alias("anchor_hash")))
clustered = graph_df.join(anchor, "cluster_id_min")
# 2) 锚点查上期映射: 有则沿用旧 OneID(稳定), 无则标记待发号
with_prev = (clustered.join(
prev_map.where(F.col("map_status") == 1)
.select(F.col("id_hash").alias("anchor_hash"),
F.col("oneid").alias("old_oneid")),
"anchor_hash", "left"))
need_new = with_prev.where(F.col("old_oneid").isNull()).select("cluster_id_min").distinct()
# 3) 新簇发号: 在 driver 端批量取雪花号段, 避免逐行调用
issuer = SnowflakeIssuer(worker_id=int(dt[-2:]) % 32)
new_rows = need_new.collect()
id_mapping = {r["cluster_id_min"]: issuer.next_id() for r in new_rows}
new_id_df = with_prev.sparkSession.createDataFrame(
[(k, v) for k, v in id_mapping.items()], ["cluster_id_min", "new_oneid"])
assigned = (with_prev.join(new_id_df, "cluster_id_min", "left")
.withColumn("oneid",
F.coalesce(F.col("old_oneid"), F.col("new_oneid"))))
return assigned.select("oneid", "id_hash", "id_type", "id_level",
"cluster_size", "cluster_id_min", "anchor_hash")
SnowflakeIssuer 的时钟回拨保护在第 7 章讲过:回拨超过 5ms 直接抛异常拒绝发号(而不是等待),由调度重试切换 worker,避免发出重复号。
15.3.5 黄金记录融合与服务层
融合(第 9 章)按"来源可信度 × 时间衰减 × 强 ID 优先"加权投票产出黄金记录,写 dim_oneid_user 和 dim_oneid_attr_history;映射写 dim_oneid_id_map 后,由装载作业把热点映射灌进 Redis,并把变更发到 Kafka oneid_changelog 供服务层失效本地缓存:
python
# oneid_core/serving/load_redis.py
"""T+1 全量映射装载 Redis + changelog 广播。
Redis 结构:
HASH oneid:map:{id_hash} -> {oneid, status, conf}
SET oneid:members:{oneid} -> 该 OneID 全部 id_hash
ZSET oneid:chg:{dt} -> 当日变更(供服务层对账)
"""
from __future__ import annotations
import json
import redis
from kafka import KafkaProducer
from pyspark.sql import DataFrame
def dump_to_redis(maps: DataFrame, redis_cfg: dict, batch: int = 2000) -> int:
r = redis.RedisCluster(**redis_cfg, decode_responses=True)
producer = KafkaProducer(
bootstrap_servers=redis_cfg["kafka_brokers"],
value_serializer=lambda v: json.dumps(v, ensure_ascii=False).encode("utf-8"))
pipe, n = r.pipeline(transaction=False), 0
for row in maps.toLocalIterator():
key = f"oneid:map:{row.id_hash}"
pipe.hset(key, mapping={"oneid": row.oneid, "status": row.map_status,
"conf": str(row.confidence)})
pipe.expire(key, 7 * 86400) # 兜底 TTL, 防止脏 key 永驻
pipe.sadd(f"oneid:members:{row.oneid}", row.id_hash)
producer.send("oneid_changelog",
{"id_hash": row.id_hash, "oneid": row.oneid,
"op": "upsert", "dt": row.dt}) # 广播失效 L1
n += 1
if n % batch == 0:
pipe.execute()
pipe.execute()
producer.flush()
return n
服务层(第 10 章)订阅 oneid_changelog,收到 upsert 就删本地 L1 缓存;收到 tombstone(注销,坑⑩)就把映射改写为 status=tombstoned,所有查询直接返回"已注销",下游停止触达。
15.4 DolphinScheduler 调度:每日主链路 DAG
离线链路用 DolphinScheduler 3.x 编排,工作流名 oneid_daily,每天 02:00 由上游数仓 ODS 就绪事件触发。DAG 依赖关系如下:
[sensor: ods_ready]
│
┌──────────┬──────────┼──────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
std_app std_mini std_h5 std_store std_cs (标准化, 5并发)
└──────────┴──────────┴──────────┴──────────────┘
│ all success
▼
[edge_build 建边]
│
┌─────────┴─────────┐
▼ ▼
[cc_graphx 连通] [edge_qc 边质量] (质量旁路, 失败不阻断)
│
▼
[super_cluster_scan 超级簇熔断]
│
┌─────┴─────┐
▼ ▼
[assign 分配] [super_pending 转人工审核]
│
┌───────┼───────┬──────────────┐
▼ ▼ ▼ ▼
[fusion] [load_redis] [dump_dims] [quality_daily]
黄金记录 缓存装载 维度表导出 质量指标+告警
│
▼
[changelog_compact 变更压缩] ──► 通知下游/营销/DMP
任务定义(DolphinScheduler 任务 JSON 节选,完整参数模板见附录 C):
json
{
"workflow": "oneid_daily",
"schedule": "0 0 2 * * ?",
"global_params": [
{"key": "dt", "value": "$[yyyy-MM-dd-1]"},
{"key": "spark_queue", "value": "root.oneid"},
{"key": "alarm_group", "value": "data-oneid-oncall"}
],
"tasks": [
{
"name": "std_app",
"type": "SPARK",
"resource": "oneid_core/jobs/standardize_job.py",
"main_args": "--dt ${dt} --touch app",
"spark": {"driver_memory": "4g", "executor_memory": "8g",
"executor_cores": 4, "num_executors": 60},
"retry": {"times": 3, "interval": "5m", "strategy": "fixed"},
"timeout": {"minutes": 50, "policy": "FAIL_ALARM"},
"deps": ["sensor_ods_ready"],
"critical": true
},
{
"name": "edge_build",
"type": "SPARK",
"resource": "oneid_core/jobs/edge_build_job.py",
"main_args": "--dt ${dt}",
"spark": {"driver_memory": "8g", "executor_memory": "12g",
"executor_cores": 4, "num_executors": 150},
"retry": {"times": 2, "interval": "10m"},
"timeout": {"minutes": 90, "policy": "FAIL_ALARM"},
"deps": ["std_app", "std_mini", "std_h5", "std_store", "std_cs"]
},
{
"name": "cc_graphx",
"type": "SPARK_SCALA",
"resource": "oneid_core-jobs-1.0.jar",
"main_class": "oneid_core.graph.ConnectedComponentsJob",
"main_args": "${dt}",
"spark": {"driver_memory": "16g", "executor_memory": "16g",
"executor_cores": 4, "num_executors": 300,
"conf": {"spark.graphx.pregel.maxIterations": "20",
"spark.sql.shuffle.partitions": "2000",
"spark.memory.fraction": "0.7"}},
"retry": {"times": 2, "interval": "15m"},
"timeout": {"minutes": 150, "policy": "FAIL_ALARM"},
"deps": ["edge_build"]
},
{
"name": "super_cluster_scan",
"type": "SQL",
"sql": "INSERT OVERWRITE TABLE oneid_review.super_cluster_pending PARTITION(dt='${dt}') SELECT cluster_id_min, count(*) c FROM dwm.dwm_oneid_graph_df_tmp WHERE dt='${dt}' GROUP BY cluster_id_min HAVING c>5000",
"deps": ["cc_graphx"],
"on_success": {"if_rows_gt": 0, "action": "ALARM", "level": "WARN"}
},
{
"name": "assign",
"type": "SPARK",
"resource": "oneid_core/jobs/assign_job.py",
"main_args": "--dt ${dt}",
"spark": {"driver_memory": "8g", "executor_memory": "8g",
"executor_cores": 4, "num_executors": 100},
"retry": {"times": 2, "interval": "10m"},
"deps": ["super_cluster_scan"]
},
{
"name": "load_redis",
"type": "PYTHON",
"resource": "oneid_core/jobs/load_redis_job.py",
"main_args": "--dt ${dt} --cluster prod-redis-oneid",
"retry": {"times": 3, "interval": "5m"},
"timeout": {"minutes": 80, "policy": "FAIL_ALARM"},
"deps": ["assign", "fusion"]
},
{
"name": "quality_daily",
"type": "SPARK_SQL",
"resource": "oneid_core/quality/daily_metrics.sql",
"deps": ["assign"],
"retry": {"times": 1},
"blocking": false,
"alarm": {"connect_rate_drop_gt": 0.02, "merge_rate_spike_gt": 0.005}
}
],
"alert": {
"channels": ["wechat:data-oneid-oncall", "phone:tech-lead"],
"rules": [
{"when": "task_failed_after_retry", "to": "wechat"},
{"when": "workflow_lag_gt_90min", "to": ["wechat", "phone"]},
{"when": "quality_daily.metric_breach", "to": "phone"}
]
}
}
几个生产上磨出来的调度约定:
- 重试只给幂等任务 。所有 Spark 任务都写
INSERT OVERWRITE动态分区或 IcebergoverwritePartitions,天然幂等,重试安全;load_redis用 HSET 覆盖写 + changelog 按(id_hash, dt, version)去重,也是幂等的。非幂等节点(如发号)禁止重试,雪花发号在任务内做号段预留,失败恢复后从预留点继续。 - 质量任务旁路不阻断 。
quality_daily/edge_qc失败只告警不阻断主链路------质量作业本身挂了不应该让当天 OneID 产不出来;但质量指标越界(连通率日环比跌 2 个百分点、合并率突增 0.5%)直接电话告警,因为那通常意味着误并事故(坑①)。 - 超时即失败。每个任务都设 timeout,到点 kill + 告警,防止某个 task 数据倾斜挂死把整条链路拖到白天业务高峰。
- SLA 兜底告警 。工作流 06:00 前未跑完(
workflow_lag_gt_90min相对 04:30 的预期完成点)电话叫人------08:00 营销早高峰前 Redis 必须是新映射。
15.5 灰度切流:不拿全量用户赌第一天
OneID 是底层身份数据,一旦大面积误并,画像、券、推荐全部跟着错,所以上线采用三阶段灰度 + 双跑核对:
阶段0(影子) 阶段1(小流量) 阶段2(单业务) 阶段3(全量)
双跑不对外 → 1% 白名单用户 → 仅"营销短信"业务 → 全部下游切流
新旧两套并行 客服侧可见OneID 用新OneID发券 旧ID Mapping下线
每日对账 观察2周误并率 观察ROI与投诉 保留回滚开关30天
双跑对账的核心 SQL(每日对比新旧两套映射的差异率,差异超过阈值自动阻断灰度推进):
sql
-- oneid_core/quality/gray_diff.sql
-- 新 OneID 与旧 ID Mapping 在"强ID两两是否同簇"上的差异
WITH new_pair AS ( -- 新体系: 同一 oneid 下任意两个强ID视为同属一人
SELECT a.id_hash AS h1, b.id_hash AS h2
FROM dim_oneid_id_map a JOIN dim_oneid_id_map b
ON a.oneid = b.oneid AND a.id_hash < b.id_hash
WHERE a.dt='${dt}' AND b.dt='${dt}'
AND a.map_status=1 AND b.map_status=1
AND a.id_type IN ('mdn','unionid','member_id')
AND b.id_type IN ('mdn','unionid','member_id')
),
old_pair AS ( -- 旧体系: 旧 mapping 表同口径
SELECT id1 AS h1, id2 AS h2 FROM ods.old_id_pair_di WHERE dt='${dt}'
)
SELECT
(SELECT count(*) FROM new_pair) AS new_pairs,
(SELECT count(*) FROM old_pair) AS old_pairs,
sum(CASE WHEN o.h1 IS NULL THEN 1 ELSE 0 END) AS merge_gain, -- 新打通的(收益)
sum(CASE WHEN n.h1 IS NULL THEN 1 ELSE 0 END) AS merge_conflict-- 新拆/旧合(需人工看)
FROM new_pair n FULL OUTER JOIN old_pair o ON n.h1=o.h1 AND n.h2=o.h2;
-- 灰度推进门槛: merge_conflict / old_pairs < 0.3%, 且冲突抽样人工确认无误并
回滚开关在服务层:FastAPI 配置中心有一个 oneid.routing={new|old|shadow} 开关,10 秒内全集群生效。一旦发现误并扩大,切回 old,业务无感(坑①的 10 万账号事故就是靠这个开关 15 分钟内切回止损的)。
15.6 上线排期与团队分工
项目总周期 12 周,数据开发 4 人、平台 2 人、业务方对接人 3 人(营销/门店/客服各 1):
| 周次 | 里程碑 | 数据开发(4人) | 平台(2人) | 业务方(3人) |
|---|---|---|---|---|
| W1 | 需求对齐 & 触点盘点 | 5 端 ID 台账、强/弱 ID 分级 | 搭建开发环境、Kafka topic 申请 | 确认各触点埋点 owner、历史数据范围 |
| W2 | 标准化上线 | hasher/validator、5 端 adapter | Hive/Iceberg 表创建、队列资源 | 提供各 ID 格式规则与样例 |
| W3 | 建边 & 置信度标定 | 5 类边抽取、阈值标定(人工标注 2000 对) | Spark 大任务资源池 | 营销提供"已知同人"样本做标定 |
| W4 | GraphX 连通调通 | 连通作业、超级簇熔断 | GraphX 资源调优、2000 分区 | --- |
| W5 | 分配 & 融合 | 雪花发号、黄金记录融合 | 雪花 worker 时钟同步 | 确认属性可信度排序(门店会员卡性别最可信等) |
| W6 | 实时链路 | Flink 并查集作业、状态 TTL | Flink 集群、Checkpoint 存储 | 提供登录/绑定事件埋点 |
| W7 | 服务化 | FastAPI 四接口、多级缓存 | Redis 集群(16 分片)、K8s 部署 | 申请 AK/SK、提调用量预估 |
| W8 | 质量体系 | 六大质量指标 SQL、告警规则 | 看板、告警通道 | 参与月度抽样标注流程 |
| W9 | 双跑 & 对账 | 新旧双跑、灰度对账 SQL | 影子流量镜像 | 客服侧试用 OneID 视图 |
| W10 | 小流量灰度 | 1% 白名单、误并工单流 | 灰度开关、回滚演练 | 营销短信试点、客服反馈 |
| W11 | 全链路压测 & 故障演练 | 压测脚本、对账核对 | 实时/离线压测、Chaos 演练 | 大促预案签字 |
| W12 | 全量切流 & 旧链路下线 | 切流、30 天回滚窗口值守 | 旧 Mapping 只读归档 | 全业务接入验收、培训 |
分工上有三条经验:
- 置信度标定必须业务方出样本。纯数据团队拍脑袋定阈值就是坑①的根源;W3 由营销提供"我们确定是同一个人/确定不是同一个人"各 1000 对标注样本,阈值才有据可依。
- 平台同学提前 3 周进场。Redis 分片数、Flink Checkpoint 存储、GraphX 队列资源这些都是长周期申请,W4 才提工单必然卡脖子。
- 业务方不是"验收方"而是"共建方"。客服同学在 W9 试用时就发现了"注销用户仍被弹窗"(坑⑩的早期信号),比全量后被投诉早了 3 周。
15.7 上线效果:前后对比
全量切流后 30 天的核心指标(before 为打通前基线,after 为切流后稳定期):
| 指标 | 口径 | before | after | 变化 |
|---|---|---|---|---|
| 全渠道 ID 连通率 | 强 ID 可达的 ID 占比 | 42% | 91% | +49pp |
| 跨端识别率(5 端中 ≥2 端可关联) | 日活用户中多端可拼比例 | 23% | 78% | +55pp |
| 人群圈选跨端覆盖率 | DMP 人群包含 ≥2 端 ID 的包占比 | 18% | 86% | +68pp |
| 全渠道归因准确率 | 人工抽检 2000 笔转化的首末触点归因正确率 | 61% | 93% | +32pp |
| 营销短信 ROI | 核销 GMV / 短信成本 | 1:3.2 | 1:5.7 | +78% |
| 优惠券核销率 | 跨端去重后发券的核销比例 | 4.1% | 6.8% | +66%(相对) |
| 重复发券浪费 | 同一人多端重复领券预算占比 | 11.5% | 1.8% | -84% |
| 客服平均处理时长 | 来电到查全渠道资料的时间 | 8 分 12 秒 | 3 分 05 秒 | -62% |
| 推荐 CTR(首页千人千面) | 用 OneID 拼跨端行为后 | 2.9% | 3.8% | +31%(相对) |
| 误并投诉率 | 用户侧"账号串了"工单数 / 百万日活 | 无体系不可比 | 0.7 件/百万 | 稳定低于 1/百万 |
几个数字的读法,避免被"数据包装"误导:
- 连通率 42%→91% 不是"算法神奇":大头来自小程序 unionid 与 App mdn 的绑定边(强边,置信度 0.98)和门店会员卡 mdn 的接入------工程上把"绑定关系"这一最可靠的边吃干抹净,就贡献了约 35 个百分点;算法(弱关系共现)只贡献剩下的部分,且每一步都有熔断。
- ROI 1:3.2→1:5.7 的核心不是"识别更多人",而是"不再重复打扰 + 跨端归因算清账":重复发券浪费从 11.5% 降到 1.8% 直接省出预算,归因准确后市场部敢给门店导购算提成,线下配合度上来形成正循环。
- 0.7 件/百万误并投诉是我们最看重的指标:连通率可以靠放松阈值刷上去(坑①就是刷指标刷出来的事故),误并率才是 OneID 的生命线。
15.8 五端接入登记规范:把"混乱"挡在标准化之前
项目里最早立的规矩不是算法,而是一张接入登记表------任何触点的任何 ID 要进 OneID,必须先登记、评审、灰度。表长这样(存在配置中心,标准化作业启动时加载校验):
yaml
# oneid_core/conf/source_registry.yaml
- touch_point: mini
source_table: ods.ods_mini_event_di
owner: zhangsan@xinggou.com
id_types:
- id_type: unionid
id_level: 1 # 强ID
salt_key: salt_wechat # KMS 盐值引用(第14章)
pattern: '^o[A-Za-z0-9_-]{20,}$'
critical: true # 缺失则阻断链路
rel_sources: [bind] # 该ID能产生哪些边
- id_type: openid
id_level: 0
salt_key: salt_wechat
pattern: '^o[A-Za-z0-9_-]{20,}$'
critical: false
rel_sources: [] # openid 不直接建边, 只作属性挂载
report_sdk: wechat-mini-sdk@2.3.1 # 上报SDK版本(客户端预哈希)
cdc_or_batch: batch
sla_arrival: '01:30' # 数据到仓SLA, sensor 据此判断就绪
埋点侧三条铁律(客户端 SDK 封装,业务方改不了):
- 客户端预哈希:手机号等强 ID 在端上就用内置盐做 HMAC 后上报,明文不出客户端;盐按 App 版本下发、不落客户端日志;
- 每条事件必带"事件时刻已有 ID 全集" :登录事件要同时带
device_id + mdn_hash + unionid,建边就靠这种"同事件多 ID 共存",缺一个 ID 就少一条最可靠的 bind 边; - ID 变更必须显式事件 :换绑手机号、退出登录、注销都是独立事件类型(
bind/unbind/cancel),不能靠"新 ID 不再出现"隐式推断------隐式推断正是注销漏处理(坑⑩)的根源。
接入评审 checklist(数据 + 安全 + 业务三方签字):ID 分级是否合理?弱 ID 是否被误标成强 ID(历史上 H5 的 cookie 曾被误报为强 ID)?盐值与 KMS key 是否申请?数据 SLA 能否赶上 02:00 主链路?该触点会不会引入公共设备噪声(门店导购 Pad、客服共用坐席机)?
15.9 一个用户的全链路旅程:小王的 72 小时
抽象的链路不如一个具体的人。灰度期间我们抽样追踪了用户"小王"(真实数据脱敏)的 72 小时,验证五端数据如何汇聚成一个 OneID:
| 时刻 | 触点 | 事件 | 涉及 ID(哈希简写) | 链路动作 |
|---|---|---|---|---|
| D1 10:02 | H5 | 微信里点开分享链接,浏览商品 | cookie=c1, fp=f1 | 标准化建档,弱 ID 孤立节点,无 OneID(匿名态) |
| D1 10:08 | 小程序 | 跳转小程序领券,微信授权 | unionid=u1, openid=o1 | u1 发 OneID=100238;c1/f1 与 u1 暂无边 |
| D1 20:30 | App | 下载 App,用同手机号登录 | mdn=m1, device=d1, idfa=i1 | 登录事件 bind(m1,u1) → 实时合并,d1/i1 并入 100238;c1/f1 经设备指纹共现(co_event×4 次)次日离线并入 |
| D2 14:00 | 门店 | 门店导购扫会员码,办会员卡 | member_id=mb1, mdn=m1 | POS 录入 m1,T+1 bind(m1,mb1) 强边 → mb1 并入 100238 |
| D2 15:10 | 客服 | 打电话问门店订单物流 | 来电 mdn=m1 | 客服系统弹屏:OneID 100238 的全渠道视图(App 订单+小程序券+门店会员卡),处理时长 42 秒 |
| D3 09:00 | 营销 | 圈选"App 加购未购 + 小程序领券 + 门店会员" | OneID=100238 命中 | 跨端人群包一次命中(旧体系要 3 个包),推送 20 元券 |
| D3 19:20 | 门店 | 到店核销优惠券 | mb1 核销 | 归因闭环:线上广告触点 → 门店成交,ROI 归到实时券 |
这个案例在项目里被反复使用,因为它同时验证了四件事:匿名→实名的实时合并(D1 晚)、T+1 弱边收敛(c1/f1 次日并入)、线下强边接入(mb1)、跨端归因闭环(D3 核销)。任何一个环节断了,小王的旅程就会断成几个人------这就是连通率 42%→91% 的微观含义。
15.10 运维排障工具箱:常用 SQL
生产值班时最高频的三类排查:"这个 ID 归到谁了?""这个簇为什么这么大?""这个 OneID 昨天到今天变过没有?"对应三条 SQL:
sql
-- ① 单 ID 全链路追溯: 一个 id_hash 的标准化→边→簇→映射
WITH node AS (
SELECT id_hash, id_type, id_level, touch_point, first_seen_time
FROM ods.ods_user_id_detail_di WHERE dt='${dt}' AND id_hash='${h}'),
edges AS (
SELECT src_id_hash, dst_id_hash, rel_type, confidence, is_strong
FROM dwd.dwd_id_relation_edge_df WHERE dt='${dt}'
AND (src_id_hash='${h}' OR dst_id_hash='${h}') AND is_pruned=0)
SELECT n.*, e.rel_type, e.confidence, e.is_strong,
g.oneid, g.cluster_size, m.map_status
FROM node n
LEFT JOIN edges e ON 1=1
LEFT JOIN dwm.dwm_oneid_graph_df g ON g.id_hash=n.id_hash AND g.dt='${dt}'
LEFT JOIN dim.dim_oneid_id_map m ON m.id_hash=n.id_hash AND m.dt='${dt}';
-- ② 大簇诊断: 某簇的ID构成与强ID密度(判断是真人还是黑产)
SELECT id_type, id_level, count(*) cnt
FROM dwm.dwm_oneid_graph_df
WHERE dt='${dt}' AND cluster_id_min='${cluster}'
GROUP BY id_type, id_level ORDER BY cnt DESC;
-- 真人簇: 强ID(mdn/unionid/member)占比高, id_type 3~6 种且均衡
-- 黑产簇: 99% 是 cookie/device, 强ID 零星, id_type 高度集中
-- ③ 映射变更追溯: 某 OneID 近 7 天的成员变化(定位跳变/拆分)
SELECT dt, id_hash, id_type, oneid, map_status, start_time, end_time
FROM dim.dim_oneid_id_map
WHERE oneid=${oneid} AND dt BETWEEN '${d-7}' AND '${dt}'
ORDER BY dt, id_hash;
第16章 生产案例二:实时 OneID 与营销实时触达
离线 T+1 链路解决的是"昨天的数据今天准",但营销场景有三个等不到明天的时刻:用户刚登录(此刻就要把他的设备和账号拼起来)、用户刚加购(分钟级圈选人群)、用户刚到店/刚浏览(优惠券秒级触达)。本章讲星购的实时 OneID 链路如何用 Flink 1.18 + Redis 7 支撑这三个时刻,并给出压测报告与大促前的故障演练预案。
16.1 业务场景:三个"等不到明天"
| 场景 | 触发事件 | 实时诉求 | 旧做法(T+1)的损失 |
|---|---|---|---|
| 登录即打通 | App/小程序登录、绑定手机号 | 登录事件发生后 3 秒内,device_id ↔ mdn ↔ unionid 合并为同一 OneID | 新用户首日行为挂在"匿名设备"上,首单券发不出去 |
| 分钟级人群圈选 | 加购、浏览类目、领券 | 行为发生后 1 分钟内进入/退出人群包 | 大促整点爆发时,人群包是昨天的,券发完了人还没进包 |
| 实时券触达 | 到店(LBS)、购物车放弃 10 分钟 | 命中人群后 5 秒内推券到用户当前触点 | 用户离开 App 才收到 push,核销率极低 |
三个场景对实时 OneID 的要求是一句话:任意 ID 事件进来,秒级回答"它属于哪个 OneID、这个 OneID 此刻在哪些人群包里、该触达哪个频道"。
16.2 作业拓扑:Flink 实时并查集 + Redis 热查
Kafka 事件流 Flink 作业 oneid_realtime (并行度 128)
┌──────────────────┐
│ app_login (绑定)│──┐
│ mini_login (绑定)│──┤ keyBy: 事件涉及的"任一已知ID"
│ h5_event (共现)│──┼──────────────┐
│ pos_scan (门店)│──┤ ▼
│ cs_bind (客服)│──┘ ┌────────────────────────┐ ┌─────────────────────┐
└──────────────────┘ │ UnionFindProcessFunc │────►│ Redis 集群(16分片) │
│ (KeyedProcessFunction) │ │ oneid:map:{hash} │
Kafka CDC │ 状态: ValueState<UF> │ │ oneid:members:{oid}│
┌──────────────────┐ │ RocksDB + TTL 7天 │ │ crowd:{crowd_id} │
│ bind_log CDC │─────►│ 定时器: 5min 批量合并 │ └─────────┬───────────┘
│ account_cancel │─────►└───────────┬────────────┘ │
└──────────────────┘ │ 侧输出 │ 热查
┌───────────▼────────────┐ ▼
│ 合并changelog(主流) │ ┌─────────────────────┐
│ 超级簇告警(侧输出) │ │ 触达决策服务(FastAPI)│
└───────────┬────────────┘ │ /crowd/hit /coupon │
▼ └─────────┬───────────┘
Kafka oneid_changelog ──► 人群计算Flink作业 ──► 券触达(push/短信/小程序订阅消息)
│
└──► 离线 HDFS 归档(每日与 GraphX 全量对账)
设计要点(为什么长这样):
- keyBy 用"事件涉及的任一已知 ID"而不是 OneID :事件进来时 OneID 可能还不存在(新设备),所以 keyBy 原始 id_hash;并查集在状态里维护
id → root,合并发生在 root 相遇时。 - Redis 是"读缓存"不是"状态真源":并查集真状态在 Flink RocksDB state(Checkpoint 保障),Redis 只服务在线查询;Flink 每次合并后双写 Redis,Redis 故障时作业不停(写失败进侧输出重放队列,见 16.6)。
- 5 分钟批量合并定时器:同一个用户登录瞬间会产生登录、绑定、埋点等 5~10 个事件,逐条合并会产生大量中间态;用 5 分钟定时器攒批,一次 union 到位,changelog 只发最终结果。
16.3 核心处理函数代码
python
# oneid_core/realtime/union_find_func.py
"""Flink 实时并查集(PyFlink 1.18,DataStream API)。
职责: 消费 ID 事件, 维护增量并查集, 合并后双写 Redis + 发 changelog。
容错: RocksDB 状态 + Checkpoint(Exactly-Once); Redis 写失败走侧输出重放。
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass, field
from typing import Optional
from pyflink.datastream import (StreamExecutionEnvironment, KeyedProcessFunction,
RuntimeContext, OutputTag)
from pyflink.datastream.state import ValueStateDescriptor
from pyflink.common.typeinfo import Types
from pyflink.common import Configuration
# 侧输出: Redis 写失败的变更(重放队列消费); 疑似超级簇(告警)
DLQ_REDIS_TAG = OutputTag("redis-write-fail")
SUPER_TAG = OutputTag("super-cluster-alarm")
# 合并阈值: 实时只认强边(绑定/支付), 弱边留给离线 T+1 收敛------实时误并代价远大于漏并
STRONG_REL = {"bind", "pay"}
MERGE_BATCH_MS = 5 * 60 * 1000 # 5 分钟攒批
CLUSTER_ALARM_SIZE = 2000 # 实时侧簇大小告警线(比离线5000更保守)
@dataclass
class UFNode:
"""并查集节点状态(序列化成 JSON 存 ValueState)。"""
parent: dict[str, str] = field(default_factory=dict) # id_hash -> root
size: dict[str, int] = field(default_factory=dict) # root -> 簇大小
anchor: dict[str, str] = field(default_factory=dict) # root -> 锚点强ID
oneid: dict[str, int] = field(default_factory=dict) # root -> OneID(已发号)
pending: list[dict] = field(default_factory=list) # 待合并边(攒批)
class UnionFindProcessFunc(KeyedProcessFunction):
"""keyed by id_hash: 每个 key 持有自己涉及过的并查集分片。"""
def open(self, ctx: RuntimeContext):
# 状态 TTL: 7 天不活跃的 key 自动清理(坑⑦状态膨胀的第一道防线)
ttl_config = Configuration()
ttl_config.set_string("state.backend.rocksdb.ttl", "7d")
desc = ValueStateDescriptor("uf", Types.STRING())
self.uf_state = ctx.get_state(desc)
self.redis_client = None # 由 RichFunction 初始化注入, 连接池在 open() 建立
def process_element(self, event: str, ctx: "KeyedProcessFunction.Context"):
try:
evt = json.loads(event)
except json.JSONDecodeError:
ctx.output(DLQ_REDIS_TAG, {"raw": event, "err": "bad_json"})
return
uf = self._load(ctx)
rel = evt.get("rel_type", "co_event")
# 实时只合并强关系; 弱关系仅归档, 等离线 GraphX 收敛(避免实时误并)
if rel not in STRONG_REL:
self._archive(evt)
return
a, b = evt["src_id_hash"], evt["dst_id_hash"]
uf.pending.append({"a": a, "b": b, "rel": rel,
"ts": evt["ts"], "touch": evt.get("touch_point")})
# 注册/复用 5 分钟定时器做批量合并
timer = ctx.timer_service().current_processing_time() + MERGE_BATCH_MS
ctx.timer_service().register_processing_time_timer(timer)
self._save(ctx, uf)
def on_timer(self, ts: int, ctx: "KeyedProcessFunction.OnTimerContext", out):
uf = self._load(ctx)
if not uf.pending:
return
changes: list[dict] = []
for edge in uf.pending:
chg = self._union(uf, edge["a"], edge["b"], edge["rel"], ctx)
if chg:
changes.append(chg)
uf.pending.clear()
self._save(ctx, uf)
# 双写 Redis: 失败进侧输出, 不挡主流(At-Least-Once 写 + 幂等 HSET)
for chg in changes:
if not self._write_redis(chg):
ctx.output(DLQ_REDIS_TAG, chg)
out.collect(json.dumps(chg, ensure_ascii=False)) # 主流 changelog
# ---------- 并查集核心: union by size + 锚点稳定(坑②: 严禁路径压缩改 root 指向) ----------
def _union(self, uf: UFNode, a: str, b: str, rel: str, ctx) -> Optional[dict]:
ra, rb = self._root(uf, a), self._root(uf, b)
if ra == rb:
return None
# 小簇并入大簇; 但锚点(强ID)所在簇永远为 root------保证 OneID 不跳变
if uf.anchor.get(rb) and not uf.anchor.get(ra):
ra, rb = rb, ra
if uf.size.get(ra, 1) < uf.size.get(rb, 1) and not uf.anchor.get(rb):
ra, rb = rb, ra
uf.parent[rb] = ra
uf.size[ra] = uf.size.get(ra, 1) + uf.size.get(rb, 1)
# 锚点继承: 任一簇有强ID锚点则合并后保留
uf.anchor[ra] = uf.anchor.get(ra) or uf.anchor.get(rb)
# OneID 继承: 已有 OneID 的簇为准; 都没有则发新号(调离线发号服务, 雪花)
oid = uf.oneid.get(ra) or uf.oneid.get(rb) or self._issue_oneid(uf, ra)
uf.oneid[ra] = oid
if uf.size[ra] > CLUSTER_ALARM_SIZE:
ctx.output(SUPER_TAG, {"root": ra, "size": uf.size[ra], "rel": rel})
return {"op": "merge", "oneid": oid, "root": ra, "merged_root": rb,
"rel": rel, "ts": int(time.time() * 1000)}
def _root(self, uf: UFNode, x: str) -> str:
# 注意: 只做"查找时的父链跟随", 不做路径压缩回写------
# 路径压缩会改变非 root 节点的父指针, 与离线锚点规则冲突会导致 OneID 跳变(坑②)
root = x
while uf.parent.get(root, root) != root:
root = uf.parent[root]
return root
def _issue_oneid(self, uf: UFNode, root: str) -> int:
"""调用发号服务(雪花), 带本地号段缓存; 时钟回拨由服务端拒绝(第7章)。"""
# 生产为 HTTP 调用 allocator 服务, 此处省略重试与超时
raise NotImplementedError # 见 oneid_core/allocator/client.py
def _write_redis(self, chg: dict) -> bool:
"""HSET/SADD 幂等写; 异常返回 False 进 DLQ。"""
try:
# self.redis_client.hset(f"oneid:map:{...}", ...) / sadd members
return True
except Exception:
return False
def _load(self, ctx) -> UFNode:
raw = ctx.get_state(self.uf_state) # 简化: 实际为 self.uf_state.value()
return UFNode(**json.loads(raw)) if raw else UFNode()
def _save(self, ctx, uf: UFNode) -> None:
self.uf_state.update(json.dumps(uf.__dict__, ensure_ascii=False))
def _archive(self, evt: dict) -> None:
"""弱边归档到 HDFS, 供离线 GraphX 全量收敛。"""
pass
注:上面
_load/_save/ctx.get_state为讲解简化,生产代码通过self.uf_state.value()/self.uf_state.update()访问;_issue_oneid的 HTTP 客户端、Redis 客户端初始化在open()中完成并带连接池与熔断。
状态 TTL 与容错配置(作业主函数):
python
# oneid_core/realtime/job.py
def build_job(env: StreamExecutionEnvironment):
env.set_parallelism(128)
# Checkpoint: Exactly-Once, 间隔 60s, 增量 Checkpoint(RocksDB), 超时 10min
env.enable_checkpointing(60_000)
ck_cfg = env.get_checkpoint_config()
ck_cfg.set_checkpointing_mode(CheckpointingMode.EXACTLY_ONCE)
ck_cfg.set_checkpoint_timeout(600_000)
ck_cfg.set_min_pause_between_checkpoints(30_000)
ck_cfg.set_max_concurrent_checkpoints(1)
ck_cfg.enable_unaligned_checkpoints() # 反压时 Checkpoint 不超时
ck_cfg.set_checkpoint_storage("hdfs:///oneid/ckpt/oneid_realtime")
ck_cfg.enable_externalized_checkpoints(
ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION) # 取消后保留, 便于回退
# 重启策略: 固定延迟, 最多 5 次, 每次 30s(配合 Kafka 位点从 Checkpoint 恢复)
env.set_restart_strategy(RestartStrategies.fixed_delay_restart(5, 30_000))
# 状态后端: RocksDB + 增量快照; 状态 TTL 在 StateTtlConfig 中按描述符设置 7 天
env.set_state_backend("rocksdb")
return env
三条容错铁律:
- Kafka 消费位点随 Checkpoint 提交:作业重启从最近 Checkpoint 恢复,合并逻辑幂等(union 同一对节点多次结果相同),Redis 写也是幂等 HSET,所以端到端 effectively exactly-once。
- Checkpoint 取消后保留 :发版/回滚时可以从指定 Checkpoint 重启,不会丢状态;
RETAIN_ON_CANCELLATION是实时身份作业的标配。 - Redis 不是单点依赖 :写 Redis 失败进 DLQ(Kafka 主题
oneid_redis_dlq),由一个独立的重放作业兜底重放;读 Redis 失败时 FastAPI 降级读本地 L1 + 布隆过滤器(第 10 章),实时合并本身不受影响。
16.4 分钟级人群圈选与实时触达
OneID changelog 下游挂两个 Flink 作业:人群计算(维护 crowd:{crowd_id} 集合,OneID 进出包实时更新)和触达决策(OneID + 行为事件命中规则 → 发券)。触达决策的关键是频控 + 频道选择 + 幂等:
python
# oneid_core/realtime/reach_decision.py(业务侧营销引擎, 与 OneID 服务对接)
"""实时触达决策: 命中人群 -> 选频道 -> 频控 -> 发券。
幂等: (oneid, coupon_id, biz_date) 为幂等键, Redis SETNX 去重。
频控: 同一 OneID 5 分钟内最多 1 次触达, 每天最多 3 次。"""
import time
FREQ_WINDOW_SEC = 300
DAILY_CAP = 3
def decide_and_send(oneid: int, crowd_ids: list[str], event: dict, redis_client) -> dict:
now = int(time.time())
day = time.strftime("%Y-%m-%d")
# 1) 日频控
daily_key = f"reach:daily:{oneid}:{day}"
if int(redis_client.get(daily_key) or 0) >= DAILY_CAP:
return {"sent": False, "reason": "daily_cap"}
# 2) 窗口频控(SETNX 原子占坑, TTL 5 分钟)
win_key = f"reach:win:{oneid}"
if not redis_client.set(win_key, "1", nx=True, ex=FREQ_WINDOW_SEC):
return {"sent": False, "reason": "window_freq"}
# 3) 幂等: 同券同人当天只发一次
coupon = pick_coupon(crowd_ids, event)
idem_key = f"reach:idem:{oneid}:{coupon['id']}:{day}"
if not redis_client.set(idem_key, "1", nx=True, ex=86400):
return {"sent": False, "reason": "duplicate_coupon"}
# 4) 频道选择: 用户当前在线触点优先(情境触达), 否则按可达性降级
channel = pick_channel(oneid, event, redis_client) # app_push > 小程序订阅 > 短信
ok = send(channel, oneid, coupon, event)
if ok:
redis_client.incr(daily_key)
redis_client.expire(daily_key, 86400)
return {"sent": ok, "channel": channel, "coupon": coupon["id"]}
16.5 压测报告(大促前全链路压测)
压测环境与生产同规格(Flink 128 并行度、TM 8C16G × 64;Redis 16 分片 × 主从;Kafka 3 broker × 12 分区/主题),用历史大促峰值 3 倍流量回放 + 影子写入,结果如下:
| 指标 | 日常均值 | 大促峰值(压测) | 3 倍峰值(压测极限) | 结论 |
|---|---|---|---|---|
| 输入事件 QPS | 4.2 万 | 9.8 万 | 26 万 | 3 倍峰值开始出现轻微反压 |
| 合并吞吐(changelog/s) | 1.1 万 | 3.6 万 | 7.2 万 | 攒批定时器合并率 92% |
| 端到端延迟 P50(事件→Redis 可查) | 1.8s | 3.2s | 6.5s | 满足"登录 3 秒可查"SLO |
| 端到端延迟 P99 | 5.4s | 8.9s | 21s | 3 倍峰值 P99 超 15s SLO,需扩容到 192 并行度 |
| 触达决策延迟 P99(命中→发券) | 120ms | 260ms | 480ms | 满足 5 秒触达 |
| Flink 状态大小(RocksDB,含 TTL 7 天) | 1.2TB | 1.2TB | 1.25TB | TTL 生效,不随峰值线性涨 |
| Checkpoint 时长(增量) | 38s | 75s | 142s | 3 倍峰值接近 60s 间隔,需调间隔到 90s |
| Checkpoint 失败率 | 0% | 0% | 0.3%(unaligned 后归零) | 非对齐 Checkpoint 是关键 |
| Redis CPU(单分片峰值) | 42% | 67% | 91% | 热 key 散列后无单点(坑③) |
| Redis 查询 P99 | 2.1ms | 4.6ms | 9.8ms | 远低于服务 30ms 预算 |
| Kafka 消费延迟(lag) | <1 万 | 8 万 | 35 万(10 分钟追平) | 峰值回落 10 分钟内追平 |
压测得到的三个容量结论,直接写进了大促预案:
- 大促当天 Flink 扩到 160 并行度(留 1.6 倍余量),Redis 不动(16 分片在 2 倍峰值内安全)。
- Checkpoint 间隔大促临时调到 90s,避免反压时 Checkpoint 排队;代价是故障时最多多丢 90s 的合并进度(可由离线对账补回)。
- 触达决策服务无状态,K8s HPA 按 CPU 60% 自动扩,压测验证从 8 副本扩到 32 副本耗时 45 秒。
16.6 故障演练:大促前 Chaos 演练与应急预案
大促前 2 周做了三轮 Chaos 演练,以下是演练过的三类故障和验证过的预案:
故障一:Redis 单分片主节点宕机(演练:kill 主节点)
- 现象:该分片约 6% 的查询瞬间超时,FastAPI 错误率从 0.01% 升到 3.2%,持续约 40 秒(哨兵切主)。
- 预案验证:
- FastAPI 客户端配置
socket_timeout=50ms、retries=1,超时立刻降级到本地 L1 + 布隆过滤器,业务不报错 (返回最近一次缓存的 OneID,标记stale=true); - Flink 侧写 Redis 失败自动进 DLQ,哨兵切主后由重放作业追平,演练中 40 秒故障期间积压 4.2 万条变更,3 分钟追平;
- 告警:分片不可用 >30 秒电话值班;Redis 集群整体不可用 >2 分钟自动切换"只读降级"开关,触达作业暂停发券(宁可不发不可错发)。
- FastAPI 客户端配置
故障二:Checkpoint 连续失败(演练:人为把 HDFS 配额打满)
- 现象:Checkpoint 连续 3 次失败,Flink 按重启策略重启,消费暂停约 2 分钟。
- 预案验证:
- 监控规则:Checkpoint 连续失败 2 次即告警(不等作业挂);
- 第一优先恢复 Checkpoint 存储(清理配额/切备用 HDFS 目录),而不是重启作业------频繁重启只会反复失败;
- 若 10 分钟内无法恢复,执行"有状态降级":作业从最近成功 Checkpoint 重启 + Kafka 限流到 5 万 QPS,保证不积压;
- 事后用离线 T+1 GraphX 全量结果与 Redis 对账,自动补全故障窗口的合并(第 12 章对账 SQL)。
故障三:Kafka 消息积压(演练:下游触达服务主动 sleep 制造反压)
- 现象:
oneid_changelog消费 lag 涨到 1200 万,人群包更新延迟 15 分钟。 - 预案验证:
- OneID 合并作业与触达作业资源隔离(不同 Flink 集群/队列),触达反压不影响身份合并------这是演练验证过的最重要的隔离决策;
- 触达作业扩容:并行度 64→192,Kafka 分区 12→48(分区数提前按峰值建好,平时不消费),15 分钟扩完,lag 在 25 分钟内追平;
- 积压期间营销侧开关:延迟 >10 分钟自动暂停"时效敏感券"(如购物车放弃 10 分钟券,发晚了反而是骚扰),保留"日级券"继续消费;
- 频控幂等键保证追平时不会"补发轰炸":积压期间错过的窗口券在追平时已过频控窗口,SETNX 直接去重。
演练后沉淀的值班手册一句话原则:身份链路(Flink 合并)优先保命,触达链路宁可降级不可错发,所有恢复动作结束后必须跑离线对账兜底。
16.7 实时与离线怎么对账:两条链路的"日结"
实时链路追求秒级,离线链路追求全量准确,二者必然有短暂不一致。生产上用"日结 + 滚动修正"收口:每天 04:30 离线全量产出后,自动跑对账作业,把实时链路的"临时结论"与离线"权威结论"比对,差异分三类处理:
| 差异类型 | 含义 | 处理 |
|---|---|---|
| 实时漏并(离线有、实时无) | 弱边 T+1 才收敛,属预期 | 以离线为准回刷 Redis,不告警 |
| 实时合并方向相反 | 实时把 A 并到 B,离线锚点判定应为 B 并到 A | 以离线锚点为准回刷 OneID;若量大(>0.1%)告警查实时锚点逻辑 |
| 实时多并(实时有、离线无) | 实时误并(强边误判)或离线漏边 | 高优告警,人工核对;确认误并则按坑①流程拆分 |
对账核心逻辑(伪 SQL 骨架):
sql
-- oneid_core/quality/realtime_offline_recon.sql
SELECT
sum(CASE WHEN rt.oneid IS NULL THEN 1 ELSE 0 END) AS rt_miss, -- 实时漏
sum(CASE WHEN rt.oneid <> off.oneid AND off.is_anchor=1
AND rt.oneid NOT IN (off.oneid) THEN 1 ELSE 0 END) AS direction_conflict,
sum(CASE WHEN off.oneid IS NULL THEN 1 ELSE 0 END) AS rt_extra -- 实时多并
FROM (SELECT id_hash, oneid FROM redis_snapshot_di WHERE dt='${dt}') rt
FULL OUTER JOIN (SELECT id_hash, oneid, is_anchor
FROM dim.dim_oneid_id_map WHERE dt='${dt}' AND map_status=1) off
ON rt.id_hash = off.id_hash;
-- 阈值: direction_conflict > 0.1% 或 rt_extra > 0.05% 触发电话告警
对账回刷是幂等的:离线结果写 Redis 用 HSET 覆盖,OneID 以离线锚点为准;被回刷的 id_hash 发 changelog,下游人群包自动修正。实时链路快但允许错(错了有日结兜底),离线链路慢但是非裁判------这个权责划分是两条链路共存不打架的关键。
16.8 冷启动与补数:作业从头消费不压垮服务
实时作业新上线或从空状态重启时,有一个容易忽略的坑:Kafka 历史位点(或新接入触点的历史事件)如果直接从头灌,瞬间 QPS 会冲垮 Redis 和下游。星购的冷启动流程是三段式:
① 离线打底(不依赖实时): 先跑 T-1 全量, Redis 灌好全量映射 ------ 服务先可用
② 实时从"离线产出时刻"的位点启动: 不补历史, 历史已在离线结果里
③ 缺口兜底: 实时位点与离线快照之间的窗口事件, 由补数作业
有界回放(限速 2 万 QPS), 合并逻辑幂等, 重复消费无副作用
补数作业与正常作业的区别只有两个:限速 (通过令牌桶控制写出 QPS,避免冷流量冲击)和只读旁路 (补数期间 changelog 标记 backfill=true,下游触达服务对 backfill 消息只更新人群包、不发实时券------给三天前的行为发"实时券"是骚扰)。
16.9 实时链路成本小结
| 资源 | 规格 | 月成本(虚构内部核算价) | 说明 |
|---|---|---|---|
| Flink 集群(实时合并) | 64 TM × 8C16G,3 个作业 | 约 18 万 | 大促弹性扩到 96 TM |
| Flink 集群(人群+触达) | 48 TM × 8C16G | 约 12 万 | 与身份链路资源隔离 |
| Redis 集群 | 16 分片 × 64G 主从 | 约 9 万 | 映射热数据 + 频控 |
| Kafka | 3 broker × 12 主题分区 | 约 4 万 | 事件/changelog/DLQ |
| Checkpoint 存储(HDFS) | 增量快照保留 7 天 | 约 1.5 万 | RocksDB 增量 |
| 合计 | 约 44.5 万/月 | 离线链路约 57 万/月(第 13 章) |
实时链路成本约为离线的 78%,但它换来的是"登录 3 秒可打通、触达分钟级"------大促期间实时券核销率比日级券高 2.3 倍,这 44.5 万是营销侧主动买单的。
第17章 踩坑与事故复盘:10 个生产大坑
OneID 是"错一处、错一片"的底层数据:一次误并会顺着映射表污染画像、券、推荐、客服所有下游。本章复盘星购真实发生(或在灰度阶段拦下)的 10 个事故,每个按【现象】【原因】【修复】【预防】四段记录,关键坑附代码/SQL。建议当作上线前 checklist 逐条对照。
坑① 弱关系阈值过低,一夜误合并 10 万账号
【现象】 灰度第 3 天早上,客服侧开始出现"我账号里出现了别人的订单/优惠券"投诉,2 小时内 40 多起;质量看板显示当日"合并率"(新产生合并的 ID 对数)从日常 0.8% 突增到 6.3%,误并投诉率冲到 9 件/百万日活。受影响账号约 10.3 万个,集中在 H5 触点。
【原因】 前一天为了冲"连通率 KPI",把 co_event(同设备共现)边的置信度阈值从 0.70 降到 0.55、最小共现次数从 3 次降到 1 次。大量"网吧/商场公共 WiFi 下同设备同会话"的偶然共现被当成同一人,弱边把大量无关账号串成超大簇。根因有两个:一是阈值改动没走标注样本回归(第 15 章 W3 的标定流程被跳过);二是合并率突增没有实时熔断。
【修复】 紧急回滚 + 拆分,全流程约 4 小时:
15 分钟内: 服务层切 oneid.routing=old(回滚开关), 错误 OneID 停止对外服务 ← 止损
1 小时内: 定位问题配置(co_event 阈值), 回退建边作业参数, 重跑当日 edge_build
2 小时内: 重跑 cc_graphx + assign(强锚点 OneID 不变, 被误并的弱簇自动脱离)
4 小时内: load_redis 全量重灌 + changelog 广播; 离线对账确认误并簇清零
关键回滚 SQL(找出"今天新合并、且合并依据全是弱边"的簇,直接标记拆分):
sql
-- oneid_core/ops/emergency_split_weak.sql
-- 误并簇特征: 簇内无强ID锚点 或 簇内强ID之间只靠弱边连通
INSERT OVERWRITE TABLE dim.dim_oneid_id_map PARTITION(dt='${dt}')
SELECT m.id_hash, m.id_type,
-- 无强锚点的成员: 回退到"每个强ID各自成簇"的临时 OneID(发新号, 标记待审核)
CASE WHEN g.has_anchor = 1 THEN m.oneid
ELSE tmp_oneid_for_orphan(m.id_hash) END AS oneid,
CASE WHEN g.has_anchor = 1 THEN 1 ELSE 2 END AS map_status, -- 2=待审核
m.confidence, m.start_time, m.end_time, m.etl_time
FROM dim.dim_oneid_id_map m
JOIN (SELECT cluster_id_min,
MAX(CASE WHEN id_level=1 THEN 1 ELSE 0 END) AS has_anchor
FROM dwm.dwm_oneid_graph_df WHERE dt='${dt}'
GROUP BY cluster_id_min) g
ON m.id_hash = g.cluster_id_min -- 简化示意: 实际经 graph 表关联
WHERE m.dt='${dt}';
【预防】
- 阈值变更必须过标注集回归 :任何
RELATION_RULES改动,自动跑 2000 对标注样本,精确率低于 99.5% 禁止上线; - 合并率实时熔断 :Flink 作业里簇大小告警线(2000)+ 离线合并率日环比突增 0.5% 电话告警,告警触发自动阻断
load_redis(新映射不进 Redis,业务继续用前一天的); - 弱边永远不能单独成簇 :无强 ID 锚点的连通簇只发"待审核"临时 OneID,不进正式映射(代码里
map_status=2)。
坑② 并查集路径压缩导致 OneID 大面积跳变
【现象】 实时链路上线第二周,监控发现约 0.3% 的 OneID 在一天内发生变化------同一个 id_hash 早上查到 OneID=A,晚上变成 OneID=B。下游特征拼接出现"同一用户特征断档",推荐效果小幅下跌。
【原因】 实时并查集实现里用了标准的"路径压缩"优化:find() 时把节点父指针直接挂到 root。问题在于并查集的 root 选择规则是"按簇大小 union by size",而离线分配的铁律是"强 ID 锚点所在簇的 OneID 永不改变"。路径压缩 + union by size 会让 root 在合并后漂移到更大的簇,实时侧据此把 OneID 改成了大簇的号,与离线锚点规则冲突,OneID 就跳了。
【修复】
- 并查集
find()只沿父链查找,不回写父指针 (去掉路径压缩);union 时强制"锚点簇为 root",无锚点才按大小(见 16.3 代码中_union/_root); - OneID 只从锚点继承,root 漂移不改 OneID;
- 对已跳变的 ID 跑一次离线全量校准,以离线
dim_oneid_id_map为准回刷 Redis。
【预防】
- 代码评审把"并查集 root 规则"列为必查项:身份场景的并查集不是教科书实现,稳定性优先于查询复杂度(不压缩,find 仍是 O(log n) 的树高,10 亿级 ID 树高不超过 30,完全可接受);
- 每日对账加一条:`id_hash 的 OneID 只允许"新 ID 首次分配"和"弱簇并入强簇"两种变化,强锚点 ID 的 OneID 变化数必须为 0**,不为 0 即告警。
坑③ Redis 热 key 打垮单分片
【现象】 大促预热晚 8 点,Redis 集群一个分片 CPU 打满到 100%,该分片所有查询 P99 飙到 200ms,FastAPI 大面积超时;其余 15 个分片 CPU 只有 30%。排查发现热 key 是 oneid:members:{oneid} 中一个包含 8 万成员的集合------对应一个黑产控制的超大连通簇,/expand 接口被风控批量调用,全部打到这个 key 所在的单分片。
【原因】 两个问题叠加:一是超级簇没在实时侧拦住(坑⑤的早期形态),单个 key 的 value 巨大;二是 oneid:members 这类"反向大 key"没有打散,热点集中在一个 slot。
【修复】
- 应急:对该 key 做本地缓存(FastAPI L1 缓存 60 秒)+ 限流
/expand单调用方 QPS,5 分钟内分片 CPU 回落; - 大 key 打散:members 集合按 OneID 哈希分 16 片存储,读取时 pipeline 合并:
python
# oneid_core/serving/redis_keys.py
SHARD = 16
def members_keys(oneid: int) -> list[str]:
# 大 key 打散: oneid:members:{oid}:{0..15}, 避免单 slot 热点
return [f"oneid:members:{oneid}:{i}" for i in range(SHARD)]
def add_member(r, oneid: int, id_hash: str) -> None:
shard = oneid % SHARD
r.sadd(f"oneid:members:{oneid}:{shard}", id_hash)
def get_members(r, oneid: int) -> set[str]:
pipe = r.pipeline(transaction=False)
for k in members_keys(oneid):
pipe.smembers(k)
members: set[str] = set()
for part in pipe.execute():
members.update(part)
return members
- 超大 members 只读熔断:簇大小 >5000 的
/expand结果走异步导出审批,不提供同步在线查询。
【预防】 上线前做热 key 扫描(redis-cli --hotkeys + 业务 key 大小巡检),所有"一对多"大 key 强制分片设计;/expand 接口默认分页(每页 200)、默认限流。
坑④ HMAC 盐值轮换操作不当,全量哈希一夜失效
【现象】 安全团队按第 14 章的盐值轮换策略轮换 mdn 的 HMAC 盐值,当天夜间标准化作业跑完,次日发现新增 ID 与历史 ID 的哈希对不上:同一个手机号,老数据哈希是 X,新数据哈希是 Y,图里出现大量"同一个人两个节点",连通率虚高后又回落,Redis 映射与离线表出现约 8% 的 ID 对不上。
【原因】 盐值轮换做成了"一刀切":标准化作业直接切新盐计算,但历史 ods_user_id_detail_di 和 Redis 里的存量哈希还是旧盐算的,新老哈希无法 join。正确做法(第 14 章设计了但作业里没实现)是双写过渡期 + 双读兼容,作业实现时漏了。
【修复】
- 立即回退标准化作业到旧盐,停止错误分区产出;
- 实现正确的轮换流程后重跑:
python
# oneid_core/standardize/hasher.py(盐值轮换正确姿势)
def hmac_hash_id(raw: str, id_type: str, salt_versions: dict[str, list[str]]) -> list[str]:
"""盐值轮换: 返回(当前盐哈希, 旧盐哈希列表)。
过渡期(建议90天)标准化双写: 新盐哈希为主键, 旧盐哈希写入 alias 列;
建边/查询双读: 先用新盐查, miss 再用旧盐查, 命中后异步回写新盐。"""
salts = salt_versions[id_type] # [current_salt, prev_salt, ...]
return [hmac_sha256(s, raw) for s in salts]
- 过渡期内离线 join 使用
id_hash IN (new, old...),Redis 双 key 指向同一 OneID;过渡期结束、旧盐哈希全部迁移完成后,再下线旧盐。
【预防】 盐值轮换做成标准化的"变更工单":必须包含双写开关、过渡期时长(≥90 天,覆盖 Redis 7 天 TTL 的数倍)、回滚方案;轮换后第一周每日监控"新老哈希可 join 率",低于 99.9% 自动告警。盐值本身仍只存 KMS、代码里只引用 key(第 14 章)。
坑⑤ 超大连通簇:公共 WiFi 黑产把 37 万账号串成一簇
【现象】 质量看板的簇分布监控报警:出现一个 37.4 万节点的连通簇,且每天增长 5 万+。簇内 ID 类型杂乱(大量 cookie/device_id),强 ID 只有零星几个。该簇已经分配了 OneID,导致风控把这批黑产设备误判为"同一个高价值用户",薅走新人券约 200 万元。
【原因】 黑产在商场/车站公共 WiFi 下批量操控设备,所有设备共用同一出口 IP、同 WiFi BSSID,早期建边规则里有 wifi 边(同 WiFi 共现,置信度 0.30)且未剪枝;弱边链式传递(A-B 共现、B-C 共现......)把几十万台设备串成一个巨型簇。GraphX 连通本身没算错------是输入的边"语义错了":同 WiFi 不等于同人。
【修复】
- 紧急:该簇 OneID 冻结(map_status=2 待审核),下游券系统加黑名单,止损薅券;
wifi边全量下线(is_pruned=1默认剪枝,只留档不入图,见 15.3.2 代码);- 连通后增加簇规模熔断 + 簇内强 ID 密度检查:
sql
-- oneid_core/quality/super_cluster_scan.sql
-- 超级簇判定: 节点>5000 或 强ID占比<2% 且节点>500
SELECT cluster_id_min,
count(*) AS cluster_size,
sum(CASE WHEN id_level=1 THEN 1 ELSE 0 END) AS strong_cnt,
sum(CASE WHEN id_level=1 THEN 1 ELSE 0 END)*1.0
/ count(*) AS strong_ratio
FROM dwm.dwm_oneid_graph_df
WHERE dt='${dt}'
GROUP BY cluster_id_min
HAVING count(*) > 5000
OR (count(*) > 500 AND sum(CASE WHEN id_level=1 THEN 1 ELSE 0 END)*1.0/count(*) < 0.02);
-- 命中的簇: 不分配正式 OneID, 转 oneid_review.super_cluster_pending 人工/规则审核
- 审核规则:簇内切"最大双连通分量"找核,核外用强边重连,弱边全部丢弃;确认的黑产设备 ID 进黑名单库,后续直接拒绝建边。
【预防】 弱边只允许"挂靠"强簇、不允许"串联"------工程上实现为:弱边参与连通后,任何不含强 ID 的簇整体不生效;公共 WiFi/IP 类环境特征永久排除在关系边之外(它们是"地点"不是"人")。
坑⑥ CDC 重复消费导致关系边重复,簇被"撑大"
【现象】 业务库绑定关系表(t_user_bind)通过 Flink CDC 入仓,某天发现 dwd_id_relation_edge_df 里同一对 (src, dst) 边出现了 3 条,co_occur_cnt 异常翻倍;更严重的是下游统计"簇大小"时把重复边算成多节点关系,误触发了超级簇告警,一批正常用户被误冻结审核。
【原因】 CDC 作业做过一次从 savepoint 迁移到 Checkpoint 的重启,Binlog 位点与 Flink 消费位点出现重叠区间(约 12 分钟),绑定事件被重复消费;建边作业的聚合用了 sum(co_occur_cnt) 但没对"事件主键"去重,重复事件直接累加。
【修复】
- 建边聚合改为按事件幂等键去重后再聚合:
sql
-- 建边前先对 CDC 事件去重(幂等键: binlog file+pos 或 事件唯一 id)
INSERT OVERWRITE TABLE dwd.dwd_id_relation_edge_df PARTITION(dt='${dt}')
SELECT src_id_hash, dst_id_hash, src_id_type, dst_id_type, rel_type,
max(confidence) AS confidence,
count(DISTINCT event_id) AS co_occur_cnt, -- 关键: count(distinct 事件id) 而非 sum
min(first_time) AS first_time, max(last_time) AS last_time,
source_touch, max(is_strong) AS is_strong, min(is_pruned) AS is_pruned
FROM (
SELECT *, row_number() OVER (
PARTITION BY event_id ORDER BY ingest_time DESC) AS rn
FROM ods.ods_bind_cdc_di WHERE dt='${dt}'
) t
WHERE rn = 1 -- 同事件只保留最新一条
GROUP BY src_id_hash, dst_id_hash, src_id_type, dst_id_type,
rel_type, source_touch;
- 实时侧在 Flink 里用
KeyedState + event_id TTL 24h做去重,重复事件直接丢弃; - 对已产生的重复边重跑当日分区,误冻结用户自动解除。
【预防】 CDC 入仓一律带 event_id(Binlog 位点或业务主键+变更版本),所有下游聚合遵循"先 row_number 去重、再聚合";每日质量 SQL 加一条边唯一性检查:count(*) vs count(distinct src,dst) 偏差 >0.1% 告警。
坑⑦ Flink 状态膨胀:7 天 TTL 没拦住"定时器泄漏"
【现象】 实时并查集作业上线 3 周后,RocksDB 状态从上线初的 300GB 涨到 1.8TB,Checkpoint 时长从 30 秒涨到 9 分钟,反压频繁;但状态 TTL 明明天设置的是 7 天。
【原因】 状态膨胀的不是并查集本身,而是定时器 :每个事件到来都 registerProcessingTimeTimer,但没有"同一 key 已有待触发定时器就复用"的逻辑,一个活跃 key 在 5 分钟窗口内被注册了上万个定时器;定时器在 RocksDB 里也是状态,且定时器不随 ValueState 的 TTL 清理 。另外 pending 列表在定时器触发清空之前持续累积。
【修复】
- 定时器去重:每个 key 只保留一个待触发定时器,注册前先查状态里有没有:
python
# process_element 内: 仅当没有 pending 定时器时才注册
if not uf.pending: # pending 为空说明上一个定时器已触发, 才注册新的
timer = ctx.timer_service().current_processing_time() + MERGE_BATCH_MS
ctx.timer_service().register_processing_time_timer(timer)
# 非空时已有定时器在等待, 事件追加进 pending 即可, 不再注册
- 状态 TTL 显式挂到描述符(而不是全局配置字符串),并对
pending单独设短 TTL(1 小时); - 增加状态大小监控:
flink_state_size{job="oneid_realtime"}按算子上报,日增长率 >10% 告警。
【预防】 实时状态作业的容量评估要把"定时器数量"和"每 key 平均状态"都算进去;代码评审中凡是出现 registerTimer 必须检查注册前的去重逻辑;每周巡检状态增长曲线与 TTL 实际清理量(RocksDB compaction 后才释放空间,监控看磁盘而不是看逻辑条数)。
坑⑧ 布隆过滤器误判引发缓存穿透,Redis 被打满
【现象】 大促零点,Redis QPS 从日常 12 万飙升到 40 万,其中 70% 是查询"不存在的 id_hash"(爬虫和黑产用随机伪造设备 ID 刷接口)。布隆过滤器本应拦住这些查询,但当晚布隆过滤器因版本更新被重建,重建期间误判率(false positive)参数被误配成 0.5,一半"不存在"的 ID 被判为"可能存在"穿透到 Redis,Redis 连接数打满,正常查询也开始超时。
【原因】 两个问题:一是布隆过滤器重建脚本里容量参数按"ID 总量"估算但漏乘了 10 亿级的基数,bit 数组相对过小导致误判率飙升;二是缓存层对"布隆说可能存在、Redis 实际 miss"的结果没有空值缓存,同一个伪造 ID 反复穿透。
【修复】
- 空值缓存兜底:miss 结果写短 TTL 空标记,挡住重复穿透:
python
# oneid_core/serving/cache.py
NULL_MARK = "__null__"
def resolve_with_null_cache(redis_client, id_hash: str) -> dict | None:
key = f"oneid:map:{id_hash}"
val = redis_client.hgetall(key)
if val:
return None if val.get("oneid") == NULL_MARK else val
# 布隆放行但 Redis miss: 写 60s 空标记, 防同一伪造 ID 反复穿透
redis_client.hset(key, mapping={"oneid": NULL_MARK, "status": "miss"}, ex=60)
return None
- 布隆过滤器重建参数修正:按预期元素数 × 1.5 容量、误判率 0.1% 计算 bit 数(约 14.4 bit/元素),重建后先在影子环境验证误判率再切流;
- 接口层对"连续 miss 的调用方"自动降权限流(同一 AK 每分钟 miss 率 >80% 触发验证码级限流)。
【预防】 布隆过滤器只作为"第一道粗筛",永远要有空值缓存作为第二道;布隆重建纳入变更工单,必须带误判率验证步骤;缓存穿透、击穿、雪崩三类问题在压测中分别用随机 ID、热点 key、批量 TTL 到期做专项演练。
坑⑨ 属性融合权重配置错误,性别大面积翻转
【现象】 融合作业上线后一周,客服收到大量"App 里显示的性别和我注册时相反"的反馈;数据核对发现约 260 万用户的黄金记录性别在某天夜里从 M 翻成 F(或相反),且 dim_oneid_attr_history 里新值的来源是 H5 触点的浏览器行为推断。
【原因】 属性融合是"来源可信度加权投票",配置文件里各来源权重在一次合并冲突时被改反了:H5 行为推断性别(弱来源,可信度应 0.3)被错配成 0.95,而 App 注册性别(强来源,应 0.9)被配成 0.3。更致命的是融合逻辑里"高权重新值直接覆盖黄金值",没有"可信度差距不足时保守保留 + 进人工审核"的保护。
【修复】
- 紧急回滚融合配置到上个版本,用
dim_oneid_attr_history的历史快照重算黄金记录(属性历史表在此刻证明了价值------所有取值都有留痕,可以精确回滚到翻转前):
sql
-- 用属性历史表回滚: 取翻转事件(当天 02:00)之前的最后一个 is_current 值
INSERT OVERWRITE TABLE dim.dim_oneid_user PARTITION(dt='${fix_dt}')
SELECT u.oneid,
h.attr_value AS gender, h.src_type AS gender_src,
u.birth_year, u.city, u.nickname, u.mdn_hash, u.unionid_hash,
u.member_id_hash, u.id_cnt, u.touch_points,
u.first_active, u.last_active, u.oneid_status, u.version, u.etl_time
FROM dim.dim_oneid_user u
JOIN (SELECT oneid, attr_value, src_type,
row_number() OVER (PARTITION BY oneid
ORDER BY valid_from DESC) AS rn
FROM dim.dim_oneid_attr_history
WHERE attr_name='gender' AND valid_from < '${bad_dt} 02:00:00'
AND audit_status IN (0,2)) h
ON u.oneid = h.oneid AND h.rn = 1
WHERE u.dt='${bad_dt}';
- 融合逻辑加"翻转保护":新值与黄金值冲突且新来源可信度优势 <0.2 时,不直接覆盖,写
audit_status=1待审核:
python
# oneid_core/fusion/golden.py
FLIP_GUARD_MARGIN = 0.2 # 新来源可信度必须高出黄金来源 0.2 才允许直接翻转
def resolve_gender(candidates: list[AttrCandidate], current: AttrCandidate | None):
winner = max(candidates, key=lambda c: c.weight * c.time_decay)
if current and winner.value != current.value:
if winner.weight - current.weight < FLIP_GUARD_MARGIN:
return AttrDecision(value=current.value, action="hold",
audit="pending", reason="权重优势不足, 保守保留")
return AttrDecision(value=winner.value, action="flip" if current else "set")
【预防】 来源权重配置进配置中心、改动走双人复核 + 标注集回归(和坑①同一套机制);任何"大面积属性翻转"都是高风险信号------融合作业产出时统计"当日黄金值变更率",性别/年龄这类稳定属性日变更率 >0.5% 直接阻断写入并告警。
坑⑩ 账号注销后 OneID 被重新分配给别人
【现象】 有用户注销账号后重新注册,发现自己的"新账号"里挂着陌生人的浏览记录;还有合规审计发现,个别已注销用户的 OneID 出现在了营销触达名单里------这在个保法下是严重事故(注销后个人数据不得再用于自动化决策)。
【原因】 两个独立缺陷:①注销链路只清了业务库,没向 OneID 发 tombstone 事件,映射仍有效;②OneID 分配的"号段复用"逻辑有 bug------雪花 worker 重启后号段游标从一个过低的位置恢复,发出的号与历史已注销 OneID 撞号,导致新用户继承了旧 OneID 的映射关系。
【修复】
- 注销全链路打 tombstone(第 14 章设计补齐):注销事件经 CDC → Kafka → Flink → Redis/离线全链路置墓碑:
python
# oneid_core/realtime/tombstone.py
def handle_cancel(evt: dict, redis_client, producer) -> None:
"""账号注销: OneID 置墓碑, 所有查询返回 tombstoned, 下游停止触达。
注意: 不删除映射(审计需要), 只改状态; 成员关系保留 30 天后匿名化。"""
oneid = resolve_oneid(redis_client, evt["id_hash"])
if not oneid:
return
# 1) Redis: 主记录与所有成员 key 置 tombstone
redis_client.hset(f"oneid:user:{oneid}", mapping={"status": "tombstoned"})
for h in get_members(redis_client, oneid):
redis_client.hset(f"oneid:map:{h}", mapping={"status": "tombstoned"})
# 2) 广播: 服务层清缓存, 营销/推荐/人群计算把该 OneID 移出所有包
producer.send("oneid_changelog", {"op": "tombstone", "oneid": oneid})
# 3) 离线: dim_oneid_user.oneid_status=0; dim_oneid_id_map.map_status 置墓碑态
- 雪花发号修复号段复用:worker 启动时先向号段协调服务领取"严格大于历史最大号"的号段(号段高水位持久化到 ZooKeeper/MySQL,启动时读取并校验),从根上杜绝撞号;
- 数据修复:撞号 OneID 全部作废重发,受影响用户映射重建;已注销仍触达的记录纳入合规事件上报。
【预防】 注销是 OneID 的"一等公民事件",上线验收必须包含注销端到端用例(注销后 /resolve 返回 tombstoned、人群包剔除、触达拦截、30 天后匿名化);雪花发号增加"号段单调递增"的每日巡检(当日发号最小值 > 历史最大值,否则拒绝发号并告警)。
17.x 小结:10 个坑背后的 4 条共性规律
误并类(①⑤⑨) ── 阈值/权重是"数据产品", 必须标注回归 + 熔断
跳变类(②⑩) ── 身份标识稳定 > 一切优化, 锚点/号段单调不可破
容量类(③⑦⑧) ── 热点/状态/穿透都要"压测过 + 兜底降级"
一致性类(④⑥) ── 变更要灰度双写, 数据要幂等去重, 历史要可回溯
- 凡是"拍脑袋的阈值/权重"迟早出事故------置信度、融合权重、TTL、告警线,都要用标注样本或压测数据标定,改动走回归;
- OneID 的第一属性是"稳定"而不是"正确"------宁可漏并(明天离线补上)不可误并/跳变(污染所有下游且难挽回);
- 所有兜底都要是"降级可用"而不是"报错"------布隆 miss 有空值缓存、Redis 挂有 L1、Checkpoint 挂有离线对账;
- 留痕是最后的救命绳 ------
dim_oneid_attr_history(坑⑨)、边表快照(坑①)、changelog(坑⑩)让每一次事故都能精确回滚,没有留痕的系统不配跑在生产。
17.11 事故响应时间线模板(on-call 照做)
每次事故复盘我们都按同一条时间线记录,建议直接抄进值班手册:
T+0min 告警触发(合并率/簇大小/投诉量任一越线)
T+5min on-call 确认影响面: 误并 or 漏并? 量级? 受影响触点? 建 incident 群
T+10min 止损决策:
误并/跳变类 → 切 oneid.routing=old(回滚开关), 阻断 load_redis
容量类(热key/状态/穿透) → 限流+降级, 不切流
T+30min 定位根因: 配置变更? 上游脏数据? 容量水位? (查变更记录优先于猜代码)
T+60min 修复方案评审: 数据开发+平台+业务三方确认回滚/重跑范围
T+4h 数据修复完成(重跑分区/回刷Redis), 对账 SQL 验证清零
T+24h 复盘报告: 时间线/根因/修复/预防项(必须落成代码或监控, 不接受"下次注意")
T+7d 预防项验收: 回归测试/告警规则/熔断开关全部上线后关单
三条用学费换来的纪律:①止损永远优先于定位 ------先切 old 再查原因,15 分钟的错误映射比 2 小时的精确根因更伤业务;②所有变更可查 ------阈值/权重/盐值/开关的每次改动都进变更日志,事故定位第一动作是查"昨晚发了什么",10 个坑里 6 个是变更引起的;③预防项必须工程化------复盘结论如果只停留在文档里,同样的坑会以新面目再来一次。
本册小结
- 第 15 章 :把标准化→建边→GraphX 连通→雪花分配→融合→服务化八段串成星购 5 端生产链路;DolphinScheduler
oneid_daily工作流(依赖/重试/超时/告警)、四阶段灰度切流与双跑对账 SQL、12 周排期与三方分工、连通率 42%→91% 等前后效果对比; - 第 16 章:实时 OneID 支撑"登录即打通/分钟级圈选/秒级触达";Flink 增量并查集(强边实时、弱边离线、定时器攒批、锚点稳定不压缩)、Checkpoint/TTL/DLQ 容错设计、压测报告(P99 8.9s@峰值)与 Redis 宕机/Checkpoint 失败/消息积压三类故障演练预案;
- 第 17 章:10 个生产事故(误并/跳变/热 key/盐轮换/超级簇/CDC 重复/状态膨胀/缓存穿透/属性翻转/注销复用),每个含现象-原因-修复-预防与可直接抄用的 SQL/代码;共性规律是"阈值靠标定、稳定优先于优化、兜底要降级、留痕能救命"。
下一篇是收尾篇:30 道面试题速查(第 18 章)与附录 A--D(代码目录、全套 DDL、配置模板、术语表)