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

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 → 黄金记录摘要(性别/城市/会员等级) 推荐特征、实时画像

设计原则:

  1. 只收哈希不收明文:调用方传入的 id 必须先经 HMAC 标准化(第 4 章 hasher),服务端永不接触明文手机号/身份证。客户端 SDK 封装哈希逻辑,业务方连盐值都拿不到。
  2. 批量上限 500:防止超大包拖垮服务;超过走异步导出通道(审批 + 审计)。
  3. 反向接口最小权限 :expand 能看到用户关联了多少设备/账号,属于敏感能力,仅风控、审计角色可调,且全量审计。
  4. 不提供"枚举遍历":没有任何 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 为例):

  1. 布隆过滤器:本地内存加载全量 id_hash 布隆位图(每日离线产出,约 2.3GB,mmap 加载)。不在过滤器中 → 直接返回"不存在",不打 Redis。挡掉约 15% 的爬虫/脏 ID 流量。
  2. L1 本地缓存 Caffeine:TTL 10 秒、容量 100 万条,命中率约 65%(热点 ID 集中)。命中直接返回。
  3. 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()

下游接入三条铁律:

  1. 必须以 event_id 幂等:changelog 至少一次投递,重复是常态;
  2. MERGE 只认 oneid_keep:oneid_drop 永久 tombstone,任何写入 drop 号的数据在次日对账时会被纠偏;
  3. 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 抽样标注评估准确率

聚合指标只能发现"量级异常",发现不了"错得不大但错了"。星购每月做一次人工标注评估:

  1. 分层抽样 2000 个 OneID:强锚点簇 1000、弱锚点簇 500、超大/异常簇 500;
  2. 人工通过原始 ID 的业务数据(收货地址、实名、常用设备、行为序列)判断"这些 ID 是否属于同一自然人";
  3. 计算:
    • 精确率 Precision = 判对"同属一人"的合并 / 全部合并 → 目标 ≥ 99.5%;
    • 召回率 Recall = 应合并且已合并 / 全部应合并 → 目标 ≥ 95%(漏并可补救,错并代价高,故精确率优先);
  4. 标注结果回流:错并案例 → 拆分工单 + 规则修正(调权重/加黑名单);漏并案例 → 补边规则。

历史趋势(星购上线 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 章已述,此处对应执行层):

  1. 黑名单提前过滤(公共 WiFi/导购机/机房 IP 根本不进建边);
  2. 度数 cap=2000:建边聚合时统计 key 度数,超 cap 的弱边直接 is_pruned=1;
  3. 加盐打散: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 包加密导出;境外渠道不投

五条红线:

  1. 明文手机号/身份证禁止出现在 dim/dwm 层和日志;
  2. 盐值禁止出现在代码仓库、UDF 参数、日志、配置明文(KMS 托管);
  3. 反向查询(expand)禁止批量导出,只许风控/审计点查;
  4. 注销请求 15 天内必须全链路完成(法律要求);
  5. 人群包导出必须双人审批 + 加密 + 审计。

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 + 自研解析)回答三个问题:

  1. 上游断了影响谁 :某来源表(如 ods_wx_profile_di)延迟 → 自动列出受影响的下游任务清单(特征/标签/人群包);
  2. 字段变更影响谁:dim_oneid_user 加字段/改语义 → 血缘图上反向追溯所有消费该字段的任务;
  3. 问题数据回查:下游发现某个 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 快照 与在线同密级;冷存储加密桶

防泄露的三道工程闸门:

  1. 出口扫描:离线查询网关对所有查询结果做正则扫描,命中手机号/身份证模式且该任务无明文权限 → 拦截 + 告警;
  2. 日志拦截 :统一日志 appender 内置脱敏(mask_mdn/mask_idcard),CI 中加静态检查(禁止 log.info(f"...{mdn}...") 直接打印原始变量名);
  3. 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 项目上线前完成《个人信息保护影响评估》,核心章节:

  1. 处理目的:跨渠道识别同一用户,用于服务连续性、营销、风控(均有告知同意依据);
  2. 数据最小化:只采集 ID 标识与必要属性,不采集聊天内容、精确轨迹;
  3. 风险分析:误合并(把两个人识别为一人)→ 精确率 99.8% + 拆分回滚机制;泄露 → 14.6 三道闸门;注销不彻底 → 季度抽查核验;
  4. 权限控制:RBAC + 审计 + 字段分级;
  5. 应急:泄露 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 章)

相关推荐
SelectDB5 小时前
ELK 做不了的分析,我用 Doris 物化视图补上了:配置、开关与四个排错现场
大数据·数据库·数据分析
SelectDB5 小时前
Agent 日志检索慢、存储还贵?search() + VARIANT 的落地命令和几个当场踩出来的问题
大数据·数据库·数据分析
SelectDB5 小时前
ClickHouse 存日志踩过的并发坑:压测脚本、检索写法与排错命令
大数据·数据库·数据分析
SelectDB5 小时前
NESTED 怎么搜 VARIANT 里的嵌套数组:一份可直接复制的统一引擎实战笔记
大数据·数据库·数据分析
麦豆GEO6 小时前
GEO信源布局策略:看懂大模型信源偏好,搭建动态可迭代的全域信源矩阵
大数据·人工智能·矩阵
当下新鲜事6 小时前
四方电气DX100开环矢量变频器在精雕机主轴驱动中的参数分析
大数据·物联网·业界资讯
西部驯兽师7 小时前
制造企业的信息化选型课题(二)
大数据·人工智能·制造
怪奇云呼军7 小时前
从 ElevenLabs 看工具调用:闪电智能 Voice Agent 的企业集成验收设计
android·大数据·运维·服务器·网络·人工智能·kotlin
adinnet20267 小时前
意图识别(Intent Recognition):智能体怎么听懂你想干啥
大数据·数据库
云上先途7 小时前
对话智能体和普通聊天机器人有什么区别?能不能对接企业自有知识库?
大数据·人工智能·机器人