召回活动带来的“一万二千回流”
沉睡用户召回上线两周后,运营看板给出了一个漂亮的数字:活动带来 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 天。长窗口会把很久以前的触点和今天的自然行为强行关联。
- 用客户端时间归因。设备时间可改,事件可能延迟上报,口径会漂移。
- 同一时刻的触点按输入顺序取。并发和重跑会得到不同结果,报表无法复现。
- 无触点时返回错误。调用方被迫把自然量归给某个默认活动,增量计算失真。
- 把“入队成功”当成“送达”。厂商限流、设备离线都会让实际送达远低于入队数。
- 不做对照组也不设基线。没有自然量估计,任何活动看起来都有效果。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。