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

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

上一册:part5(第 15--17 章)------全渠道端到端落地、实时 OneID 与营销触达、10 个生产事故复盘。

本册为收尾速查篇 :第 18 章把全系列浓缩为 35 道面试题(30 道速答:原理/算法/工程/风控/合规各 6 题,加 5 道开放设计题,均为"题目→一句话标准答案→加分项"三行格式);附录 A 给出 oneid_core 完整代码目录与文件职责;附录 B 汇总六张固定表的全套 DDL 与核心质量 SQL;附录 C 是可直接抄用的配置参数模板;附录 D 是术语表。

本册导航:

  • [第18章 面试题速查(30 题速答 + 5 题开放设计)](#第18章 面试题速查(30 题速答 + 5 题开放设计))
  • [附录 A:oneid_core 代码目录清单](#附录 A:oneid_core 代码目录清单)
  • [附录 B:全套 DDL 汇总](#附录 B:全套 DDL 汇总)
  • [附录 C:关键配置参数模板](#附录 C:关键配置参数模板)
  • [附录 D:术语表](#附录 D:术语表)

第18章 面试题速查(30 题速答 + 5 题开放设计)

使用方法:每题三行------Q 是题目,A 是一句话标准答案(面试时先说这一句定调),+ 是加分项(面试官追问或想拉开差距时展开)。前五类共 30 题覆盖原理/算法/工程/风控/合规,最后一类 5 道开放设计题用于展示体系化思维。

一、原理类(6 题)

Q1:OneID 到底是什么?

A:以"自然人"为单位的统一身份标识,把同一人在 App/小程序/H5/门店/客服等触点的各种 ID 通过确定性绑定和概率性关系统一映射到一个不可变 ID。

  • 强调它是"映射体系 + 图谱 + 服务"三件套,不是一张映射表;OneID 一经分配稳定不变,变化的只是"哪些 ID 映射到它"。

Q2:OneID 和 ID Mapping 有什么区别?

A:ID Mapping 是"两个 ID 之间是否等价"的关系集合(边),OneID 是把这些关系做连通分量后、给每个"人"分配的全局主键(点的归属);Mapping 回答 A 等不等于 B,OneID 回答 A、B、C 同属哪个人。

  • Mapping 可以是规则表/接口实时判定,OneID 必须离线全量图计算 + 增量维护,且带生命周期管理(注销墓碑)。

Q3:什么是强 ID、什么是弱 ID?

A:强 ID 是能唯一确定自然人的 ID(手机号、unionid、会员号、支付账号),置信度高、可作簇锚点;弱 ID 是可能多人共用或一人多个的 ID(设备号、cookie、IP、WiFi),只能辅助挂靠。

  • 分级不是固定的:设备号在"账号已登录设备"语境下接近强 ID,在"公共设备"语境下是噪声;分级要随关系类型动态判断。

Q4:为什么不用图数据库(NeoHugeGraph/JanusGraph)算连通分量,而用 Spark GraphX?

A:10 亿节点 20 亿边的全量连通分量是批处理 OLAP 任务,GraphX/Pregel 在数仓内就地计算、可水平扩到数百 executor、和 Iceberg/Hive 数据零搬运;图数据库擅长在线多点查询,全量算法的批量吞吐和成本都不划算。

  • 图数据库可作为在线"关系探索"补充(风控查 N 度关系),但全量 T+1 连通用分布式图计算;在线查询走 Redis 而非图数据库。

Q5:OneID 和 CDP/DMP 里的"统一用户"是一回事吗?

A:OneID 是 CDP/DMP 的身份底座------CDP 管标签和人群,它假设"人"已经被识别;OneID 负责把跨触点 ID 拼成"人",CDP 在 OneID 之上拼行为和属性。

  • 没有 OneID 的 CDP 人群包只能按单端 ID 圈选,跨端归因和去重做不了。

Q6:OneID 为什么强调"不可变"(immutable)?

A:OneID 是全公司数据的 join key,画像、特征、券、订单都挂在它上面,一旦变了就造成特征断档、数据血缘断裂;所以合并时只允许"小簇并入锚点簇、保留锚点 OneID",绝不重新编号。

  • 对比:有些方案用"图版本号 + 重定向表"处理拆分,星购的做法是 OneID 永不变、映射关系带生效时间和状态(有效/待审核/已拆分/墓碑)。

二、算法类(6 题)

Q7:并查集的时间复杂度?

A:带按秩/按大小合并 + 路径压缩,均摊接近 O(α(n))(反阿克曼函数,可视为常数);不带路径压缩只做 union by size 是 O(log n)。

  • 身份场景刻意不做路径压缩(防止父指针改写导致 root 漂移、OneID 跳变),O(log n) 在 10 亿规模树高不超过 30,完全够用------稳定性优先于理论复杂度。

Q8:连通分量算法在大图上怎么跑?

A:GraphX 的 connectedComponents 本质是 Pregel 迭代:每个顶点初始以自己为分量 ID,每轮沿边传播最小顶点 ID,直到不再变化收敛;星购 20 亿边约 8--12 轮收敛。

  • 工程调优:EdgePartition2D 分区降低跨分片通信、shuffle 分区 2000、顶点 ID 加盐打散防倾斜、maxIterations 设 20 兜底。

Q9:建边/连通时数据倾斜怎么处理?

A:热点 key(热门设备、公共 IP)在 join 前加盐打散:把热点 key 复制 N 份加随机后缀分发给 N 个 reducer,聚合后再去盐合并;同时对超高频 ID 直接进黑名单不入图。

  • 判断热点:shuffle read 各 task 数据量监控 + 提前统计 id 频次 topN;加盐只解决"计算倾斜","语义错误"(公共 WiFi 串人)要靠剪枝和超级簇熔断。

Q10:什么是超级节点(超级簇)问题?怎么解?

A:弱边链式传递把几万~几百万无关 ID 串成一个连通簇(公共 WiFi、黑产设备农场是典型成因);解法是三层:弱边高阈值剪枝(公共 WiFi 边直接不入图)、连通后簇规模熔断(>5000 节点或强 ID 占比<2% 进人工审核不分配 OneID)、实时侧簇大小告警线 2000。

  • 进一步可在图上切"最大双连通分量/稠密集"找簇核,核外节点只按强边重连,弱边全部丢弃。

Q11:增量合并怎么做,避免每天全量重算?

A:离线每日只对"新增/变更的边"做增量并查集(新 ID union 到既有簇,锚点 OneID 沿用);实时侧 Flink 维护有状态并查集处理登录/绑定事件;每日再用全量 GraphX 结果做权威校准(日结)。

  • 增量的难点是"拆分"(误并后拆开)比合并难------所以实时只合并强边,弱边留给离线,降低拆分概率;拆分靠 map_status + 生效时间留痕。

Q12:置信度分数怎么标定,而不是拍脑袋?

A:准备标注样本(业务方提供"确定同人/确定不同人"各 1000+ 对),对每类关系(绑定/支付/地址/共现/WiFi)统计其在样本上的精确率作为基础置信度,再用共现次数做时间衰减加成;任何阈值调整必须跑标注集回归,精确率低于 99.5% 禁止上线。

  • 共现次数不是越多越可信:同公共 WiFi 共现 100 次仍是噪声,要看"关系类型 × 场景",所以 WiFi 类环境特征永久排除。

三、工程类(6 题)

Q13:实时和离线两条链路结果不一致怎么办?

A:权责划分------实时求快(只合并强边、秒级)、离线求准(全量弱边收敛、T+1 权威);每日离线产出后自动对账,差异分"实时漏并/方向相反/实时多并"三类,以离线锚点为准幂等回刷 Redis,方向冲突和多并超阈值告警。

  • 冷启动先离线打底再启实时,历史缺口用限速补数作业回放(backfill 消息只更人群不发券)。

Q14:雪花算法的时钟回拨问题怎么处理?

A:发号服务启动时从协调存储(ZK/MySQL)读取历史最大号段高水位,保证新号段严格大于历史最大值;运行中检测到时钟回拨超过阈值(5ms)直接拒绝发号并告警切 worker,而不是等待或强行发号。

  • 每日巡检"当日发号最小值 > 历史最大值",不满足拒绝发号;worker_id 配置化并纳入冲突校验,防止两个 worker 同号段撞号。

Q15:OneID 查询服务的缓存怎么设计?

A:三级缓存------本地 L1(Caffeine,10 秒,changelog 广播失效)+ Redis 集群(HASH 存映射、SET 存成员,大 key 分 16 片打散)+ 布隆过滤器前置拦截"一定不存在"的 ID;miss 再写 60 秒空值标记防穿透。

  • 数字:日均 12 亿次调用、峰值 6.5 万 QPS、P99 21ms;布隆只回答"可能存在/一定不存在",永不提供遍历接口防拖库。

Q16:实时并查集的状态怎么容错、会不会丢?

A:Flink RocksDB 状态后端 + 增量 Checkpoint(60s,Exactly-Once,Kafka 位点随 Checkpoint 提交),合并与 Redis 写入都是幂等的,端到端 effectively exactly-once;Checkpoint 取消后保留以便回退;Redis 写失败进 DLQ 由重放作业兜底。

  • 反压时开非对齐 Checkpoint 防止 barrier 对齐超时;状态 TTL 7 天 + 定时器去重防状态膨胀。

Q17:全链路怎么做幂等?

A:写操作全部设计为幂等------离线用 INSERT OVERWRITE/Iceberg 动态分区覆盖;CDC 事件带 event_id(Binlog 位点),聚合前 row_number 去重;Redis 用 HSET/SADD 覆盖写;触达用 (oneid, coupon_id, biz_date) SETNX 去重;changelog 按 (id_hash, version) 去重。

  • 原则:重试是调度和消息系统的常态(at-least-once),所有副作用必须"重复执行结果不变"。

Q18:DolphinScheduler 上怎么编排和告警?

A:工作流 oneid_daily 由 ODS 就绪 sensor 触发,五端标准化并行→建边→GraphX 连通→超级簇熔断→分配→(融合/装 Redis/质量旁路);任务设重试(仅幂等任务)、超时 kill、SLA 兜底电话告警;质量指标越界(连通率日跌 2pp、合并率突增 0.5%)直接电话。

  • 质量任务旁路不阻断主链路(质量作业挂不该影响产出),但指标越界阻断 load_redis(新映射不进缓存)。

四、风控类(6 题)

Q19:黑产怎么和 OneID 体系对抗?

A:黑产用设备农场 + 公共 WiFi/IP 制造"假同人"骗新人券,或用大量伪造 ID 刷接口;对策是弱边剪枝(WiFi/IP 不入图)、超级簇熔断(强 ID 密度<2% 冻结)、黑产设备 ID 黑名单库、接口侧按 AK 的 miss 率限流。

  • 风控反向利用 OneID:用 /expand 看一个 OneID 下挂了多少设备/手机号,设备数异常多、强 ID 缺失的簇就是高风险簇。

Q20:误合并了怎么回滚?

A:服务层有 oneid.routing 开关 10 秒切回旧映射止损;数据层靠边表快照 + 映射表生效时间/状态字段重跑------无强锚点的误并簇标 map_status=待审核/已拆分,强锚点 OneID 不变、被误并的弱 ID 自动脱离,Redis 全量重灌 + changelog 广播。

  • 关键前提:映射表从不物理删除历史(end_time 回填留痕),属性历史表保留每次取值,回滚是"查历史重放"而不是"从备份还原"。

Q21:设备指纹在 OneID 里扮演什么角色?

A:设备指纹(idfa/oaid/IMEI 替代品 + 浏览器指纹 + 设备特征聚类)是 App/H5 匿名阶段的主要弱 ID,用于把"未登录行为"挂到后续登录的账号上;但它可被篡改、可重置,只能做弱边挂靠,不能单独成簇。

  • 黑产对抗上,设备指纹的"稳定性评分"本身就是特征:指纹频繁重置/特征矛盾的设备,其建边置信度再降一级。

Q22:怎么评估 OneID 的准确率,而不是只看连通率?

A:连通率可以靠放松阈值刷高,生命线是误并率------每月抽样 2000 个簇人工标注精确率(目标≥99.5%)、监控误并投诉率(<1/百万日活)、簇大小分布、强 ID 密度;灰度期新旧双跑对比"强 ID 对"冲突率(<0.3% 才推进)。

  • 漏并(保守)代价低(明天离线补上、少发一张券),误并(激进)代价高(隐私串号、券错发、合规风险),所以阈值偏向保守。

Q23:内部滥用(员工查人)怎么防?

A:服务接口鉴权(AK/SK 签名 + RBAC),/expand 反向查询仅风控/审计角色可调且全量审计日志;只收哈希不收明文、盐值 KMS 托管代码不可见;无任何 list/遍历接口;批量导出走审批 + 加密包 + 水印。

  • 审计日志本身按 oneid 建索引,定期跑"异常查询模式"扫描(短时间大量 expand、跨权限查询)。

Q24:优惠券/营销场景怎么避免被 OneID 错误放大?

A:触达决策在 OneID 之上加三道闸:频控(5 分钟 1 次/每天 3 次,SETNX 原子占坑)、幂等(同人同券当天一次)、时效降级(实时链路延迟>10 分钟自动暂停时效券,宁可不发不可错发);待审核/墓碑状态的 OneID 直接拦截不触达。

  • 人群包实时更新也要去重------OneID 合并 changelog 触发进包/出包,用 OneID 而非原始 ID 做包成员,天然跨端去重。

五、合规类(6 题)

Q25:个保法(PIPL)下 OneID 的红线是什么?

A:最小必要、告知同意、可注销可查删;OneID 只存哈希不存明文、盐值分级托管、跨触点合并需在隐私政策告知;注销必须全链路打墓碑并停止一切自动化决策(触达/画像/人群)。

  • OneID 设计为不可逆:拿到 OneID 反推不出任何原始 ID,降低数据泄露影响面。

Q26:用户注销账号,OneID 链路怎么处理?

A:注销是一等公民事件:CDC→Kafka→Flink→Redis 置 tombstoned(查询直接返回"已注销")、所有人群包剔除、触达拦截;离线 dim_oneid_user.oneid_status=0;映射关系保留 30 天供审计后匿名化,OneID 本身不复用。

  • 验收必须含端到端注销用例;雪花发号保证号段单调递增,从根上防止注销号被重新分配给他人。

Q27:HMAC 盐值怎么管理?轮换时怎么不出事故?

A:盐值按 ID 类型分级(强 ID 用独立盐),只存 KMS,代码和表里只引用 salt_key,明文不落盘;轮换走"双写过渡期(≥90 天)+ 双读兼容":新盐哈希为主、旧盐哈希存 alias,join/查询先新后旧、命中异步回写,过渡期结束再下线旧盐。

  • 轮换纳入变更工单(双写开关/回滚方案/可 join 率监控<99.9% 告警);一刀切换盐会导致全量哈希对不上(真实事故)。

Q28:OneID 数据能不能给广告第三方/出境?

A:OneID 是内部关联主键,原则上不出域;对外合作只用"单向盐化后的合作方专用 ID"(不同合作方不同盐,互相不能 join),且只传人群包布尔标记(在/不在包)不传 OneID 本体;数据出境需单独合规评估。

  • 这套"合作方专用盐 + 布尔人群"模式同时解决了数据合作和"OneID 不跨公司流通"两个要求。

Q29:数据主体权利(查询/更正/删除)怎么落地?

A:查询:凭强 ID 经客服核验后可调出该 OneID 的属性历史(attr_history 表就是为可追溯设计);更正:属性融合支持人工审核工单(audit_status)覆盖黄金值;删除:注销链路 + 30 天后匿名化(ID 哈希与属性解耦、属性置空)。

  • 所有人工操作进 oneid_audit_log,谁在什么时间因为什么工单改了什么,全部留痕。

Q30:埋点里还没收上来同意(consent)的用户数据怎么处理?

A:未同意用户的 ID 只做"计数级"匿名统计,不进 OneID 图谱、不建边、不发 OneID、不进人群包;consent 事件到达后再从 ods 明细追溯建边(明细按 dt 分区可回溯),拒绝同意则明细到期物理清理。

  • 工程上在标准化阶段就打 consent 标记,后续所有作业默认过滤 consent=0,而不是靠下游"记得过滤"。

六、开放设计题(5 题,拉开差距)

Q31:让你从零设计这个 OneID 体系,你的分阶段路线是什么?

A:先做"确定性绑定"打通最强 ID(绑定/支付边),快速拿到 70% 收益;再上概率性弱边和图连通补长尾;然后实时化;最后质量与合规体系兜底------每一步都先有度量(连通率/误并率基线)再做优化。

  • 面试官想听的是"不贪大求全":先标准化建表(第 2-4 章)→ 强边连通(第 5-6 章)→ 服务化(第 10 章)让业务先用起来 → 再补弱边/实时/质量;12 周排期里 W1-W5 离线先上线、W6-W8 实时和质量、W9-W12 灰度切流,就是这个顺序。

Q32:ID 规模从 10 亿涨到 100 亿,架构哪里会先扛不住?

A:先扛不住的是 GraphX 全量连通的 shuffle 和 Redis 单集群内存;解法是"增量为主、全量兜底"------日常用增量并查集只算变化部分,全量降频到周级且按时间窗/ID 范围分片跑子图,Redis 按 OneID 一致性哈希多集群拆分,冷数据下沉到 HBase/SSD。

  • 关键权衡:全量图在 100 亿边时 shuffle 落盘量指数上升,工程上改用"锚点分片"------以强 ID 锚点为 key 把图切成独立子图,子图内连通、子图间只通过强边桥接,避免全局 shuffle。

Q33:OneID 的误并率和漏并率,业务上你更怕哪个?为什么?

A:更怕误并------漏并是"少发一张券、漏斗少算一段",损失可量化且明天离线能补上;误并是"把 A 的隐私、券、订单给了 B",是合规事故和信任事故,不可逆且会沿映射污染所有下游。

  • 这就是所有阈值偏保守、实时只合强边、弱簇不发正式 OneID 的根本原因;面试时用"误并代价 ≈ 漏并代价 × 100"来量化表达这个不对称。

Q34:如果业务方要求"匿名访客也要给个稳定 ID",你怎么设计?

A:给匿名态用"设备/浏览器指纹聚类"生成临时 anon_id(独立号段,与 OneID 显式区分),登录绑定后把 anon_id 并入 OneID 但不反向追溯匿名期行为到实名画像(或按合规要求追溯),anon_id 到点过期;绝不让纯弱 ID 直接拿 OneID。

  • 强调状态机:匿名(anon_id)→ 实名(OneID)是单向晋升,实名 OneID 永不退回匿名;匿名期数据的使用范围单独过合规评审。

Q35:怎么向不懂技术的业务老板证明 OneID 项目值这个钱?

A:不连通率和算法,讲三个钱袋子------重复营销预算(同一人多端重复发券,上线前 11.5%、后 1.8%)、跨端归因(门店成交算不回线上广告,ROI 从 1:3.2 到 1:5.7)、客服人力(全渠道视图让处理时长从 8 分钟降到 3 分钟);再补一个风险账本:误并投诉和合规风险的规避。

  • 用第 15 章的前后对比表说话:每个指标都有口径、有基线、有 after 数字;老板记住的是"一年省/赚多少钱",不是 GraphX。

七、面试速答脑图与自查清单

脑图(回答"讲讲你做的 OneID"时按此展开,控制在 5 分钟):

复制代码
OneID 项目讲解框架(STAR 变体)
├── 背景: 5 触点 5 个孤岛, 连通率 42%, 重复营销/归因算不清/客服切3系统
├── 架构: 标准化→建边→GraphX连通→雪花分配→融合→服务化 八段
│         ├─ 离线 T+1(Spark/GraphX/Iceberg) 全量权威
│         └─ 实时(Flink 并查集+Redis) 秒级合并, 日结对账
├── 难点(挑 3 个讲透):
│         ├─ 误并防控: 强弱ID分级/置信度标定/超级簇熔断/灰度回滚
│         ├─ 稳定性: 锚点不跳变/雪花时钟回拨/幂等/留痕回滚
│         └─ 规模: 20亿边倾斜加盐/16分片Redis/热key打散/状态TTL
├── 质量: 连通率/误并率/漏并率/抽样标注, 告警阈值与熔断
└── 结果: 连通率91%, 短信ROI 1:3.2→1:5.7, 误并0.7/百万, 月成本可控

自查清单(面试前确认每个都能讲出细节,说不上来的回看对应章节):

  • 能默写六张表的名字、层级关系和每张表的主键/分区(附录 B)
  • 能画出端到端全景图(part5 第 15.2 节)和 Flink 拓扑图(part5 第 16.2 节)
  • 能讲清强 ID/弱 ID 的判定,以及"为什么 WiFi/IP 不能当人"
  • 能手写并查集 union/find,并解释为什么身份场景不用路径压缩
  • 能说清实时/离线一致性方案:谁权威、怎么对账、冲突怎么办
  • 能讲 3 个以上事故:现象→根因→修复→预防(part5 第 17 章)
  • 能给出误并回滚的完整链路(开关止损→重跑→回刷→对账)
  • 能说明合规设计:哈希/盐/KMS/墓碑/不可逆/合作方专用盐
  • 能量化项目收益,且数字都有口径(part5 第 15.7 节)

八、追问快答:面试官常追问的 20 个细节

F1:为什么不直接用手机号当 OneID? 很多人没手机号(H5 匿名)、会换号、明文手机号不能当 join key 到处传播(合规);OneID 是内部不可逆编号,手机号只是它身上的一个强 ID 属性。

F2:unionid 和 openid 的区别在体系里怎么用? openid 是用户在单个小程序维度的 ID、跨小程序不通用;unionid 是同一微信开放平台账号下的全局 ID、可跨 App/小程序绑定,所以 unionid 是强 ID、openid 只作属性挂载不直接建边。

F3:idfa/oaid 算强还是弱? 弱 ID。用户可重置(关广告追踪)、黑产可批量伪造;但它在"登录前设备行为挂接"上价值高,做法是设备 ID 与账号产生绑定关系后才升级为该簇成员,永不单独成簇。

F4:置信度怎么随时间衰减? 关系会过期:一年前的共现和昨天的共现可信度不同;公式上用 base_conf × (1 + log(1+co_occur_cnt)) × decay(age),decay 半年半衰期,老边只维持弱连接,新绑定边权重最高。

F5:为什么地址边要"同地址 + 同电话尾号 + 2 次"三个条件? 只同地址会把家人/室友/公司前台误并(同一收货地址几十人);加电话尾号解决大部分家人混淆,要求 2 次挡住一次性礼品代发;即使 0.85 置信度,地址边仍不是强边,成簇还需要簇内有强锚点兜底。

F6:GraphX 为什么最小迭代次数收敛不固定? 连通分量传播轮次等于图的"直径"------最坏情况一条链要 n 轮;真实社交/设备图直径很短(星购 8--12 轮),公共 WiFi 那种"星型大簇"反而是 2 轮就收敛(这也说明收敛快不等于结果对,输入边的语义才是关键)。

F7:2000 个 shuffle 分区怎么定的? 经验值 = 总 core 数的 2--3 倍,让每个分区处理 128--256MB;过多则调度开销大、过少则内存压力大;生产中以 AQE coalesce 后的实际分区数和 task 耗时分布反推调优。

F8:盐值为什么按 ID 类型分级,不用一个全局盐? 分级隔离:某类 ID 的盐意外泄露(如埋点 SDK 被逆向)只影响该类 ID,手机号强 ID 用独立高权限盐;同时轮换可以按类型分批做,降低全量失效风险(坑④)。

F9:OneID 为什么用 BIGINT 而不是 UUID/哈希串? BIGINT 在 Hive/Redis/特征系统里存储和索引成本低(8 字节 vs 36/64 字节)、有序可趋势分析、雪花自带时间趋势方便排查"什么时候发的号";哈希串做对外暴露的 id_hash,BIGINT 做内部 join key。

F10:雪花号会不会被人从编号反推发号量/业务规模? 会有趋势信息,但不含身份信息;对外部合作方根本不暴露 OneID(用合作方专用盐哈希),内部使用可接受;需要完全无序时可改用号段打乱或随机化的发号器,但牺牲时间趋势。

F11:实时合并为什么 5 分钟攒批,不是来一条合一条? 登录瞬间 5--10 个事件逐条合并会产生大量中间态(A 合 B、再合 C、再合 D,每次都发 changelog),下游人群包/缓存被反复抖动;攒批后一次 union 到位只发最终态,changelog 量降一个数量级,Redis 写压力同步下降。

F12:定时器为什么会泄漏,TTL 清不掉? Flink 的定时器注册在内部 timer service 上,和 ValueState 是两套存储,StateTtlConfig 只清理 keyed state 不清理定时器;所以定时器要业务层去重(每 key 一个),并在定时器触发/清理时显式删除。

F13:RocksDB 状态 1.2TB 为什么内存放得下? RocksDB 是落盘状态后端,状态在本地 SSD、内存只放 block cache 和 memtable;靠增量 Checkpoint(只传新 SST 文件)控制快照时长,这是大状态作业必须用 RocksDB 而不是 HashMap 状态后端的原因。

F14:布隆过滤器说"可能存在"但实际没有,怎么办? 这是误判(false positive),允许发生------查一次 Redis miss 即可,代价是一次缓存穿透,所以再叠 60 秒空值缓存防同一个 ID 反复穿透;布隆"说不存在"则一定不存在,直接拒绝、不打 Redis。

F15:空值缓存和正常 TTL 过期怎么区分? 空值标记 value 里带 status=miss 和写入时间,正常映射带 oneid;业务读到 miss 标记当作"无此人"处理但不缓存太久(60 秒),防止用户在此期间注册建档后仍被判 miss;正常映射 TTL 7 天且每日全量重灌。

F16:触达频控为什么用 SETNX 而不是先 GET 再 SET? GET-then-SET 有竞态:高并发下两个请求都 GET 到"没发过"然后都发券;SETNX(set if not exists)是 Redis 单命令原子操作,配合 TTL 实现原子占坑,频控和幂等都必须用原子原语。

F17:离线对账发现实时多并了 1000 个 ID,怎么处理? 先确认是不是误并(人工抽查 20 个):确属误并------按坑①流程在离线上标记拆分(map_status=3)、回刷 Redis、changelog 通知下游人群包移除并回滚当日发出的相关券(拦截未核销的);属离线漏边------给离线补边规则,实时结论保留。

F18:注销 30 天后匿名化具体怎么做? 30 天内保留映射关系供审计/申诉;到期后把 id_hash 与 oneid 的关联解除(map_status=4 且 members 清空),dim_oneid_user 属性全部置空只保留聚合统计所需的不可识别记录;OneID 编号本身永不复用(坑⑩)。

F19:下游 DMP 人群包很大(上亿 OneID),实时更新怎么扛? 人群包不用 Redis SET 存全量(内存扛不住),用 RoaringBitmap 按 OneID 分段存(每个 bitmap 覆盖 100 万号段),进/出包是位图 set 位操作,毫秒级且压缩率高;查询用布隆预判 + 位图确认。

F20:上线后效果数字怎么防止被质疑"调参调出来的"? 所有效果指标都做"双重对照":上线前后 30 天同期对比(控大促季节性)+ 灰度白名单与未灰度用户的 A/B 对比;连通率/误并率有每日自动报表和人工标注抽样,ROI 由营销侧财务口径独立核算,数据团队不碰业务数字。

F21:边有方向吗?共现边怎么保证 A-B 不重复存两条? 关系边逻辑上无向(同设备共现不分方向),存储时把两个 id_hash 按字典序排序后再写(src=min、dst=max),group by 这对规范化后的键,天然不会出现 (A,B)(B,A) 两条。

F22:绑定关系解绑了怎么办(用户换绑手机号)? 解绑是独立 unbind 事件:旧 mdn 与账号的边标记失效(end_time 回填),不是物理删除;旧 mdn 如果还挂着其他强关系(历史订单/历史登录设备),它的归属按当前仍有效的边重新连通------典型场景是手机号二次放号,旧机主的历史行为绝不能跟新机主合并,这是手机号边也必须带"有效时间窗"的原因。

F23:为什么离线连通结果全量重算而不是只算增量? 全量重算是"真相源":增量并查集可能累积错误(实时漏边/拆分传播不到位),每天一次全量 GraphX 等价于"重启校准",把所有状态拉回到由有效边推导出的唯一正确答案;全量幂等且便宜(300 executor 跑 2 小时),是最简单可靠的纠错机制。

F24:客服坐席共用电脑设备,怎么不把客服和用户并成一人? 环境型 ID 一律打"公共设备"标记进黑名单:客服坐席、门店导购 Pad、演示机的 device_id 在接入登记时标记 shared_device=1,这类设备只做行为归属上下文,不参与建边;识别方法是该设备每天关联几十个不同强 ID------自动化扫描后人工确认入库。

F25:属性融合时城市和性别策略一样吗? 不一样。城市是"易变属性"(用户搬家/出差 IP 漂移),用近 30 天加权 + 允许低门槛更新;性别是"稳定属性",用翻转保护(0.2 权重差门槛)+ 冲突进审核。融合策略必须按属性的稳定性分级配置,一套权重套所有属性正是坑⑨的根源之一。

F26:实时作业发版怎么做到不停机? 蓝绿两套消费组 + savepoint 切换:新版本从旧版本 savepoint 启动、用新 consumer group 追平 lag 后,切流 changelog 生产者(Redis 写双跑 10 分钟对账),确认一致后下线旧作业;回滚就是反向切一次。关键前提是 Checkpoint 状态 schema 向后兼容(UFNode 加字段给默认值)。

F27:布隆过滤器怎么构建和更新? 每日全量映射产出后,从 dim_oneid_id_map 抽取当日有效 id_hash 集合构建新布隆(容量按 ID 总量 ×1.5、误判率 0.1%),推送到对象存储;服务实例定时拉取新版本做原子替换(内存 map 引用切换),旧版保留 24 小时可回退;增量 ID 靠 Redis 本身兜底(新 ID 布隆可能说"不存在",所以查询侧是"布隆说不存在才拦截"------新增 ID 误拦截的代价大于一次穿透)。

F28:为什么 Redis key 要设 7 天 TTL 又每天全量重灌? TTL 是兜底防线:万一装载作业漏删某个失效 key(拆分/墓碑),7 天后自动过期,脏数据不会永驻;每日全量重灌保证命中率。两者配合------TTL 防"脏",重灌防"丢"。

F29:营销侧问"为什么这个用户没收到券",怎么排查? 按决策链倒查:OneID 是否解析成功(resolve 接口/布隆)→ 人群包是否命中(RoaringBitmap 查成员)→ 频控是否拦截(reach:win/reach:daily 两个 key)→ 幂等是否去重(reach:idem)→ 渠道是否可达(push token/订阅授权);每一步都有结构化日志带 trace_id,5 分钟内能定位到具体闸门。

F30:数据倾斜和超级簇有什么区别? 倾斜是"计算问题"------某个 key 数据量大拖慢 task(解法是加盐打散,语义不变);超级簇是"语义问题"------很多 ID 真的被边连在一起了(打散计算也没用,错的是边)。两者经常同源(公共设备既造成 join 倾斜又造成超级簇),但要分别处理:加盐解决"算得动",剪枝和熔断解决"算得对"。

F31:离线表为什么选 Iceberg 而不是纯 Hive? 三个刚需:行级 upsert/删除(墓碑状态更新、映射覆盖不用整分区重写)、快照隔离与回滚(坑①直接 rollback_to_snapshot 分钟级恢复)、隐藏分区 + zstd 压缩省存储;Hive 场景也能跑(附录 B 给了 Hive 改写说明),但失去快照回滚能力,事故恢复要靠业务 SQL 重放。

F32:OneID 服务被爬虫刷,除了限流还能做什么? 三层:网络层(WAF 识别机器流量)、接口层(miss 率异常 AK 降权 + 验证码挑战 + 批量接口压测配额)、数据层(伪造 ID 天然过不了布隆过滤器,根本打不到 Redis);爬虫流量的特征是"几乎全 miss + 无强 ID 行为关联",这个指纹本身也能反哺风控。

F33:接入一个新触点(比如抖音小程序)要改多少东西? 标准流程:接入登记(source_registry 加配置,ID 分级评审)→ 写一个 SourceAdapter(实现 to_long 契约)→ 埋点 SDK 接入预哈希 → 灰度该触点数据(先只标准化不建边观察 1 周)→ 开绑定边 → 进主链路。核心代码零改动(注册制的价值),工作量集中在埋点和数据质量观察,约 1--2 周。

F34:怎么度量"客服 360 视图"的收益? 两个指标:平均处理时长(8:12→3:05,直接折算人力成本)和"二次来电率"(资料不全导致用户再次来电的比例,下降 18%);还有一个隐性收益------客服看到用户全渠道画像后,投诉升级率下降,因为客服能主动提及"您在小程序领的券门店也能用"这类跨端事实,用户感知明显。

F35:如果只能保留一套质量监控,你保留哪个? 保留"强锚点 OneID 日变化数 = 0"这条(B.7 第③条 SQL)。它是整个体系的生命线:锚点跳变意味着 root 规则被破坏(坑②)或撞号(坑⑩),是所有严重事故的共同信号;连通率高低是业务效果问题,锚点稳定性是数据安全问题。

九、白板代码高频考点

考点 1:手写并查集(30 行版,面试最高频)

python 复制代码
class UnionFind:
    """OneID 专用并查集: union by size + 锚点优先, 不做路径压缩(防 OneID 跳变)。"""
    def __init__(self) -> None:
        self.parent: dict[str, str] = {}
        self.size: dict[str, int] = {}
        self.anchor: dict[str, str] = {}      # root -> 锚点强ID
        self.oneid: dict[str, int] = {}       # root -> OneID

    def add(self, x: str, is_strong: bool = False, oneid: int | None = None) -> None:
        if x not in self.parent:
            self.parent[x] = x
            self.size[x] = 1
            if is_strong:
                self.anchor[x] = x
            if oneid is not None:
                self.oneid[x] = oneid

    def root(self, x: str) -> str:
        # 只沿父链查找, 不回写(不做路径压缩): 保证 root 语义稳定 = OneID 稳定
        while self.parent[x] != x:
            x = self.parent[x]
        return x

    def union(self, a: str, b: str) -> str | None:
        ra, rb = self.root(a), self.root(b)
        if ra == rb:
            return None
        # 锚点簇永远为 root; 都无锚点才按大小并
        if self.anchor.get(rb) and not self.anchor.get(ra):
            ra, rb = rb, ra
        elif not self.anchor.get(ra) and not self.anchor.get(rb) \
                and self.size[ra] < self.size[rb]:
            ra, rb = rb, ra
        self.parent[rb] = ra
        self.size[ra] += self.size[rb]
        self.anchor[ra] = self.anchor.get(ra) or self.anchor.get(rb)
        self.oneid[ra] = self.oneid.get(ra) or self.oneid.get(rb)
        return ra    # 返回新 root(发号/写 Redis 以它为准)

面试讲解三句话:①root() 不压缩是和教科书最大的区别,身份场景要 root 稳定;②union 的优先级是"锚点 > 大簇",保证强 ID 的 OneID 不被大簇吞并;③oneid[root] 只从旧 root 继承、绝不新造,合并后成员查到的 OneID 永远是锚点簇那个号。

考点 2:实时合并幂等性证明(被追问"exactly-once 怎么保证")

复制代码
输入: Kafka 事件(bind A,B), 可能重放 N 次
状态: UFNode(parent/size/anchor/oneid) 在 Checkpoint 内
幂等论证:
  1) union(A,B) 重复执行: 第一次 ra≠rb 合并, 后续 ra==rb 直接返回 None(无副作用)
  2) Redis HSET/SADD 重复执行: 写同一 key 同一 value, 结果不变(覆盖写/集合去重)
  3) changelog 下游按 (id_hash, oneid, version) 去重, 重复消息丢弃
  4) 发号: 新号在 union 首次发生时申请, 重放时 root 已有 oneid 不再申请
结论: 端到端 effectively exactly-once, 不需要 Kafka 事务也能保证结果一致

考点 3:误并回滚的 SQL 思路(白板写思路即可)

sql 复制代码
-- 面试官: "发现昨天误并了一批, 怎么用今天的代码恢复?"
-- 思路四步:
-- 第一步: 找到受影响簇 ------ 昨天新产生、且簇内无强锚点的簇(误并特征)
-- 第二步: 这些簇的成员映射标 map_status=3(已拆分), end_time=now()
-- 第三步: 被拆出的强 ID 各自作为新簇锚点, 发新 OneID(map_status=2 待审核观察)
-- 第四步: 弱 ID 回到孤立态, 等当晚离线全量 GraphX 用修正后的边重新连通
-- 关键: 全程不删数据(end_time 留痕), 强锚点 OneID 一律不动, 回滚可重入可审计

考点 4:讲清一条边从产生到查询的延迟分布

阶段 实时链路 离线链路
事件产生→Kafka <100ms T 日全天
Flink 处理→Redis 可查 P50 1.8s / P99 8.9s ---
标准化/建边 --- T+1 02:00--03:30
GraphX 连通+分配 --- T+1 03:30--04:30
Redis 装载+服务生效 实时 T+1 04:30--06:00
弱边(共现/WiFi 剪枝后)生效 不实时(只归档) T+1 全量收敛

讲清这张表等于讲清了"为什么要两条链路":强边(登录/绑定/支付)实时合并保证秒级体验,弱边(行为共现)离线收敛保证准确,日结对账收口。

考点 5:雪花发号核心代码(时钟回拨保护)

python 复制代码
# oneid_core/allocator/snowflake.py(白板精简版)
import time

class SnowflakeIssuer:
    """64 位: 1bit 符号 | 41bit 毫秒时间戳 | 5bit worker | 17bit 序列号。"""
    EPOCH = 1704067200000          # 2024-01-01 00:00:00 UTC
    WORKER_BITS, SEQ_BITS = 5, 17
    MAX_WORKER = (1 << WORKER_BITS) - 1
    MAX_SEQ = (1 << SEQ_BITS) - 1

    def __init__(self, worker_id: int, last_watermark_ms: int) -> None:
        if not 0 <= worker_id <= self.MAX_WORKER:
            raise ValueError(f"worker_id 越界: {worker_id}")
        self.worker_id = worker_id
        self.last_ms = max(last_watermark_ms, self._now())  # 高水位起步防撞号
        self.seq = 0

    @staticmethod
    def _now() -> int:
        return int(time.time() * 1000)

    def next_id(self) -> int:
        now = self._now()
        if now < self.last_ms:
            # 时钟回拨: 小回拨(<5ms)自旋等, 大回拨直接拒绝(由调用方切 worker/告警)
            delta = self.last_ms - now
            if delta <= 5:
                time.sleep(delta / 1000)
                now = self._now()
            else:
                raise ClockRollbackError(f"时钟回拨 {delta}ms, 拒绝发号")
        if now == self.last_ms:
            self.seq = (self.seq + 1) & self.MAX_SEQ
            if self.seq == 0:                    # 同毫秒序列耗尽, 等下一毫秒
                now = self._wait_next_ms(now)
        else:
            self.seq = 0
        self.last_ms = now
        return ((now - self.EPOCH) << (self.WORKER_BITS + self.SEQ_BITS)) \
               | (self.worker_id << self.SEQ_BITS) | self.seq

    def _wait_next_ms(self, now: int) -> int:
        while (t := self._now()) <= now:
            time.sleep(0.0005)
        return t


class ClockRollbackError(RuntimeError):
    """时钟回拨超阈值------宁可拒发也不发重号。"""

要点:last_ms 从持久化高水位初始化(worker 重启也不会退回历史号段),回拨超 5ms 直接抛异常让调度切 worker,而不是等待或硬发。

考点 6:黄金记录融合的"翻转保护"(白板逻辑)

python 复制代码
# oneid_core/fusion/golden.py(白板精简版)
from dataclasses import dataclass

@dataclass
class AttrCandidate:
    value: str
    source_weight: float        # 来源可信度: app注册0.9 > 门店会员0.85 > 行为推断0.3
    age_days: int               # 属性年龄(时间衰减)
    is_strong_id_src: bool

FLIP_MARGIN = 0.2               # 新值权重必须高出黄金值 0.2 才允许翻转
HALF_LIFE_DAYS = 365

def decide(cands: list[AttrCandidate], current: AttrCandidate | None):
    def score(c: AttrCandidate) -> float:
        decay = 0.5 ** (c.age_days / HALF_LIFE_DAYS)
        return c.source_weight * decay * (1.0 if c.is_strong_id_src else 0.8)
    winner = max(cands, key=score)
    if current is None:
        return ("set", winner)                    # 首次赋值
    if winner.value == current.value:
        return ("hold", current)                  # 一致, 维持
    if score(winner) - score(current) >= FLIP_MARGIN:
        return ("flip", winner)                   # 压倒性新证据, 翻转
    return ("pending", current)                   # 证据不足: 保守保留 + 人工审核

讲给面试官的重点:稳定属性(性别/生日)天然不该频繁变,所以翻转门槛要高;行为推断类低权重来源永远无法直接推翻注册/门店强来源------坑⑨的性别大面积翻转就是因为没这层保护。

考点 7:多级缓存读取流程(接口侧核心逻辑)

python 复制代码
# oneid_core/serving/cache.py(白板版)
class ResolveService:
    def __init__(self, redis_client, bloom, l1):
        self.r = redis_client          # Redis 集群
        self.bloom = bloom             # 布隆过滤器(本地内存, 每日原子替换)
        self.l1 = l1                   # Caffeine 本地缓存(TTL 10s)

    def resolve(self, id_hash: str) -> dict:
        # 第0级: 布隆粗筛 ------ "一定不存在"直接拒, 不打 Redis(防穿透)
        if not self.bloom.may_contain(id_hash):
            return {"status": "miss", "via": "bloom_reject"}
        # 第1级: 本地 L1(热点 key, 10s)
        hit = self.l1.get(id_hash)
        if hit is not None:
            return hit
        # 第2级: Redis
        try:
            val = self.r.hgetall(f"oneid:map:{id_hash}")
        except redis.TimeoutError:
            stale = self.l1.get_stale(id_hash)             # 降级: 返回过期缓存
            return stale or {"status": "degraded"}
        if not val or val.get("oneid") == "__null__":
            # miss 写空标记 60s, 防同一伪造 ID 反复穿透
            self.r.hset(f"oneid:map:{id_hash}",
                        mapping={"oneid": "__null__", "status": "miss"}, ex=60)
            return {"status": "miss", "via": "redis"}
        result = {"status": "hit", "oneid": int(val["oneid"]),
                  "map_status": int(val["status"])}
        if result["map_status"] == 4:
            return {"status": "tombstoned"}                # 墓碑: 注销, 下游停止触达
        self.l1.put(id_hash, result)
        return result

三个必答细节:布隆"说不存在才拦截"(新增 ID 宁可穿透不可误拒);降级返回 stale 缓存并打标(业务可自行决定信任或忽略);墓碑态优先级最高(合规要求,不能因缓存延迟而触达已注销用户)。

考点 8:接口签名鉴权(AK/SK,30 秒讲清)

调用方与服务端共享 SK(密钥):请求参数(AK + 时间戳 + body 摘要)用 SK 做 HMAC-SHA256 得签名放 Header;服务端按 AK 查 SK 重算比对,时间戳超过 5 分钟拒绝(防重放)。SK 永不在网络传输、定期轮换;每次鉴权结果写 oneid_audit_log(AK、接口、oneid、时间、来源 IP)。回答时补一句"签名只解决身份认证,能调什么接口由 RBAC 角色决定,两件事分开"。



十、动手实践清单(从 demo 到生产的 8 级台阶)

如果你想把这套案例真正跑起来(面试作品/团队 POC),按下面 8 个台阶走,每级都有明确的验收标准。单机即可完成 1--4 级(本地 Spark/Flink standalone + 单节点 Redis),5 级起需要分布式环境。

级别 目标 涉及模块 验收标准
L1 六张表建表 + 造数 附录 B DDL、common/schema DDL 在本地 Spark 跑通,插入 10 万条模拟 ID/边,分区/分桶属性正确
L2 标准化跑通 standardize/* 五端各造 10 万事件,产出 ods_user_id_detail_di,无效 ID 被正确标记,明文不落盘
L3 建边 + 连通 graph/*(PySpark 版先用 GraphFrames 替代) 连通分量结果正确(可构造 100 个已知"人"验证),WiFi 边被剪枝
L4 发号 + 融合 + 映射 allocator/*fusion/* 重复运行幂等(结果不变)、强锚点 OneID 稳定、黄金记录性别按权重正确
L5 FastAPI + Redis 服务化 serving/* 四接口可调、布隆拦截不存在 ID、压测单实例 P99 <30ms
L6 Flink 实时并查集 realtime/union_find_func.py 注入 bind 事件后 3 秒内 Redis 可查;Kafka 重放结果不变(幂等验证)
L7 质量监控 + 事故演练 quality/*ops/* 人为降低阈值制造误并,合并率告警触发、回滚开关切 old、拆分 SQL 恢复
L8 全链路调度 DolphinScheduler + 全部 DAG 每日稳定产出、任务失败重试/超时/告警全部按预期触发

造数建议:用 tests/fixtures 的"人模型"生成器------先定义 N 个自然人,每个人随机拥有 1--6 个强 ID 和 2--8 个弱 ID、活跃在 1--5 个触点,再由"人"反推事件和边;这样你天然知道正确答案,可以精确计算连通率和误并率(黑产数据则单独生成:几百个强 ID 带几万个弱设备、全部挂同一 WiFi,验证超级簇熔断)。

学习顺序建议:第 1--3 章建全局观 → 第 4 章哈希标准化动手 → 第 5--6 章建边和连通(算法核心)→ 第 10 章先把服务化做出来有正反馈 → 第 7--9 章补分配和实时 → 第 12/14 章质量与合规(生产必读)→ 第 15--17 章案例和事故(面试素材)。

十一、面试答题避坑:这些说法会直接扣分

错误/危险说法 为什么扣分 正确说法
"我们用图数据库实时算连通分量" 10 亿节点全量连通是批处理任务,在线图库批量吞吐和成本都不对 全量 T+1 用 GraphX/Pregel 分布式批算,在线只查 Redis,图库最多做风控 N 度探索
"ID 都一样,拼起来就行" 没有强/弱 ID 分级意识,必然导致公共设备误并 强 ID 定锚点、弱 ID 只挂靠,无强锚点的簇不发正式 OneID
"我们准确率 99.9%" 但没有口径 没有标注样本和计算口径的准确率是口号 每月抽样 2000 簇人工标注,精确率≥99.5%,误并投诉率<1/百万,口径可复核
"误并了重新跑一遍就好了" 没有回滚机制和留痕意识,事故会污染下游且无法挽回 回滚开关 10 秒切旧映射止损,边快照+属性历史+映射生效时间支撑精确回滚
"实时结果就是最终结果" 实时弱边误并不敢纠正,会把错误固化 实时求快只合强边、离线求准日结对账,冲突以离线锚点为准
"手机号哈希后就是安全的" 无盐哈希可被彩虹表反推,明文手机号空间只有 10^11 HMAC 加盐、盐存 KMS、按类型分级、轮换走双写过渡期
"注销把数据删掉就行" 删除映射会断审计血缘,且 tombstone 广播不到下游 置墓碑态全链路拦截、30 天后匿名化、编号永不复用
"并查集路径压缩优化了性能" 在身份场景路径压缩会导致 root 漂移、OneID 跳变 不做路径压缩,union 锚点优先,稳定性优先于理论复杂度
"阈值是算法工程师调的" 阈值是数据产品,拍脑袋就是事故源(坑①) 业务方标注样本标定、变更跑回归、越界自动熔断
"缓存就一层 Redis,挂了重试" 没有降级设计,Redis 故障即全站不可用 L1+布隆+空值缓存三级,故障降级返回 stale 并打标,DLQ 兜底写

答题总原则:先说口径和边界条件,再说方案;先讲怎么兜底和回滚,再讲怎么优化。OneID 面试考的不是"知不知道连通分量",而是"你做的系统错了会不会炸、炸了能不能活"。


附录 A:oneid_core 代码目录清单

全新独立代码库 oneid_core,Python 3.10+(PySpark 3.5 / PyFlink 1.18)+ Scala(GraphX 作业)混合构建。目录与职责如下:

复制代码
oneid_core/
├── pyproject.toml                     # 依赖锁定: pyspark==3.5.x, pyflink==1.18.x,
│                                      #   redis>=7.0, kafka-python, fastapi>=0.110, pydantic v2
├── conf/                              # 配置(环境隔离 dev/staging/prod)
│   ├── source_registry.yaml           # 五端接入登记: 触点/ID类型/强弱分级/盐key/正则/SLA(15.8)
│   ├── relation_rules.yaml            # 关系边规则: 类型/基础置信度/强边/最小共现/剪枝开关
│   ├── fusion_weights.yaml            # 黄金记录融合: 各来源可信度权重(改权重走双人复核)
│   ├── spark_prod.conf                # spark-submit 参数模板(附录C)
│   ├── flink_realtime.yaml            # Flink 作业参数: 并行度/Checkpoint/TTL/DLQ
│   └── redis_prod.yaml                # Redis 集群: 分片数/超时/连接池/降级开关
│
├── oneid_core/
│   ├── common/                        # 公共模块
│   │   ├── config.py                  # 配置加载(环境变量 + yaml), 启动时校验必填项
│   │   ├── logger.py                  # 结构化日志(JSON), 带 job/dt/trace_id 字段
│   │   ├── salt.py                    # KMS 取盐客户端; 代码只持有 salt_key 不持有明文
│   │   ├── hasher.py                  # HMAC-SHA256 加盐哈希; 轮换期双哈希(坑④)
│   │   ├── spark.py                   # SparkSession 工厂: Iceberg/动态分区/队列配置
│   │   ├── id_dict.py                 # ID 类型字典广播(强/弱、盐key、正则、虚商号段)
│   │   └── schema.py                  # 六张表的 StructType 定义(单一事实来源)
│   │
│   ├── standardize/                   # 第4章: ID 标准化
│   │   ├── pipeline.py                # 标准化主作业: 五端 union→校验→哈希→ods 明细
│   │   ├── sources/                   # 触点适配器(可插拔)
│   │   │   ├── base.py                #   SourceAdapter 抽象基类: to_long() 统一契约
│   │   │   ├── app_adapter.py         #   App: app_uid/mdn/device_id/idfa/oaid
│   │   │   ├── mini_adapter.py        #   小程序: unionid/openid
│   │   │   ├── h5_adapter.py          #   H5: cookie/浏览器指纹/留资 mdn
│   │   │   ├── store_adapter.py       #   门店 POS: member_id/mdn
│   │   │   └── cs_adapter.py          #   客服: ticket_id/来电 mdn/unionid
│   │   ├── validator.py               # 格式正则校验、虚商号段过滤、consent 过滤
│   │   └── hasher.py                  # 按 id_type 选盐的哈希封装
│   │
│   ├── graph/                         # 第5-6章: 建边 + GraphX 连通
│   │   ├── edge_build.py              # PySpark 建边: bind/pay/address/co_event/wifi 五类
│   │   ├── edges/
│   │   │   ├── bind_edge.py           #   账号绑定边(强 0.98): CDC t_user_bind
│   │   │   ├── pay_edge.py            #   同支付账号边(强 0.92)
│   │   │   ├── address_edge.py        #   同收货地址+电话尾号(0.85, 需2次)
│   │   │   ├── co_event_edge.py       #   同设备同会话共现(0.70, 需3次, 加盐防倾斜)
│   │   │   └── wifi_edge.py           #   公共 WiFi(0.30, 默认剪枝只留档, 坑⑤)
│   │   ├── confidence.py              # 置信度标定/时间衰减/共现加成
│   │   ├── super_cluster.py           # 超级簇熔断扫描(规模>5000 或强ID密度<2%)
│   │   └── src/main/scala/oneid_core/graph/
│   │       ├── ConnectedComponentsJob.scala  # GraphX Pregel 连通(主作业)
│   │       └── HashId.scala                  # 哈希串→VertexId 转换
│   │
│   ├── union_find/                    # 第8章: 离线增量并查集
│   │   ├── uf.py                      # 并查集: union by size + 锚点优先, 不做路径压缩(坑②)
│   │   ├── incremental.py             # 增量合并: 新边 union 旧簇, 输出变更 changelog
│   │   └── split.py                   # 拆分工具: 误并簇按强边重连(应急回滚, 坑①)
│   │
│   ├── allocator/                     # 第7章: OneID 分配
│   │   ├── snowflake.py               # 雪花算法: 时钟回拨拒绝 + worker 号段
│   │   ├── segment_keeper.py          # 号段高水位(ZK/MySQL 持久化), 防撞号(坑⑩)
│   │   ├── assign.py                  # 锚点选取→旧号沿用→新簇发号(15.3.4)
│   │   └── client.py                  # 实时作业调发号服务的 HTTP 客户端(重试/超时)
│   │
│   ├── realtime/                      # 第9章/第16章: Flink 实时链路
│   │   ├── job.py                     # 作业主入口: env/Checkpoint/重启策略/状态后端
│   │   ├── union_find_func.py         # KeyedProcessFunction 增量并查集(核心, 16.3)
│   │   ├── dedup.py                   # event_id 去重状态(TTL 24h, 坑⑥)
│   │   ├── reach_decision.py          # 触达决策: 频控/幂等/频道选择(16.4)
│   │   ├── crowd_update.py            # 人群包实时进出(crowd:{id} 集合)
│   │   ├── tombstone.py               # 注销事件→全链路墓碑(坑⑩)
│   │   ├── backfill.py                # 冷启动限速补数(backfill 标记不发券)
│   │   └── redis_dlq_replay.py        # Redis 写失败 DLQ 重放作业
│   │
│   ├── fusion/                        # 第9章: 黄金记录
│   │   ├── golden.py                  # 加权投票融合 + 翻转保护(权重差<0.2 进审核, 坑⑨)
│   │   ├── sources.py                 # 属性来源适配(注册资料/门店/行为推断)
│   │   ├── time_decay.py              # 属性时间衰减函数
│   │   └── history_writer.py          # 写 attr_history: 旧值回填 valid_to、留痕
│   │
│   ├── serving/                       # 第10章: FastAPI 服务
│   │   ├── app.py                     # FastAPI 应用: 四接口/异常处理/生命周期
│   │   ├── api/
│   │   │   ├── resolve.py             #   POST /oneid/resolve 正向单查
│   │   │   ├── batch.py               #   POST /oneid/resolve/batch(≤500)
│   │   │   ├── expand.py              #   POST /oneid/expand 反向(RBAC, 分页, 审计)
│   │   │   └── profile.py             #   POST /oneid/profile 黄金记录摘要
│   │   ├── auth.py                    # AK/SK 签名校验 + RBAC 角色
│   │   ├── ratelimit.py               # 令牌桶限流(429)
│   │   ├── cache.py                   # L1(Caffeine 10s) + 空值缓存(坑⑧) + 降级
│   │   ├── bloom.py                   # 布隆过滤器(0.1% 误判, 重建走验证工单)
│   │   ├── redis_keys.py              # key 规范 + 大 key 分 16 片(坑③)
│   │   ├── changelog_consumer.py      # 订阅 oneid_changelog 失效 L1/处理墓碑
│   │   └── load_redis.py              # T+1 全量映射装载 Redis(15.3.5)
│   │
│   ├── quality/                       # 第12章: 质量体系
│   │   ├── daily_metrics.sql          # 六大指标: 连通率/误并率/漏并率/簇分布/覆盖率
│   │   ├── super_cluster_scan.sql     # 超级簇扫描(坑⑤)
│   │   ├── realtime_offline_recon.sql # 实时离线对账(16.7)
│   │   ├── gray_diff.sql              # 灰度双跑差异(15.5)
│   │   ├── edge_qc.py                 # 边唯一性/置信度分布质检
│   │   ├── sampling_audit.py          # 月度抽样 2000 簇人工标注流转
│   │   └── alerts.py                  # 指标越界→企业微信/电话告警
│   │
│   └── ops/                           # 运维/应急
│       ├── emergency_split_weak.sql   # 误并紧急拆分(坑①)
│       ├── salt_rotation.py           # 盐值轮换双写/双读编排(坑④)
│       ├── rollback_switch.py         # oneid.routing=new/old/shadow 开关
│       └── trace_sql/                 # 15.10 排障三条 SQL
│
├── jobs/                              # 调度入口薄封装(参数解析→调包内逻辑)
│   ├── standardize_job.py  edge_build_job.py  cc_graphx_scala.sh
│   ├── assign_job.py  fusion_job.py  load_redis_job.py  quality_job.py
├── tests/                             # 测试
│   ├── test_union_find.py  test_fusion_flip.py  test_salt_rotation.py
│   ├── smoke_realtime.py              # 实时作业冒烟
│   ├── load_gen.py / load_observe.sh  # 压测构造与指标采集(16.5)
│   └── fixtures/                      # 标注样本/五端样例数据
└── dolphinscheduler/
    ├── oneid_daily.json               # 每日主工作流(15.4)
    ├── oneid_realtime.yaml            # 实时作业常驻定义
    └── alert_rules.yaml               # 告警规则

文件职责的几条设计原则:① common/schema.py 是六张表字段的单一事实来源 ,所有作业从这里引用 StructType,杜绝字段漂移;② 触点适配器(sources/*)和关系边(edges/*)都是可插拔注册制,新增触点/边类型不改主流程;③ 应急脚本(ops/)和正式作业同库管理,保证事故时跑的是经过评审的代码而不是临时手写 SQL。

A.7 包依赖方向与数据契约

包间只允许上层依赖下层,禁止反向依赖(避免作业逻辑渗进公共包):

复制代码
serving / realtime / fusion / allocator / graph / standardize
   └──> union_find (离线增量与实时共用一套并查集语义)
        └──> common (config/logger/salt/hasher/spark/schema/id_dict)
quality / ops 只读上层产出表 + 调 common, 不反向被依赖
代码片段(出现位置) 文件 输入 输出
五端标准化主作业 standardize/pipeline.py(part5 15.3.1) 五端 ODS 日志 ods_user_id_detail_di
五类边抽取 graph/edge_build.py(15.3.2) ods 明细 + 行为事件 dwd_id_relation_edge_df
GraphX 连通 graph/.../ConnectedComponentsJob.scala(15.3.3) 剪枝后边 + 节点 dwm_oneid_graph_df_tmp
锚点发号 allocator/assign.py(15.3.4) 图谱 + 上期映射 dwm_oneid_graph_df.oneid
Redis 装载 serving/load_redis.py(15.3.5) dim_oneid_id_map Redis + changelog
实时并查集 realtime/union_find_func.py(16.3) Kafka 事件流 Redis + oneid_changelog
触达频控 realtime/reach_decision.py(16.4) 人群命中 + 行为事件 发券/不发决策
误并紧急拆分 ops/emergency_split_weak.sql(17 坑①) 当日图谱 map_status 重写
墓碑处理 realtime/tombstone.py(17 坑⑩) 注销 CDC tombstone 全链路

附录 B:全套 DDL 汇总

六张固定表的完整建表语句(Iceberg on Spark 风格,含分区/分桶/存储格式/注释;Hive 场景将 USING iceberg 改为 STORED AS PARQUET 并保留 PARTITIONED BY)。表名全系列固定,禁止改名。

B.1 ODS 层:ID 标准化明细表

sql 复制代码
-- ods_user_id_detail_di ------ ID标准化明细(每个原始ID一行), 按天+触点分区
CREATE TABLE IF NOT EXISTS ods.ods_user_id_detail_di (
    raw_id          STRING  COMMENT '原始ID(明文仅内存,落盘为哈希)',
    id_hash         STRING  COMMENT 'HMAC-SHA256加盐哈希后的标准ID(节点主键)',
    id_type         STRING  COMMENT 'ID类型: mdn/unionid/openid/device_id/cookie/fp/member_id/ticket_id',
    id_level        INT     COMMENT 'ID等级: 1强ID 0弱ID',
    touch_point     STRING  COMMENT '触点: app/mini/h5/store/cs',
    app_id          STRING  COMMENT '来源应用标识',
    consent         TINYINT COMMENT '用户同意标记: 1已同意 0未同意(未同意不进图谱)',
    first_seen_time TIMESTAMP COMMENT '首次出现时间',
    last_seen_time  TIMESTAMP COMMENT '最近出现时间',
    is_valid        INT     COMMENT '格式校验是否通过 1/0',
    invalid_reason  STRING  COMMENT '无效原因: bad_format/virtual_segment/blacklist',
    etl_time        TIMESTAMP COMMENT 'ETL处理时间'
) USING iceberg
PARTITIONED BY (dt STRING COMMENT '分区日期 yyyy-MM-dd', touch_point STRING COMMENT '触点')
STORED AS PARQUET
TBLPROPERTIES (
    'format-version'='2',
    'write.format.default'='parquet',
    'write.parquet.compression-codec'='zstd',
    'write.distribution-mode'='hash',
    'write.bucket-spec'='id_hash(32)'
);

B.2 DWD 层:关系边表

sql 复制代码
-- dwd_id_relation_edge_df ------ 关系边表(全量快照 df), 按天分区
CREATE TABLE IF NOT EXISTS dwd.dwd_id_relation_edge_df (
    edge_id         STRING  COMMENT '边唯一ID(幂等键): md5(src|dst|rel_type)',
    src_id_hash     STRING  COMMENT '边起点(标准ID哈希)',
    dst_id_hash     STRING  COMMENT '边终点(标准ID哈希)',
    src_id_type     STRING  COMMENT '起点ID类型',
    dst_id_type     STRING  COMMENT '终点ID类型',
    rel_type        STRING  COMMENT '关系类型: bind/pay/address/co_event/wifi',
    confidence      DECIMAL(6,4) COMMENT '置信度 0~1',
    co_occur_cnt    BIGINT  COMMENT '共现次数(count distinct event_id)',
    first_time      TIMESTAMP COMMENT '首次建立关系时间',
    last_time       TIMESTAMP COMMENT '最近共现时间',
    source_touch    STRING  COMMENT '关系来源触点',
    is_strong       INT     COMMENT '是否强关系边 1/0',
    is_pruned       INT     COMMENT '是否被剪枝(弱低置信/wifi类) 1/0'
) USING iceberg
PARTITIONED BY (dt STRING COMMENT '快照日期')
STORED AS PARQUET
TBLPROPERTIES (
    'format-version'='2',
    'write.parquet.compression-codec'='zstd',
    'write.distribution-mode'='hash',
    'write.bucket-spec'='src_id_hash(64)'
);

B.3 DWM 层:OneID 图谱表

sql 复制代码
-- dwm_oneid_graph_df ------ OneID图谱(连通分量结果), 按天分区
CREATE TABLE IF NOT EXISTS dwm.dwm_oneid_graph_df (
    oneid           BIGINT  COMMENT '统一ID(雪花算法分配)',
    id_hash         STRING  COMMENT '成员标准ID哈希',
    id_type         STRING  COMMENT '成员ID类型',
    id_level        INT     COMMENT '1强 0弱',
    cluster_size    BIGINT  COMMENT '所在连通簇节点数',
    cluster_id_min  STRING  COMMENT '连通分量代表节点(GraphX最小顶点)',
    is_anchor       INT     COMMENT '是否锚点(强ID,决定簇稳定性) 1/0',
    anchor_hash     STRING  COMMENT '簇锚点ID哈希(OneID 沿锚点继承)',
    path_confidence DECIMAL(6,4) COMMENT '该ID到簇内强ID的最小路径置信度',
    join_time       TIMESTAMP COMMENT '入簇时间'
) USING iceberg
PARTITIONED BY (dt STRING COMMENT '快照日期')
STORED AS PARQUET
TBLPROPERTIES (
    'format-version'='2',
    'write.parquet.compression-codec'='zstd',
    'write.distribution-mode'='hash',
    'write.bucket-spec'='oneid(64)'
);

B.4 DIM 层:OneID 主表(黄金记录)

sql 复制代码
-- dim_oneid_user ------ OneID主表(每个自然人一行, 黄金记录), 按天分区
CREATE TABLE IF NOT EXISTS dim.dim_oneid_user (
    oneid           BIGINT  COMMENT '统一ID',
    gender          STRING  COMMENT '黄金性别: M/F/U',
    gender_src      STRING  COMMENT '性别取值来源ID类型',
    birth_year      INT     COMMENT '出生年(黄金)',
    city            STRING  COMMENT '常驻城市(黄金)',
    nickname        STRING  COMMENT '昵称(黄金,脱敏)',
    mdn_hash        STRING  COMMENT '主手机号哈希(黄金强ID)',
    unionid_hash    STRING  COMMENT '主unionid哈希',
    member_id_hash  STRING  COMMENT '主会员ID哈希',
    id_cnt          INT     COMMENT '关联ID总数',
    touch_points    STRING  COMMENT '覆盖触点列表,逗号分隔: app,mini,h5,store,cs',
    first_active    TIMESTAMP COMMENT '首次活跃',
    last_active     TIMESTAMP COMMENT '最近活跃',
    oneid_status    TINYINT COMMENT '状态: 1正常 0已注销(tombstone)',
    version         INT     COMMENT '黄金记录版本号(每次融合+1)',
    etl_time        TIMESTAMP COMMENT 'ETL时间'
) USING iceberg
PARTITIONED BY (dt STRING COMMENT '分区日期')
STORED AS PARQUET
TBLPROPERTIES ('format-version'='2','write.parquet.compression-codec'='zstd');

B.5 DIM 层:ID 映射表(服务层主查)

sql 复制代码
-- dim_oneid_id_map ------ ID映射表(任意ID → OneID), 服务层正向/反向主查
CREATE TABLE IF NOT EXISTS dim.dim_oneid_id_map (
    id_hash     STRING  COMMENT '标准ID哈希(查询键)',
    id_type     STRING  COMMENT 'ID类型',
    oneid       BIGINT  COMMENT '统一ID',
    map_status  TINYINT COMMENT '映射状态: 1有效 2待审核 3已拆分 4墓碑(注销)',
    confidence  DECIMAL(6,4) COMMENT '归属置信度',
    anchor_hash STRING  COMMENT '所属簇锚点',
    start_time  TIMESTAMP COMMENT '生效时间',
    end_time    TIMESTAMP COMMENT '失效时间(拆分/注销时回填, 生效中为NULL)',
    etl_time    TIMESTAMP COMMENT 'ETL时间'
) USING iceberg
PARTITIONED BY (dt STRING COMMENT '分区日期')
STORED AS PARQUET
TBLPROPERTIES (
    'format-version'='2',
    'write.parquet.compression-codec'='zstd',
    'write.distribution-mode'='hash',
    'write.bucket-spec'='id_hash(64)'
);

B.6 DIM 层:属性历史表

sql 复制代码
-- dim_oneid_attr_history ------ 属性历史(时间线/冲突留痕/审核/回滚依据)
CREATE TABLE IF NOT EXISTS dim.dim_oneid_attr_history (
    oneid        BIGINT  COMMENT '统一ID',
    attr_name    STRING  COMMENT '属性名: gender/city/nickname/birth_year',
    attr_value   STRING  COMMENT '属性值',
    src_id_hash  STRING  COMMENT '来源ID哈希',
    src_type     STRING  COMMENT '来源触点/系统: app_register/store_card/h5_infer',
    confidence   DECIMAL(6,4) COMMENT '该来源可信度',
    valid_from   TIMESTAMP COMMENT '生效开始',
    valid_to     TIMESTAMP COMMENT '生效结束(被新值取代时回填)',
    is_current   TINYINT COMMENT '1当前黄金值 0历史值',
    audit_status TINYINT COMMENT '0无需审核 1待审核 2通过 3驳回',
    etl_time     TIMESTAMP COMMENT 'ETL时间'
) USING iceberg
PARTITIONED BY (dt STRING COMMENT '分区日期', attr_name STRING COMMENT '属性名')
STORED AS PARQUET
TBLPROPERTIES ('format-version'='2','write.parquet.compression-codec'='zstd');

配套辅助表(非六张主表,运营/审核用):dim_id_type_dict(ID 类型字典,MySQL 广播)、oneid_salt_config(盐值 key 配置,盐本体在 KMS)、oneid_review.super_cluster_pending(超级簇待审)、oneid_review_ticket(人工审核工单)、oneid_audit_log(服务审计日志)、oneid_supernode_blacklist(黑产 ID 黑名单)。

B.7 核心质量/监控 SQL(值班每日必看)

sql 复制代码
-- ① 连通率: 强ID可达的 ID 占比(第15章核心指标, 基线42% → 91%)
SELECT
  count(DISTINCT id_hash)                                          AS total_ids,
  count(DISTINCT CASE WHEN has_strong_neighbor=1 THEN id_hash END) AS connected_ids,
  count(DISTINCT CASE WHEN has_strong_neighbor=1 THEN id_hash END)*1.0
    / count(DISTINCT id_hash)                                      AS connect_rate
FROM (
  SELECT m.id_hash,
    MAX(CASE WHEN g.id_level=1 AND g.cluster_size>1 THEN 1 ELSE 0 END) AS has_strong_neighbor
  FROM dim.dim_oneid_id_map m
  JOIN dwm.dwm_oneid_graph_df g ON m.id_hash=g.id_hash AND m.dt=g.dt
  WHERE m.dt='${dt}' AND m.map_status=1
  GROUP BY m.id_hash
) t;

-- ② 误并/漏并代理指标: 弱边成簇率(无强锚点簇的 ID 占比, 应<0.5%)
SELECT
  sum(CASE WHEN g.strong_cnt=0 THEN 1 ELSE 0 END)*1.0 / count(*) AS weak_only_cluster_ratio
FROM (
  SELECT cluster_id_min, count(*) nodes,
         sum(CASE WHEN id_level=1 THEN 1 ELSE 0 END) strong_cnt
  FROM dwm.dwm_oneid_graph_df WHERE dt='${dt}'
  GROUP BY cluster_id_min
) g;

-- ③ OneID 稳定性巡检: 强锚点 ID 的 OneID 与昨日不同的数量(必须为 0, 坑②)
SELECT count(*) AS anchor_jump_cnt
FROM (
  SELECT t.id_hash
  FROM (SELECT id_hash, oneid FROM dim.dim_oneid_id_map
        WHERE dt='${dt}' AND map_status=1) t
  JOIN (SELECT id_hash, oneid FROM dim.dim_oneid_id_map
        WHERE dt='${dt-1}' AND map_status=1) y
    ON t.id_hash=y.id_hash AND t.oneid<>y.oneid
  JOIN (SELECT DISTINCT anchor_hash FROM dwm.dwm_oneid_graph_df WHERE dt='${dt}') a
    ON t.id_hash=a.anchor_hash
) x;

-- ④ 墓碑一致性: 已注销 OneID 不得出现在有效映射/人群中(坑⑩)
SELECT count(*) AS tombstone_leak
FROM dim.dim_oneid_user u
JOIN dim.dim_oneid_id_map m ON u.oneid=m.oneid AND u.dt=m.dt
WHERE u.dt='${dt}' AND u.oneid_status=0 AND m.map_status NOT IN (4);

质量告警阈值速查:连通率日环比跌幅 >2pp 电话;合并率日突增 >0.5% 电话;弱边成簇率 >0.5% 阻断 load_redis;锚点跳变数 ≠0 立即事故响应;墓碑泄漏 >0 合规级告警。


附录 C:关键配置参数模板

C.1 Spark submit 参数(离线主链路)

properties 复制代码
# conf/spark_prod.conf ------ 以 cc_graphx(最重任务)为例, 其余任务按 15.4 JSON 缩减
spark.master                     yarn
spark.submit.deployMode          cluster
spark.yarn.queue                 root.oneid
spark.driver.memory              16g
spark.driver.cores               4
spark.executor.memory            16g
spark.executor.cores             4
spark.executor.instances         300
spark.sql.shuffle.partitions     2000
spark.default.parallelism        2000
spark.memory.fraction            0.7
spark.sql.sources.partitionOverwriteMode   dynamic
spark.sql.adaptive.enabled       true
spark.sql.adaptive.coalescePartitions.enabled true
spark.sql.adaptive.skewJoin.enabled        true
# Iceberg
spark.sql.extensions             org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions
spark.sql.catalog.dwm            org.apache.iceberg.spark.SparkCatalog
spark.sql.catalog.dwm.type       hive
# GraphX
spark.graphx.pregel.maxIterations 20
spark.graphx.partitionStrategy    EdgePartition2D
# 稳定性
spark.task.maxFailures           4
spark.stage.maxConsecutiveAttempts 4
spark.sql.broadcastTimeout       1200
spark.network.timeout            600s
spark.rpc.askTimeout             300s
# 压缩/Shuffle
spark.sql.parquet.compression.codec zstd
spark.shuffle.compress           true
bash 复制代码
# jobs/cc_graphx_scala.sh ------ GraphX 连通作业提交
spark-submit \
  --master yarn --deploy-mode cluster \
  --queue root.oneid \
  --name oneid_cc_${dt} \
  --class oneid_core.graph.ConnectedComponentsJob \
  --driver-memory 16g --executor-memory 16g --executor-cores 4 --num-executors 300 \
  --conf spark.sql.shuffle.partitions=2000 \
  --conf spark.graphx.pregel.maxIterations=20 \
  --conf spark.executor.memoryOverhead=4g \
  --conf spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions \
  --files conf/spark_prod.conf,conf/relation_rules.yaml \
  hdfs:///apps/oneid/oneid_core-jobs-1.0.jar "${dt}"

C.2 Flink 作业参数(实时并查集)

yaml 复制代码
# conf/flink_realtime.yaml
job:
  name: oneid_realtime
  parallelism: 128              # 大促临时调到 160
  max_parallelism: 256
  restart_strategy:
    type: fixed-delay
    attempts: 5
    delay: 30s
checkpoint:
  interval: 60000               # ms; 大促临时 90000
  mode: EXACTLY_ONCE
  timeout: 600000
  min_pause: 30000
  max_concurrent: 1
  unaligned: true               # 反压时 barrier 不排队(坑: 对齐超时)
  externalized: RETAIN_ON_CANCELLATION
  storage: hdfs:///oneid/ckpt/oneid_realtime
  incremental: true             # RocksDB 增量快照
state:
  backend: rocksdb
  ttl:
    uf_state: 7d                # 并查集状态
    dedup_state: 24h            # event_id 去重
    pending_state: 1h           # 攒批待合并
  rocksdb:
    localdir: /mnt/ssd/rocksdb
    writebuffer: 256m
    thread_num: 4
kafka:
  brokers: kafka-1:9092,kafka-2:9092,kafka-3:9092
  source_topics: [app_login, mini_login, h5_event, pos_scan, cs_bind, bind_cdc, account_cancel]
  source_group: oneid_realtime_g01
  partition_count: 48          # 按峰值建好, 平时并行度可低于分区数
  sink_changelog: oneid_changelog
  dlq_topic: oneid_redis_dlq
union_find:
  strong_rels: [bind, pay]     # 实时只合强边
  merge_batch_ms: 300000       # 5 分钟攒批定时器
  cluster_alarm_size: 2000     # 实时超级簇告警线
backfill:
  rate_limit_qps: 20000
  tag: backfill=true           # 下游只更人群不发券

C.3 Redis 配置

yaml 复制代码
# conf/redis_prod.yaml
redis:
  mode: cluster
  nodes: 16                      # 16 分片 × 主从
  shards: 16
  password_ref: kms://redis/oneid_prod   # 密码走 KMS, 不落配置
  socket_timeout_ms: 50          # 快速超时降级, 不等慢节点
  socket_connect_timeout_ms: 100
  max_connections_per_node: 64
  retry_on_timeout: 1
  decode_responses: true
key_ttl:
  map_hash: 604800               # oneid:map:{hash} 兜底 7 天(每日全量重灌)
  null_cache: 60                 # 空值缓存 60s(防穿透)
  l1_local: 10                   # FastAPI 本地 Caffeine 10s
  reach_window: 300              # 触达频控窗口
  reach_daily: 86400
members_shard: 16                # oneid:members 大 key 分 16 片
degrade:
  bloom_fpp: 0.001               # 布隆误判率 0.1%
  bloom_capacity_factor: 1.5
  on_cluster_down: read_l1_stale # 集群级故障: 返回 stale L1, 标记 stale=true
  reach_pause_on_lag_ms: 600000  # 人群延迟>10min 暂停时效券

C.4 FastAPI 服务配置

yaml 复制代码
# oneid_core/serving/conf/service.yaml
service:
  name: oneid-serving
  host: 0.0.0.0
  port: 8080
  workers: 16                    # uvicorn worker, K8s 多副本
  replicas: 8                    # HPA: CPU 60% 触发, 8→32
  max_batch_size: 500            # /resolve/batch 上限
auth:
  type: aksk_hmac_sha256
  sign_window_s: 300             # 签名时间窗(防重放)
  rbac:
    resolve: [marketing, dmp, cs, risk, rec]
    expand:  [risk, audit]       # 反向接口最小权限
    profile: [rec, dmp, marketing]
ratelimit:
  default_qps_per_ak: 2000
  expand_qps_per_ak: 100
  miss_rate_block: 0.8           # 单 AK miss 率>80% 触发验证码级限流
sla:
  p99_budget_ms: 30
  cache_l1_ttl_s: 10
changelog:
  topic: oneid_changelog
  group: oneid_serving_g01
  on_tombstone: invalidate_all   # 墓碑: 清缓存并拦截
observability:
  metrics_path: /metrics
  audit_log: oneid_audit_log     # 全量请求审计(AK/接口/oneid/时间)

C.5 DolphinScheduler 任务配置(YAML 视图,与 15.4 JSON 等价)

yaml 复制代码
# dolphinscheduler/oneid_daily.yaml(精简视图)
workflow: oneid_daily
schedule: "0 0 2 * * ?"           # 每日 02:00
params:
  dt: "${yyyy-MM-dd-1}"
  queue: root.oneid
tasks:
  - {name: sensor_ods_ready, type: SENSOR, timeout_m: 60}
  - {name: std_app,   type: SPARK, job: standardize_job.py, args: "--dt ${dt} --touch app",
     execs: 60, exec_mem: 8g, retry: 3, retry_interval_m: 5, timeout_m: 50,
     deps: [sensor_ods_ready], critical: true}
  - {name: std_mini,  type: SPARK, job: standardize_job.py, args: "--dt ${dt} --touch mini",
     deps: [sensor_ods_ready], critical: true}
  - {name: std_store, type: SPARK, job: standardize_job.py, args: "--dt ${dt} --touch store",
     deps: [sensor_ods_ready], critical: true}
  - {name: std_h5,    type: SPARK, job: standardize_job.py, args: "--dt ${dt} --touch h5",
     deps: [sensor_ods_ready], critical: false}
  - {name: std_cs,    type: SPARK, job: standardize_job.py, args: "--dt ${dt} --touch cs",
     deps: [sensor_ods_ready], critical: false}
  - {name: edge_build, type: SPARK, job: edge_build_job.py, args: "--dt ${dt}",
     execs: 150, exec_mem: 12g, retry: 2, timeout_m: 90,
     deps: [std_app, std_mini, std_h5, std_store, std_cs]}
  - {name: edge_qc, type: SPARK_SQL, job: edge_qc.sql, blocking: false,
     deps: [edge_build]}
  - {name: cc_graphx, type: SPARK_SCALA, jar: oneid_core-jobs-1.0.jar,
     class: oneid_core.graph.ConnectedComponentsJob, args: "${dt}",
     execs: 300, exec_mem: 16g, retry: 2, retry_interval_m: 15, timeout_m: 150,
     deps: [edge_build]}
  - {name: super_cluster_scan, type: SQL, deps: [cc_graphx],
     alarm_if_rows_gt: 0}
  - {name: assign, type: SPARK, job: assign_job.py, args: "--dt ${dt}",
     execs: 100, retry: 2, deps: [super_cluster_scan]}
  - {name: fusion, type: SPARK, job: fusion_job.py, args: "--dt ${dt}",
     retry: 2, deps: [assign]}
  - {name: load_redis, type: PYTHON, job: load_redis_job.py,
     args: "--dt ${dt} --cluster prod-redis-oneid",
     retry: 3, retry_interval_m: 5, timeout_m: 80, deps: [assign, fusion]}
  - {name: quality_daily, type: SPARK_SQL, job: daily_metrics.sql,
     blocking: false, deps: [assign],
     alarm: {connect_rate_drop_gt: 0.02, merge_rate_spike_gt: 0.005}}
alert:
  channels: [wechat:data-oneid-oncall, phone:tech-lead]
  rules:
    - {on: task_failed_after_retry, to: wechat}
    - {on: workflow_lag, deadline: "06:00", to: [wechat, phone]}
    - {on: quality_breach, to: phone}

C.6 服务部署与监控告警(K8s + Prometheus)

yaml 复制代码
# oneid_core/serving/deploy/deployment.yaml(节选)
apiVersion: apps/v1
kind: Deployment
metadata: {name: oneid-serving, labels: {app: oneid-serving}}
spec:
  replicas: 8
  selector: {matchLabels: {app: oneid-serving}}
  template:
    spec:
      containers:
        - name: oneid-serving
          image: registry.xinggou.com/oneid/serving:1.8.0
          ports: [{containerPort: 8080}]
          resources:
            requests: {cpu: "2", memory: 4Gi}
            limits:   {cpu: "4", memory: 8Gi}
          env:
            - {name: REDIS_CONFIG, value: /conf/redis_prod.yaml}
            - {name: KMS_ENDPOINT, value: "https://kms.internal:9400"}
            - {name: ROUTING, value: "new"}        # new/old/shadow 回滚开关(配置中心热更新)
          readinessProbe: {httpGet: {path: /health, port: 8080}, periodSeconds: 5}
          livenessProbe:  {httpGet: {path: /health, port: 8080}, periodSeconds: 15}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: {name: oneid-serving-hpa}
spec:
  scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: oneid-serving}
  minReplicas: 8
  maxReplicas: 32
  metrics:
    - type: Resource
      resource: {name: cpu, target: {type: Utilization, averageUtilization: 60}}
yaml 复制代码
# 关键告警规则(Prometheus AlertManager)
groups:
- name: oneid
  rules:
  - alert: OneidResolveP99High
    expr: histogram_quantile(0.99, rate(oneid_resolve_latency_seconds_bucket[5m])) > 0.03
    for: 3m
    annotations: {summary: "OneID 查询 P99 > 30ms, 检查 Redis/L1/降级"}
  - alert: OneidErrorRateHigh
    expr: rate(oneid_resolve_total{status=~"5.."}[5m]) / rate(oneid_resolve_total[5m]) > 0.01
    for: 2m
  - alert: OneidRedisShardHot
    expr: max by (shard) (rate(redis_commands_processed_total[1m])) > 80000
    for: 1m
  - alert: OneidFlinkCkptFail
    expr: increase(flink_taskmanager_job_numberOfFailedCheckpoints[10m]) >= 2
  - alert: OneidFlinkLagHigh
    expr: kafka_consumer_group_lag{group=~"oneid_.*"} > 1000000
    for: 5m
  - alert: OneidMergeRateSpike        # 误并熔断(与离线质量SQL双保险)
    expr: rate(oneid_merge_changelog_total[10m]) / rate(oneid_event_in_total[10m]) > 0.02
    for: 2m
    annotations: {action: "自动阻断 load_redis, 电话 on-call"}

监控看板分四行:第一行服务健康(QPS/错误率/P99/降级比例),第二行 Redis(分片 CPU/内存/热 key/连接数),第三行 Flink(吞吐/lag/Checkpoint 时长/状态大小/反压),第四行业务质量(连通率/合并率/墓碑量/超级簇数量)。每行都有"正常基线区间"标注,值班不用记阈值,看颜色即可。

C.7 Iceberg 表维护与日常运维命令

sql 复制代码
-- ① 小文件合并(每日主链路后执行, 降低快照数和扫描成本)
CALL catalog.system.rewrite_data_files(
  table => 'dwd.dwd_id_relation_edge_df',
  strategy => 'binpack',
  options => map('min-input-files','5','target-file-size-bytes','134217728'));

-- ② 过期快照清理(保留最近 7 天, 同时支持按快照回滚)
CALL catalog.system.expire_snapshots(
  table => 'dwm.dwm_oneid_graph_df',
  older_than => TIMESTAMP '${dt-7} 00:00:00',
  retain_last => 7);

-- ③ 孤立文件清理(失败任务残留)
CALL catalog.system.remove_orphan_files(table => 'dim.dim_oneid_id_map');

-- ④ 回滚到指定快照(误并事故时的表级回退, 与 ops 脚本配合)
CALL catalog.system.rollback_to_snapshot(
  table => 'dim.dim_oneid_id_map', snapshot_id => ${good_snapshot_id});

-- ⑤ 分区行数/文件分布巡检(判断倾斜与小文件)
SELECT dt, count(*) AS files, sum(record_count) AS rows,
       sum(file_size_in_bytes)/1024/1024 AS size_mb
FROM dwd.dwd_id_relation_edge_df.files
WHERE dt='${dt}' GROUP BY dt;
bash 复制代码
# 日常运维速查
# Flink 从指定 Checkpoint 启动(回滚/迁移)
flink run -s hdfs:///oneid/ckpt/oneid_realtime/chk-12345 \
  -d oneid-realtime-1.0.jar --conf conf/flink_realtime.yaml

# Kafka 消费位点查看与重置(补数前确认)
kafka-consumer-groups.sh --bootstrap-server $BK --group oneid_realtime_g01 --describe
kafka-consumer-groups.sh --bootstrap-server $BK --group oneid_realtime_g01 \
  --topic oneid_changelog:0,1,2 --reset-offsets --to-datetime 2025-11-10T02:00:00.000 --execute

# Redis 热 key / 大 key 巡检(大促前例行)
redis-cli --cluster call $REDIS --hotkeys
redis-cli --bigkeys -i 0.1

# 工作流手动补数(DolphinScheduler 补数: 指定日期区间重跑)
#  界面操作: 工作流 oneid_daily → 补数 → 起止日期 → 并行度 2(避免资源抢占)

附录 D:术语表

按"先基础、后算法、再工程"排序,共 30 条。

术语 简明解释
OneID 以自然人为单位的全局统一标识(星购用雪花算法生成的 BIGINT),全公司数据的 join key;一经分配稳定不变。
ID Mapping(ID 映射) 描述"两个 ID 是否属于同一人"的关系集合(边);OneID 是映射关系做连通后给"人"的编号(点归属)。
强 ID(Strong ID) 能较唯一定位自然人的 ID:手机号 mdn、微信 unionid、会员号 member_id、支付账号;可作连通簇锚点。
弱 ID(Weak ID) 可能多人共用或一人多个的 ID:设备号、idfa/oaid、cookie、浏览器指纹、IP、WiFi;只能辅助挂靠、不能单独成簇。
触点(Touchpoint) 用户与业务接触的渠道:星购为 App、微信小程序(mini)、H5、线下门店(store)、客服(cs) 五端。
ID 标准化 把各触点异构 ID 清洗、格式校验、统一 HMAC 加盐哈希成标准 id_hash 的过程(ods_user_id_detail_di)。
HMAC 加盐哈希 用密钥(盐)对明文 ID 做 HMAC-SHA256,落盘与查询只用哈希;盐存 KMS、按 ID 类型分级、轮换走双写过渡期。
关系边(Edge) 连接两个 ID 的关系记录:bind(绑定)/pay(同支付)/address(同地址)/co_event(共现)/wifi(同 WiFi),带置信度。
置信度(Confidence) 一条边/一个归属"属于同一人"的概率分(0~1),由关系类型基础分 × 共现次数 × 时间衰减标定;阈值用标注样本回归。
剪枝(Prune) 低置信/高噪声边(如公共 WiFi、共现次数不足)标记 is_pruned=1,不参与图连通,只留档可追溯。
连通分量(Connected Component) 无向图中互相可达的最大子图;同一分量内的 ID 被视为同一自然人,GraphX connectedComponents 计算。
并查集(Union-Find / Disjoint Set) 动态维护"元素分组"的数据结构,union 合并、find 查根;星购版本用 union by size + 锚点优先,不做路径压缩以防 OneID 跳变。
锚点(Anchor) 连通簇中作为稳定性基准的强 ID;合并时锚点簇的 OneID 永远保留,弱簇并入锚点簇。
超级节点 / 超级簇(Super Cluster) 节点数异常巨大(>5000)或强 ID 密度极低(<2%)的连通簇,多为公共 WiFi/黑产设备农场造成;熔断后人工审核不发 OneID。
数据倾斜(Skew) 分布式计算中个别 key(热门设备/公共 IP)数据量远超其他,导致少数 task 拖慢全局;解法是加盐打散 + 黑名单。
黄金记录(Golden Record) 一个 OneID 的最佳属性值(性别/城市/生日等),由多来源按可信度加权投票融合产生,存 dim_oneid_user
属性融合(Fusion) 冲突属性按"来源可信度 × 时间衰减 × 强 ID 优先"加权决策;权重差不足时保守保留并转人工审核(翻转保护)。
墓碑(Tombstone) 注销标记:oneid_status=0/map_status=4,映射不删除但所有查询返回"已注销"、触达全拦截,30 天后匿名化。
雪花算法(Snowflake) 时间戳 + worker_id + 序列号组成的分布式发号算法;星购额外做时钟回拨拒绝与号段高水位持久化防撞号。
时钟回拨(Clock Rollback) 服务器时钟因 NTP 同步等原因倒退,可能导致雪花发出重复号;回拨超阈值即拒绝发号并切 worker。
Changelog(变更日志) OneID/映射变更事件流(Kafka oneid_changelog):服务层据此失效本地缓存、下游人群包实时更新。
Checkpoint Flink 定期把算子状态持久化到 HDFS(星购 60s/增量/Exactly-Once),故障后从最近快照恢复、位点随快照回滚。
状态 TTL(State TTL) Flink 状态的存活时长(并查集 7 天、去重 24 小时),过期自动清理防状态膨胀;定时器不随 ValueState TTL 清理需单独去重。
DLQ(死信队列) 处理失败的消息(如 Redis 写失败)转入独立 Kafka 主题,由重放作业兜底重试,保证主流程不被阻塞。
布隆过滤器(Bloom Filter) 空间高效的"集合是否存在"过滤器,回答"一定不在/可能在";用于拦截不存在 ID 的缓存穿透,有误判率(星购 0.1%)。
缓存穿透/击穿/雪崩 穿透=查不存在数据绕过缓存打 DB(空值缓存防);击穿=热点 key 过期瞬间打爆(互斥重建/不过期);雪崩=大批 key 同时失效(TTL 加随机抖动)。
热 key(Hot Key) 访问量远超其他的 Redis key(如黑产大簇的 members);解法是大 key 分片存储 + 本地缓存 + 限流。
灰度切流(Canary) 影子双跑→1% 白名单→单业务→全量的渐进上线;配合回滚开关 oneid.routing=new/old/shadow 与双跑差异对账。
日结对账(Daily Reconciliation) 离线 T+1 全量(权威)与实时(快但可错)每日比对:漏并/方向冲突/多并三类差异分别处理,以离线锚点为准回刷。
RBAC / AK/SK RBAC=基于角色的接口权限(expand 仅风控/审计);AK/SK=调用方密钥对,请求用 HMAC 签名鉴权并全量审计。
DAG 有向无环图,调度工作流里任务依赖的拓扑(如五端标准化→建边→连通→分配的依赖链);DolphinScheduler 据此决定执行顺序与并行度。
SLA / SLO SLA=服务承诺(如查询 P99 ≤30ms、主链路 06:00 前产出);SLO=内部目标线;越界触发分级告警(企业微信→电话)。

系列尾声:全系列总索引

《OneID 从 0 到 1 完整生产案例》全六册,围绕虚构电商"星购电商"(App/小程序/H5/门店/客服 5 触点、日活 500 万、ID 10 亿级、边 20 亿级)展开,代码库统一为 oneid_core,六张核心表名全篇固定。

分册文件 覆盖章节 内容一句话
oneid_case_part1_ch01-03.md 第 1--3 章 OneID 价值与背景、整体架构、五层数仓设计与六表 DDL、ID 类型字典与盐值体系
oneid_case_part2_ch04-06.md 第 4--6 章 ID 标准化与 HMAC 哈希、五类关系边抽取与置信度标定、GraphX 连通分量生产实现
oneid_case_part3_ch07-09.md 第 7--9 章 雪花发号、离线增量并查集、Flink 实时并查集、黄金记录属性融合
oneid_case_part4_ch10-14.md 第 10--14 章 FastAPI+Redis 服务化、下游对接、数据质量体系、性能优化与成本、安全合规
oneid_case_part5_ch15-17.md 第 15--17 章 五端全渠道端到端落地(DAG/排期/效果)、实时 OneID 营销触达(压测/故障演练)、10 个生产事故复盘
oneid_case_part6_ch18_appendix.md 第 18 章 + 附录 A--D 30 道面试题速查、代码目录、全套 DDL、配置模板、术语表(本册)

贯穿全系列的 10 条工程信条(也是第 17 章 10 个坑的正面总结):

  1. 强 ID 定锚点、弱 ID 只挂靠------没有强 ID 的簇不发正式 OneID;
  2. OneID 稳定压倒一切------宁可漏并(明天补),不可误并/跳变(污染所有下游);
  3. 阈值和权重是数据产品------标注样本标定、变更必回归、越界即熔断;
  4. 实时只合强边、弱边离线收敛------实时求快、离线求准、日结对账收口;
  5. 环境特征(WiFi/IP)是"地点"不是"人"------永远不进关系图;
  6. 所有写操作幂等、所有变更可回滚------INSERT OVERWRITE、HSET、SETNX、留痕表;
  7. 兜底是降级可用不是报错------布隆+空值缓存+L1 三级、DLQ 重放、离线对账;
  8. 留痕是救命绳------属性历史、边快照、changelog、审计日志一个不能少;
  9. 合规内建而非补丁------客户端预哈希、盐不出 KMS、注销即墓碑、OneID 不可逆;
  10. 止损先于定位------回滚开关 10 秒生效,先切 old 再查原因。

全系列完。

祝你在面试和实战中都能回答好那道终极问题:"给你 10 亿个跨渠道 ID,你怎么知道哪些是同一个人,并且每天都算得准、错了能回滚、挂了能降级?"------这套案例就是一份可以照着落地的答卷。

相关推荐
小五传输1 小时前
聚焦卫健数字化建设 文件传输服务器守护跨机构敏感医疗数据交换
大数据·运维·安全
万岳科技系统开发1 小时前
私域直播AI数字人系统:AI技术如何重构企业直播运营模式
大数据·人工智能·重构
AI_Cloud_推荐1 小时前
SpringBoot集成百度人脸识别SDK实战:人脸检测、比对与注册
大数据·人工智能·spring boot·安全·百度·视觉检测
大大大大晴天️1 小时前
把湖仓一体放进 K8s:Iceberg、Hudi、Paimon 如何重塑云原生大数据架构
大数据·云原生·kubernetes
doitnow20002 小时前
淘宝开店教程收费前要确认哪些项目?
大数据·人工智能
迈巴赫车主2 小时前
Doris简介
大数据·数据仓库·doris
2503_931712482 小时前
2026年广东佛山头层真皮/头层牛皮/摔纹皮/油蜡皮/真皮皮料企业盘点!
大数据
2603_965148116 小时前
家居百货蓝海:API挖掘高复购率生活小商品
大数据·服务器·人工智能·python·生活
宸津-代码粉碎机11 小时前
AI攻防战升级!基于Spring AI构建Java应用自动免疫安全体系
java·大数据·开发语言·人工智能·python·安全·spring