一个邀请码,把发奖任务卡死了
「刚好」做了一个邀请拉新活动:老用户把邀请码发给朋友,朋友注册并完成首单后,双方各得一张 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 当成截断。超过深度应该报错,静默截断会让调用方误以为归因成功。
- 空邀请链被当成错误。自然流量本来就没有邀请人,返回空字符串更利于调用方分支处理。
- 绑定和环检测不在同一事务。两个并发请求可能各自通过检查,最后一起写入形成环。
- 用当前关系表解释历史订单。邀请人可能变化,归因结果应该在下单时做快照。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。