《互联网产品的客户端架构·交易、增长与平台化》12.3 漏斗与归因的端上边界

端上漏斗只能回答「用户在我这里走到哪一步」,不能替服务端宣布支付或退款成功。本节定义步骤、同会话同订单、归因窗口三个口径,把能算与不能算的指标写成边界表,并给出一个按会话串联、跨会话断开、服务端事件单独计数的可运行漏斗模块,并说明对账时如何解释差异。

端上漏斗最常见的三个错

「回声」的端上漏斗曾经给出 12% 的支付转化率,服务端订单表只有 9%。产品先怀疑支付渠道,再怀疑埋点丢失,最后发现端上把三件不同的事算进了同一个分母:

  • 曝光在组件渲染时上报,预渲染和折叠区域里的商品也被算作「看过」。
  • 支付回跳就算支付成功,没有等待服务端确认。
  • 用户换了一次会话,端上仍然把前后两段拼成一条旅程。

三个错误都指向同一件事:漏斗不是「按事件名计数」,而是先定义口径,再决定哪些事件可以进入端上计算。

步骤、会话、订单:三个口径必须先定

口径要回答的问题端上的默认做法
步骤哪几个动作构成一条漏斗曝光 → 点击 → 加购 → 提交 → 支付
会话什么算同一次访问同一 sessionId,跨会话不拼接
订单订单步骤归属哪笔订单同一会话只允许一个 orderId,多个则整段排除
窗口两步之间最多隔多久相邻步骤 30 分钟,超时断开
来源哪些事件能进端上口径只算 client,服务端事件单独计数

这张表里,订单口径最容易被低估。用户在同一会话里先下了一单未支付,又重新下了一单并支付,端上如果把两个 orderId 混在一起,就会得到一条不存在的「提交到支付」路径。正确的做法不是猜,而是把这段会话标记为「订单不唯一」,交给服务端按订单维度重算。

端上能算与不能算

端上漏斗的边界,不是「技术能不能拿到」,而是「这个结论由谁负责」。下面这张表是本节的核心:

指标端上口径说明
曝光可算只统计真正可见的曝光
点击可算按会话去重
加购可算与服务端购物车对账
发起提交可算只代表用户点了提交
提交成功只能估算必须等待服务端回执
支付成功不能算以渠道与服务端确认结果为准
退款不能算退款是服务端事实
GMV不能算金额必须由订单汇总

「提交成功只能估算」是最容易被写成事实的一项。端上收到 200 响应,不代表订单已经落库;服务端可能因为库存、风控、幂等冲突返回一个业务失败。端上可以展示「提交完成」,但在报表里必须标成估算,等订单接口确认后再转成事实。

一个可运行的端上漏斗

下面的模块属于 L1:输入是事件数组与配置,输出是每步会话数、转化率和排除原因。它不读系统时间、不发请求,所有时间都来自事件本身。

// file: src/analytics/funnel.ts
// tier: L1
// verify: node --test test/analytics/funnel.test.ts

export type FunnelEventName =
  | "trade.product_impression"
  | "trade.product_click"
  | "trade.cart_add"
  | "trade.order_submit"
  | "trade.order_pay";

export type EventSource = "client" | "server";

export interface FunnelEvent {
  eventId: string;
  name: FunnelEventName;
  sessionId: string;
  orderId: string | null;
  occurredAt: number;
  source: EventSource;
}

export interface FunnelStep {
  key: string;
  eventName: FunnelEventName;
  requireOrder: boolean;
}

export interface FunnelConfig {
  steps: FunnelStep[];
  windowMs: number;
}

export interface StepResult {
  key: string;
  sessions: number;
  conversionFromPrev: number | null;
  conversionFromStart: number;
}

export interface FunnelResult {
  sessions: number;
  steps: StepResult[];
  excluded: {
    serverTruth: number;
    outsideWindow: number;
    ambiguousOrder: number;
  };
}

export const DEFAULT_STEPS: FunnelStep[] = [
  { key: "impression", eventName: "trade.product_impression", requireOrder: false },
  { key: "click", eventName: "trade.product_click", requireOrder: false },
  { key: "cart", eventName: "trade.cart_add", requireOrder: false },
  { key: "submit", eventName: "trade.order_submit", requireOrder: true },
  { key: "pay", eventName: "trade.order_pay", requireOrder: true },
];

export const DEFAULT_CONFIG: FunnelConfig = {
  steps: DEFAULT_STEPS,
  windowMs: 30 * 60_000,
};

function ratio(part: number, whole: number): number | null {
  if (whole === 0) return null;
  return Math.round((part / whole) * 10_000) / 10_000;
}

export function buildFunnel(
  events: FunnelEvent[],
  config: FunnelConfig = DEFAULT_CONFIG,
): FunnelResult {
  if (config.steps.length === 0) throw new Error("漏斗至少需要一个步骤");
  if (!Number.isInteger(config.windowMs) || config.windowMs <= 0) {
    throw new Error("归因窗口必须是正整数毫秒");
  }
  const bySession = new Map<string, FunnelEvent[]>();
  for (const event of events) {
    const list = bySession.get(event.sessionId);
    if (list === undefined) bySession.set(event.sessionId, [event]);
    else list.push(event);
  }
  const counts = config.steps.map(() => 0);
  const excluded = { serverTruth: 0, outsideWindow: 0, ambiguousOrder: 0 };
  let sessions = 0;

  for (const list of bySession.values()) {
    const clientEvents = list.filter((event) => event.source === "client");
    excluded.serverTruth += list.length - clientEvents.length;
    if (clientEvents.length === 0) continue;

    const orderIds = new Set(
      clientEvents
        .map((event) => event.orderId)
        .filter((id): id is string => id !== null && id !== ""),
    );
    if (orderIds.size > 1) {
      excluded.ambiguousOrder += 1;
      continue;
    }
    const orderId = orderIds.size === 1 ? [...orderIds][0] : null;
    const sorted = [...clientEvents].sort(
      (a, b) => a.occurredAt - b.occurredAt || a.eventId.localeCompare(b.eventId),
    );

    let cursor = -Infinity;
    let anchor: number | null = null;
    let matched = 0;
    for (let i = 0; i < config.steps.length; i += 1) {
      const step = config.steps[i];
      const candidate = sorted.find(
        (event) =>
          event.name === step.eventName &&
          event.occurredAt >= cursor &&
          (!step.requireOrder || (orderId !== null && event.orderId === orderId)),
      );
      if (candidate === undefined) break;
      if (anchor !== null && candidate.occurredAt - anchor > config.windowMs) {
        excluded.outsideWindow += 1;
        break;
      }
      matched += 1;
      counts[i] += 1;
      cursor = candidate.occurredAt;
      anchor = candidate.occurredAt;
    }
    if (matched > 0) sessions += 1;
  }

  const start = counts[0];
  return {
    sessions,
    steps: config.steps.map((step, i) => ({
      key: step.key,
      sessions: counts[i],
      conversionFromPrev: i === 0 ? null : ratio(counts[i], counts[i - 1]),
      conversionFromStart: ratio(counts[i], start) ?? 0,
    })),
    excluded,
  };
}

export type Metric =
  | "impression"
  | "click"
  | "add_to_cart"
  | "submit_started"
  | "submit_succeeded"
  | "paid"
  | "refunded"
  | "gmv";

export type ComputeTier = "client" | "client_estimate" | "server_truth";

export interface MetricBoundary {
  tier: ComputeTier;
  caveat: string;
}

const BOUNDARIES: Record<Metric, MetricBoundary> = {
  impression: { tier: "client", caveat: "只统计可见曝光,避免预渲染撑大分母" },
  click: { tier: "client", caveat: "端上直接记录,需按会话去重" },
  add_to_cart: { tier: "client", caveat: "端上可算,但要与服务端购物车对账" },
  submit_started: { tier: "client", caveat: "只代表发起提交,不代表下单成功" },
  submit_succeeded: {
    tier: "client_estimate",
    caveat: "必须等待服务端回执,端上只能估算",
  },
  paid: { tier: "server_truth", caveat: "以支付渠道与服务端确认结果为准" },
  refunded: { tier: "server_truth", caveat: "退款是服务端事实,端上不能自行判定" },
  gmv: { tier: "server_truth", caveat: "金额口径必须由服务端订单汇总" },
};

export function metricBoundary(metric: Metric): MetricBoundary {
  return { ...BOUNDARIES[metric] };
}

export function clientMayCompute(metric: Metric): boolean {
  return BOUNDARIES[metric].tier === "client";
}

export function needsServerTruth(metric: Metric): boolean {
  return BOUNDARIES[metric].tier === "server_truth";
}

算法有四个关键动作。

按 sessionId 分组。 不同会话的同名事件不会被拼在一起。即使用户是同一设备,端上也没有可靠身份把两次会话合并,交给服务端做身份归一更合适。

先把服务端事件挑出来。 source === "server" 的事件计入 excluded.serverTruth,但不进入端上漏斗。这不是丢弃,而是明确「这部分由另一套口径负责」。

订单号不唯一就整段排除。 同一会话出现两个及以上 orderId 时,端上无法判断哪个订单对应哪次支付,整段会话不进入漏斗,计入 ambiguousOrder。

顺序匹配加窗口。 每一步只找「时间不早于上一步」的第一个候选;相邻两步超过 windowMs 就断开,并计入 outsideWindow。没有候选不算超窗,只算正常掉队。

测试与真实输出

本机运行结果如下:

$ node --test test/analytics/funnel.test.ts
✔ 完整链路在默认五步漏斗中逐级计数 (1.114834ms)
✔ 同一会话的重复事件只计一次 (0.105334ms)
✔ 未走到下一步的会话在对应步骤掉队 (0.095083ms)
✔ 跨会话的事件不会被串成同一条漏斗 (0.090709ms)
✔ 同一会话出现多个订单号时整段排除 (0.071375ms)
✔ 订单步骤要求与前面步骤使用同一个订单号 (0.068959ms)
✔ 订单步骤遇到空订单号时不匹配 (0.074709ms)
✔ 超过归因窗口的下一步被断开 (0.070583ms)
✔ 刚好落在窗口边界内的事件仍可归因 (0.075125ms)
✔ 服务端事件不进入端上漏斗但单独计数 (0.114458ms)
✔ 没有候选下一步时不计入窗口外排除 (0.062875ms)
✔ 转化率同时给出环比与相对首步 (0.081917ms)
✔ 没有事件时所有计数为零且不除零 (0.054292ms)
✔ 空步骤与非法窗口被拒绝 (0.222542ms)
✔ 支付与退款属于服务端事实 (0.079834ms)
✔ 提交成功只是端上估算,不能当事实 (0.047958ms)
✔ 曝光、点击与加购可由端上直接计算 (0.040458ms)
✔ 默认漏斗只覆盖到支付,不把退款算作转化 (0.044625ms)
ℹ tests 18
ℹ suites 0
ℹ pass 18
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 170.310917

十八条用例覆盖:完整链路、重复事件、逐级掉队、跨会话、订单不唯一、同订单匹配、空订单号、超窗、窗口边界、服务端事件、无候选、两类转化率、空输入、非法配置、支付与退款边界、提交估算、端上可算指标、默认步骤。

一次把「服务端支付成功」算进端上漏斗的复盘

某交易客户端的漏斗配置里,支付步骤同时接受 trade.order_pay 和一条服务端回调事件。端上团队的理由很充分:回调事件也带 sessionId,看起来能拼上。

上线后支付转化率明显高于订单表。复盘发现两个问题:

  1. 服务端回调的 sessionId 来自支付会话,与浏览会话不是同一个值,端上却按 sessionId 分组,导致一部分支付被算进了错误的浏览路径。
  2. 回调可能重复到达,端上没有按 eventId 去重,同一笔支付被计了两次。

修复做了三件事:漏斗只接受 source === "client" 的事件;服务端事件单独进入对账报表;支付步骤改用「端上发起支付」而不是「服务端确认支付」,并在展示时明确标注这是端上口径。

回归后,端上漏斗与服务端订单表的差距回到可解释范围。团队留下一条约定:端上漏斗回答「用户走到了哪一步」,服务端报表回答「交易是否成立」,两者可以对照,不能互相替代。

端上漏斗与服务端报表如何对账

对账不是要求两个数字相等,而是要求差异能被解释。常见的三类差异:

  • 时间差:端上在回跳时计数,服务端在回调时计数,中间可能隔几十秒。
  • 身份差:端上按会话,服务端按用户或订单,跨设备行为只在服务端能合并。
  • 事实差:端上把「提交成功」当估算,服务端可能因风控或库存拒绝。

健康的做法是给每个差异写一个预期区间和原因,而不是每次都对不上就改口径。口径一旦为了「对齐」而频繁调整,历史数据就失去了可比性。

小结

端上漏斗先定口径再计数:步骤、会话、订单、窗口、来源。只算客户端事件,同会话只允许一个订单号,相邻步骤受归因窗口约束,服务端事件单独计数。

buildFunnel 把这三个约束写成代码,metricBoundary 把「端上能算、只能估算、不能算」写成查表。支付、退款、GMV 永远属于服务端事实,端上只能展示,不能宣布。

下一节进入第四部分的收尾:把选品、下单、支付、退款串成一条端到端链路,检查前面各章的约束是否真的接得上。

阅读导航:上一节:12.2 上报队列、离线缓存与去重 · 下一节:13.1 端到端:选品、下单、支付、退款 。

常见错误与边界

  • 曝光在渲染时上报,预渲染与折叠区域把漏斗分母撑大。
  • 把支付回跳当成支付成功,端上漏斗与订单表长期对不上。
  • 跨会话按设备号硬拼旅程,用户换设备后路径被错误合并。
  • 同一会话多个订单号仍然计数,得到一条不存在的提交到支付路径。
  • 归因窗口不设上限,三天前的曝光与今天的支付被算成一次转化。
  • 服务端事件直接混入端上漏斗,同一笔支付被重复计数。
  • 把「提交成功」当事实,忽略服务端业务失败与幂等冲突。
  • 为了让端上数字对齐服务端而反复改口径,历史数据失去可比性。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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