《互联网产品的服务端架构·交易、增长与平台化》7.1 邀请关系与归因

邀请活动最容易出的问题不是奖励太少,而是关系方向不清:A 邀请 B、B 又邀请 A,发奖任务在环里打转。文章拆解单向绑定、归因窗口、最后点击与深度上限,给出 Bind 与 Attribution 的 Go 实现、环检测测试和一次真实运行输出,并以案例假设值说明预算如何在无人察觉时被循环领走。

一个邀请码,把发奖任务卡死了

「刚好」做了一个邀请拉新活动:老用户把邀请码发给朋友,朋友注册并完成首单后,双方各得一张 20 元券。活动预算 30 万元,预期两周用完。这些数字是案例假设值,用于说明结构,不是线上实测。

上线第五天,财务发现券的消耗速度是预期的三倍,但新增用户并没有同步增长。更奇怪的是,发奖任务每天凌晨跑完后,总有几个分片迟迟不结束,第二天重叠执行,同一批奖励被重复写入。

排查发现,问题出在一对互相邀请的账号上。A 先邀请了 B,B 注册后看到「填写邀请码得奖励」的入口,又把自己收到的邀请码填给了 A。产品逻辑把两次填写都当成有效邀请,关系表里同时存在 A→B 和 B→A 两条边。

发奖任务沿着「下级」关系向上遍历,本来只想找直接邀请人,但实现里写的是 while inviter != ""。碰到 A 和 B 的环之后,循环永远不结束;即使加了最大循环次数,前几次循环也已经把同一笔奖励发了好几遍。

这不是一个复杂的攻击,而是两个普通用户在一次活动里的正常操作。邀请系统的第一道防线不是风控模型,而是关系本身必须是有向无环的。

邀请关系是单向绑定,不是双向好友

邀请和好友是两种不同的关系。好友通常是对称的:A 加 B,B 的列表里也会出现 A。邀请是单向的:B 是被 A 邀请来的,这个事实不会因为 B 后来也邀请了别人而反过来成立。

服务端应该只存一条边:invitee_id → inviter_id。主键或唯一约束落在 invitee_id 上,保证一个人只能被绑定一次。邀请人可以有多条指向自己的边,这是他邀请过很多人;被邀请人只有一条指向外的边,这是他的来源。

概念存储方向是否可变作用
邀请关系被邀请人 → 邀请人绑定后不可变确定来源,发奖依据
邀请码邀请码 → 邀请人可失效、可轮换活动入口
归因快照用户 → 顶层邀请人下单时固化避免历史关系变化影响已发奖励
下级列表邀请人 → 被邀请人集合查询时反查运营看板与层级统计

绑定动作要满足四个条件:邀请码、邀请人、被邀请人都非空;不能邀请自己;被邀请人此前没有绑定记录;绑定后不能形成环。前三条是数据校验,第四条是图结构校验。

环校验不能只在发奖时做。发奖时才发现环,意味着脏数据已经落库,此时无论拒绝发奖还是跳过,都会留下难以解释的账。正确的位置是写入前:沿着邀请人向上走,如果遇到当前被邀请人,说明这次绑定会首尾相接,直接拒绝。

归因窗口与最后点击

「谁邀请了他」和「这单该给谁发奖」是两个问题。前者是绑定关系,后者是归因规则。归因规则至少要回答三件事:看哪个触点、窗口多长、多个邀请码冲突时谁赢。

常见做法是最后点击归因:用户在注册前点击过多个邀请链接,最后一个有效点击对应的邀请人获得归因。窗口通常设为 7 天或 30 天,超过窗口的点击不再计入。注册完成后再补填邀请码,是另一种入口,必须单独定义它是否受窗口约束。

归因规则判定依据适用场景风险
首次点击最早的有效点击品牌型活动低估临门一脚的邀请人
最后点击最晚的有效点击效果型拉新高估收割型渠道
绑定时刻注册时填写的邀请码强关系邀请无法覆盖先点击后注册的长路径
人工补录客服或运营指定争议处理可解释性差,容易被滥用

规则一旦定下,就要写进活动文档和代码注释,不能月底按结果调整。归因窗口是产品承诺的一部分:用户看到「30 天内注册都算」和「7 天内注册才算」,参与意愿完全不同。

服务端落库时,建议同时保存绑定时间和归因快照。绑定关系回答「来源是谁」,快照回答「当时按什么规则判定」。两者混成一张表,后续改规则时历史订单的解释成本会非常高。

失败场景:A 邀请 B,B 又邀请 A

回到开头的案例。关系表允许双向写入,发奖任务按「向上找邀请人」的逻辑遍历。A→B 和 B→A 同时存在时,遍历路径是 A→B→A→B……,没有终止条件。

第一个后果是任务不结束。分片超时后被调度器重新拉起,同一批订单被第二次处理;如果发奖接口不是幂等的,券就重复入账。第二个后果是预算被环吃掉:每绕一圈都触发一次「下级首单奖励」,两个账号就能把一条链上的奖励反复领走。

第三个后果更隐蔽:报表里的层级深度失真。运营看到某个邀请人的下级有几十层,以为是裂变效果好,实际是环被当成了深度。基于这份报表追加的投放预算,全部打在了一个不会带来新用户的循环上。

修复要分三层。写入层拒绝形成环;读取层用 visited 集合和最大深度兜底;发奖层用幂等键保证同一个订单、同一个奖励对象只发一次。三层里任何一层单独存在都不够:只做幂等,环仍然会消耗计算资源;只做环检测,历史脏数据仍然可能在某次查询里被遍历到。

用 Bind 与 Attribution 收口关系与归因

把关系写入和归因查询做成两个纯函数,存储层的唯一约束和事务负责并发,纯函数负责规则。这样环检测、深度上限和空链行为都能被单元测试固定下来。

// file: internal/growth/invite.go
// tier: L1
// verify: go test ./internal/growth/

// Package growth 提供邀请归因、奖励发放与增长实验度量的纯函数。
package growth

import (
	"errors"
	"fmt"
)

var (
	// ErrInvalidInvite 表示邀请记录或邀请链存在空字段或非法值。
	ErrInvalidInvite = errors.New("growth: invalid invite")
	// ErrAlreadyBound 表示被邀请人已经绑定过邀请人。
	ErrAlreadyBound = errors.New("growth: invitee already bound")
	// ErrCycle 表示邀请关系已经或将要形成环。
	ErrCycle = errors.New("growth: invite cycle")
	// ErrDepthExceeded 表示归因深度超过调用方允许的上限。
	ErrDepthExceeded = errors.New("growth: attribution depth exceeded")
)

// Invite 是一条邀请绑定记录。Code 是邀请码,At 是绑定发生的 Unix 秒。
// InviterID 是发出邀请的人,InviteeID 是接受邀请的人,方向不能反过来。
type Invite struct {
	Code      string
	InviterID string
	InviteeID string
	At        int64
}

// Bind 把 inv 写入 bound:bound[InviteeID] = InviterID。
//
// 邀请关系是单向的:被邀请人只能绑定一次,绑定后不会因为邀请人后来
// 又绑定了别人而改变。写入前会沿着已有链向上检查,如果邀请人已经
// 在被邀请人的下游,这次绑定就会形成环,直接拒绝。
func Bind(bound map[string]string, inv Invite) error {
	if bound == nil {
		return fmt.Errorf("%w: nil bound map", ErrInvalidInvite)
	}
	if inv.Code == "" || inv.InviterID == "" || inv.InviteeID == "" {
		return fmt.Errorf("%w: empty field", ErrInvalidInvite)
	}
	if inv.InviterID == inv.InviteeID {
		return fmt.Errorf("%w: self invite %s", ErrInvalidInvite, inv.InviterID)
	}
	if _, ok := bound[inv.InviteeID]; ok {
		return fmt.Errorf("%w: %s", ErrAlreadyBound, inv.InviteeID)
	}
	// 从邀请人向上走,如果遇到被邀请人,说明被邀请人已经在邀请人的
	// 上游,再绑定就会首尾相接。
	cur := inv.InviterID
	seen := map[string]bool{}
	for {
		if cur == inv.InviteeID {
			return fmt.Errorf("%w: %s -> %s", ErrCycle, inv.InviteeID, inv.InviterID)
		}
		if seen[cur] {
			return fmt.Errorf("%w: broken chain at %s", ErrCycle, cur)
		}
		seen[cur] = true
		next, ok := bound[cur]
		if !ok {
			break
		}
		cur = next
	}
	bound[inv.InviteeID] = inv.InviterID
	return nil
}

// Attribution 沿 chain 从 userID 向上找到最顶层的邀请人。
// chain[user] = inviter,通常就是 Bind 写入的同一张表。
//
// 用户没有邀请记录时返回空字符串和 nil,表示自然流量。每向上一步
// 计一层,超过 maxDepth 返回 ErrDepthExceeded;链中出现环返回
// ErrCycle。函数只读 chain,不会修改入参。
func Attribution(chain map[string]string, userID string, maxDepth int) (string, error) {
	if userID == "" {
		return "", fmt.Errorf("%w: empty user id", ErrInvalidInvite)
	}
	if maxDepth < 0 {
		return "", fmt.Errorf("%w: max depth %d", ErrInvalidInvite, maxDepth)
	}
	cur, top, depth := userID, "", 0
	seen := map[string]bool{userID: true}
	for {
		parent, ok := chain[cur]
		if !ok {
			return top, nil
		}
		if parent == "" {
			return "", fmt.Errorf("%w: empty parent of %s", ErrInvalidInvite, cur)
		}
		if seen[parent] {
			return "", fmt.Errorf("%w: revisit %s", ErrCycle, parent)
		}
		depth++
		if depth > maxDepth {
			return "", fmt.Errorf("%w: %s depth %d > %d", ErrDepthExceeded, userID, depth, maxDepth)
		}
		seen[parent] = true
		top = parent
		cur = parent
	}
}

Bind 先做空值和自邀请校验,再检查被邀请人是否已经绑定。环检测从邀请人开始向上走:如果路径上遇到当前被邀请人,说明被邀请人已经在邀请人的上游,再绑定就会形成环。检查失败时不写 map,调用方看到的关系表保持原样。

Attribution 返回最顶层的邀请人,而不是直接邀请人。这个选择适合「按顶层归因发奖」的活动;如果业务只要直接邀请人,把循环里的 top 改成第一次拿到的 parent 即可,深度和环检测逻辑不变。链为空时返回空字符串和 nil,表示自然流量,不把「没有邀请人」当成错误。

maxDepth 是读取层的兜底。正常情况下写入层已经保证无环,但历史数据、人工修数、跨系统同步都可能引入脏边,读取时仍然要有上限。超过上限返回错误而不是截断,是因为截断会让调用方误以为归因成功,错误必须显式暴露。

这两个函数都是纯函数,线上还需要两件事:数据库对 invitee_id 建唯一索引,绑定和环检测放在同一个事务里;归因结果按下单时刻做快照,避免邀请人后续变化影响已发生的订单。

测试:环、重复绑定与深度上限

测试要覆盖三类边界:正常的多层链、非法输入、以及环。环又分直接环和间接环,前者是 A→B 再 B→A,后者是 A→B→C 再 C→A。

// file: internal/growth/invite_test.go
// tier: L1
// verify: go test ./internal/growth/

package growth

import (
	"errors"
	"testing"
)

func TestBindWritesOneWayRelation(t *testing.T) {
	bound := map[string]string{}
	if err := Bind(bound, Invite{Code: "A1", InviterID: "u1", InviteeID: "u2", At: 100}); err != nil {
		t.Fatalf("Bind u1->u2 = %v, want nil", err)
	}
	if err := Bind(bound, Invite{Code: "A2", InviterID: "u2", InviteeID: "u3", At: 200}); err != nil {
		t.Fatalf("Bind u2->u3 = %v, want nil", err)
	}
	if got := bound["u2"]; got != "u1" {
		t.Fatalf("bound[u2] = %q, want u1", got)
	}
	if got := bound["u3"]; got != "u2" {
		t.Fatalf("bound[u3] = %q, want u2", got)
	}
	top, err := Attribution(bound, "u3", 5)
	if err != nil {
		t.Fatalf("Attribution u3 = %v, want nil", err)
	}
	if top != "u1" {
		t.Fatalf("Attribution u3 = %q, want u1", top)
	}
	top, err = Attribution(bound, "u1", 5)
	if err != nil || top != "" {
		t.Fatalf("Attribution u1 = %q, %v; want empty, nil", top, err)
	}
}

func TestAttributionMissingChain(t *testing.T) {
	for name, chain := range map[string]map[string]string{
		"nil":   nil,
		"empty": {},
	} {
		t.Run(name, func(t *testing.T) {
			top, err := Attribution(chain, "nobody", 3)
			if err != nil || top != "" {
				t.Fatalf("Attribution = %q, %v; want empty, nil", top, err)
			}
		})
	}
}

func TestAttributionDepthLimit(t *testing.T) {
	chain := map[string]string{"u4": "u3", "u3": "u2", "u2": "u1"}
	top, err := Attribution(chain, "u4", 3)
	if err != nil || top != "u1" {
		t.Fatalf("Attribution depth 3 = %q, %v; want u1, nil", top, err)
	}
	if _, err := Attribution(chain, "u4", 2); !errors.Is(err, ErrDepthExceeded) {
		t.Fatalf("Attribution depth 2 = %v, want ErrDepthExceeded", err)
	}
	if _, err := Attribution(chain, "u2", 0); !errors.Is(err, ErrDepthExceeded) {
		t.Fatalf("Attribution depth 0 = %v, want ErrDepthExceeded", err)
	}
}

func TestAttributionDetectsCycle(t *testing.T) {
	chain := map[string]string{"a": "b", "b": "a"}
	if _, err := Attribution(chain, "a", 10); !errors.Is(err, ErrCycle) {
		t.Fatalf("Attribution cycle = %v, want ErrCycle", err)
	}
}

func TestBindRejectsInvalidInput(t *testing.T) {
	cases := []struct {
		name string
		inv  Invite
		want error
	}{
		{"empty-code", Invite{InviterID: "u1", InviteeID: "u2"}, ErrInvalidInvite},
		{"empty-inviter", Invite{Code: "c", InviteeID: "u2"}, ErrInvalidInvite},
		{"empty-invitee", Invite{Code: "c", InviterID: "u1"}, ErrInvalidInvite},
		{"self-invite", Invite{Code: "c", InviterID: "u1", InviteeID: "u1"}, ErrInvalidInvite},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if err := Bind(map[string]string{}, c.inv); !errors.Is(err, c.want) {
				t.Fatalf("Bind = %v, want %v", err, c.want)
			}
		})
	}
	if err := Bind(nil, Invite{Code: "c", InviterID: "u1", InviteeID: "u2"}); !errors.Is(err, ErrInvalidInvite) {
		t.Fatalf("Bind nil map = %v, want ErrInvalidInvite", err)
	}
}

func TestBindRejectsAlreadyBound(t *testing.T) {
	bound := map[string]string{"u2": "u1"}
	err := Bind(bound, Invite{Code: "c", InviterID: "u9", InviteeID: "u2"})
	if !errors.Is(err, ErrAlreadyBound) {
		t.Fatalf("Bind = %v, want ErrAlreadyBound", err)
	}
	if got := bound["u2"]; got != "u1" {
		t.Fatalf("bound[u2] = %q, want original u1", got)
	}
}

func TestBindRejectsCycle(t *testing.T) {
	bound := map[string]string{"u2": "u1"}
	err := Bind(bound, Invite{Code: "c", InviterID: "u2", InviteeID: "u1"})
	if !errors.Is(err, ErrCycle) {
		t.Fatalf("direct cycle = %v, want ErrCycle", err)
	}
	if _, ok := bound["u1"]; ok {
		t.Fatalf("failed Bind wrote bound[u1] = %q", bound["u1"])
	}

	bound = map[string]string{"u2": "u1", "u3": "u2"}
	err = Bind(bound, Invite{Code: "c", InviterID: "u3", InviteeID: "u1"})
	if !errors.Is(err, ErrCycle) {
		t.Fatalf("indirect cycle = %v, want ErrCycle", err)
	}
	if _, ok := bound["u1"]; ok {
		t.Fatalf("failed Bind wrote bound[u1] = %q", bound["u1"])
	}
}

TestBindWritesOneWayRelation 建立 u1→u2→u3 的链,断言每一步都写入成功,并且 Attribution 从 u3 能找到顶层 u1。它还验证 u1 自己没有邀请人,返回空字符串而不是报错。

TestAttributionMissingChain 分别用 nil map 和空 map 查询,断言结果都是空字符串加 nil。TestAttributionDepthLimit 用三层链验证 maxDepth 恰好等于深度时通过、少一层时返回 ErrDepthExceeded。TestAttributionDetectsCycle 直接构造一张有环的表,确认读取层不会死循环。

TestBindRejectsInvalidInput 覆盖空邀请码、空邀请人、空被邀请人和自邀请。TestBindRejectsAlreadyBound 验证重复绑定返回 ErrAlreadyBound 且原关系不变。TestBindRejectsCycle 同时覆盖直接环和间接环,并断言失败的绑定没有写入任何新边。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/growth/ -run 'TestBind|TestAttribution' -count=1 -v

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

=== RUN   TestBindWritesOneWayRelation
--- PASS: TestBindWritesOneWayRelation (0.00s)
=== RUN   TestAttributionMissingChain
=== RUN   TestAttributionMissingChain/nil
=== RUN   TestAttributionMissingChain/empty
--- PASS: TestAttributionMissingChain (0.00s)
    --- PASS: TestAttributionMissingChain/nil (0.00s)
    --- PASS: TestAttributionMissingChain/empty (0.00s)
=== RUN   TestAttributionDepthLimit
--- PASS: TestAttributionDepthLimit (0.00s)
=== RUN   TestAttributionDetectsCycle
--- PASS: TestAttributionDetectsCycle (0.00s)
=== RUN   TestBindRejectsInvalidInput
=== RUN   TestBindRejectsInvalidInput/empty-code
=== RUN   TestBindRejectsInvalidInput/empty-inviter
=== RUN   TestBindRejectsInvalidInput/empty-invitee
=== RUN   TestBindRejectsInvalidInput/self-invite
--- PASS: TestBindRejectsInvalidInput (0.00s)
    --- PASS: TestBindRejectsInvalidInput/empty-code (0.00s)
    --- PASS: TestBindRejectsInvalidInput/empty-inviter (0.00s)
    --- PASS: TestBindRejectsInvalidInput/empty-invitee (0.00s)
    --- PASS: TestBindRejectsInvalidInput/self-invite (0.00s)
=== RUN   TestBindRejectsAlreadyBound
--- PASS: TestBindRejectsAlreadyBound (0.00s)
=== RUN   TestBindRejectsCycle
--- PASS: TestBindRejectsCycle (0.00s)
PASS
ok  	webapp/internal/growth	0.257s

输出里的每个子测试都对应一条规则:单向写入、空链、深度、环、非法字段、重复绑定。测试通过只说明纯函数的规则正确;并发写入下的唯一约束、事务隔离和归因快照仍然需要存储层保证。

小结

邀请关系是单向绑定:被邀请人只有一条来源边,绑定后不可变。归因是另一层规则,要明确触点、窗口和冲突判定;把绑定关系当成归因结果,改规则时就会牵连历史数据。

环必须在写入前拒绝,读取时再用 visited 和 maxDepth 兜底,发奖时再用幂等键收口。Bind 和 Attribution 把这三类规则中的前两类固定成可测试的纯函数;下一节讨论第三层,也就是奖励真正发出去时如何保证只发一次、且不超过上限。

阅读导航:上一节:目录与学习路径 · 下一节:7.2 奖励发放与反作弊 。

常见错误与边界

  • 把邀请关系建成双向好友关系。双向边会让「谁邀请谁」失去方向,环检测也无从谈起。
  • 允许被邀请人重复绑定。后一次绑定覆盖前一次,归因结果随请求顺序漂移,历史奖励无法解释。
  • 只在发奖时检测环。脏数据已经落库,任务可能已经重复执行,应该把校验前移到写入。
  • 归因查询没有 visited 集合。历史脏数据里的环会让查询或发奖任务无限循环。
  • 把 maxDepth 当成截断。超过深度应该报错,静默截断会让调用方误以为归因成功。
  • 空邀请链被当成错误。自然流量本来就没有邀请人,返回空字符串更利于调用方分支处理。
  • 绑定和环检测不在同一事务。两个并发请求可能各自通过检查,最后一起写入形成环。
  • 用当前关系表解释历史订单。邀请人可能变化,归因结果应该在下单时做快照。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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