窗口回答的是「哪次点击有资格」
7.1 把点击、安装、激活、回传连成了一条记录,但没有回答一个前置问题:玩家可能在一周内点过五个广告、看过三次开屏,最后装了游戏,哪一次算数?归因窗口就是这条资格线。
案例里两款产品使用同一套窗口设定:点击后 7 天、浏览后 1 天。点击是主动行为,玩家看过素材并点了进来,记忆和意图更强,给 7 天;浏览只是素材曝光,没有交互,给 1 天。超过窗口的点击不是「无效点击」,它仍然计费,只是不再具备把安装记到自己名下的资格。
窗口不是越长越好。把点击窗口从 7 天继续拉长,会有更多安装被归到买量,CPI 立刻变好看,ROI 曲线也提前;代价是归因结果来得更晚,等更晚的点击被匹配时,对应的投放早就结束了,优化动作只能事后复盘。窗口是一种「用决策时效换归因覆盖率」的交易,不是单纯的参数。
四条判定规则
窗口判定看起来只是比较两个时间戳,实际有四条规则,顺序不能乱:
| 顺序 | 规则 | 判定 | 处理 |
|---|---|---|---|
| 1 | 触点时间晚于安装时间 | future | 拒绝,通常是时钟漂移或伪造 |
| 2 | 按触点类型取窗口 | 点击 7 天、浏览 1 天 | 不能用同一个窗口一刀切 |
| 3 | 年龄恰好等于窗口 | in-window | 接受;边界取闭区间 |
| 4 | 年龄大于窗口 | too-old | 拒绝,但保留在 rejected 列表 |
第一条必须先判。若触点时间在未来,年龄是负数,直接拿去和窗口比较会永远落在窗口内。时钟漂移在移动端很常见:设备时区错误、系统时间被手动修改、服务端与客户端时钟不同步,都会制造未来时间。把 future 单独一个 code,才能和「过期」区分开。
第三条的闭区间是刻意选择。ageMs > windowMs 才拒绝,意味着恰好 7 天整仍然有效。如果写成 >=,跨时区、跨秒级截断的边界样本会随机丢失,而且这种丢失很难复现。边界规则要写死在一个函数里,不能让每个渠道各自实现一遍。
第四条要求保留 rejected 列表,而不是简单丢弃。运营需要知道「有多少点击因为过期没算」,这个比例突然上升,通常意味着渠道延迟回传、客户端时钟异常或窗口配置被改动。
多触点:最后触点与首次触点
一次安装可能有多条合格触点。选哪一条,是归因模型,不是窗口规则:
| 模型 | 规则 | 适合回答的问题 | 偏向 |
|---|---|---|---|
| 最后触点 | 取窗口内时间最晚的触点 | 哪次投放促成了这次安装 | 效果广告、临门一脚 |
| 首次触点 | 取窗口内时间最早的触点 | 哪个渠道最先带来这个用户 | 品牌曝光、拉新归因 |
本卷案例按最后触点结算,首次触点只作为观察口径并行计算。这样做的原因是买量预算按渠道分配,渠道需要看到「最后一次说服玩家」的功劳;如果按首次触点结算,品牌广告会拿走大量功劳,效果渠道的 ROI 被压低,预算分配会失真。
同一毫秒的并列需要确定性的 tie-break。代码里的规则是:时间相同时点击优先于浏览,类型也相同时按 touch_id 字典序。这不是业务真理,而是为了让重放结果稳定。没有确定的 tie-break,同一批数据跑两次可能给出不同归属,对账时无法解释。
窗口外的触点不参与选择,但也不应该被静默丢弃。Selection 里同时返回 eligibleCount 和 rejected,前者说明这次归属有多少候选,后者说明有多少条因为过期或未来时间被排除。只看最终 channel,无法判断这个结果是「唯一候选」还是「五个候选里的最后一个」。
去重三层,缺一层就会重复记账
归因链路上有三种不同的重复,必须分别处理:
| 层 | 键 | 重复来源 | 处理 |
|---|---|---|---|
| 点击层 | click_id | 广告平台重试、落地页刷新 | 同一 click_id 只保留最早一条 |
| 安装层 | install_id 或设备键 | 重装、商店重复通知 | 重装单独记账,不产生新安装归因 |
| 事件层 | install_id + event_id | 回传超时重试、服务重启 | 值相同记 duplicate,值不同记 conflict |
三层里最容易漏的是事件层。很多实现只用 event_id 去重,理由是「事件 id 全局唯一」;但客户端生成的 id 可能只在本机唯一,两个安装完全可能撞出同一个字符串。键里必须带 install_id,才能把不同安装的同名事件分开。
冲突比重复更值得警惕。同一个 install_id + event_id 到达两次,事件名或金额却不同,通常意味着三种情况之一:客户端重试时改了金额、服务端两次读取了不同的订单状态、或者有人在篡改回传。无论哪一种,都不能用后到的值覆盖先到的值,而应该保留两条证据并告警。
案例里的重复量并不小。《潮汐消消》的埋点事件重复率是 0.8%,《潮汐方舟》是 0.4%。消消每月 42 万笔订单、方舟 15 万笔订单,事件量只会比订单更大,重复事件不是边角料;没有事件层去重,回传收入会被系统性地高估。
隐私限制:标识降级是一条决策树
窗口和去重都假设「能看见标识」。现实是标识的可得性在持续下降:用户拒绝追踪、系统限制广告标识、平台只提供聚合结果。归因系统必须把「用哪种标识」做成一条显式决策,而不是在代码里到处写 if (id) ... else ...。
案例里的决策顺序是:
| 条件 | 可用模式 | 结果用途 |
|---|---|---|
| 用户明确拒绝 | none | 不做个体归因,只保留自然量统计 |
| 需要同意但状态未知 | aggregated | 只看聚合报表,不回传个体事件 |
| 设备标识可用 | device-id | 正常归因与回传 |
| 标识受限且允许指纹 | fingerprint | 低置信度归因,只进观察通道 |
| 标识受限且不允许指纹 | aggregated | 聚合归因,不进入单条结算 |
| 标识不可用且不允许聚合 | none | 放弃归因,记录原因 |
这条决策树有两个刻意的保守点。第一,用户拒绝后不做任何个体归因,即使指纹在技术上仍能猜出设备;合规风险高于归因收益。第二,指纹永远不进入结算通道,只作为观察值,原因在 7.1 已经说过:误配会把自然量和买量互相污染。
聚合口径不是「退而求其次」的废数据。平台级聚合结果能回答「这个渠道本周带来多少安装、多少收入」,只是不能回答「哪一条安装属于哪一次点击」。用它校准总量、发现渠道异常足够,用它做单用户召回或客服查询不够。系统要明确区分这两种用途,不能让聚合数据悄悄流进个体结算。
双案例:标识可得性的差异
《潮汐消消》买量占 45%、国内安卓占 55%,设备标识可得率相对高,但弱联网下的时钟漂移更多,future 判定要把设备时间与服务端时间都记下来。
《潮汐方舟》iOS 占 45%、买量占 65%,标识受限的比例更高,聚合口径承担了更大的校准职责;它的付费率高,单条归因错误的金额影响也更大。
把窗口、去重与隐私写成可运行的规则
同一个决策树,消消更多落在 device-id,方舟更多落在 aggregated。这不是实现差异,而是平台结构差异,不能用一套覆盖率指标横向比较两款产品,也不能靠一套硬编码的分支同时服务两者。
下面三个函数分别对应本节的三块规则。窗口判定按触点类型取不同长度,触点选择支持最后触点与首次触点,去重按 install_id 与 event_id 组合,标识决策按同意状态与标识可得性逐级降级。
// file: src/biz/attribution-window.ts
// tier: L1
// verify: node --test test/biz/attribution-window.test.ts
export type TouchKind = "click" | "view";
export type ChannelId = "adnet-alpha" | "adnet-beta" | "adnet-gamma";
export type AttributionModel = "last-touch" | "first-touch";
export interface Touch {
readonly touchId: string;
readonly channel: ChannelId;
readonly kind: TouchKind;
readonly touchedAtMs: number;
}
export interface WindowPolicy {
readonly clickWindowMs: number;
readonly viewWindowMs: number;
readonly model: AttributionModel;
}
export type WindowCode = "in-window" | "too-old" | "future";
export interface WindowCheck {
readonly code: WindowCode;
readonly ageMs: number;
readonly windowMs: number;
}
export function checkTouch(touch: Touch, installAtMs: number, policy: WindowPolicy): WindowCheck {
const windowMs = touch.kind === "click" ? policy.clickWindowMs : policy.viewWindowMs;
const ageMs = installAtMs - touch.touchedAtMs;
if (ageMs < 0) {
return { code: "future", ageMs, windowMs };
}
if (ageMs > windowMs) {
return { code: "too-old", ageMs, windowMs };
}
return { code: "in-window", ageMs, windowMs };
}
export interface RejectedTouch {
readonly touchId: string;
readonly code: WindowCode;
}
export interface Selection {
readonly status: "attributed" | "unattributed";
readonly touch: Touch | null;
readonly eligibleCount: number;
readonly rejected: readonly RejectedTouch[];
readonly detail: string;
}
function isBetter(candidate: Touch, current: Touch, model: AttributionModel): boolean {
if (candidate.touchedAtMs !== current.touchedAtMs) {
return model === "last-touch"
? candidate.touchedAtMs > current.touchedAtMs
: candidate.touchedAtMs < current.touchedAtMs;
}
if (candidate.kind !== current.kind) {
return candidate.kind === "click";
}
return candidate.touchId < current.touchId;
}
export function selectTouch(
touches: readonly Touch[],
installAtMs: number,
policy: WindowPolicy,
): Selection {
const eligible: Touch[] = [];
const rejected: RejectedTouch[] = [];
for (const touch of touches) {
const check = checkTouch(touch, installAtMs, policy);
if (check.code === "in-window") {
eligible.push(touch);
} else {
rejected.push({ touchId: touch.touchId, code: check.code });
}
}
if (eligible.length === 0) {
return {
status: "unattributed",
touch: null,
eligibleCount: 0,
rejected,
detail: `${rejected.length} touch(es) outside the window`,
};
}
let touch = eligible[0] as Touch;
for (const candidate of eligible) {
if (isBetter(candidate, touch, policy.model)) {
touch = candidate;
}
}
return {
status: "attributed",
touch: touch ?? null,
eligibleCount: eligible.length,
rejected,
detail: `${policy.model} picked ${touch?.touchId ?? "-"} among ${eligible.length} eligible`,
};
}
export type EventName = "install" | "activate" | "purchase";
export interface PostbackEvent {
readonly eventId: string;
readonly installId: string;
readonly eventName: EventName;
readonly valueCents: number;
readonly receivedAtMs: number;
}
export type DedupeCode = "accepted" | "duplicate" | "conflict";
export interface DedupeResult {
readonly code: DedupeCode;
readonly events: readonly PostbackEvent[];
readonly detail: string;
}
export function dedupePostback(
seen: readonly PostbackEvent[],
incoming: PostbackEvent,
): DedupeResult {
const key = `${incoming.installId}:${incoming.eventId}`;
const existing = seen.find(
(event) => `${event.installId}:${event.eventId}` === key,
);
if (existing === undefined) {
return { code: "accepted", events: [...seen, incoming], detail: `${key} accepted` };
}
if (existing.eventName === incoming.eventName && existing.valueCents === incoming.valueCents) {
return { code: "duplicate", events: seen, detail: `${key} already recorded` };
}
return {
code: "conflict",
events: seen,
detail: `${key} arrived with a different event name or value`,
};
}
export type ConsentState = "granted" | "denied" | "unknown";
export type IdState = "available" | "limited" | "unavailable";
export type IdentifierMode = "device-id" | "fingerprint" | "aggregated" | "none";
export interface PrivacyPolicy {
readonly requireConsent: boolean;
readonly allowFingerprint: boolean;
readonly allowAggregated: boolean;
}
export interface IdentifierDecision {
readonly mode: IdentifierMode;
readonly allowed: boolean;
readonly reason: string;
}
export function resolveIdentifier(
consent: ConsentState,
ids: IdState,
policy: PrivacyPolicy,
): IdentifierDecision {
if (consent === "denied") {
return { mode: "none", allowed: false, reason: "consent denied" };
}
if (policy.requireConsent && consent === "unknown") {
return policy.allowAggregated
? { mode: "aggregated", allowed: true, reason: "consent unknown, aggregated only" }
: { mode: "none", allowed: false, reason: "consent unknown and aggregation disabled" };
}
if (ids === "available") {
return { mode: "device-id", allowed: true, reason: "device identifier available" };
}
if (ids === "limited") {
if (policy.allowFingerprint) {
return { mode: "fingerprint", allowed: true, reason: "identifier limited, fingerprint fallback" };
}
return policy.allowAggregated
? { mode: "aggregated", allowed: true, reason: "identifier limited, aggregated only" }
: { mode: "none", allowed: false, reason: "identifier limited and fallbacks disabled" };
}
return policy.allowAggregated
? { mode: "aggregated", allowed: true, reason: "identifier unavailable, aggregated only" }
: { mode: "none", allowed: false, reason: "identifier unavailable and aggregation disabled" };
}
export function formatSelection(selection: Selection): string {
const touch = selection.touch;
if (touch === null) {
return `unattributed rejected=${selection.rejected.length}`;
}
return `${selection.status}:${touch.channel}:${touch.kind} eligible=${selection.eligibleCount}`;
}
代码里的判定顺序
checkTouch 先判 ageMs < 0 得到 future,再比较窗口,边界用 > 而不是 >=。窗口长度由 kind 决定,调用方不能传一个统一值进来,避免把浏览当成点击。
selectTouch 先过滤出合格触点,再用 isBetter 逐个比较。最后触点比较时间更大,首次触点比较时间更小;时间相同时点击优先,类型相同按 touch_id 字典序。这个比较函数是纯的,因此同一批输入永远得到同一个结果。
dedupePostback 的键是 install_id:event_id。新键返回 accepted 并追加;同键同值返回 duplicate 且不改变集合;同键不同值返回 conflict 且保留原记录。三种结果都不抛异常,因为重复和冲突是回传链路的正常输入。
resolveIdentifier 是一条短路决策树。同意被拒绝时直接返回 none,不再看标识状态;需要同意但状态未知时,只在允许聚合的前提下返回 aggregated;标识可用才返回 device-id;受限时依次尝试指纹和聚合;都不可用则返回 none。每一步都带 reason,便于统计「为什么这条安装没有归因」。
formatSelection 只输出一行稳定文本,用于日志与排查。它不参与结算,也不应该被当成归因结果本身。
取舍:窗口越长,报表越好看,决策越慢
把点击窗口继续拉长,最直接的效果是归因安装变多、CPI 变低、D7 ROI 变高。这三个变化都会让投放看起来更健康,但它们来自口径变化,不是产品变好。更隐蔽的代价是反馈延迟:更晚的点击要等到安装后才会被匹配,优化系统拿到的信号永远是滞后的。
案例选择 7 天,是在覆盖率和时效之间取了一个中间值。它牺牲了一部分「长决策周期」的安装:玩家看了广告、两周后才下载,这条安装会被记成自然量。补偿方式是看平台侧聚合报表和周维度的自然量趋势,而不是把窗口继续拉长。
去重也有代价。严格按 install_id + event_id 拒绝冲突,会让一些真实但字段被改写的回传被挡住;如果业务上确实存在「同一事件金额合法变更」的场景,应该建模成新的事件 id,而不是放宽去重键。隐私决策树同样有代价:拒绝个体归因会损失一部分可归因收入,但换来的是合规确定性和可解释的审计记录。
小结
- 窗口是资格线,不是效果放大器;案例统一为点击 7 天、浏览 1 天,边界取闭区间。
- 判定顺序必须是未来时间、触点类型、边界、过期;未来时间不先判,会被误认为在窗口内。
- 最后触点适合效果结算,首次触点适合观察拉新;并列时要有确定性 tie-break。
- 去重分点击、安装、事件三层,事件层的键必须带 install_id,冲突要留证而不是覆盖。
- 隐私限制下按同意状态与标识可得性逐级降级,指纹只进观察通道,聚合结果只用于校准。
阅读导航:上一节:7.1 归因链路:点击、安装、激活与事件回传 · 下一节:7.3 买量 ROI 与回收周期 。
常见错误与边界
- 点击和浏览共用一个窗口。浏览没有交互意图,用 7 天窗口会把大量曝光算成功劳;必须按触点类型分别取窗口。
- 窗口边界用大于等于。恰好落在 7 天整的样本会被判为过期,跨时区时尤其难复现;应固定为闭区间并写在一处。
- 不先判未来时间。负年龄会永远落在窗口内,伪造或时钟错误会被当成有效触点。
- 丢弃窗口外触点而不记录。看不到过期比例,就无法发现渠道延迟或配置被改。
- 多点触控只保留最终渠道。没有 eligibleCount 和 rejected,无法复核对账,也无法切换归因模型。
- tie-break 不确定。同一批数据两次运行得到不同归属,对账时无法解释;必须有稳定的排序规则。
- 只用 event_id 去重。不同安装可能撞出相同 id,必须把 install_id 拼进键。
- 冲突时用后到的值覆盖。金额被改写或订单状态不一致会被静默接受;应保留两条证据并告警。
- 用户拒绝后仍用指纹归因。技术可行不等于合规可行;拒绝应直接落到
none。 - 把聚合数据用于个体结算。聚合口径只能校准总量,不能回答单条安装归属;混用会制造无法复核的账。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。