《互联网产品的服务端架构·交易、增长与平台化》9.2 召回链路与转化归因

沉睡用户召回上线后,看板显示推送活动带来一万二千回流,预算继续加投;扣除自然回流后,真实增量不到三成。文章说明触达、点击、转化为什么要分开记,归因窗口与最后触点怎样定义,自然量如何识别,并给出 Attribute 的 Go 实现、边界测试与真实输出。

召回活动带来的“一万二千回流”

沉睡用户召回上线两周后,运营看板给出了一个漂亮的数字:活动带来 12,400 名回流用户(案例假设值),其中短信渠道贡献 41%。运营准备把下季度预算继续向短信倾斜,数据同学却被要求先复算一遍。

复算发现三个问题。第一,归因把活动期间所有重新活跃的用户都算成了活动效果,没有扣除本来就会回来的自然量;第二,同一用户在同一天既点了推送又点了短信,两条触点分别计数,人数被重复放大;第三,归因窗口设成了 30 天,活动结束第三天才回来的用户也被算在活动头上。

按用户去重、扣除自然回流基线后,真实增量只有 2,600 人左右。不是活动没效果,而是看板把“回流”和“活动带来的回流”混成了一个指标。

触达、点击、转化要分开记

召回链路至少有三个阶段,每个阶段的事件、口径和负责人都不一样。把它们混在一张表里,就会出现“触达一万人、转化一万二千人”这种明显矛盾的数字。

阶段事件服务端记录常见错误
触达消息已提交给通道用户、渠道、活动、时间把“入队”当成“送达”
点击用户打开落地页用户、活动、触点 ID、时间用短链总数代替去重人数
转化用户完成目标行为用户、订单或行为 ID、时间把自然行为也算给活动
自然量无触点也发生的回流同期对照或历史基线完全不扣,活动效果虚高

触达数回答“我们发了多少”,点击数回答“有多少人愿意看”,转化数回答“有多少人真的做了”。三者之间是漏斗关系,但分子分母必须按同一口径去重。推送厂商回执的“送达”也可能把厂商侧的失败重试算进去,服务端要以自己的消息 ID 为准,不能用厂商报表直接当分母。

归因窗口回答的是“一次转化可以追溯多久以内的触点”。窗口太长,会把用户自己的回访算给很久以前的活动;窗口太短,又会让长决策周期的品类漏掉真实贡献。窗口内取最近一次触点,是最后触点模型;它简单、可解释,但对“先看短信、后点推送”的组合会低估前者的作用。选择模型之前,先保证事件记录完整,否则换什么模型都只是换一种错法。

自然量必须单独估计。常见做法是留一小部分同质用户不触达作为对照组,或者用活动前的同期回流率做基线。没有对照组时,至少要把“无任何触点的回流”单独统计出来,不能默认它为零。

渠道维度也要拆开。推送、短信、站内弹窗的成本和打扰程度不同,不能共用一个“活动转化率”。同一活动在三个渠道同时投放时,至少要有渠道级的分母:该渠道实际送达多少人、其中多少人点击、多少人转化。否则短信的高触达会掩盖推送的低点击,预算分配失去依据。

事件模型还要保留触点 ID 和活动 ID。触点 ID 用于去重和排查单条消息,活动 ID 用于跨渠道汇总。只有用户 ID 和时间,无法回答“这个用户是被哪一条消息打动的”;只有活动 ID,又无法处理同一活动下的多次投放。三个字段缺一不可。

失败场景:把自然回流算成活动效果

某次召回活动覆盖了 30 天未登录的用户。活动期间正好有一次版本更新,更新后大量用户重新打开 App 领取新功能奖励。看板把这次更新带来的回流全部算给了短信活动,短信渠道的“转化率”从平时的 3% 涨到 11%(案例假设值)。

运营据此把短信预算翻倍。下一轮活动没有版本更新,同样的短信模板、同样的受众,转化率跌回 2.8%,增量几乎为零。预算已经花出去,复盘时才发现上一轮的高转化主要来自版本更新,而不是短信。

第二个变种是重复计数。用户在一天内收到推送、点了短信、又自己搜索进入 App,三条路径都记录了转化。按触点统计,一次转化变成三次;按用户统计,只有一个转化。如果看板按触点汇总,渠道之间会互相抢功,预算分配随之失真。

短链统计是另一个常见陷阱。运营为了看点击,给每个渠道生成不同短链,但短链服务只记录跳转次数,不记录用户。同一用户反复点开、微信预览自动抓取、安全扫描器访问,都会让点击数虚高。点击必须以登录态或设备指纹去重,并且区分“人”和“请求”。

第三个变种是窗口漂移。客户端上报时间、服务端接收时间、渠道回执时间可能相差几分钟到几小时。归因用哪个时间,要在事件模型里固定。用客户端时间容易被改机;用回执时间又会把点击算到触达之后很久。统一用服务端接收时间,并在事件里保留原始时间,是更稳妥的折中。

还有一种更常见的错误:把归因窗口当成固定的产品配置,所有品类共用 7 天。低频高客单的品类决策周期可能是几周,7 天窗口会把真实贡献截断;高频低客单的品类 7 天又太长,用户自己回来的比例很高。窗口应该按品类和渠道分别配置,并且和对照组一起评估。

Go 实现:Attribute 只回答最后一次触点

Attribute 不负责去重、扣自然量或计算增量,它只回答一个问题:在给定窗口内,这个用户最后一次触点属于哪个活动。把这个问题做确定,上层才可能把漏斗算对。

// file: internal/reach/attribution.go
// tier: L1
// verify: go test ./internal/reach/

package reach

import (
	"errors"
	"fmt"
)

// ErrInvalidWindow 表示归因窗口不是正数。窗口为 0 或负数时,任何“最近
// 一次触点”都没有意义,必须让调用方显式修正配置。
var ErrInvalidWindow = errors.New("reach: attribution window must be positive")

// Touch 是一次营销触点,例如推送、短信、弹窗或优惠券曝光。At 是服务端
// 收到触点事件的时间,Campaign 是活动标识。同一次活动可能对同一用户产生
// 多条触点,归因只关心窗口内最近的一条。
type Touch struct {
	UserID   string
	Campaign string
	At       int64
}

// Attribute 返回 userID 在归因窗口内的最后一次触点所属活动。窗口是闭区间
// [now-windowSeconds, now]:恰好落在窗口起点和 now 上的触点都算,未来时间
// 的触点不算。
//
// 同一时刻有多条触点时,取 Campaign 字典序最小的一条,保证结果与输入顺序
// 无关、可复现。窗口内没有触点时返回空字符串和 nil 错误,调用方据此把
// 回流记为自然量,而不是硬塞给某个活动。
func Attribute(touches []Touch, userID string, now, windowSeconds int64) (string, error) {
	if windowSeconds <= 0 {
		return "", fmt.Errorf("%w: %d", ErrInvalidWindow, windowSeconds)
	}

	since := now - windowSeconds
	bestCampaign := ""
	var bestAt int64
	found := false

	for _, t := range touches {
		if t.UserID != userID {
			continue
		}
		if t.At < since || t.At > now {
			continue
		}
		switch {
		case !found:
		case t.At > bestAt:
		case t.At == bestAt && t.Campaign < bestCampaign:
		default:
			continue
		}
		bestCampaign = t.Campaign
		bestAt = t.At
		found = true
	}

	if !found {
		return "", nil
	}
	return bestCampaign, nil
}

实现里有几个关键点:

  • 窗口必须是正数。windowSeconds<=0 直接返回 ErrInvalidWindow,而不是悄悄返回空字符串,因为配置错误和“没有触点”是两种完全不同的情况。

  • 窗口是闭区间 [now-windowSeconds, now]:恰好落在起点和 now 上的触点都算,未来时间的触点不算。这里的边界和 9.3 的频控窗口刻意不同,调用方不能混用。

  • 只处理 UserID 匹配的触点,其他用户的数据不影响结果。

  • 选择最近一次触点;同一时刻有多条时取 Campaign 字典序最小的一条。这样结果与输入顺序无关,重跑和并发都能复现。

  • 没有命中时返回空字符串和 nil 错误。空字符串表示自然量,调用方不应把它当成“归因失败”。

  • 函数不修改 touches,也不返回切片,调用方可以安全地把它用在只读快照上。

  • 返回的 Campaign 是活动标识,不是展示文案。活动结束后文案可能改版,归因结果必须仍然能对上预算系统里的活动编号。

  • 窗口计算使用 since := now - windowSeconds。如果上游传入的时间单位是毫秒而这里是秒,窗口会大一千倍;时间单位必须在接口文档里写死,并在写入触点时统一转换。

最后触点模型有一个明确边界:它不判断触点是否真的被用户看到。推送入队、短信提交、弹窗曝光都可能记录成触点,但用户可能根本没打开。要衡量真实影响,还需要曝光和点击事件,以及对照组。

另一个边界是跨设备。用户在手机上点开推送,最后在平板上完成下单。如果转化事件只带设备 ID,归因会丢失;必须落到登录用户 ID 上,并允许匿名阶段的触点通过登录后合并。合并规则要幂等,否则同一次点击会被合并多次,转化数再次虚高。

测试:窗口边界、同刻并列与自然量

归因测试要盯住四个语义:最近一次、窗口闭区间、同刻字典序、无触点即自然量。这些条件任何一个写反,看板都会给出看似合理但错误的数字。

// file: internal/reach/attribution_test.go
// tier: L1
// verify: go test ./internal/reach/

package reach

import (
	"errors"
	"testing"
)

func TestAttributePicksLatestTouch(t *testing.T) {
	touches := []Touch{
		{UserID: "u1", Campaign: "push-old", At: 900},
		{UserID: "u1", Campaign: "push-new", At: 980},
		{UserID: "u2", Campaign: "other", At: 990},
	}
	got, err := Attribute(touches, "u1", 1000, 120)
	if err != nil {
		t.Fatalf("Attribute = %v, want nil", err)
	}
	if got != "push-new" {
		t.Fatalf("Attribute = %q; want push-new", got)
	}
}

func TestAttributeWindowIsClosed(t *testing.T) {
	touches := []Touch{
		{UserID: "u1", Campaign: "outside", At: 879},
		{UserID: "u1", Campaign: "start", At: 880},
		{UserID: "u1", Campaign: "now", At: 1000},
	}
	got, err := Attribute(touches, "u1", 1000, 120)
	if err != nil {
		t.Fatalf("Attribute = %v, want nil", err)
	}
	if got != "now" {
		t.Fatalf("Attribute = %q; want now", got)
	}

	onlyStart := []Touch{{UserID: "u1", Campaign: "start", At: 880}}
	got, err = Attribute(onlyStart, "u1", 1000, 120)
	if err != nil || got != "start" {
		t.Fatalf("start boundary = %q, %v; want start, nil", got, err)
	}
}

func TestAttributeIgnoresFutureTouch(t *testing.T) {
	touches := []Touch{
		{UserID: "u1", Campaign: "past", At: 990},
		{UserID: "u1", Campaign: "future", At: 1001},
	}
	got, err := Attribute(touches, "u1", 1000, 120)
	if err != nil {
		t.Fatalf("Attribute = %v, want nil", err)
	}
	if got != "past" {
		t.Fatalf("Attribute = %q; want past", got)
	}
}

func TestAttributeTieBreaksByCampaign(t *testing.T) {
	touches := []Touch{
		{UserID: "u1", Campaign: "sms-b", At: 990},
		{UserID: "u1", Campaign: "sms-a", At: 990},
		{UserID: "u1", Campaign: "push-z", At: 990},
	}
	got, err := Attribute(touches, "u1", 1000, 120)
	if err != nil {
		t.Fatalf("Attribute = %v, want nil", err)
	}
	if got != "push-z" {
		t.Fatalf("Attribute = %q; want push-z (lexicographically smallest)", got)
	}
}

func TestAttributeNoTouchIsNatural(t *testing.T) {
	got, err := Attribute(nil, "u1", 1000, 120)
	if err != nil {
		t.Fatalf("Attribute = %v, want nil", err)
	}
	if got != "" {
		t.Fatalf("Attribute = %q; want empty for natural traffic", got)
	}
}

func TestAttributeRejectsBadWindow(t *testing.T) {
	for _, w := range []int64{0, -1} {
		if _, err := Attribute(nil, "u1", 1000, w); !errors.Is(err, ErrInvalidWindow) {
			t.Fatalf("window=%d err = %v; want ErrInvalidWindow", w, err)
		}
	}
}

TestAttributePicksLatestTouch 用两条同用户触点验证取最近一次,同时放入另一用户的数据确认不会串号。TestAttributeWindowIsClosed 把触点放在窗口起点和 now 上,确认两端都包含;再单独验证只有起点触点时仍能归因。TestAttributeIgnoresFutureTouch 放入一条 At > now 的脏数据,确认未来触点不会覆盖真实历史。

TestAttributeTieBreaksByCampaign 让三条触点同一时刻,期望 push-z,因为它在三个活动名里字典序最小。这个断言固定了“结果与输入顺序无关”。TestAttributeNoTouchIsNatural 验证无触点返回空字符串和 nil。TestAttributeRejectsBadWindow 覆盖 0 和负数窗口,要求返回 ErrInvalidWindow。

这些测试只覆盖单用户的纯函数。真实链路还要在写入触点时保证幂等:同一个 Touch 因重试被写两次,归因结果不能变化。因为 Attribute 取的是最近一次和字典序,重复触点不会改变答案,但会让上游的触点计数虚高。幂等键应该由活动 ID、用户 ID、消息 ID 和时间共同组成。

运行命令与真实输出

在 webapp-lab 目录执行:

go test ./internal/reach/ -run '^TestAttribute' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestAttributePicksLatestTouch
--- PASS: TestAttributePicksLatestTouch (0.00s)
=== RUN   TestAttributeWindowIsClosed
--- PASS: TestAttributeWindowIsClosed (0.00s)
=== RUN   TestAttributeIgnoresFutureTouch
--- PASS: TestAttributeIgnoresFutureTouch (0.00s)
=== RUN   TestAttributeTieBreaksByCampaign
--- PASS: TestAttributeTieBreaksByCampaign (0.00s)
=== RUN   TestAttributeNoTouchIsNatural
--- PASS: TestAttributeNoTouchIsNatural (0.00s)
=== RUN   TestAttributeRejectsBadWindow
--- PASS: TestAttributeRejectsBadWindow (0.00s)
PASS
ok  	webapp/internal/reach	0.240s

六条用例全部通过。它们保证单次归因的确定性;去重、对照组和增量计算属于分析层,需要单独的口径文档和数据管道。

小结

召回链路要把触达、点击、转化和自然量分开记录,归因窗口与最后触点要写死,窗口内没有触点时返回空字符串而不是硬塞一个活动。只有这样,预算才会流向真正带来增量的渠道。

Attribute 只做窗口内的最近触点选择,同刻用字典序兜底,未来时间忽略。下一节讨论另一条更硬的约束:用户退订之后,系统必须真的停止发送。

归因结果最终要能解释给运营听:这个用户为什么算给短信,而不是推送;这个增量为什么不是自然回流。如果回答不了,报表数字再漂亮也不能作为加预算的依据。

阅读导航:上一节:9.1 人群圈选与触达节奏 · 下一节:9.3 退订、频控与合规 。

常见错误与边界

  • 把“活动期间回流”直接当成“活动带来回流”。版本更新、节假日和自然回访都会混进来,增量会被高估。
  • 按触点汇总转化。同一用户的多条路径被重复计数,渠道之间互相抢功。
  • 归因窗口随手设成 30 天。长窗口会把很久以前的触点和今天的自然行为强行关联。
  • 用客户端时间归因。设备时间可改,事件可能延迟上报,口径会漂移。
  • 同一时刻的触点按输入顺序取。并发和重跑会得到不同结果,报表无法复现。
  • 无触点时返回错误。调用方被迫把自然量归给某个默认活动,增量计算失真。
  • 把“入队成功”当成“送达”。厂商限流、设备离线都会让实际送达远低于入队数。
  • 不做对照组也不设基线。没有自然量估计,任何活动看起来都有效果。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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