三个症状:少了、多了、对不上
10.1 定义了事件长什么样,10.2 定义了怎么用它算漏斗。这一节回答一个更基础的问题:当报表和账单对不上时,怎么判断问题出在哪。
数据质量问题最终只会表现为三种症状:
- 少了:事件没有到达,或者到达了但没有进入正式表。
- 多了:同一件事被记录了多次,或者一条事件被拆成了多条。
- 对不上:数量没错,但两边对同一个字段的理解不同。
三种症状的排查路径完全不同。丢失要看链路,重复要看幂等,口径漂移要看 schema 与版本。把它们统称为「数据不准」,只会让排查停在互相怀疑。
丢失:链路上有四段
一条事件从产生到可查询,至少经过四段链路,每一段都可能丢:
| 链路 | 失败方式 | 典型信号 |
|---|---|---|
| 客户端缓冲 | 进程被杀、缓冲区满、磁盘写入失败 | 启动后首屏事件缺失 |
| 网络传输 | 弱网、超时、DNS 失败、证书错误 | 某个地区或运营商整体偏低 |
| 接入网关 | 限流、鉴权失败、字段超长被拒 | 429/4xx 集中在某个版本 |
| 入库 | 表结构不匹配、分区写满、任务失败 | 整批延迟,而非零散缺失 |
《潮汐消消》的埋点丢失率是 2.5%,弱网是主要原因;《潮汐方舟》是 1.2%,强联网让传输段更稳定。两个数字都来自案例设定,不代表所有产品;重点是丢失率必须按链路拆开,否则无法定位。
客户端缓冲最常见的错误是「只在退出时上报」。移动端进程随时可能被系统回收,退出回调并不保证执行。更稳的做法是事件产生即写入本地队列,按批发送,发送成功后再删除;队列要有上限,超限时丢弃最旧的非关键事件,而不是让整个队列阻塞支付。
网络段的丢失不能靠客户端单方面解决。重试要有退避和上限,否则弱网下会把网关打垮;重试的事件要带稳定的事件 id,否则服务端无法区分「重试」和「新事件」。10.1 里的 eventId 就是为这一段准备的。
入库段的丢失通常不是「丢」,而是「延迟」。分区写满、任务重跑、表结构变更都会让数据晚到几小时甚至一天。监控如果只看「今天有没有数据」,会把延迟误判为丢失;应该同时看「按事件时间分区的到达延迟」。
重复:至少一次投递的必然结果
只要客户端会重试、消息队列会重投、离线补发会重放,重复就是必然的。区别只在于重复率高低:消消 0.8%,方舟 0.4%。
重复有三种来源:
- 客户端重试:请求超时但服务端其实已收到,客户端再次发送。
- 队列重投:消费者处理失败或确认超时,消息被重新投递。
- 离线补发:用户重新联网后,本地队列整体重放。
三种来源都指向同一个解法:幂等键。事件 id 由稳定输入哈希得到,服务端在写入前按 id 去重;去重窗口要覆盖最长的重试与补发周期,而不是只覆盖几分钟。
只靠「事件名 + 用户 + 时间戳」去重是不够的。同一毫秒内可能真的发生两次相同事件,例如连续两次广告奖励到账;用时间戳做键会把合法事件误杀。稳定 id 应该把序号或业务主键纳入哈希,10.1 的实现把 sequence 放进了输入。
去重也有代价。服务端要为每个 id 保留一段时间的记录,存储成本随事件量和窗口线性增长;窗口太短会漏掉延迟补发,窗口太长会推高成本。案例里的选择是「按渠道设定窗口」:弱网渠道覆盖更长的补发周期,强网渠道缩短窗口。
口径漂移:同名字段不同含义
比丢失和重复更隐蔽的是口径漂移:字段名没变,含义变了;事件名没变,触发时机变了;版本号没变,枚举多了一个值。这类问题不会让数据变少或变多,只会让报表在某个时间点后悄悄转向。
常见的漂移场景:
| 场景 | 表现 | 后果 |
|---|---|---|
| 字段单位变化 | amount 从元变成分 | 流水放大 100 倍 |
| 枚举含义变化 | channel=other 从「未知」变成「聚合渠道」 | 渠道占比失真 |
| 触发时机变化 | 曝光从「可见」变成「渲染」 | 曝光量虚高 |
| 必填改可选 | 新版本开始漏传 order_id | 订单漏斗断链 |
| 版本未升 | schema 改了但 version 没变 | 下游无法分辨新旧数据 |
检测漂移不能只看字段是否存在,还要看类型、必填性和版本。最有效的手段是对比两份契约:一份是线上正在消费的基线,一份是即将发布的候选;任何删除、类型变化、必填新增都属于破坏性变更。
版本号是漂移检测的锚点。如果 schema 改了但版本没升,下游会把两套语义当成同一套,任何按版本分组的检查都会失效。所以 10.1 把「改结构必须升版本」写成硬规则,这一节把它变成可执行的检测。
对账:埋点不是账本
商业化数据的最终裁判不是埋点,而是渠道账单和订单系统。埋点负责解释行为,账单负责确认收入。两者的差异要能被量化,而不是靠「大概差不多」。
对账的基本形式是:以账单为基准,计算埋点或报表的相对偏差,落在容差内算通过,超出则分方向处理。
- 埋点偏高:通常是重复、测试数据、退款未回冲。
- 埋点偏低:通常是丢失、延迟、渠道回调未接入。
- 两边都偏离账单:先查账单口径,例如是否含税、是否含渠道分成。
容差不是一个固定数字,而是按渠道、按币种、按结算周期设定。Apple App Store 与 Google Play 的账单粒度不同,国内安卓渠道的对账周期也不同;用同一个容差套所有渠道,只会得到一堆无意义的告警。
消消月内购流水约 609 万元、方舟约 2,730 万元,量级差异意味着同一个绝对误差对两者的意义完全不同。对账必须用相对偏差,并且分母固定为账单金额,不能一会儿用账单、一会儿用埋点。
可运行的质量检查器
下面的实现覆盖四件事:丢失率、重复率、对账偏差、契约漂移。所有函数都对非法输入返回错误码,而不是静默算出看似合理的数字。
// file: src/biz/data-quality.ts
// tier: L1
// verify: node --test test/biz/data-quality.test.ts
export type FieldType = "string" | "number" | "boolean";
export interface Contract {
readonly name: string;
readonly version: number;
readonly required: readonly string[];
readonly fields: Readonly<Record<string, FieldType>>;
}
export type MeasureCode =
| "bad-sent"
| "received-exceeds-sent"
| "bad-total"
| "distinct-exceeds-total"
| "bad-amount"
| "bad-tolerance";
export type Measure =
| { readonly ok: true; readonly value: number }
| { readonly ok: false; readonly code: MeasureCode; readonly detail: string };
export type ReconcileStatus = "within" | "over" | "under";
export type ReconcileResult =
| {
readonly ok: true;
readonly diffCents: number;
readonly diffPct: number;
readonly status: ReconcileStatus;
readonly tolerancePct: number;
}
| { readonly ok: false; readonly code: MeasureCode; readonly detail: string };
export type DriftKind =
| "name-changed"
| "field-removed"
| "required-added"
| "type-changed"
| "field-added"
| "version-not-bumped";
export interface Drift {
readonly kind: DriftKind;
readonly field: string;
readonly breaking: boolean;
readonly detail: string;
}
export interface DriftResult {
readonly ok: boolean;
readonly drifts: readonly Drift[];
}
export const CASE_QUALITY: Readonly<
Record<"xiaoxiao" | "fangzhou", { readonly lossPct: number; readonly duplicatePct: number; readonly events: number }>
> = {
xiaoxiao: { lossPct: 2.5, duplicatePct: 0.8, events: 38 },
fangzhou: { lossPct: 1.2, duplicatePct: 0.4, events: 46 },
};
export function lossRatePct(sent: number, received: number): Measure {
if (!Number.isInteger(sent) || sent <= 0) {
return { ok: false, code: "bad-sent", detail: `${sent} is not a positive batch size` };
}
if (!Number.isInteger(received) || received < 0) {
return { ok: false, code: "bad-sent", detail: `${received} is not a received count` };
}
if (received > sent) {
return {
ok: false,
code: "received-exceeds-sent",
detail: `received ${received} exceeds sent ${sent}; count duplicates before computing loss`,
};
}
return { ok: true, value: round4(((sent - received) / sent) * 100) };
}
export function duplicateRatePct(total: number, distinct: number): Measure {
if (!Number.isInteger(total) || total <= 0) {
return { ok: false, code: "bad-total", detail: `${total} is not a positive total` };
}
if (!Number.isInteger(distinct) || distinct < 0) {
return { ok: false, code: "bad-total", detail: `${distinct} is not a distinct count` };
}
if (distinct > total) {
return {
ok: false,
code: "distinct-exceeds-total",
detail: `distinct ${distinct} exceeds total ${total}`,
};
}
return { ok: true, value: round4(((total - distinct) / total) * 100) };
}
export function reconcileCents(
ledgerCents: number,
reportedCents: number,
tolerancePct: number,
): ReconcileResult {
if (!Number.isFinite(ledgerCents) || ledgerCents <= 0) {
return { ok: false, code: "bad-amount", detail: `ledger ${ledgerCents} must be positive` };
}
if (!Number.isFinite(reportedCents) || reportedCents < 0) {
return { ok: false, code: "bad-amount", detail: `reported ${reportedCents} must be non-negative` };
}
if (!Number.isFinite(tolerancePct) || tolerancePct < 0) {
return { ok: false, code: "bad-tolerance", detail: `tolerance ${tolerancePct} must be non-negative` };
}
const diffCents = Math.round(reportedCents - ledgerCents);
const diffPct = round4((diffCents / ledgerCents) * 100);
if (Math.abs(diffPct) <= tolerancePct) {
return { ok: true, diffCents, diffPct, status: "within", tolerancePct };
}
return {
ok: true,
diffCents,
diffPct,
status: diffPct > 0 ? "over" : "under",
tolerancePct,
};
}
export function detectDrift(baseline: Contract, candidate: Contract): DriftResult {
const drifts: Drift[] = [];
if (baseline.name !== candidate.name) {
drifts.push({
kind: "name-changed",
field: "name",
breaking: true,
detail: `${baseline.name} became ${candidate.name}`,
});
}
for (const [field, type] of Object.entries(baseline.fields)) {
const next = candidate.fields[field];
if (next === undefined) {
drifts.push({
kind: "field-removed",
field,
breaking: true,
detail: `${field} disappeared from v${candidate.version}`,
});
continue;
}
if (next !== type) {
drifts.push({
kind: "type-changed",
field,
breaking: true,
detail: `${field} changed from ${type} to ${next}`,
});
}
}
for (const field of Object.keys(candidate.fields)) {
if (baseline.fields[field] === undefined) {
drifts.push({
kind: "field-added",
field,
breaking: false,
detail: `${field} is new in v${candidate.version}`,
});
}
}
for (const field of candidate.required) {
if (!baseline.required.includes(field)) {
drifts.push({
kind: "required-added",
field,
breaking: true,
detail: `${field} became required in v${candidate.version}`,
});
}
}
if (drifts.length > 0 && candidate.version <= baseline.version) {
drifts.push({
kind: "version-not-bumped",
field: "version",
breaking: true,
detail: `schema changed but version stayed at ${candidate.version}`,
});
}
return { ok: drifts.every((drift) => !drift.breaking), drifts };
}
export function formatQualityReport(
label: string,
loss: Measure,
duplicate: Measure,
reconcile: ReconcileResult,
): string {
const lossText = loss.ok ? `${loss.value}%` : `error:${loss.code}`;
const duplicateText = duplicate.ok ? `${duplicate.value}%` : `error:${duplicate.code}`;
const reconcileText = reconcile.ok
? `${reconcile.status}(${reconcile.diffPct > 0 ? "+" : ""}${reconcile.diffPct}%)`
: `error:${reconcile.code}`;
return `quality ${label} loss=${lossText} dup=${duplicateText} reconcile=${reconcileText}`;
}
function round4(value: number): number {
return Math.round(value * 10000) / 10000;
}
代码里有几个刻意的设计。
第一,lossRatePct 拒绝「收到数大于发送数」。这种情况通常意味着重复没有先去重,如果直接算丢失率会得到负数,进一步掩盖重复问题。
第二,duplicateRatePct 用「总数减唯一数」做分子,而不是用「重复条数」做分子。两种算法在重复次数分布不均时会得到不同结果,团队必须固定一种,并在报表里注明。
第三,reconcileCents 以账单为基准,偏差带方向。within、over、under 三个状态对应三种排查路径,比一个布尔值更有用;容差边界算通过,避免浮点误差导致临界值反复告警。
第四,detectDrift 把漂移分成破坏性与非破坏性。删除字段、改类型、新增必填、改事件名都是破坏性的;新增可选字段是非破坏性的。只要存在结构变化而版本号没升,就追加 version-not-bumped。
第五,formatQualityReport 只输出一行,方便放进日志和告警。它不隐藏错误码:输入非法时显示 error:bad-sent 这类信息,而不是显示 0%。
双案例:弱网与强网的差异
| 维度 | 消消 | 方舟 | 质量策略含义 |
|---|---|---|---|
| 商业化事件数 | 38 个 | 46 个 | 方舟链路长,漂移面更大 |
| 埋点丢失率 | 2.5% | 1.2% | 消消优先补发,方舟优先校验 |
| 事件重复率 | 0.8% | 0.4% | 消消的去重窗口要覆盖更长补发 |
| 月订单 | 42 万笔 | 15 万笔 | 消消的重复绝对量更大 |
| 掉单率 | 0.35% | 0.20% | 消消的支付链路损耗更高 |
| 退款率 | 1.2% | 0.8% | 消消的退款回冲更频繁 |
消消是弱联网、混合变现、国内安卓占比 55%,事件丢失和重复都更高;方舟是强联网、内购为主、iOS 占比 45%,链路更稳定,但单笔金额更高,一次口径漂移造成的金额误差更大。
因此两边的质量门禁不能共用一套阈值。消消应该把「丢失率突增」作为主告警,容忍更高的重复率;方舟应该把「对账偏差」作为主告警,因为它的订单少、单笔大,一笔错账就足以触发人工核查。
质量门禁:阈值、告警与隔离
质量检查的结果要能阻止坏数据进入正式报表,而不只是发一条消息。建议把门禁分三层:
- 接入层:字段缺失、类型错误、PII 直接拒绝,进入隔离表。
- 聚合层:按小时计算丢失率、重复率、对账偏差,超过阈值暂停当批数据入正式表。
- 报表层:对已入表数据做口径校验,发现漂移时给报表打标,而不是直接改写历史。
三层的职责不同。接入层保证单条事件合格,聚合层保证批次可信,报表层保证历史可解释。把三层合成一层,通常会导致「为了不影响报表而放宽校验」,最后门禁形同虚设。
阈值要按产品、渠道、事件类别分别设定。消消的广告曝光事件在弱网下丢失 2.5% 是可接受的,但支付成功事件丢失 0.1% 就必须告警;方舟的订单事件少,任何一笔缺失都值得追查。用统一阈值会让关键事件被平均数掩盖。
取舍:补发提升完整率,但放大重复
解决丢失最直接的手段是补发:客户端把未确认的事件留在本地,下次启动或联网时重放。补发能显著提升完整率,但会把重复率推高,因为「服务端已收到但确认丢失」的事件会被再次发送。
案例里的折中是「补发 + 幂等」:
- 所有可补发事件都带稳定 id,服务端按 id 去重。
- 补发队列有上限和过期时间,超过 7 天的事件不再补发,避免历史数据污染当前口径。
- 支付、发货等关键事件优先补发,曝光类事件可以丢弃。
- 去重窗口按渠道设定,弱网渠道覆盖更长的补发周期。
代价是服务端要维护去重状态,客户端要维护本地队列,两边都增加了复杂度。收益是丢失率下降,同时重复率可控。如果没有幂等,补发只会把「少了」变成「多了」,问题并没有消失,只是换了一个症状。
小结
- 数据质量只有三种症状:少了、多了、对不上;分别对应链路、幂等和口径。
- 丢失要按客户端缓冲、网络、网关、入库四段拆开,不能只看总数。
- 重复是至少一次投递的必然结果,稳定事件 id 与足够长的去重窗口是基础。
- 口径漂移靠契约对比与版本号检测,删除、改类型、新增必填都是破坏性变更。
- 对账以账单为基准、用相对偏差、按渠道设容差;补发必须与幂等同时上线。
阅读导航:上一节:10.2 漏斗、留存与付费转化 · 下一节:11.1 推送通道与到达率 。
常见错误与边界
- 把丢失、重复、漂移统称为「数据不准」。三类问题排查路径完全不同,混在一起只会互相怀疑;必须先分症状。
- 只在退出时上报事件。移动端进程随时被回收,退出回调不保证执行;应产生即入队、批量发送。
- 重试不带稳定事件 id。服务端无法区分重试与新事件,重复率会随弱网程度上升;id 必须由稳定输入哈希。
- 用时间戳做去重键。同一毫秒内的两次合法事件会被误杀;去重键要包含序号或业务主键。
- 去重窗口只覆盖几分钟。离线补发会在几小时后重放,短窗口等于没有去重;窗口要覆盖最长补发周期。
- 丢失率算出负数仍继续。收到数大于发送数说明重复未处理,直接算会掩盖问题;应拒绝并先查重复。
- schema 改了不升版本。下游把两套语义当成一套,按版本分组的检查全部失效;结构变更必须升版本。
- 只检查字段是否存在。类型变化、必填变可选同样会破坏下游;漂移检测要覆盖类型与必填性。
- 用同一个容差套所有渠道。账单粒度和结算周期不同,统一容差会产生大量无效告警;容差应按渠道设定。
- 对账用绝对误差。消消与方舟的流水量级差近三倍,绝对误差不可比;必须用相对偏差并固定分母。
- 补发没有上限和过期时间。历史事件重放会污染当前口径,补发队列必须有容量与时效。
- 门禁只告警不阻断。坏数据进入正式表后再修,成本远高于在聚合层暂停;关键指标必须能阻断。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。