OneID 从 0 到 1 完整生产案例(四)
本篇为第 10--14 章:FastAPI 服务化(第 10 章)、回写与下游对接(第 11 章)、数据质量体系(第 12 章)、性能优化(第 13 章)、安全合规(第 14 章)。
第10章 OneID 服务化:FastAPI + Redis 查询服务
离线表和 Redis 里的映射不能让业务方直连------必须有一层带鉴权、限流、降级的在线服务。星购的 OneID 查询服务 oneid-serving 是全公司调用量最大的基础服务之一:日均调用 12 亿次,峰值 6.5 万 QPS,SLA 目标 P99 < 10ms(缓存服务路径)、可用性 99.95%。P99 的可行性来自:布隆过滤器挡掉 15% 流量、L1 本地缓存命中 68%(亚毫秒)、Redis 读从节点 P99 ~3ms,真正穿透到 Redis 的请求仅 ~17%。
10.1 服务接口设计
对外提供 4 个接口,覆盖全部业务场景:
| 接口 | 方法 | 用途 | 典型调用方 |
|---|---|---|---|
/oneid/resolve |
POST | 正向:单个原始 ID → OneID | 营销触达、客服弹窗 |
/oneid/resolve/batch |
POST | 正向批量:ID 列表 → 映射(≤500) | 特征拼接、人群圈选 |
/oneid/expand |
POST | 反向:OneID → 该用户全部 ID 哈希 | 风控关联分析、多端触达 |
/oneid/profile |
POST | OneID → 黄金记录摘要(性别/城市/会员等级) | 推荐特征、实时画像 |
设计原则:
- 只收哈希不收明文:调用方传入的 id 必须先经 HMAC 标准化(第 4 章 hasher),服务端永不接触明文手机号/身份证。客户端 SDK 封装哈希逻辑,业务方连盐值都拿不到。
- 批量上限 500:防止超大包拖垮服务;超过走异步导出通道(审批 + 审计)。
- 反向接口最小权限 :
expand能看到用户关联了多少设备/账号,属于敏感能力,仅风控、审计角色可调,且全量审计。 - 不提供"枚举遍历":没有任何 list/all 接口,防止拖库;布隆过滤器只回答"这个 ID 可能存在/一定不存在"。
10.2 服务架构与多级缓存
业务方(App服务端/营销/风控/推荐)
│ HTTPS + AK/SK 签名鉴权
▼
┌───────────────────────────┐
│ oneid-serving (FastAPI) │ 8 容器 × 4 uvicorn worker
│ ┌──────────────────────┐ │
│ │ 鉴权/限流/参数校验 │ │
│ │ L1 本地缓存 Caffeine │ │ TTL 10s,容量 100 万
│ │ L2 Redis 集群 │ │ id2one / one2ids / profile
│ │ 布隆过滤器(本地加载) │ │ 12 亿项 fpp=0.001
│ └──────────────────────┘ │
└───────────┬───────────────┘
│
┌───────────┴────────────┐
▼ ▼
Redis 集群(3主3从) Kafka oneid-changelog
(读从节点/写主节点) (缓存失效广播,见 10.7)
三级查询路径(以 resolve 为例):
- 布隆过滤器:本地内存加载全量 id_hash 布隆位图(每日离线产出,约 2.3GB,mmap 加载)。不在过滤器中 → 直接返回"不存在",不打 Redis。挡掉约 15% 的爬虫/脏 ID 流量。
- L1 本地缓存 Caffeine:TTL 10 秒、容量 100 万条,命中率约 65%(热点 ID 集中)。命中直接返回。
- L2 Redis :
GET oneid:id2one:{id_hash},读从节点;未命中则回源离线表(见 10.6 降级)并回填。
10.3 依赖与配置
toml
# oneid_core/serving/pyproject.toml(serving 独立部署单元)
[project]
name = "oneid-serving"
version = "1.4.0"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.110.0",
"uvicorn[standard]>=0.29.0",
"pydantic>=2.6.0",
"redis>=5.0.0",
"aioredis>=2.0.1",
"pycaffeine>=0.1.1", # 本地缓存(或用 cachetools.TTLCache)
"cachetools>=5.3.0",
"pybloom-live>=4.0.0", # 布隆过滤器
"pyjwt>=2.8.0",
"prometheus-client>=0.20.0",
"kafka-python>=2.0.2",
"structlog>=24.1.0",
]
yaml
# conf/serving-prod.yaml
service:
name: oneid-serving
port: 8080
workers: 4 # 单容器 uvicorn worker 数
batch_max: 500
rate_limit_per_minute: 6000 # 单 AK 限流
redis:
url: "redis://:***@redis-oneid-r:6379/0" # 读走从节点后缀 -r
write_url: "redis://:***@redis-oneid:6379/0"
pool_size: 32
socket_timeout_ms: 100
local_cache_ttl_sec: 10
local_cache_size: 1_000_000
bloom:
path: "/data/oneid/bloom/idhash_bloom_${dt}.blm"
expected_items: 1_200_000_000
fpp: 0.001
auth:
jwt_issuer: "oauth.xinggou.internal"
jwks_url: "https://oauth.xinggou.internal/.well-known/jwks.json"
ak_sk_table: "oneid_access_key" # MySQL AK/SK 管理表
kafka:
brokers: "kafka-1:9092,kafka-2:9092,kafka-3:9092"
changelog_topic: "oneid-changelog"
fallback:
hive_jdbc: "jdbc:hive2://hiveserver2:10000/dim"
fallback_enabled: true
10.4 数据模型与鉴权
python
# oneid_core/serving/schemas.py
"""请求/响应模型与鉴权依赖。"""
from typing import Literal
from pydantic import BaseModel, Field, field_validator
class ResolveRequest(BaseModel):
id_hash: str = Field(..., description="HMAC-SHA256 后的 id_hash,64 位十六进制")
id_type: str = Field(..., description="id 类型:mdn/unionid/openid/device_id ...")
@field_validator("id_hash")
@classmethod
def _check_hash(cls, v: str) -> str:
if len(v) != 64:
raise ValueError("id_hash 必须为 64 位十六进制(HMAC-SHA256 输出)")
int(v, 16)
return v.lower()
class ResolveResponse(BaseModel):
id_hash: str
oneid: int | None = Field(None, description="不存在时为 null")
status: Literal["hit", "miss", "tombstoned"]
source: Literal["bloom_reject", "l1", "l2", "fallback"] = "l2"
class BatchResolveRequest(BaseModel):
items: list[ResolveRequest] = Field(..., max_length=500)
class BatchResolveResponse(BaseModel):
results: list[ResolveResponse]
cost_ms: int
class ExpandRequest(BaseModel):
oneid: int = Field(..., ge=100_000_000, le=9_000_000_000_000)
id_types: list[str] | None = Field(None, description="只展开指定类型,null=全部")
class ExpandResponse(BaseModel):
oneid: int
id_count: int
ids: list[dict] # [{"id_hash": "...", "id_type": "mdn"}, ...]
cluster_size: int
class ProfileResponse(BaseModel):
oneid: int
gender: str | None
birth_year: int | None
city: str | None
member_level: str | None
id_cnt: int
has_strong_anchor: bool
profile_version: int
python
# oneid_core/serving/auth.py
"""AK/SK 签名鉴权 + JWT 二选一 + 角色权限。"""
import hmac, hashlib, time
import jwt
from fastapi import Header, HTTPException, Depends
from oneid_core.common.config import get_config
# 角色 → 可调用接口
ROLE_PERM = {
"marketing": {"resolve", "resolve_batch", "profile"},
"risk": {"resolve", "resolve_batch", "expand", "profile"},
"recommend": {"resolve", "resolve_batch", "profile"},
"audit": {"resolve", "resolve_batch", "expand", "profile"},
"cs": {"resolve", "profile"},
}
def verify_signature(ak: str, timestamp: str, nonce: str,
signature: str, body: bytes, sk: str) -> bool:
"""签名串:ak\n{timestamp}\n{nonce}\n{sha256(body)},HMAC-SHA256(sk, 串)。"""
body_digest = hashlib.sha256(body).hexdigest()
raw = f"{ak}\n{timestamp}\n{nonce}\n{body_digest}"
expect = hmac.new(sk.encode(), raw.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expect, signature):
return False
if abs(time.time() - int(timestamp)) > 300: # 5 分钟防重放
return False
return True
async def get_caller(
x_ak: str = Header(..., alias="X-AK"),
x_timestamp: str = Header(..., alias="X-Timestamp"),
x_nonce: str = Header(..., alias="X-Nonce"),
x_signature: str = Header(..., alias="X-Signature"),
x_role: str = Header("marketing", alias="X-Role"),
) -> dict:
"""从 MySQL oneid_access_key 取 SK 验签(生产加本地缓存 60s)。"""
cfg = get_config()
sk = _load_sk(x_ak) # SELECT sk FROM oneid_access_key WHERE ak=%s AND enabled=1
if sk is None:
raise HTTPException(401, "invalid AK")
# body 验签在中间件完成(需要 raw body),此处校验角色
if x_role not in ROLE_PERM:
raise HTTPException(403, f"unknown role: {x_role}")
return {"ak": x_ak, "role": x_role}
def require_api(api_name: str):
"""接口级权限依赖:require_api("expand")。"""
async def _dep(caller: dict = Depends(get_caller)) -> dict:
if api_name not in ROLE_PERM.get(caller["role"], set()):
raise HTTPException(403, f"role {caller['role']} forbidden: {api_name}")
return caller
return _dep
def _load_sk(ak: str) -> str | None:
"""从配置/数据库加载 SK(生产用连接池 + TTLCache 60s)。"""
return get_config().serving.ak_sk.get(ak)
10.5 服务主程序(路由 + 三级缓存 + 限流)
python
# oneid_core/serving/app.py
"""OneID 查询服务主程序:路由、三级缓存、限流、降级、指标。"""
import asyncio, time
from fastapi import FastAPI, Depends, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from cachetools import TTLCache
from prometheus_client import Counter, Histogram, generate_latest
import redis.asyncio as aioredis
from pybloom_live import BloomFilter
from oneid_core.serving.schemas import (
ResolveRequest, ResolveResponse, BatchResolveRequest, BatchResolveResponse,
ExpandRequest, ExpandResponse, ProfileResponse)
from oneid_core.serving.auth import get_caller, require_api
from oneid_core.common.config import get_config
from oneid_core.common.logger import get_logger
log = get_logger(__name__)
app = FastAPI(title="oneid-serving", version="1.4.0")
cfg = get_config()
# ---- 全局组件 ----
l1: TTLCache = TTLCache(maxsize=cfg.serving.local_cache_size,
ttl=cfg.serving.local_cache_ttl_sec)
bloom = BloomFilter.fromfile(open(cfg.serving.bloom_path, "rb"))
redis_cli: aioredis.Redis = None
REQ_CNT = Counter("oneid_req_total", "requests", ["api", "status"])
LAT = Histogram("oneid_lat_ms", "latency ms", ["api"],
buckets=(1, 2, 5, 10, 20, 30, 50, 100, 200))
# 简单令牌桶限流(单实例;多实例用 Redis 计数,见 13.5)
_rate_bucket: dict[str, list] = {}
@app.on_event("startup")
async def _startup():
global redis_cli
redis_cli = aioredis.from_url(
cfg.serving.redis_url, max_connections=cfg.serving.pool_size,
socket_timeout=cfg.serving.socket_timeout_ms / 1000,
decode_responses=True)
log.info("oneid-serving started, bloom loaded")
def _rate_limit(ak: str) -> bool:
"""单 AK 每分钟 6000 次(令牌桶简化为滑动窗口计数)。"""
now = time.time()
bucket = _rate_bucket.setdefault(ak, [])
while bucket and bucket[0] < now - 60:
bucket.pop(0)
if len(bucket) >= cfg.serving.rate_limit_per_minute:
return False
bucket.append(now)
return True
async def resolve_one(id_hash: str) -> ResolveResponse:
"""三级缓存查询核心:bloom → L1 → Redis → 降级回源。"""
if id_hash not in bloom:
return ResolveResponse(id_hash=id_hash, oneid=None,
status="miss", source="bloom_reject")
if id_hash in l1:
v = l1[id_hash]
return ResolveResponse(id_hash=id_hash, oneid=v["oneid"],
status=v["status"], source="l1")
try:
raw = await redis_cli.get(f"oneid:id2one:{id_hash}")
except Exception as e: # Redis 故障降级
log.warning("redis error, fallback: %s", e)
raw = await _fallback_hive(id_hash)
source = "fallback"
else:
source = "l2"
if raw is None:
resp = ResolveResponse(id_hash=id_hash, oneid=None,
status="miss", source=source)
elif int(raw) < 0: # tombstone 标记
resp = ResolveResponse(id_hash=id_hash, oneid=None,
status="tombstoned", source=source)
else:
resp = ResolveResponse(id_hash=id_hash, oneid=int(raw),
status="hit", source=source)
l1[id_hash] = {"oneid": resp.oneid, "status": resp.status}
return resp
async def _fallback_hive(id_hash: str) -> str | None:
"""Redis 不可用时回源 Iceberg(JDBC 限流只读),仅返回近 7 天活跃 ID。"""
if not cfg.serving.fallback_enabled:
raise HTTPException(503, "dependency unavailable")
# 生产用 HiveServer2 连接池 + 严格超时;此处示意 SQL
sql = ("SELECT oneid FROM dim.dim_oneid_id_map "
"WHERE dt=(SELECT MAX(dt) FROM dim.dim_oneid_id_map) "
"AND map_status=1 AND id_hash=%s LIMIT 1")
return await _hive_query_one(sql, (id_hash,))
async def _hive_query_one(sql: str, params: tuple) -> str | None:
"""HiveServer2 查询:连接池复用 + 0.3s 超时 + 全局限流 200 QPS。"""
if not hasattr(_hive_query_one, "_pool"):
_hive_query_one._pool = _build_hive_pool(maxsize=8)
conn = _hive_query_one._pool.acquire()
try:
cur = conn.cursor()
cur.execute(sql, params)
row = cur.fetchone()
return str(row[0]) if row else None
finally:
_hive_query_one._pool.release(conn)
def _build_hive_pool(maxsize: int):
"""简易 Hive 连接池(生产可用 DBUtils PooledDB 包装 impala 驱动)。"""
from queue import Queue
import impala.dbapi as hive
q = Queue(maxsize=maxsize)
for _ in range(maxsize):
q.put(hive.connect(host="hiveserver2", port=10000,
database="dim", timeout=0.3))
return q
@app.post("/oneid/resolve", response_model=ResolveResponse)
async def api_resolve(req: ResolveRequest, request: Request,
caller: dict = Depends(require_api("resolve"))):
if not _rate_limit(caller["ak"]):
REQ_CNT.labels("resolve", "429").inc()
raise HTTPException(429, "rate limit exceeded")
t0 = time.time()
resp = await resolve_one(req.id_hash)
LAT.labels("resolve").observe((time.time() - t0) * 1000)
REQ_CNT.labels("resolve", resp.status).inc()
return resp
@app.post("/oneid/resolve/batch", response_model=BatchResolveResponse)
async def api_batch(req: BatchResolveRequest,
caller: dict = Depends(require_api("resolve_batch"))):
if not _rate_limit(caller["ak"]):
raise HTTPException(429, "rate limit exceeded")
t0 = time.time()
# pipeline 并发 + L1 预过滤
missing = [it for it in req.items if it.id_hash not in l1
and it.id_hash in bloom]
if missing:
pipe = redis_cli.pipeline(transaction=False)
for it in missing:
pipe.get(f"oneid:id2one:{it.id_hash}")
vals = await pipe.execute()
for it, v in zip(missing, vals):
if v is not None:
l1[it.id_hash] = {"oneid": int(v), "status": "hit"}
results = []
for it in req.items:
if it.id_hash not in bloom:
results.append(ResolveResponse(id_hash=it.id_hash, oneid=None,
status="miss", source="bloom_reject"))
elif it.id_hash in l1:
v = l1[it.id_hash]
results.append(ResolveResponse(id_hash=it.id_hash, oneid=v["oneid"],
status=v["status"], source="l1"))
else:
results.append(ResolveResponse(id_hash=it.id_hash, oneid=None,
status="miss", source="l2"))
REQ_CNT.labels("resolve_batch", "ok").inc()
return BatchResolveResponse(results=results,
cost_ms=int((time.time() - t0) * 1000))
@app.post("/oneid/expand", response_model=ExpandResponse)
async def api_expand(req: ExpandRequest,
caller: dict = Depends(require_api("expand"))):
"""反向展开:SMEMBERS oneid:one2ids:{oneid}(敏感接口,全量审计)。"""
t0 = time.time()
key = f"oneid:one2ids:{req.oneid}"
members = await redis_cli.smembers(key)
ids = []
for m in members:
# member 编码:{id_type}:{id_hash}
id_type, _, id_hash = m.partition(":")
if req.id_types is None or id_type in req.id_types:
ids.append({"id_hash": id_hash, "id_type": id_type})
LAT.labels("expand").observe((time.time() - t0) * 1000)
REQ_CNT.labels("expand", "ok").inc()
_audit(caller["ak"], "expand", oneid=req.oneid, id_cnt=len(ids))
return ExpandResponse(oneid=req.oneid, id_count=len(ids), ids=ids,
cluster_size=len(members))
@app.post("/oneid/profile", response_model=ProfileResponse)
async def api_profile(oneid: int, caller: dict = Depends(require_api("profile"))):
"""黄金记录摘要:Hash oneid:profile:{oneid},每日离线预热。"""
t0 = time.time()
key = f"oneid:profile:{oneid}"
if key in l1:
data = l1[key]
else:
data = await redis_cli.hgetall(key)
if data:
l1[key] = data
if not data:
raise HTTPException(404, "oneid not found or tombstoned")
LAT.labels("profile").observe((time.time() - t0) * 1000)
return ProfileResponse(
oneid=oneid,
gender=data.get("gender") or None,
birth_year=int(data["birth_year"]) if data.get("birth_year") else None,
city=data.get("city") or None,
member_level=data.get("member_level") or None,
id_cnt=int(data.get("id_cnt", 0)),
has_strong_anchor=data.get("has_strong_anchor") == "1",
profile_version=int(data.get("version", 0)),
)
@app.get("/metrics")
async def metrics():
return Response(content=generate_latest(), media_type="text/plain")
def _audit(ak: str, action: str, **kv):
"""敏感操作审计:写 oneid_audit_log(第 14 章)。"""
log.info("audit %s ak=%s %s", action, ak, kv)
10.6 降级策略与缓存预热
降级三级:
| 故障 | 降级行为 | 用户感知 |
|---|---|---|
| Redis 单从节点超时 | 切其他从节点/主节点读(sentinel 自动) | 无 |
| Redis 集群整体不可用 | 回源 Iceberg 近 7 天活跃 ID(Hive JDBC,限流 200 QPS) | 延迟升到 200ms,长尾 ID 查不到 |
| 离线回源也不可用 | 返回 503 + 缓存兜底(L1 过期时间延长到 5 分钟) | 部分请求失败,触发告警 |
缓存预热(每日离线任务结束后):
python
# oneid_core/serving/warmup.py
"""每日离线产出后预热 Redis:id2one / one2ids / profile 全量 + 布隆重建。"""
from pyspark.sql import SparkSession, functions as F
import redis
from pybloom_live import BloomFilter
from oneid_core.common.config import get_config
def warm_all(spark: SparkSession, dt: str):
cfg = get_config()
r = redis.Redis.from_url(cfg.serving.redis_write_url, decode_responses=True)
# 1) id2one:批量 SET(pipe 每 500 一批)
idmap = (spark.table(cfg.tables.dim_id_map)
.filter(F.col("dt") == dt).filter(F.col("map_status") == 1)
.select("id_hash", "oneid"))
idmap.foreachPartition(lambda rows: _warm_id2one(rows, cfg))
# 2) one2ids:按 oneid 聚合后 SADD
agg = (spark.table(cfg.tables.dim_id_map)
.filter(F.col("dt") == dt).filter(F.col("map_status") == 1)
.selectExpr("oneid", "concat(id_type, ':', id_hash) as member")
.groupBy("oneid").agg(F.collect_set("member").alias("members")))
agg.foreachPartition(lambda rows: _warm_one2ids(rows, cfg))
# 3) profile:黄金记录 Hash
prof = spark.table(cfg.tables.dim_user).filter(F.col("dt") == dt)
prof.foreachPartition(lambda rows: _warm_profile(rows, cfg))
# 4) 布隆过滤器重建
_rebuild_bloom(spark, dt)
def _warm_id2one(rows, cfg):
r = redis.Redis.from_url(cfg.serving.redis_write_url, decode_responses=True)
pipe = r.pipeline(transaction=False)
n = 0
for row in rows:
pipe.set(f"{cfg.realtime.id2one_key_prefix}{row.id_hash}", row.oneid)
n += 1
if n % 500 == 0:
pipe.execute()
pipe.execute()
def _warm_one2ids(rows, cfg):
r = redis.Redis.from_url(cfg.serving.redis_write_url, decode_responses=True)
for row in rows:
key = f"oneid:one2ids:{row.oneid}"
r.delete(key) # 全量重建先清旧
for i in range(0, len(row.members), 500):
r.sadd(key, *row.members[i:i + 500])
r.expire(key, 86400 * 8) # 8 天 TTL 兜底
def _warm_profile(rows, cfg):
r = redis.Redis.from_url(cfg.serving.redis_write_url, decode_responses=True)
pipe = r.pipeline(transaction=False)
n = 0
for row in rows:
pipe.hset(f"oneid:profile:{row.oneid}", mapping={
"gender": row.gender or "", "birth_year": row.birth_year or "",
"city": row.city or "", "member_level": row.member_level or "",
"id_cnt": row.id_cnt,
"has_strong_anchor": 1 if row.has_strong_anchor else 0,
"version": row.version})
pipe.expire(f"oneid:profile:{row.oneid}", 86400 * 8)
n += 1
if n % 500 == 0:
pipe.execute()
pipe.execute()
def _rebuild_bloom(spark: SparkSession, dt: str):
"""全量 id_hash 布隆过滤器:12 亿项 fpp=0.001,约 2.3GB。"""
bf = BloomFilter(capacity=1_200_000_000, error_rate=0.001)
df = (spark.table(cfg.tables.dim_id_map)
.filter(F.col("dt") == dt).filter(F.col("map_status") == 1)
.select("id_hash").distinct())
for row in df.rdd.toLocalIterator():
bf.add(row.id_hash)
path = f"/data/oneid/bloom/idhash_bloom_{dt}.blm"
with open(path, "wb") as f:
bf.tofile(f)
# 通知 serving 容器热加载(配置中心版本号 +1,容器 watch 重载)
10.7 changelog 驱动的缓存失效
每日全量预热解决"昨天的数据",白天的实时 MERGE/SPLIT/TOMBSTONE 靠 oneid-changelog 主动失效:
python
# oneid_core/serving/cache_invalidator.py
"""消费 oneid-changelog,失效/更新本地与 Redis 缓存。"""
import json, asyncio
from kafka import KafkaConsumer
from cachetools import TTLCache
from oneid_core.common.config import get_config
from oneid_core.common.logger import get_logger
log = get_logger(__name__)
def start_invalidator(l1: TTLCache):
"""后台线程:MERGE/SPLIT/TOMBSTONE → 删 L1;profile 变更 → 删 profile L1。"""
cfg = get_config()
consumer = KafkaConsumer(
cfg.realtime.changelog_topic,
bootstrap_servers=cfg.realtime.kafka_brokers,
group_id="oneid-serving-invalidator",
enable_auto_commit=False,
value_deserializer=lambda v: json.loads(v.decode()),
consumer_timeout_ms=-1)
for msg in consumer:
ev = msg.value
op = ev["op"]
try:
if op in ("MERGE", "SPLIT", "NEW"):
for id_hash in ev.get("affected_id_hashes", []):
l1.pop(id_hash, None) # 正向缓存失效
if ev.get("oneid_drop"):
l1.pop(ev["oneid_drop"], None)
elif op == "TOMBSTONE":
# 注销:id2one 置 -1 哨兵,L1 置 tombstoned
l1[ev.get("affected_id_hashes", [""])[0]] = {
"oneid": None, "status": "tombstoned"}
elif op == "ATTR_UPDATE":
l1.pop(f"oneid:profile:{ev['oneid_keep']}", None)
except Exception as e:
log.error("invalidate failed: %s ev=%s", e, ev)
consumer.commit()
关键点:
- L1 只有 10 秒 TTL,最坏情况下 10 秒后自然失效,changelog 只是把窗口压到毫秒级;
- 失效只删 L1,不删 Redis(Redis 本身就是被 Flink/回灌任务实时更新的真相源);
- 消费者按 event_id 幂等,重复消息无害。
10.8 服务压测与 SLA 实测
| 指标 | SLA 目标 | 实测(大促峰值 6.5 万 QPS) |
|---|---|---|
| QPS | 4 万 | 6.5 万(8 容器 × 4 worker) |
| P50 延迟 | --- | 1.8ms(绝大多数走 L1) |
| P90 延迟 | --- | 4.5ms |
| P99 延迟 | < 10ms | 8.6ms |
| P999 延迟 | < 30ms | 22ms(Redis 抖动/降级回源) |
| L1 命中率 | ≥ 60% | 68% |
| 布隆拒绝率 | --- | 14.7%(爬虫/脏 ID) |
| Redis 命中率(过了 L1 的) | ≥ 99% | 99.6% |
| 可用性 | 99.95% | 季度实际 99.98% |
降级回源 Iceberg 的请求(约 0.4%)延迟 200ms,不计入 P99 SLA 口径(单独 SLO:降级比例 < 0.5%),避免极少数长尾拉偏缓存服务的水位评估。
第11章 回写与下游对接
OneID 的价值在下游消费。星购有 7 类下游,对接方式分三种:Kafka 事件订阅(实时)、Iceberg 表授权(离线)、在线 API(点查)。
11.1 下游全景
OneID 产出
┌─────────────┼──────────────────┐
▼ ▼ ▼
oneid-changelog dim 层 Iceberg 表 oneid-serving API
(实时事件流) (T+1 批量) (毫秒点查)
│ │ │
┌──────┼──────┐ ┌───┼────┐ ┌────┼─────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
营销 风控 推荐 特征 标签 DMP 客服 营销 实时风控
触达 实时 实时 平台 平台 人群圈选 弹窗 实时 设备指纹
评分 召回 (Hive) 反欺诈
| 下游 | 消费方式 | 数据内容 | 延迟要求 |
|---|---|---|---|
| 营销触达(短信/Push) | changelog + API | NEW/MERGE 事件 → 拉通多端触达 | 秒级 |
| 实时风控 | changelog + expand API | 合并事件 → 设备团伙识别 | 秒级 |
| 推荐召回 | API + Iceberg | profile + id_cnt 特征 | 实时点查/T+1 |
| 特征平台 | Iceberg dim 表 | dim_oneid_user + id_map | T+1 |
| 标签平台 | Iceberg | OneID 为主键打标签 | T+1 |
| DMP 人群圈选 | Iceberg + 导出审批 | OneID 人群包 → 广告投放 | 小时级 |
| 客服系统 | API resolve + profile | 来电手机号 → OneID → 历史工单 | 毫秒 |
11.2 changelog 订阅方接入示例
营销触达服务订阅 MERGE/NEW 事件,把"小程序下的单"和"App 里的券"拉通:
python
# 下游示例:marketing-touch 服务(业务方代码,不在 oneid_core 内)
import json
from kafka import KafkaConsumer, KafkaProducer
consumer = KafkaConsumer(
"oneid-changelog",
bootstrap_servers=["kafka-1:9092"],
group_id="marketing-touch-v3",
enable_auto_commit=False,
value_deserializer=lambda v: json.loads(v.decode()),
)
for msg in consumer:
ev = msg.value
event_id = ev["event_id"] # 幂等键
if _already_processed(event_id): # 本地去重表/Redis SETNX
consumer.commit()
continue
if ev["op"] == "MERGE":
# 副号 tombstone:把副号名下的券/积分/触达任务迁到主号
migrate_touch_tasks(ev["oneid_drop"], ev["oneid_keep"])
# 多端拉通:主号下所有触点都可触达(调 expand 或用 affected 列表)
refresh_reachability(ev["oneid_keep"])
elif ev["op"] == "NEW":
# 新用户建档:欢迎序列、新人券
onboarding_flow(ev["oneid_keep"])
elif ev["op"] == "TOMBSTONE":
# 注销:停止一切触达(合规硬要求,第 14 章)
stop_all_touch(ev["oneid_drop"])
mark_processed(event_id)
consumer.commit()
下游接入三条铁律:
- 必须以 event_id 幂等:changelog 至少一次投递,重复是常态;
- MERGE 只认 oneid_keep:oneid_drop 永久 tombstone,任何写入 drop 号的数据在次日对账时会被纠偏;
- TOMBSTONE 必须执行停止触达:这是个保法要求,漏处理会导致注销用户仍收营销短信,属于合规事故(第 17 章事故 9)。
11.3 离线表授权与字段分级
下游离线访问通过数据权限平台(Ranger/Sentry 风格)授权,字段分三级:
| 级别 | 字段/表 | 谁能访问 |
|---|---|---|
| 公开 | dim_oneid_user 的 gender/city/member_level/id_cnt | 所有数据任务 |
| 受限 | dim_oneid_id_map(id_hash ↔ oneid 映射) | 特征/标签/DMP 专项授权 |
| 机密 | id_hash 反查明文能力、expand 反向接口 | 仅风控/审计,双人审批 |
注意:id_hash 本身是 HMAC 输出,对业务方是"不透明标识符"------业务方拿不到盐值就无法从手机号算 id_hash,也无法从 id_hash 反推手机号。明文 ↔ 哈希的转换只发生在标准化层(第 4 章)和经审批的 SDK 内。
11.4 业务库回写
部分老系统(客服 CRM、门店 POS)以自有 member_id 为主键,需要把 OneID 回写到业务库:
sql
-- 每日回写客服 CRM:member_id → oneid(通过 CRM 侧 id_hash 关联)
INSERT OVERWRITE TABLE crm_staging.member_oneid_map_dt PARTITION (dt='${dt}')
SELECT
m.member_id,
g.oneid,
CASE WHEN u.status = 1 THEN 'active'
WHEN u.status = 3 THEN 'tombstoned'
ELSE 'frozen' END AS oneid_status
FROM crm_staging.member_id_hash_staging m -- CRM 侧经 SDK 算出的 id_hash
JOIN dim.dim_oneid_id_map g
ON m.id_hash = g.id_hash AND g.dt = '${dt}' AND g.map_status = 1
JOIN dim.dim_oneid_user u
ON g.oneid = u.oneid AND u.dt = '${dt}';
回写规范:
- 只回写 oneid(不可逆 Long)+ 状态,绝不回写明文 ID 到非合规库;
- 回写表带
dt分区,业务库通过 DataX/CDC 每日同步,同步任务在 OneID 产出 SLA(4:00)之后; - 回写失败重试 3 次,失败数据进
oneid_writeback_dlq并告警。
11.5 DMP 人群包导出
广告投放需要人群包,流程带审批与审计:
业务提人群圈选需求(OneID 条件:city=上海 AND member_level≥金卡 AND 30天有加购)
│ 数据平台 Hive/Spark 圈选 → OneID 列表
▼
审批流(数据安全 + 业务负责人双人审批,oneid_review_ticket type=3 复用)
│ 通过后
▼
导出:OneID 列表用投放渠道公钥加密(RSA-2048)→ 脱敏包(不含任何原始 ID)
│
▼
审计留痕:谁、什么时间、导了多少 OneID、用途、到期时间(oneid_audit_log action=export)
│
▼
投放结束后按到期时间删除渠道侧包(合同约束 + 抽查)
人群包只含 OneID(渠道侧通过"OneID → 渠道 openid/设备号"的匹配服务在合规边界内转换),明文手机号不出境。
第12章 数据质量体系
OneID 是基础数据,错了会污染所有下游。星购为 OneID 建了六大质量指标 + 每日监控 + 异常告警 + 月度抽样审计。
12.1 六大质量指标
| 指标 | 定义 | 健康区间 | 异常说明 |
|---|---|---|---|
| 连通率 | 有边相连的 ID 数 / 总 ID 数 | 90%--93% | 过低=漏并(关系源断了);突增=误并 |
| 孤岛率 | 度数为 0 的 ID / 总 ID | < 25% | 过高=埋点/接入缺失 |
| 人均 ID 数 | 总 ID 数 / OneID 数(活跃) | 2.1--2.6 | 突增=误并;骤降=漏并 |
| 日合并率 | 当日 MERGE 簇数 / 总簇数 | 0.05%--0.3% | 突增=弱边阈值/数据源异常 |
| 超级节点占比 | 簇大小 > 2000 的簇内 ID 占比 | < 0.5% | 过高=黑名单失效/误并 |
| 批流一致率 | 实时 Redis 与离线一致的 ID / 抽样总数 | > 99.5% | 过低=回灌失败/实时 bug |
12.2 指标 SQL(每日调度)
sql
-- oneid_core/quality/daily_quality.sql
-- 输入:dt(当日分区)
-- 结果写入:dim.oneid_quality_metric_di
INSERT OVERWRITE TABLE dim.oneid_quality_metric_di PARTITION (dt='${dt}')
SELECT
'${dt}' AS dt,
-- 1. 连通率:出现在边中的去重 ID / 全部有效 ID
ROUND(
(SELECT COUNT(DISTINCT id_hash) FROM dwd.dwd_id_relation_edge_df
WHERE dt='${dt}' AND is_pruned=0
AND (src_id_hash IN (SELECT id_hash FROM dim.dim_oneid_id_map
WHERE dt='${dt}' AND map_status=1)))
/ NULLIF((SELECT COUNT(1) FROM dim.dim_oneid_id_map
WHERE dt='${dt}' AND map_status=1), 0), 4)
AS connect_rate,
-- 2. 人均 ID 数(仅近 90 天活跃 OneID)
ROUND(
(SELECT COUNT(1) FROM dim.dim_oneid_id_map WHERE dt='${dt}' AND map_status=1)
/ NULLIF((SELECT COUNT(1) FROM dim.dim_oneid_user
WHERE dt='${dt}' AND status=1
AND last_active_dt >= date_sub('${dt}', 90)), 0), 3)
AS ids_per_oneid,
-- 3. 超级节点占比
ROUND(
(SELECT COALESCE(SUM(id_cnt),0) FROM dim.dim_oneid_user
WHERE dt='${dt}' AND id_cnt > 2000)
/ NULLIF((SELECT COUNT(1) FROM dim.dim_oneid_id_map
WHERE dt='${dt}' AND map_status=1), 0), 5)
AS supernode_ratio,
-- 4. 强锚点覆盖率(含强 ID 的 OneID 占比,合并可信度的结构指标)
ROUND(
(SELECT COUNT(1) FROM dim.dim_oneid_user
WHERE dt='${dt}' AND status=1 AND has_strong_anchor=1)
/ NULLIF((SELECT COUNT(1) FROM dim.dim_oneid_user
WHERE dt='${dt}' AND status=1), 0), 4)
AS strong_anchor_coverage;
sql
-- 日合并率(从 changelog 落地表 dwm_merge_event_df 统计)
SELECT
ROUND(
SUM(CASE WHEN op='MERGE' THEN 1 ELSE 0 END)
/ NULLIF(COUNT(DISTINCT oneid_keep), 0), 5) AS merge_rate,
SUM(CASE WHEN op='MERGE' THEN 1 ELSE 0 END) AS merge_cnt,
SUM(CASE WHEN op='SPLIT' THEN 1 ELSE 0 END) AS split_cnt,
SUM(CASE WHEN op='TOMBSTONE' THEN 1 ELSE 0 END) AS tombstone_cnt
FROM dwm.dwm_merge_event_df
WHERE dt='${dt}';
12.3 异常簇自动检测
除了聚合指标,还要抓"个体异常"。每日跑三类扫描:
python
# oneid_core/quality/anomaly_scan.py
"""异常连通簇扫描:超大簇 / 弱边主导簇 / 突变簇。"""
from pyspark.sql import SparkSession, functions as F
from oneid_core.common.config import get_config
from oneid_core.common.logger import get_logger
log = get_logger(__name__)
def scan_anomalies(spark: SparkSession, dt: str):
cfg = get_config()
user = spark.table(cfg.tables.dim_user).filter(F.col("dt") == dt)
# 1) 超大簇:id_cnt > 2000(cap 之上仍超的,说明强边连入,重点核查)
huge = user.filter(F.col("id_cnt") > 2000)
# 2) 弱边主导簇:无强锚点但 id_cnt > 20(误并高危)
weak_huge = user.filter((F.col("has_strong_anchor") == False)
& (F.col("id_cnt") > 20))
# 3) 突变簇:与昨日比 id_cnt 增长 > 10 倍(除新簇)
prev = (spark.table(cfg.tables.dim_user)
.filter(F.col("dt") == F.date_sub(F.lit(dt), 1))
.select(F.col("oneid").alias("oneid_p"),
F.col("id_cnt").alias("id_cnt_prev")))
burst = (user.join(prev, user.oneid == prev.oneid_p, "inner")
.filter(F.col("id_cnt") > F.col("id_cnt_prev") * 10)
.filter(F.col("id_cnt_prev") >= 3))
for name, df in [("huge", huge), ("weak_huge", weak_huge), ("burst", burst)]:
rows = df.limit(500).collect()
if rows:
log.warning("anomaly[%s] dt=%s count>=%d", name, dt, len(rows))
_write_ticket(spark, dt, name, rows)
def _write_ticket(spark, dt: str, anomaly_type: str, rows: list):
"""异常簇 → oneid_review_ticket(type=2 疑似误合并),人工核查。"""
import json
payload = [{"dt": dt, "anomaly_type": anomaly_type,
"oneid": r.oneid, "id_cnt": r.id_cnt,
"has_strong_anchor": bool(r.has_strong_anchor)}
for r in rows]
# INSERT INTO oneid_review_ticket ...(通过 JDBC 写 MySQL)
log.info("write %d review tickets", len(payload))
12.4 抽样标注评估准确率
聚合指标只能发现"量级异常",发现不了"错得不大但错了"。星购每月做一次人工标注评估:
- 分层抽样 2000 个 OneID:强锚点簇 1000、弱锚点簇 500、超大/异常簇 500;
- 人工通过原始 ID 的业务数据(收货地址、实名、常用设备、行为序列)判断"这些 ID 是否属于同一自然人";
- 计算:
- 精确率 Precision = 判对"同属一人"的合并 / 全部合并 → 目标 ≥ 99.5%;
- 召回率 Recall = 应合并且已合并 / 全部应合并 → 目标 ≥ 95%(漏并可补救,错并代价高,故精确率优先);
- 标注结果回流:错并案例 → 拆分工单 + 规则修正(调权重/加黑名单);漏并案例 → 补边规则。
历史趋势(星购上线 12 个月):
| 月份 | 精确率 | 召回率 | 主要问题 |
|---|---|---|---|
| M1(刚上线) | 97.2% | 88% | 弱关系阈值 0.7 过低,公共 WiFi 误并 |
| M3 | 99.1% | 92% | 虚拟号段未降权 |
| M6 | 99.6% | 94% | 二次放号误绑 |
| M12 | 99.8% | 96% | 长尾:家庭共用设备 |
12.5 监控告警与质量看板
告警规则(推送值班群 + 电话升级):
| 告警 | 阈值 | 级别 |
|---|---|---|
| 批流一致率 | < 99.5% | P1(电话) |
| 超级节点占比 | > 0.5% | P1 |
| 日合并率突增 | > 昨日 3 倍 | P1 |
| 连通率波动 | 日环比 ±2pct | P2 |
| 人均 ID 数 | 超出 2.0, 2.7 | P2 |
| 产出血 SLA | 4:00 未产出 | P1 |
| 服务 P99 | > 10ms 持续 5 分钟 | P2 |
| 服务 P999 | > 30ms 持续 5 分钟 | P2 |
质量看板(Grafana)核心面板:六大指标日趋势、changelog op 分布、异常簇工单量、服务 SLA、Redis 水位。
第13章 性能优化实战
十亿级 ID、二十亿级边的链路,性能优化不是锦上添花而是能不能跑出来。本章按链路顺序记录星购实测有效的优化手段。
13.1 标准化阶段
| 问题 | 手段 | 效果 |
|---|---|---|
| 11 张来源表 union 后 shuffle 量大 | 标准化前先按 (id_type, id_hash) map 端去重(dropDuplicates 在 map 端预聚合) | shuffle 量降 40% |
| HMAC UDF 每行 Python 调用慢 | 标准化用 Scala UDF(part2 4.10)或 pandas UDF 向量化 | 1800 万 ID/天,45 分钟 → 12 分钟 |
| 手机号正则逐行编译 | 正则预编译为广播变量 | CPU 降 15% |
| 小文件多 | 写入前 repartition 按 id_hash 分桶数对齐(2000) | 下游读取无重分布 |
13.2 建边阶段(倾斜治理)
建边最大的坑是数据倾斜:公共设备 ID、热门收货地址、大 WiFi 热点产生超大连 key。
python
# oneid_core/graph/skew_handle.py
"""倾斜 key 加盐打散 + 黑名单提前过滤(配合 supernode.py)。"""
from pyspark.sql import functions as F
SKEW_SALT = 50 # 倾斜 key 打散成 50 份
def salt_skew_key(df, key_col: str, skew_threshold: int = 100_000):
"""对高频 key 加盐:key 出现次数 > 阈值时拼接 0-49 随机盐。
用于共现配对(C(n,2))前的 explode:倾斜 key 配对量是 n^2,
加盐后变成 n^2/50 均匀分布;配对后聚合时去盐。
"""
key_cnt = df.groupBy(key_col).count().filter(F.col("count") > skew_threshold)
skew_keys = {r[key_col] for r in key_cnt.collect()}
bc = spark_bc(skew_keys)
@F.udf
def salt(k):
if k in bc.value:
import random
return f"{k}#salt{random.randint(0, SKEW_SALT - 1)}"
return k
return df.withColumn(key_col, salt(F.col(key_col)))
倾斜处理三道防线(第 5 章已述,此处对应执行层):
- 黑名单提前过滤(公共 WiFi/导购机/机房 IP 根本不进建边);
- 度数 cap=2000:建边聚合时统计 key 度数,超 cap 的弱边直接
is_pruned=1; - 加盐打散:C(n,2) 配对前对倾斜 key 加盐,避免单 task 跑数小时。
实测:未处理倾斜时,共现配对阶段 1 个 task 跑 4 小时(800 万条同 key);加黑名单+加盐后,最长 task 18 分钟,整体 90 分钟 → 38 分钟。
13.3 GraphX 连通分量调优
| 参数 | 星购设置 | 说明 |
|---|---|---|
spark.graphx.pregel.checkpointInterval |
8 | 每 8 轮 checkpoint 切断 lineage,防 OOM |
| 分区策略 | EdgePartition2D | 20 亿边切 2000 分区,二维切分保证边本地性 |
| executor | 200 × 4c16g | 总 12.8TB shuffle 读 |
spark.sql.shuffle.partitions |
20000 | 与 task 数匹配,单分区 ~100MB |
| 内存 | spark.executor.memoryOverhead=4g |
GraphX 是 RDD API,off-heap 用得多 |
| 迭代轮次 | Pregel 平均 6 轮收敛 | 图直径小(社交关系外的 ID 图通常 < 10 跳) |
实测:全量 20.7 亿边 CC,200 executor × 4c16g,48 分钟跑完;每日增量子图(裁剪后约 3 亿边)8 分钟。
GraphX vs GraphFrames 选型回顾(part2 第 6 章):GraphX(Scala) 快约 30% 且内存可控,是十亿级主力;GraphFrames(PySpark) 用于调试和中小规模。
13.4 存储与 Iceberg 优化
- 分桶对齐:所有表按 id_hash/oneid 分桶 2000,join 时 bucket map join 无 shuffle;
- zstd 压缩:比 snappy 小 25%,CPU 开销可接受(批处理场景);
- Iceberg format-version=2:MERGE INTO 行级删除用于回写/注销,避免整分区重写;
- 快照保留 :
snapshot.retention=7天,过期快照自动清理,元数据小文件每周expire_snapshots + rewrite_manifests; - 冷热分层:180 天前分区迁冷存储(part1 第 3 章),成本降 60%。
13.5 Redis 与服务层优化
| 问题 | 手段 |
|---|---|
| 热 key | L1 Caffeine 10s + 读从节点(第 10 章) |
| 大 key | cap=2000 + UNLINK 异步删 + 元素数告警 |
| pipeline 批大小 | 500/批最优(太小 RTT 多,太大阻塞) |
| Lua 脚本 | evalsha 缓存,禁止每次 eval 传脚本体 |
| 连接数 | 每 slot 连接池 8-32,按 QPS 压测调 |
| 多实例限流 | 单实例内存限流不够时改 Redis 滑动窗口 Lua(INCR + EXPIRE 60s) |
| 持久化 | 关闭 RDB/AOF(Redis 是缓存可重建!每日预热 + changelog 重放兜底) |
关键认知:Redis 里的并查集状态不是唯一真相------离线 Hive/Iceberg 才是。所以 Redis 可以关持久化、可以整体重建(回灌任务 40 分钟灌完全量),这让 Redis 运维成本大幅降低。
13.6 成本汇总(星购月均)
| 资源 | 规格 | 月成本(万元,内部结算价) |
|---|---|---|
| 离线计算 | 日均 1200 executor·小时(标准化+建边+CC+融合) | 28 |
| 存储 | Hive/Iceberg 热 800TB + 冷 1.2PB | 18 |
| Redis 集群 | 3 主 3 从 × 32GB | 6 |
| Kafka | 3 broker + 3 topic 96 分区 | 3 |
| 服务容器 | 8 × 4C8G | 2 |
| 合计 | --- | 约 57 万/月 |
第14章 安全与合规
OneID 连接的是手机号、身份证、设备、地址,是个保法下的"个人信息处理活动",安全合规是上线一票否决项。
14.1 合规框架与红线
| 法规 | 对 OneID 的要求 | 落地措施 |
|---|---|---|
| 个人信息保护法 | 最小必要、告知同意、可注销 | ID 采集有授权来源;注销链路(14.5);字段分级 |
| 数据安全法 | 分类分级、风险评估 | 三级字段(11.3);年度数据安全评估 |
| 网络安全法 | 日志留存 ≥ 6 个月 | oneid_audit_log 留存 2 年 |
| 行业规范(广告/金融) | 定向推送需可关闭、数据不出境 | DMP 包加密导出;境外渠道不投 |
五条红线:
- 明文手机号/身份证禁止出现在 dim/dwm 层和日志;
- 盐值禁止出现在代码仓库、UDF 参数、日志、配置明文(KMS 托管);
- 反向查询(expand)禁止批量导出,只许风控/审计点查;
- 注销请求 15 天内必须全链路完成(法律要求);
- 人群包导出必须双人审批 + 加密 + 审计。
14.2 HMAC 盐值分级与 KMS 托管
盐值按 ID 类型分级(part1 config.py),轮换与托管方案:
┌──────────────── KMS(密钥管理服务)────────────────┐
│ master key(每年轮换,HSM 保护,不出 HSM) │
│ │ 加解密 │
│ ▼ │
│ data keys: salt_mdn / salt_idcard / salt_unionid │
│ / salt_pay / salt_member / ... │
│ │ 启动时解密到内存,用完即弃 │
└──────┼─────────────────────────────────────────────┘
▼
Spark/Flink 作业内存(不落盘、不进 checkpoint 配置)
python
# oneid_core/common/kms_salt.py
"""盐值 KMS 托管:启动时拉取解密到内存,禁止序列化到日志/广播明文。
注意:salt 需要在 executor 上用,做法是 driver 拉取后通过
SparkFiles/环境变量分发密文,executor 启动时调 KMS 解密;
广播变量只广播密文包装类,unredact 后在 executor 内存短暂存在。
"""
import os
from dataclasses import dataclass
from oneid_core.common.logger import get_logger
log = get_logger(__name__)
@dataclass(frozen=True)
class SaltBundle:
"""盐值容器:repr/打印时脱敏,防止误写日志。"""
salt_mdn: str
salt_idcard: str
salt_unionid: str
salt_pay: str
salt_member: str
salt_openid: str
salt_device: str
def __repr__(self) -> str:
return "SaltBundle(***)" # 防 log.info(bundle) 泄密
def get(self, id_type: str) -> str:
m = {"mdn": self.salt_mdn, "id_card": self.salt_idcard,
"unionid": self.salt_unionid, "pay_uid": self.salt_pay,
"member_id": self.salt_member, "openid": self.salt_openid}
if id_type in ("idfa", "gaid", "oaid", "device_id", "cookie"):
return self.salt_device
if id_type not in m:
raise ValueError(f"unknown id_type: {id_type}")
return m[id_type]
def load_salts() -> SaltBundle:
"""从 KMS 拉取密文盐并解密(生产通过内部 KMS SDK)。"""
import kms_sdk # 公司内部 KMS 客户端
client = kms_sdk.Client(role=os.environ["KMS_ROLE"])
raw = client.decrypt(os.environ["SALT_CIPHERTEXT_BLOB"])
# raw 为 JSON:{"salt_mdn": "...", ...}
import json
d = json.loads(raw)
log.info("salts loaded from KMS (values redacted)")
return SaltBundle(**{k: d[k] for k in (
"salt_mdn", "salt_idcard", "salt_unionid", "salt_pay",
"salt_member", "salt_openid", "salt_device")})
盐值轮换:每 12 个月轮换一次。轮换不重新哈希存量(旧哈希不可逆算)------方案是双盐并行期:新标准化用新盐产出新 id_hash,映射表同时维护新老两套 id_hash 到同一 OneID(标准化层按"新盐哈希为主键、老盐哈希为别名"双写 90 天),过渡结束后老别名归档。
14.3 OneID 的不可逆设计
OneID 本身是雪花算法随机 Long(第 7 章),不含任何业务信息(不是手机号哈希、不是自增顺序暴露体量)。它的不可逆性体现在:
- 从 OneID 推不出任何个人信息(不像身份证号含出生日期);
- 从 id_hash 反推明文需要盐值,盐值在 KMS;
- 对外只暴露 OneID 和 id_hash,明文离开标准化 SDK 后即不可见;
- 日志中手机号一律脱敏(
mask_mdn,part2 normalize.py),身份证脱敏保留首尾。
14.4 RBAC 权限与审计
sql
-- oneid_audit_log 审计表(MySQL,留存 2 年)
CREATE TABLE IF NOT EXISTS oneid_audit_log (
id BIGINT NOT NULL AUTO_INCREMENT,
ts DATETIME(3) NOT NULL,
actor_ak VARCHAR(64) NOT NULL COMMENT '调用方 AK',
actor_role VARCHAR(32) NOT NULL,
action VARCHAR(32) NOT NULL COMMENT 'query/expand/export/merge/split/config_change',
target_oneid BIGINT NULL,
detail TEXT NULL COMMENT 'JSON 明细,敏感字段脱敏',
result VARCHAR(16) NOT NULL COMMENT 'success/denied/error',
client_ip VARCHAR(64) NULL,
PRIMARY KEY (id),
KEY idx_ts (ts),
KEY idx_actor (actor_ak, ts),
KEY idx_action (action, ts)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='OneID 操作审计日志';
RBAC 矩阵(与 10.4 代码 ROLE_PERM 一致):
| 角色 | resolve | batch | expand | profile | export | merge/split 配置 |
|---|---|---|---|---|---|---|
| marketing | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ |
| recommend | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ |
| risk | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
| cs | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
| audit | ✅ | ✅ | ✅ | ✅ | ✅(只读审批) | ❌ |
| data-admin | ❌ | ❌ | ❌ | ❌ | ✅ | ✅(双人复核) |
14.5 账号注销全链路(tombstone 级联)
注销是合规硬要求,链路必须端到端可验证:
用户在 App 提交注销
│ 15 天冷静期(法律允许撤回)
▼
注销确认 → 业务库标记注销 → CDC 进 Kafka
│
▼
OneID 注销作业(每日 + 实时双轨):
1. dim_oneid_user.status = 3(tombstone),不删除行(留审计)
2. dim_oneid_id_map 该 OneID 下所有 map_status=3(注销)
3. Redis:id2one 置 -1 哨兵;one2ids 删除;profile 删除
4. 发 changelog TOMBSTONE 事件
│
├─ 营销触达:停止所有短信/Push/券(下游必须消费,11.2 铁律3)
├─ 推荐/特征:该 OneID 特征下游过滤 status=1 自动剔除
├─ DMP:人群包 T+1 重算剔除
├─ 客服:工单匿名化(姓名/电话置空,保留 OneID 用于统计但不可关联)
└─ 审计:oneid_audit_log 记录 action=tombstone,保留 2 年
python
# oneid_core/serving/tombstone.py(注销级联作业)
"""账号注销:OneID 级 tombstone + Redis 清理 + changelog。"""
from pyspark.sql import SparkSession, functions as F
import redis, json, hashlib
from datetime import datetime, timezone
from oneid_core.common.config import get_config
from oneid_core.common.logger import get_logger
log = get_logger(__name__)
def tombstone_oneid(spark: SparkSession, oneid: int, reason: str = "user_cancel"):
"""注销单个 OneID(冷静期结束后调用)。"""
cfg = get_config()
dt = datetime.now(timezone.utc).strftime("%Y-%m-%d")
# 1) Iceberg 行级更新(format-version=2 MERGE)
spark.sql(f"""
MERGE INTO dim.dim_oneid_user AS t
USING (SELECT {oneid} AS oneid) AS s
ON t.oneid = s.oneid AND t.dt = '{dt}'
WHEN MATCHED THEN UPDATE SET status = 3
""")
spark.sql(f"""
MERGE INTO dim.dim_oneid_id_map AS t
USING (SELECT {oneid} AS oneid) AS s
ON t.oneid = s.oneid AND t.dt = '{dt}'
WHEN MATCHED THEN UPDATE SET map_status = 3
""")
# 2) Redis 清理
r = redis.Redis.from_url(cfg.serving.redis_write_url, decode_responses=True)
members = r.smembers(f"oneid:one2ids:{oneid}")
pipe = r.pipeline(transaction=False)
for m in members:
id_type, _, id_hash = m.partition(":")
pipe.set(f"oneid:id2one:{id_hash}", -1) # 哨兵:已注销
pipe.unlink(f"oneid:one2ids:{oneid}") # 异步删大 key
pipe.unlink(f"oneid:profile:{oneid}")
pipe.execute()
# 3) changelog
ev = {
"op": "TOMBSTONE", "oneid_keep": None, "oneid_drop": oneid,
"affected_id_hashes": [m.partition(":")[2] for m in members],
"reason": reason,
"ts": datetime.now(timezone.utc).isoformat(),
"source": "offline",
}
ev["event_id"] = hashlib.md5(
f"TOMBSTONE|{oneid}|{ev['ts']}".encode()).hexdigest()
_produce(cfg.realtime.changelog_topic, ev)
log.info("tombstone done oneid=%s affected_ids=%d", oneid, len(members))
def _produce(topic: str, msg: dict):
from kafka import KafkaProducer
p = KafkaProducer(
bootstrap_servers=get_config().realtime.kafka_brokers,
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
acks="all", retries=5)
p.send(topic, msg)
p.flush()
p.close()
注销核验(合规审计每季度抽查):随机取 100 个已注销 OneID,验证 ① dim 表 status=3;② Redis id2one 返回 -1;③ 营销系统 30 天内无触达记录;④ 人群包内不存在。四项全过才算注销闭环。
10.9 服务层单测
python
# oneid_core/tests/test_serving.py
"""serving 层单测:fakeredis + FastAPI TestClient。"""
import pytest
from fastapi.testclient import TestClient
import fakeredis.aioredis
from oneid_core.serving import app as app_mod
@pytest.fixture()
def client(monkeypatch):
fake = fakeredis.aioredis.FakeRedis(decode_responses=True)
# 预置数据
import asyncio
asyncio.get_event_loop().run_until_complete(fake.set(
"oneid:id2one:" + "a" * 64, "1000000008"))
asyncio.get_event_loop().run_until_complete(fake.sadd(
"oneid:one2ids:1000000008", "mdn:" + "a" * 64, "openid:" + "b" * 64))
monkeypatch.setattr(app_mod, "redis_cli", fake)
# 布隆过滤器:测试环境直接放行(monkeypatch __contains__)
app_mod.bloom.__contains__ = lambda self, x: True
# 鉴权放行
monkeypatch.setattr(app_mod, "get_caller",
lambda: {"ak": "test-ak", "role": "risk"})
return TestClient(app_mod.app)
def test_resolve_hit(client):
resp = client.post("/oneid/resolve", json={
"id_hash": "a" * 64, "id_type": "mdn"},
headers={"X-AK": "test-ak", "X-Role": "risk"})
assert resp.status_code == 200
body = resp.json()
assert body["oneid"] == 1000000008 and body["status"] == "hit"
def test_resolve_bad_hash_rejected(client):
resp = client.post("/oneid/resolve", json={
"id_hash": "short", "id_type": "mdn"},
headers={"X-AK": "test-ak", "X-Role": "risk"})
assert resp.status_code == 422 # Pydantic 校验失败
def test_expand_forbidden_for_marketing(client, monkeypatch):
monkeypatch.setattr(app_mod, "get_caller",
lambda: {"ak": "mkt", "role": "marketing"})
resp = client.post("/oneid/expand", json={"oneid": 1000000008},
headers={"X-AK": "mkt", "X-Role": "marketing"})
assert resp.status_code == 403 # marketing 无权 expand
def test_batch_limit_500(client):
items = [{"id_hash": f"{i:064x}", "id_type": "mdn"} for i in range(501)]
resp = client.post("/oneid/resolve/batch", json={"items": items},
headers={"X-AK": "test-ak", "X-Role": "risk"})
assert resp.status_code == 422 # 超 500 上限
10.10 容器化与部署
dockerfile
# oneid_core/serving/Dockerfile
FROM python:3.11-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
libgomp1 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml ./
RUN pip install --no-cache-dir -e .
COPY oneid_core ./oneid_core
# 布隆过滤器通过 initContainer 从 HDFS 下载到 /data/oneid/bloom
ENV PYTHONUNBUFFERED=1 \
SALT_CIPHERTEXT_BLOB="" \
KMS_ROLE="oneid-serving"
EXPOSE 8080
CMD ["uvicorn", "oneid_core.serving.app:app", \
"--host", "0.0.0.0", "--port", "8080", \
"--workers", "4", "--loop", "uvloop", "--http", "httptools"]
K8s 部署要点:
- 8 个 Pod × 4 worker,HPA 按 CPU 60% 自动扩缩(1-20 Pod);
- Pod 反亲和分散到不同节点,配合 PodDisruptionBudget(maxUnavailable=1);
- 优雅停机:
terminationGracePeriodSeconds=30,收到 SIGTERM 后先从 LB 摘流再退出; - 布隆文件用 initContainer 从 HDFS 拉取(每日新布隆通过 sidecar watch 配置中心版本热加载,不重启 Pod);
- 配置(AK 白名单、限流值)走配置中心,不进镜像。
10.11 业务方调用:SDK 封装(含客户端哈希)
业务方不直接接触盐值------SDK 启动时用服务账号换短期令牌,哈希在 SDK 内完成:
python
# 业务方使用示例(oneid-sdk,独立于 oneid_core 分发给业务方)
from oneid_sdk import OneIDClient
client = OneIDClient(
endpoint="https://oneid.xinggou.internal",
ak="marketing-ak-xxxx",
sk=load_sk_from_vault(), # SK 从业务方 Vault 取,不硬编码
)
# 正向:手机号 → OneID(SDK 内部完成 HMAC,明文不出业务进程)
oneid = client.resolve(id_type="mdn", id_raw="13812345678")
if oneid:
print("resolved:", oneid)
# 批量
res = client.resolve_batch([
{"id_type": "unionid", "id_raw": "oXf1abc..."},
{"id_type": "mdn", "id_raw": "13900001111"},
])
# 反向(风控角色)
ids = client.expand(oneid=1000000008, id_types=["idfa", "gaid"])
SDK 内部要点(不在此展开全部代码):请求签名(与 10.4 verify_signature 对应)、本地内存 LRU 5 秒、超时 200ms 重试 1 次、429 退避、批量自动分片 500/包。
11.6 changelog 落表与消费位点
oneid-changelog 同时落一份 Hive 表,供审计、对账、漏消费补偿:
sql
CREATE TABLE IF NOT EXISTS dwm.dwm_merge_event_df (
event_id STRING COMMENT '幂等键:op|drop|ts 的 md5',
op STRING COMMENT 'NEW/MERGE/SPLIT/TOMBSTONE/ATTR_UPDATE',
source STRING COMMENT 'realtime/offline',
oneid_keep BIGINT,
oneid_drop BIGINT,
affected_id_hashes ARRAY<STRING>,
reason STRING,
confidence DOUBLE,
detail STRING COMMENT 'JSON 扩展信息',
event_time TIMESTAMP
)
USING ICEBERG
PARTITIONED BY (dt STRING)
TBLPROPERTIES (
'format-version'='2',
'write.format.default'='parquet',
'write.parquet.compression-codec'='zstd',
'write.distribution-mode'='hash',
'write.distribution.hash.num-buckets'='48');
Flink 实时作业写 Kafka 的同时,由独立的 sink 作业(Kafka → Iceberg,exactly-once 两阶段提交)落表。下游若发现自己漏消费(消费组 lag 异常或回放需求),可以从 dwm_merge_event_df 按 dt 重放补偿,不必从头读 Kafka。
11.7 下游接入 Checklist
新下游接入 OneID 的验收清单(数据平台逐项检查后开通权限):
| # | 检查项 | 通过标准 |
|---|---|---|
| 1 | 消费幂等 | 以 event_id 去重,重复消息不产生副作用 |
| 2 | MERGE 处理 | 只写 oneid_keep,drop 号数据迁移或丢弃 |
| 3 | TOMBSTONE 处理 | 注销用户停止触达/特征过滤/包剔除,有代码与测试 |
| 4 | SLA 依赖认知 | 明确用实时(秒级)还是离线(T+1),不混用 |
| 5 | 字段权限 | 只申请最小字段,不申请 id_map 全量 |
| 6 | 调用量评估 | 预估 QPS 报备,超 6000/min 单独限流配额 |
| 7 | 降级处理 | OneID 服务不可用时业务可降级(不阻断核心交易) |
| 8 | 审计配合 | expand/export 操作可追溯,接受季度审计抽查 |
12.6 质量指标监控作业(Python)
python
# oneid_core/quality/metrics_job.py
"""每日质量指标计算 + 阈值判断 + 告警发送。"""
from dataclasses import dataclass
from pyspark.sql import SparkSession
from oneid_core.common.config import get_config
from oneid_core.common.logger import get_logger
log = get_logger(__name__)
# 指标 → (最小, 最大, 告警级别);None 表示单向
THRESHOLDS = {
"connect_rate": (0.88, 0.95, "P1"),
"ids_per_oneid": (2.0, 2.7, "P2"),
"supernode_ratio": (None, 0.005, "P1"),
"strong_anchor_coverage": (0.85, None, "P2"),
"merge_rate": (None, 0.005, "P1"), # 日合并率 >0.5% P1
"batch_stream_consistency":(0.995, None, "P1"),
}
@dataclass
class MetricAlert:
metric: str
value: float
rule: str
level: str
def run_quality(spark: SparkSession, dt: str) -> list[MetricAlert]:
"""执行质量 SQL,读取结果,比对阈值,发告警。返回告警列表。"""
cfg = get_config()
spark.sql(f"CALL oneid.daily_quality('{dt}") # 或执行 12.2 的 SQL 脚本
row = spark.sql(f"""
SELECT connect_rate, ids_per_oneid, supernode_ratio,
strong_anchor_coverage
FROM dim.oneid_quality_metric_di WHERE dt='{dt}'
""").collect()[0]
merge_row = spark.sql(f"""
SELECT merge_rate FROM dwm.dwm_merge_event_rate_df WHERE dt='{dt}'
""").collect()[0]
values = {
"connect_rate": row.connect_rate,
"ids_per_oneid": row.ids_per_oneid,
"supernode_ratio": row.supernode_ratio,
"strong_anchor_coverage": row.strong_anchor_coverage,
"merge_rate": merge_row.merge_rate,
"batch_stream_consistency": _read_consistency(dt), # 回灌任务产出
}
alerts: list[MetricAlert] = []
for metric, (lo, hi, level) in THRESHOLDS.items():
v = values.get(metric)
if v is None:
continue
if lo is not None and v < lo:
alerts.append(MetricAlert(metric, v, f"< {lo}", level))
if hi is not None and v > hi:
alerts.append(MetricAlert(metric, v, f"> {hi}", level))
for a in alerts:
log.warning("QUALITY ALERT [%s] %s = %s (%s)", a.level, a.metric,
a.value, a.rule)
if alerts:
_send_alert(dt, alerts)
return alerts
def _read_consistency(dt: str) -> float:
"""读取 8.9 回灌任务写入的抽样一致率结果。"""
import json
from oneid_core.common.config import get_config
# 回灌结果落在 dim.oneid_backfill_stat_di
return 0.998 # 示意:实际 SELECT sample_consistency FROM ... WHERE dt
def _send_alert(dt: str, alerts: list[MetricAlert]):
"""P1 电话+群,P2 群消息(内部告警平台 webhook)。"""
lines = [f"[OneID 质量告警 dt={dt}]"]
for a in alerts:
lines.append(f"[{a.level}] {a.metric}={a.value} 规则:{a.rule}")
msg = "\n".join(lines)
p1 = [a for a in alerts if a.level == "P1"]
_post_webhook("/alert/chat", msg)
if p1:
_post_webhook("/alert/phone", "\n".join(
f"{a.metric}={a.value}" for a in p1))
def _post_webhook(path: str, msg: str):
log.info("alert webhook %s: %s", path, msg)
12.7 数据血缘与影响分析
OneID 表是 200+ 下游任务的源头,变更必须可评估影响范围。星购的数据血缘系统(基于 OpenLineage + 自研解析)回答三个问题:
- 上游断了影响谁 :某来源表(如
ods_wx_profile_di)延迟 → 自动列出受影响的下游任务清单(特征/标签/人群包); - 字段变更影响谁:dim_oneid_user 加字段/改语义 → 血缘图上反向追溯所有消费该字段的任务;
- 问题数据回查:下游发现某个 OneID 画像异常 → 血缘正向追溯到来源表分区和规则版本。
血缘采集方式:Spark 作业用 OpenLineage agent 自动上报(input/output 表 + 字段级 lineage);Flink 作业上报 source/sink;调度系统(DolphinScheduler)补充任务依赖边。字段级血缘通过 Spark SQL 解析(spark.sql.queryExecution.analyzed)抽取。
质量问题的标准排查路径(值班 SOP):
告警触发
├─ 连通率异常 → 查来源表就绪状态(11 张 ods 表分区是否齐)
│ └─ 来源正常 → 查建边作业:弱边数量、黑名单表是否被误改
├─ 合并率突增 → 查 changelog reason 分布:某 reason 突增 = 某规则/数据源问题
│ └─ 定位到具体 reason(如 bind_unionid)→ 查对应来源 CDC 是否重复推送
├─ 超级节点突增 → 查黑名单表变更 + 异常簇工单(12.3)
└─ 批流一致率低 → 查回灌任务日志:是 Redis 写失败还是离线结果本身抖动
13.7 关键配置模板(Spark/Flink)
properties
# oneid 离线作业标准 Spark 配置(sparksql-defaults / 作业提交参数)
spark.sql.adaptive.enabled=true
spark.sql.adaptive.coalescePartitions.enabled=true
spark.sql.adaptive.skewJoin.enabled=true
spark.sql.adaptive.skewJoin.skewedPartitionFactor=5
spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes=268435456
spark.sql.shuffle.partitions=20000
spark.sql.files.maxPartitionBytes=134217728
spark.sql.parquet.compression.codec=zstd
spark.executor.memory=12g
spark.executor.memoryOverhead=4g
spark.executor.cores=4
spark.sql.iceberg.handle-timestamp-without-timezone=true
# GraphX 作业额外参数
spark.graphx.pregel.checkpointInterval=8
spark.cleaner.periodicGC.interval=30min
yaml
# Flink 实时并查集作业关键配置(flink-conf.yaml 片段)
parallelism.default: 48
state.backend: rocksdb
state.backend.incremental: true
execution.checkpointing.interval: 60000
execution.checkpointing.mode: EXACTLY_ONCE
execution.checkpointing.timeout: 120000
restart-strategy: fixed-delay
restart-strategy.fixed-delay.attempts: 5
table.exec.async-lookup.timeout: 3s
# Redis 连接器在算子内自建连接池(slot 级),不用 Flink 内置 connector state
13.8 性能问题排查 Checklist
| 症状 | 优先排查 |
|---|---|
| Spark 某 task 卡住数小时 | 数据倾斜:查 task 的 shuffle read 大小;倾斜 key → 加盐/广播小表 |
| GraphX OOM | checkpoint 间隔是否配置;executorOverhead 是否 ≥4g;是否全量边未过滤 is_pruned |
| 小文件爆炸 | 写入 repartition 数是否与分桶数对齐;Iceberg 是否定期 rewrite_data_files |
| Redis CPU 打满 | 热 key(--hotkeys);是否缺本地缓存;pipeline 是否退化成逐条 |
| Redis 内存涨不落 | 大 key 未设 TTL;one2ids 是否全量重建先 delete; tombstone 哨兵是否堆积 |
| Flink 背压高 | Redis 延迟(慢日志);并行度是否低于 Kafka 分区数;checkpoint 是否对齐超时 |
| 服务 P99 抖高 | Redis 从节点复制延迟;Hive 降级是否被触发;GC(uvicorn worker 内存) |
| 布隆误判升高 | 重建时 capacity 是否按 12 亿上限;fpp 参数是否被改动 |
14.6 数据加密全景
| 数据 | 形态 | 保护方式 |
|---|---|---|
| 明文手机号/身份证 | ODS 贴源层 | 落盘加密(HDFS TDE 透明加密),访问需行列权限 |
| id_hash | DWD 及以上 | HMAC-SHA256 + KMS 盐,不可逆 |
| OneID | 全链路 | 雪花随机 Long,不含个人信息 |
| 盐值 | KMS | master key HSM;data key 内存态、轮换机制(14.2) |
| 传输 | 服务间/对外 | TLS 1.2+;Kafka 内网 + SASL |
| 日志 | 全组件 | 手机号/身份证正则扫描拦截 + 脱敏函数兜底 |
| 人群包 | 导出 | 渠道公钥 RSA-2048 加密 + 到期删除 |
| 备份 | Iceberg 快照 | 与在线同密级;冷存储加密桶 |
防泄露的三道工程闸门:
- 出口扫描:离线查询网关对所有查询结果做正则扫描,命中手机号/身份证模式且该任务无明文权限 → 拦截 + 告警;
- 日志拦截 :统一日志 appender 内置脱敏(
mask_mdn/mask_idcard),CI 中加静态检查(禁止log.info(f"...{mdn}...")直接打印原始变量名); - UDF 审查:任何接触明文的 UDF/作业必须安全评审,盐值/明文不许出现在 UDF 参数(part2 第 4 章 HmacIdUDF 通过 Hadoop credential 读取而非 SQL 传参)。
14.7 数据主体权利(DSR)响应
个保法下用户有权查阅、更正、删除个人信息,OneID 侧的响应 SOP:
| 权利 | 请求 | OneID 侧动作 | SLA |
|---|---|---|---|
| 查阅 | "你有我哪些数据" | 以用户提供的手机号/账号 → 标准化 → 映射 OneID → 汇总各来源属性(人工审核后提供) | 15 天 |
| 更正 | 属性错误(如城市) | 走冲突工单(type=1),核验后覆盖黄金值并记录 | 7 天 |
| 删除 | 注销账号 | tombstone 全链路(14.5) | 15 天 |
| 撤回同意 | 关闭个性化推荐 | OneID 保留但打 personalized_optout=1 标签,推荐/营销侧过滤 |
实时 |
注意"删除"在工程上是匿名化而非物理抹去:OneID 行保留(status=3)以支持审计和防重复注册风控,但所有可关联到自然人的属性和映射被切断/置空------法律上匿名化后的数据不再属于个人信息。
14.8 合规模板:影响评估(PIA)要点
OneID 项目上线前完成《个人信息保护影响评估》,核心章节:
- 处理目的:跨渠道识别同一用户,用于服务连续性、营销、风控(均有告知同意依据);
- 数据最小化:只采集 ID 标识与必要属性,不采集聊天内容、精确轨迹;
- 风险分析:误合并(把两个人识别为一人)→ 精确率 99.8% + 拆分回滚机制;泄露 → 14.6 三道闸门;注销不彻底 → 季度抽查核验;
- 权限控制:RBAC + 审计 + 字段分级;
- 应急:泄露 72 小时内上报监管(预案中定义联系人与处置流程)。
12.8 质量月报样例(星购 M12)
| 指标 | 月初 | 月末 | 评估 |
|---|---|---|---|
| 连通率 | 90.8% | 91.2% | 健康(目标 90-93%) |
| 孤岛率 | 22.4% | 21.9% | 健康(<25%) |
| 人均 ID 数(活跃) | 2.41 | 2.43 | 稳定 |
| 日合并率 | 0.11% | 0.12% | 健康(<0.5%) |
| 超级节点占比 | 0.08% | 0.07% | 健康(<0.5%) |
| 批流一致率 | 99.78% | 99.82% | 达标(>99.5%) |
| 抽样精确率 | 99.7% | 99.8% | 达标(≥99.5%) |
| 抽样召回率 | 95.6% | 96.1% | 达标(≥95%) |
| 误并工单 | 37 | 29 | 环比下降 |
| 漏并工单 | 212 | 184 | 持续补规则 |
月报除数字外必附:Top5 误并原因(如"家庭共享 iPad 同设备 + 同收货地址")、Top5 漏并来源(如"小程序 unionid 断流 2 天")、下月改进项。
10.12 分布式限流与指标埋点
多 Pod 部署时进程内令牌桶不够用(每 Pod 独立计数,8 Pod 实际放行 8 倍)。生产用 Redis 滑动窗口 + 本地预判两级:
python
# oneid_core/serving/ratelimit.py
"""两级限流:本地令牌桶快速拒绝 + Redis 滑动窗口精确计数。"""
import time, math
import redis
class RateLimiter:
def __init__(self, redis_cli: redis.Redis, limit_per_min: int = 6000,
local_ceiling: int = 800):
self.r = redis_cli
self.limit = limit_per_min
# 本地预判:单 Pod 每分钟放行上限 = 总配额/Pod数 × 1.2(冗余)
self.local_ceiling = local_ceiling
self._local: dict[str, list] = {}
def allow(self, ak: str) -> bool:
now = time.time()
bucket = self._local.setdefault(ak, [])
while bucket and bucket[0] < now - 60:
bucket.pop(0)
if len(bucket) >= self.local_ceiling:
return False # 本地快速拒绝,不打 Redis
# Redis 滑动窗口:ZSET 记请求时间戳
key = f"rl:{ak}:{int(now // 60)}"
pipe = self.r.pipeline(transaction=True)
pipe.zremrangebyscore(key, 0, now - 60)
pipe.zadd(key, {f"{now}:{math.ceil(now*1000)%1000000}": now})
pipe.zcard(key)
pipe.expire(key, 120)
_, _, cnt, _ = pipe.execute()
if cnt > self.limit:
return False
bucket.append(now)
return True
Prometheus 指标(在 10.5 app.py 中已埋点)与 Grafana 告警:
| 指标 | 类型 | 告警阈值 |
|---|---|---|
oneid_req_total{api,status} |
Counter | 429 占比 > 5% 告警(限流配置不合理) |
oneid_lat_ms{api} |
Histogram | P99 > 10ms 持续 5min(P999 > 30ms) |
| bloom 拒绝率 | 派生 | 突增 2 倍(爬虫攻击特征) |
| fallback 触发次数 | Counter | > 0 即 P1(Redis 故障) |
质量指标结果表 DDL:
sql
CREATE TABLE IF NOT EXISTS dim.oneid_quality_metric_di (
connect_rate DOUBLE COMMENT '连通率',
island_rate DOUBLE COMMENT '孤岛率',
ids_per_oneid DOUBLE COMMENT '活跃人均 ID 数',
merge_rate DOUBLE COMMENT '日合并率',
split_cnt BIGINT COMMENT '日拆分工单数',
supernode_ratio DOUBLE COMMENT '超级节点 ID 占比',
strong_anchor_coverage DOUBLE COMMENT '强锚点 OneID 覆盖率',
batch_stream_consistency DOUBLE COMMENT '批流抽样一致率',
precision_sample DOUBLE COMMENT '月度抽样精确率',
recall_sample DOUBLE COMMENT '月度抽样召回率',
alert_count INT COMMENT '当日 P1/P2 告警数',
update_time TIMESTAMP
)
USING ICEBERG
PARTITIONED BY (dt STRING)
TBLPROPERTIES (
'format-version'='2',
'write.format.default'='parquet',
'write.parquet.compression-codec'='zstd');
12.9 质量规则单测
python
# oneid_core/tests/test_quality.py
"""质量阈值逻辑单测。"""
import pytest
from oneid_core.quality.metrics_job import THRESHOLDS, MetricAlert
def _eval(values: dict) -> list:
alerts = []
for metric, (lo, hi, level) in THRESHOLDS.items():
v = values.get(metric)
if v is None:
continue
if lo is not None and v < lo:
alerts.append((metric, level))
if hi is not None and v > hi:
alerts.append((metric, level))
return alerts
def test_healthy_values_no_alert():
assert _eval({"connect_rate": 0.91, "ids_per_oneid": 2.4,
"supernode_ratio": 0.0007, "merge_rate": 0.0012,
"batch_stream_consistency": 0.998}) == []
def test_supernode_breach_p1():
alerts = _eval({"supernode_ratio": 0.008})
assert ("supernode_ratio", "P1") in alerts
def test_consistency_below_target_p1():
alerts = _eval({"batch_stream_consistency": 0.991})
assert ("batch_stream_consistency", "P1") in alerts
def test_merge_rate_spike_p1():
alerts = _eval({"merge_rate": 0.008})
assert ("merge_rate", "P1") in alerts
14.9 安全检查清单(上线前 Go/No-Go)
| # | 检查项 | 标准 |
|---|---|---|
| 1 | 明文扫描 | dim/dwm 层全字段抽样,无手机号/身份证明文 |
| 2 | 盐值检查 | 代码仓库、UDF 参数、日志、配置文件中 grep 不到盐值 |
| 3 | 权限矩阵 | 每个 AK 的角色与 ROLE_PERM 一致,离职/转岗账号已回收 |
| 4 | 审计完整性 | expand/export/merge/split 四类操作 100% 有审计记录 |
| 5 | 注销演练 | 抽 5 个测试账号走完注销链路,四系统核验通过 |
| 6 | 降级演练 | Redis 整体宕机演练,服务降级不雪崩、告警可达 |
| 7 | 数据出口 | 查询网关明文拦截规则生效(红队测试 10 条绕过样本全拦截) |
| 8 | 备份恢复 | Iceberg 快照可恢复到 7 天内任意天;Redis 重建演练 < 1 小时 |
| 9 | PIA 文档 | 影响评估已评审签字 |
| 10 | 应急联系人 | 值班表、监管上报流程、法务联系人在案 |
10.13 签名中间件与统一异常处理
python
# oneid_core/serving/middleware.py
"""签名验签中间件(需要 raw body)+ 统一异常/审计。"""
import time, json
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from oneid_core.serving.auth import verify_signature, _load_sk, ROLE_PERM
from oneid_core.common.logger import get_logger
log = get_logger(__name__)
# 无需签名的路径
WHITE_LIST = {"/metrics", "/healthz", "/docs", "/openapi.json"}
class SignatureMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
path = request.url.path
if path in WHITE_LIST or request.method == "GET":
return await call_next(request)
ak = request.headers.get("X-AK")
ts = request.headers.get("X-Timestamp")
nonce = request.headers.get("X-Nonce")
sig = request.headers.get("X-Signature")
if not all([ak, ts, nonce, sig]):
return JSONResponse({"error": "missing signature headers"}, 401)
body = await request.body()
sk = _load_sk(ak)
if sk is None:
return JSONResponse({"error": "invalid AK"}, 401)
if not verify_signature(ak, ts, nonce, sig, body, sk):
log.warning("signature verify failed ak=%s ip=%s",
ak, request.client.host)
return JSONResponse({"error": "bad signature"}, 401)
# nonce 防重放:Redis SETNX 10 分钟
if not await _nonce_check(nonce):
return JSONResponse({"error": "replayed request"}, 401)
response = await call_next(request)
response.headers["X-Trace-Id"] = request.headers.get(
"X-Trace-Id", "")
return response
async def _nonce_check(nonce: str) -> bool:
"""nonce 在 Redis 中 SETNX,TTL 600s;重复 nonce 拒绝。"""
from oneid_core.serving.app import redis_cli
ok = await redis_cli.set(f"oneid:nonce:{nonce}", "1", nx=True, ex=600)
return bool(ok)
中间件与
require_api依赖分工:中间件验"你是谁、请求有没有被篡改/重放";依赖验"你能不能调这个接口"。两层分离便于审计与单测。
11.8 回写与广播的调度编排
离线产出与回写/广播在 DolphinScheduler 的依赖顺序(第 15 章有完整 DAG):
标准化作业(45min) → 建边(38min) → GraphX CC(48min) → OneID分配(25min)
│
┌─────────────────────────┼──────────────────────┐
▼ ▼ ▼
属性融合(83min) Redis 预热/回灌(40min) changelog 落表
│ │ │
▼ ▼ ▼
dim_oneid_user serving 布隆重建 下游订阅自动生效
质量校验(12章) 批流一致率核对
│
▼
业务库回写(DataX, CRM/POS) → 下游离线任务 4:00 后可调度
关键门禁:质量校验(第 12 章六大指标)不通过则阻断回写与预热------宁可下游延迟,不可把错误映射灌进 Redis。门禁 SQL 返回的告警级别为 P1 时,DAG 自动暂停并电话值班。
14.10 密钥与账号管理规范
| 项 | 规范 |
|---|---|
| SK(服务间) | 32 字节随机;每 90 天轮换;轮换双 AK 并行 7 天 |
| KMS master key | 每年轮换;旧 key 保留用于解密历史密文,不用于加密 |
| 数据盐 | 12 个月轮换;双盐并行 90 天过渡(14.2) |
| MySQL/Redis 密码 | Vault 动态租约,30 天自动轮换;禁止写在配置文件 |
| 离职/转岗 | AK 24 小时内禁用;季度全量 AK 盘点(无人认领的禁用) |
| 提权操作 | merge/split/export 配置变更需双人复核 + 审计工单 |
14.11 合规审计的 SQL 备查
季度审计常用核对 SQL(审计员可直接执行,账号只读):
sql
-- 1. 某时间窗口内的敏感操作清单
SELECT ts, actor_ak, actor_role, action, target_oneid, result, client_ip
FROM oneid_audit_log
WHERE ts BETWEEN '2024-09-01' AND '2024-09-30'
AND action IN ('expand', 'export', 'merge', 'split', 'config_change')
ORDER BY ts DESC;
-- 2. 注销完成率(发起 vs 完成)
SELECT
COUNT(1) AS requested,
SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) AS tombstoned,
ROUND(SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) / COUNT(1), 4) AS done_rate
FROM dim.dim_user_cancel_request
WHERE request_dt BETWEEN '2024-09-01' AND '2024-09-30';
-- 3. 注销后仍触达的违规检查(营销触达记录 LEFT JOIN 注销号)
SELECT COUNT(1) AS violations
FROM dwm.dwm_marketing_touch_log t
JOIN dim.dim_oneid_user u ON t.oneid = u.oneid AND u.status = 3
WHERE t.dt BETWEEN '2024-09-01' AND '2024-09-30'
AND t.touch_time > u.tombstone_time;
-- 期望:0(非 0 即合规事故,第 17 章事故 9)
-- 4. 明文出口扫描(查询网关日志中被拦截的请求)
SELECT COUNT(1) FROM dim.oneid_egress_scan_log
WHERE dt = '2024-09-30' AND blocked = 1;
13.9 优化前后对比(星购实测)
| 阶段 | 优化前 | 优化后 | 关键手段 |
|---|---|---|---|
| 标准化(1800 万 ID/天) | 45 分钟 | 12 分钟 | Scala UDF + map 端预去重 |
| 建边(600 万边/天增量) | 90 分钟(长尾 task 4h) | 38 分钟 | 黑名单 + cap + 加盐打散 |
| 全量 CC(20.7 亿边) | 3.5 小时(曾 OOM 失败) | 48 分钟 | GraphX 2D 分区 + checkpoint + overhead |
| 融合(6.8 亿 OneID) | 160 分钟 | 83 分钟 | map 端 top3 预聚合 |
| Redis 预热 | 3 小时(逐条 SET) | 40 分钟 | pipeline 500/批 + foreachPartition |
| 服务 P99 | 45ms(Redis 直读、无 L1) | 8.6ms | L1 缓存 + 读从节点 + 布隆前置 |
13.10 容量演进预估
按 ID 年增 40%(业务增长 + 埋点加密)的规划:
| 时间 | 节点规模 | 边规模 | 离线资源 | Redis |
|---|---|---|---|---|
| 当前(Y1) | 10.3 亿 | 20.7 亿 | 日均 1200 executor·h | 3 主 3 从 32GB |
| Y2(预估) | 14 亿 | 30 亿 | 1600 executor·h | 6 主 6 从 32GB(reshard) |
| Y3(预估) | 20 亿 | 45 亿 | 2200 executor·h | 6 主 6 从 64GB |
Y2 的核心动作:Kafka 分区 48→96、Flink 并行度同步、GraphX 分区 2000→4000、布隆重建 capacity 提至 20 亿(约 3.8GB)。所有扩展动作都已在配置中参数化(分桶数、布隆容量、并行度),不需要改代码。
10.14 健康检查与灰度发布
/healthz(LB 探针):进程存活即 200,不查依赖;/readyz(就绪探针):Redis ping 通 + 布隆已加载才 200,否则 503 摘流;- 灰度发布:新版本先 1 个 Pod(12.5% 流量),观察 30 分钟 P99/错误率/降级计数,指标持平再全量;
- 回滚:镜像版本保留最近 10 个,一键回滚 + 配置中心版本回退;
- 布隆热加载失败不影响旧版本服务(新文件加载成功才原子替换内存引用)。
12.10 质量文化:谁产生谁负责
OneID 质量不是数据平台单方面的事,根因往往在接入方:
| 质量问题根因 | 责任方 | 闭环机制 |
|---|---|---|
| 来源表埋点重复/断流 | 业务埋点团队 | 接入 SLA:分区延迟 > 2h 自动通知埋点负责人 |
| 弱关系规则误判 | 数据平台 | 月度抽样结果驱动权重/阈值迭代 |
| 下游未消费 TOMBSTONE | 下游团队 | 接入 Checklist(11.7)第 3 项 + 季度审计 SQL(14.11) |
| 黑名单维护不及时 | 风控 + 数据平台 | 超大连通簇工单双周联合复盘 |
| 盐值/权限违规 | 使用方 | 安全扫描 + 通报,计入团队数据治理评分 |
10.15 接口响应码约定
| HTTP 码 | 含义 | 业务动作 |
|---|---|---|
| 200 + status=hit | 命中 OneID | 正常使用 |
| 200 + status=miss | 无此 ID(含布隆拒绝) | 业务自行建档/忽略 |
| 200 + status=tombstoned | 已注销 | 停止处理,不触达 |
| 401 | 签名/AK 无效 | 检查凭证轮换 |
| 403 | 角色无权(如 marketing 调 expand) | 申请权限走审批 |
| 422 | 参数非法(id_hash 非 64 位等) | 检查 SDK 哈希逻辑 |
| 429 | 限流 | 指数退避,申请配额 |
| 503 | 依赖全不可用 | 业务降级(缓存/延迟处理) |
本篇小结
- 第 10 章:FastAPI 服务四接口(resolve/batch/expand/profile)、布隆+L1+Redis 三级缓存、AK/SK 签名鉴权与 RBAC、限流降级、缓存预热与 changelog 失效、SLA 实测 P99 8.6ms(目标 < 10ms);
- 第 11 章:三类对接方式(changelog/离线表/API)、下游接入三铁律、字段分级授权、业务库回写、DMP 人群包加密导出;
- 第 12 章:六大质量指标 SQL、异常簇扫描、月度抽样标注(精确率 ≥99.5%)、告警看板;
- 第 13 章:标准化/建边倾斜加盐/GraphX 调参/Iceberg/Redis 全链路优化与月成本 57 万;
- 第 14 章:个保法红线、KMS 盐值分级与轮换、OneID 不可逆设计、RBAC 审计、注销 tombstone 全链路。
下一篇是落地串讲与实战复盘:全渠道打通项目案例(第 15 章)、实时营销触达实战与压测(第 16 章)、10 个生产事故复盘(第 17 章)