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 RedisGET 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 章)

相关推荐
OsDepK1 小时前
项目快速Git至仓库(完整版)
大数据·git·elasticsearch·搜索引擎
合米AI SOP系统2 小时前
传统产线如何快速上马落地 AI 防错?合米科技 AI SOP 7天即可上线。
大数据·人工智能·科技
思录Echo2 小时前
什么决定具身智能的最终走向?多技术路线与落地现实辨析
大数据·人工智能
xiaohaiAIgeo2 小时前
【2026年】AI监控加行为分析守护实验室安全
大数据·人工智能·科普知识
林墨聊AIGC3 小时前
动漫AI视频创作工具在哪找到的?2026年最新动漫AI视频平台与软件指南
大数据·人工智能·ai作画·aigc·音视频
故七月4 小时前
告别 AI 时代品牌 “隐身”:万域智瞰 AI‑GEO,构建品牌大模型时代营销新基建
大数据·人工智能
数字孪生视频孪生6 小时前
三维实时重构异构底座 核工危化无感定位跨境轨迹一屏统揽
大数据·运维·人工智能·重构·架构
腾讯云大数据6 小时前
腾讯云AI Native数据平台功能发布合集【8月】
大数据·人工智能·云计算·腾讯云
小蒋观天下6 小时前
2026交通安防AI摄像头完整选型指南
大数据·人工智能