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 个坑的正面总结):
- 强 ID 定锚点、弱 ID 只挂靠------没有强 ID 的簇不发正式 OneID;
- OneID 稳定压倒一切------宁可漏并(明天补),不可误并/跳变(污染所有下游);
- 阈值和权重是数据产品------标注样本标定、变更必回归、越界即熔断;
- 实时只合强边、弱边离线收敛------实时求快、离线求准、日结对账收口;
- 环境特征(WiFi/IP)是"地点"不是"人"------永远不进关系图;
- 所有写操作幂等、所有变更可回滚------INSERT OVERWRITE、HSET、SETNX、留痕表;
- 兜底是降级可用不是报错------布隆+空值缓存+L1 三级、DLQ 重放、离线对账;
- 留痕是救命绳------属性历史、边快照、changelog、审计日志一个不能少;
- 合规内建而非补丁------客户端预哈希、盐不出 KMS、注销即墓碑、OneID 不可逆;
- 止损先于定位------回滚开关 10 秒生效,先切 old 再查原因。
全系列完。
祝你在面试和实战中都能回答好那道终极问题:"给你 10 亿个跨渠道 ID,你怎么知道哪些是同一个人,并且每天都算得准、错了能回滚、挂了能降级?"------这套案例就是一份可以照着落地的答卷。