《互联网产品的客户端架构·规模化与架构演进》7.2 采样、脱敏与成本

日志量一周涨二十倍,账单和合规风险同时到来。本节给出端上采样的三张策略表:按等级采样、按字段脱敏、按等级保留;用确定性哈希保证同一会话的决策稳定,用字节数、放大倍数与单价把成本算成可复核的算式,并说明 PII 脱敏的边界与离线补传的处理规则。

上线一周,日志账单涨了二十倍

某工具类应用在 6.0 版本接入了新的埋点 SDK。为了“先看清楚”,团队把日志级别开到 debug,所有页面、所有接口、所有状态变更都上报。上线一周,日志量从每天 80 GB 涨到 1.6 TB,账单跟着涨了二十倍。

更麻烦的是合规扫描:日志里出现了用户邮箱、手机号、身份证号和完整地址。安全团队要求立刻停止上报,业务团队要求保留排查能力,两边僵住。

这不是“日志太多”的问题,而是三个问题被混在一起:

  • 采样没有分级。 debug 和 fatal 用同一个开关,要么全开,要么全关。
  • 脱敏没有入口。 各业务线自己拼属性,有的脱了邮箱,有的漏了手机号。
  • 成本没有口径。 没人知道一条事件多少字节、一天多少条、按什么单价估算。

三张策略表

端上采样与成本控制,先要三张表:采样率、脱敏规则、保留天数。三张表都按等级或字段类型定义,而不是按业务线各写一套。

等级默认采样率保留天数理由
debug1%3 天量大、价值低,只用于短期复现
info10%14 天支撑转化漏斗,抽样后仍可估计总量
warn50%30 天异常信号,需要较高保真度
error100%90 天每次失败都要能定位
fatal100%180 天崩溃与资损,保留最长

采样率不是拍脑袋定的,它由两个约束共同决定:排查需要多少样本,以及预算允许多少字节。error 与 fatal 全量保留,是因为它们的总量天然有限;debug 压到 1%,是因为它的总量最大、单条价值最低。

脱敏规则按字段类型划分:

字段类型识别方式处理
邮箱正则匹配整体替换为 [REDACTED]
手机号11 位、以 1 开头整体替换
身份证18 位,末位可为 X整体替换
IPv4四段数字整体替换
敏感键名键名包含 email、phone、token 等不检查内容,直接替换

最后一行是关键:不能只靠正则。用户可能把手机号写进 note,也可能把 token 放进 trace。键名命中敏感词时直接整体替换,正则只作为第二道防线。

采样必须确定性

最容易被忽略的一条:采样决策必须可复现。如果每次调用都重新随机,同一个会话的前半段被保留、后半段被丢弃,漏斗就断了,用户路径也无法还原。

代码用事件 id 的哈希做桶,而不是 Math.random():

// file: src/observability/sampling-cost.ts
// tier: L1
// verify: node --test test/observability/sampling-cost.test.ts

import { Buffer } from "node:buffer";

export type Severity = "debug" | "info" | "warn" | "error" | "fatal";

export type TelemetryEvent = {
  id: string;
  name: string;
  severity: Severity;
  timestamp: number;
  attributes?: Record<string, string>;
};

export type SampleDecision = { keep: boolean; rate: number; reason: "always" | "rate" };
export type SamplingReport = {
  total: number;
  kept: number;
  dropped: number;
  bySeverity: Record<Severity, { kept: number; dropped: number }>;
};
export type CostConfig = { pricePerGb: number; scaleFactor?: number };
export type CostEstimate = { events: number; bytes: number; gigabytes: number; cost: number };
export type RetentionPolicy = Record<Severity, number>;
export type RetentionPartition = { kept: TelemetryEvent[]; expired: TelemetryEvent[] };

export const SEVERITIES: Severity[] = ["debug", "info", "warn", "error", "fatal"];

export const DEFAULT_RATES: Record<Severity, number> = {
  debug: 0.01,
  info: 0.1,
  warn: 0.5,
  error: 1,
  fatal: 1,
};

export const DEFAULT_RETENTION_DAYS: RetentionPolicy = {
  debug: 3,
  info: 14,
  warn: 30,
  error: 90,
  fatal: 180,
};

export const DAY_MS = 24 * 60 * 60 * 1000;
export const REDACTED = "[REDACTED]";
export const SENSITIVE_KEYS = ["email", "phone", "mobile", "idcard", "id_card", "address", "token", "password"];

const PII_PATTERNS: RegExp[] = [
  /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
  /(?<!\d)\d{17}[\dXx](?!\d)/g,
  /(?<!\d)1[3-9]\d{9}(?!\d)/g,
  /(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)/g,
];

export function stableHash(input: string): number {
  let hash = 0x811c9dc5;
  for (let i = 0; i < input.length; i += 1) {
    hash ^= input.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash >>> 0;
}

export function sampleDecision(
  key: string,
  severity: Severity,
  rates: Record<Severity, number> = DEFAULT_RATES,
): SampleDecision {
  const rate = rates[severity];
  if (rate >= 1) {
    return { keep: true, rate: 1, reason: "always" };
  }
  if (rate <= 0) {
    return { keep: false, rate: 0, reason: "always" };
  }
  return { keep: stableHash(key) % 10000 < Math.round(rate * 10000), rate, reason: "rate" };
}

export function keepEvent(event: TelemetryEvent, rates: Record<Severity, number> = DEFAULT_RATES): boolean {
  return sampleDecision(event.id, event.severity, rates).keep;
}

export function samplingReport(
  events: TelemetryEvent[],
  rates: Record<Severity, number> = DEFAULT_RATES,
): SamplingReport {
  const bySeverity = {} as Record<Severity, { kept: number; dropped: number }>;
  for (const severity of SEVERITIES) {
    bySeverity[severity] = { kept: 0, dropped: 0 };
  }
  let kept = 0;
  let dropped = 0;
  for (const event of events) {
    if (keepEvent(event, rates)) {
      bySeverity[event.severity].kept += 1;
      kept += 1;
    } else {
      bySeverity[event.severity].dropped += 1;
      dropped += 1;
    }
  }
  return { total: events.length, kept, dropped, bySeverity };
}

export function redactText(text: string): string {
  let output = text;
  for (const pattern of PII_PATTERNS) {
    output = output.replace(pattern, REDACTED);
  }
  return output;
}

function sensitiveKey(key: string): boolean {
  const normalized = key.toLowerCase().replace(/[^a-z0-9_]/g, "");
  return SENSITIVE_KEYS.some((candidate) => normalized.includes(candidate));
}

export function redactAttributes(attributes: Record<string, string>): Record<string, string> {
  const output: Record<string, string> = {};
  for (const key of Object.keys(attributes)) {
    output[key] = sensitiveKey(key) ? REDACTED : redactText(attributes[key]);
  }
  return output;
}

export function redactEvent(event: TelemetryEvent): TelemetryEvent {
  const copy: TelemetryEvent = {
    id: event.id,
    name: redactText(event.name),
    severity: event.severity,
    timestamp: event.timestamp,
  };
  if (event.attributes) {
    copy.attributes = redactAttributes(event.attributes);
  }
  return copy;
}

export function estimateBytes(event: TelemetryEvent): number {
  return Buffer.byteLength(JSON.stringify(event), "utf8");
}

export function estimateCost(events: TelemetryEvent[], config: CostConfig): CostEstimate {
  const bytes = events.reduce((sum, event) => sum + estimateBytes(event), 0);
  const scaledBytes = bytes * (config.scaleFactor ?? 1);
  const gigabytes = scaledBytes / (1024 * 1024 * 1024);
  return { events: events.length, bytes: scaledBytes, gigabytes, cost: gigabytes * config.pricePerGb };
}

export function retentionDays(severity: Severity, policy: RetentionPolicy = DEFAULT_RETENTION_DAYS): number {
  return policy[severity];
}

export function expiresAt(event: TelemetryEvent, policy: RetentionPolicy = DEFAULT_RETENTION_DAYS): number {
  return event.timestamp + retentionDays(event.severity, policy) * DAY_MS;
}

export function isExpired(event: TelemetryEvent, now: number, policy: RetentionPolicy = DEFAULT_RETENTION_DAYS): boolean {
  return now >= expiresAt(event, policy);
}

export function partitionByRetention(
  events: TelemetryEvent[],
  now: number,
  policy: RetentionPolicy = DEFAULT_RETENTION_DAYS,
): RetentionPartition {
  const kept: TelemetryEvent[] = [];
  const expired: TelemetryEvent[] = [];
  for (const event of events) {
    (isExpired(event, now, policy) ? expired : kept).push(event);
  }
  return { kept, expired };
}

export function bytesBySeverity(events: TelemetryEvent[]): Record<Severity, number> {
  const out = {} as Record<Severity, number>;
  for (const severity of SEVERITIES) {
    out[severity] = 0;
  }
  for (const event of events) {
    out[event.severity] += estimateBytes(event);
  }
  return out;
}

stableHash 是 FNV-1a 的 32 位实现,同一个 key 永远落在同一个桶里。sampleDecision 把比率换算成万分比,bucket < rate * 10000 就保留。这样有三个直接好处:

  • 同一会话、同一事件 id 的决策稳定,重试不会一会儿保留一会儿丢弃。
  • 不同端可以用同一个 key 规则,服务端能按同样口径复算。
  • 采样率调整后,只有跨越新边界的 key 会改变,不会全量抖动。

error 与 fatal 的比率是 1,函数直接返回 reason: "always",不进入哈希计算。这不是性能优化,而是语义:这两类事件不允许被采样丢弃。

脱敏放在上报之前

redactText、redactAttributes、redactEvent 三个函数都返回新对象,不修改入参。redactEvent 先处理 name,再处理属性。属性分两步:键名敏感的直接替换,其余走正则。顺序不能反——如果先跑正则,user_email 这种键名不会被识别,但它的值可能恰好不是标准邮箱格式,于是漏掉。

不修改入参是硬要求。上报 SDK 经常在脱敏之后继续用原对象做本地缓存或调试输出,如果函数原地修改,本地缓存也会被污染,排查时看到的是 [REDACTED],反而丢失证据。

脱敏也有边界:正则只能覆盖常见格式,不能保证 100%。真正的兜底应该在接收端再做一次,并且对疑似 PII 的字段做抽样审计。客户端脱敏的目标是“不让明文离开设备”,不是“替代服务端治理”。

成本估算的口径

成本估算只需要三个数:样本字节数、放大倍数、单价。estimateBytes 把单条事件序列化成 UTF-8 字节,estimateCost 先汇总样本字节,再乘以 scaleFactor,最后按 pricePerGb 换算成金额。

举例说明口径,不代表任何厂商报价:假设抽样得到 5000 条事件,合计 4.25 MB,实际日量为样本的 200 倍,则日字节约 850 MB;按 0.4 元/GB 的假设单价,日成本约 0.33 元,30 天约 10 元。这个数字很小,但它把“日志很便宜”变成了可复核的算式——把日量换成 1.6 TB,结论立刻反过来。

估算有三个必须写清楚的假设:

  • scaleFactor 是样本到全量的放大倍数,不是采样率的倒数本身;两者混淆会差一个数量级。
  • 单价通常按压缩后或未压缩字节计价,不同厂商口径不同,必须注明。
  • 保留天数影响的是存储成本,不是 ingest 成本;两者要分开算。

保留策略与删除

保留策略按等级配置,expiresAt 计算到期时间,isExpired 判断是否过期,partitionByRetention 把一批事件拆成保留与过期两组。

这里有两个容易踩的边界:

  • 用事件时间,不用入库时间。 客户端可能离线缓存几天再上报,用入库时间会让老事件“续命”。
  • 到期判定用 >=。 恰好到期的时刻就应该算过期,否则边界事件会多留一个调度周期。

partitionByRetention 返回保留与过期两个数组,调用方可以据此做删除、归档或降采样。删除必须是幂等的:同一批事件重复处理,结果不变。

测试与真实输出

✔ 默认采样率随等级单调不减 (0.575958ms)
✔ error 与 fatal 全量保留 (0.402875ms)
✔ 同一 key 的决策稳定 (0.142583ms)
✔ stableHash 对同一输入返回同一结果 (0.072625ms)
✔ debug 采样率约为百分之一 (1.22175ms)
✔ 采样率为零时全部丢弃 (0.093041ms)
✔ 采样率为一时全部保留且标记 always (0.055542ms)
✔ 采样统计守恒且按等级分桶 (0.114666ms)
✔ 邮箱与手机号被脱敏 (0.266042ms)
✔ 身份证号被脱敏 (0.108209ms)
✔ IPv4 地址被脱敏 (0.059458ms)
✔ 敏感键名直接整体脱敏 (0.109041ms)
✔ 脱敏不修改原对象 (0.246875ms)
✔ redactEvent 同时清理名称与属性 (0.064291ms)
✔ 字节估算为正且随字段增长 (0.086667ms)
✔ 成本估算按缩放因子线性增长 (0.084875ms)
✔ 成本估算与千兆字节换算一致 (0.051166ms)
✔ 默认保留天数按等级递减 (0.049125ms)
✔ 到期时间按事件时间加保留天数 (0.047666ms)
✔ 到期判定在边界时刻生效 (0.04925ms)
✔ 保留策略拆分保留与过期事件 (0.1325ms)
✔ 自定义保留策略覆盖默认值 (0.043583ms)
✔ 按等级统计字节数覆盖全部等级 (0.065666ms)
ℹ tests 23
ℹ suites 0
ℹ pass 23
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 136.694416

23 个用例覆盖了四组行为:采样决策的稳定性与分布、脱敏的字段覆盖与不可变性、成本换算的线性关系、保留策略的边界。其中“debug 采样率约为百分之一”用 5000 个 key 做分布检查,允许 20 到 90 个命中;这个区间足够宽,不会因为哈希实现的微小差异变成随机失败,又能抓住“采样率写错一个数量级”的问题。

判据与反例

判断采样与脱敏是否可用,看五条:

判据通过标准失败表现
决策稳定同一 key 多次调用结果一致同一会话时断时续
等级有序error、fatal 不采样关键失败被丢弃
脱敏前置明文不离开设备抓包能看到手机号
成本可复算字节 × 倍数 × 单价只能看账单猜
保留可执行到期自动删除且幂等数据永久堆积

五个反例:

反例一:用随机数采样。 Math.random() < rate 每次调用都不同,重试会产生不同决策。必须用稳定的 key 做哈希。

反例二:先采样再脱敏。 如果采样 key 本身包含邮箱或手机号,哈希值会泄露可枚举信息。先脱敏,再生成采样 key,或者用 sessionId 这类无业务含义的标识。

反例三:只脱敏邮箱。 手机号、身份证、地址、token 都是 PII。按字段类型建规则,而不是按当前工单里出现的类型补丁。

反例四:把采样率当成放大倍数。 10% 采样不意味着乘以 10 就能还原总量:如果采样本身有偏,放大只会放大偏差。估算可以乘,结论必须注明置信区间。

反例五:保留策略只写在文档里。 没有定时任务、没有删除审计,策略就是一句口号。到期删除要有日志、有幂等、有回滚预案。

小结

采样、脱敏与成本不是三个独立模块,而是一条链:先定等级,再定采样率与保留天数,最后用字节数把成本算出来。

  • 采样必须确定性,error 与 fatal 不参与采样。
  • 脱敏按字段类型与键名双轨执行,且不修改入参。
  • 成本估算要写清放大倍数、单价口径与压缩口径。
  • 保留策略用事件时间,到期删除要幂等。

把这三件事写进同一份配置,团队才能在“看得见”和“付得起”之间做有依据的取舍。

阅读导航:上一节:7.1 日志、指标与追踪的最小契约 · 下一节:7.3 从告警到定位的闭环 。

常见错误与边界

  • 采样率按用户而不是按事件。 按用户采样适合分析行为路径,按事件采样适合统计总量。两者混用会让漏斗和总量对不上,必须先声明口径。
  • 脱敏后仍保留原字段长度。 用等长掩码或哈希前缀,仍可能被反推。直接整体替换,不保留长度信息。
  • 把 sessionId 当匿名标识。 如果 sessionId 能关联到账号,它本身就是准标识符。跨系统共享前要评估可关联性。
  • 成本估算忽略属性膨胀。 一条事件的固定字段可能只有 200 字节,属性可以轻松到 2 KB。按 P95 字节数估算,不要用平均值。
  • 保留策略一刀切。 所有等级保留 90 天,既浪费存储,又让高价值事件和低价值事件混在一起,删除时无法分级。
  • 脱敏规则写死在客户端。 规则需要随合规要求更新,写死在发版代码里会导致旧版本长期漏脱敏。规则应可下发,并有版本号。
  • 采样后不做人数校正。 用采样数据算 UV、转化率时,必须按采样率加权,否则会系统性低估。
  • 忽略离线补传。 客户端离线缓存后集中补传,会在短时间内形成流量尖峰。补传要有速率限制与单独的采样策略。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查