《互联网产品的服务端架构·交易、增长与平台化》9.1 人群圈选与触达节奏

会员日前运营圈出一份八百万人的推送名单,要求当晚 20:00 全量发送,结果推送通道被打满,正常订单通知延迟两小时。文章说明圈选条件怎样写成可解释的规则、触达为什么要分批、排除名单为什么优先于其他条件,并给出 Select 的 Go 实现、边界测试与真实运行输出。

会员日前的一份八百万名单

双十一前的会员日,运营在群里发来一份圈选条件:近 30 天活跃过、带 vip 或 trial 标签、排除已退订和黑名单用户,预计八百万(案例假设值)。要求当晚 20:00 全量推送,理由是“会员日只有一次,早发早转化”。

工程同学把条件直接拼成一条 SQL,导出一份 ID 文件,交给推送任务逐条发送。任务启动十分钟后,推送厂商的限流开始返回 429,队列迅速堆积;二十分钟后,订单状态变更通知也排在同一队列后面,用户付款后半小时没收到确认。真正的事故不是推送没发出去,而是营销流量挤占了事务通知。

这个场景里有三个决定:圈选规则是否可解释、触达是否分批、营销与事务是否隔离。本文只讨论前两个,第三个放在 9.3。

圈选条件要能逐条解释

圈选不是“查出一批 ID”这么简单。运营给的每个自然语言条件,都要能映射成服务端可复现的字段和比较方式,否则活动结束后没人能回答“为什么这个人收到了”。

运营条件服务端表达需要说清楚的点
近 30 天活跃LastActive >= now-30d边界是否包含当天;时区以哪个为准
会员或试用Tags 任一命中是“任一”不是“全部”;空标签是否放行
已退订、黑名单Exclude[userID]排除优先于其他条件,不能被标签覆盖
用户 ID 为空跳过脏数据不能进入发送队列
结果顺序按 ID 升序分批和重跑时顺序稳定,便于对账

最容易出错的是“任一命中”被写成“全部命中”。运营说“会员和试用用户”,口语里是两类人,代码里如果写成标签交集,人数会从八百万掉到几十万,而且不会报错。第二个坑是空标签:新用户还没有标签,如果规则要求“标签包含 vip”,他们会被静默排除;如果规则是“标签为空表示不限”,又可能把全量用户圈进来。这两种语义都必须在规则定义里写死,不能靠调用方猜。

排除名单的优先级也要固定。一个用户同时有 vip 标签和在退订表里,结果必须是排除。把排除放在最后做“二次过滤”看似等价,但一旦中间有分批、重试或人工补发,就容易绕过。Select 把排除放在标签判断之前,就是为了让“排除优先”成为代码结构,而不是口头约定。

失败场景:一次全量推送打满通道

回到会员日。八百万条推送如果按 20:00 一次性入队,假设推送厂商的单通道容量是每秒五万条(案例假设值),光入队就需要约 160 秒。问题在于入队不等于送达:每条消息还要渲染模板、查用户设备、做厂商路由,实际吞吐远低于入队速度。队列在半小时内堆到几百万条,重试又把失败消息重新放回队尾。

更糟的是,订单通知和营销推送共用了一个队列。营销消息的优先级低,但队列是先进先出,事务通知被压在后面。用户付款后收不到确认,客服工单在当晚涨了三倍。事后看,推送任务本身没有 bug,错在把“全量”当成了一个批次,并且没有把营销流量与事务流量分开。

分批不是简单地把八百万切成八个一百万。正确做法是按通道容量和下游承受能力算批次大小,批次之间留出间隔,并且给每批设置独立的超时和失败上限。第一批失败率超过阈值就暂停,而不是继续把后面七批推下去。触达节奏本质上是背压:上游生产速度不能长期高于下游消费速度。

Go 实现:Select 只做确定性过滤

Select 不负责分批、发送和重试,只负责把一份用户快照按规则过滤成确定的结果。它不修改入参,结果按 ID 排序,方便调用方按固定顺序切批和重跑。

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

package reach

import (
	"sort"
	"strings"
)

// User 是一条参与圈选的用户记录。Tags 是运营维护的标签,例如 vip、
// trial、churn_risk;LastActive 是服务端记录的最近活跃时间,单位由调用方
// 统一,通常是 Unix 秒。标签和活跃时间都来自离线或近线数仓,不是实时值。
type User struct {
	ID         string
	Tags       []string
	LastActive int64
}

// Rule 描述一次圈选。Tags 之间是“任一命中”,不是“全部命中”;空 Tags
// 表示不限制标签。ActiveSince 为 0 表示不限制活跃时间,否则要求
// LastActive 大于等于该值。Exclude 是按用户 ID 索引的排除名单,优先于
// 其他条件。
type Rule struct {
	Tags        []string
	ActiveSince int64
	Exclude     map[string]bool
}

// Select 返回命中规则的用户。结果按 ID 升序,输入切片和其中的元素都不会
// 被修改。返回的 User 是值拷贝,Tags 也做了复制,调用方修改结果不会影响
// 传入的数据。
//
// 过滤顺序固定为:空 ID、排除名单、活跃时间、标签。顺序本身不影响结果,
// 但固定顺序让日志和排查更容易复现。
func Select(users []User, r Rule) []User {
	wanted := make(map[string]struct{}, len(r.Tags))
	for _, tag := range r.Tags {
		tag = strings.TrimSpace(tag)
		if tag != "" {
			wanted[tag] = struct{}{}
		}
	}

	out := make([]User, 0, len(users))
	for _, u := range users {
		if u.ID == "" {
			continue
		}
		if r.Exclude[u.ID] {
			continue
		}
		if r.ActiveSince != 0 && u.LastActive < r.ActiveSince {
			continue
		}
		if len(wanted) > 0 && !hasAnyTag(u.Tags, wanted) {
			continue
		}
		u.Tags = append([]string(nil), u.Tags...)
		out = append(out, u)
	}

	sort.SliceStable(out, func(i, j int) bool {
		return out[i].ID < out[j].ID
	})
	return out
}

// hasAnyTag 判断用户标签是否至少命中一个圈选标签。空字符串在两边都被
// 忽略,避免脏数据把无关用户圈进来。
func hasAnyTag(tags []string, wanted map[string]struct{}) bool {
	for _, tag := range tags {
		if _, ok := wanted[strings.TrimSpace(tag)]; ok {
			return true
		}
	}
	return false
}

实现里有几个取舍:

  • 标签先去空白再匹配,规则里的 " vip " 和用户身上的 "vip" 视为同一个标签。脏数据不应该让圈选结果漂移。
  • 空 Tags 表示不限制标签,而不是“匹配不到任何人”。这个语义在代码注释和规则配置里都要写清楚。
  • ActiveSince=0 表示不限制活跃时间。用 0 而不是负数做哨兵,是因为时间戳 0 本身没有业务含义。
  • Exclude 用 map,查找是 O(1);nil map 也可以安全读取,调用方不需要先初始化。
  • 输出做了值拷贝和标签复制,调用方后续修改结果不会污染输入快照。
  • 排序用 sort.SliceStable,相同 ID 的相对顺序保持稳定。正常数据里 ID 唯一,但脏数据可能重复,稳定排序让重跑结果一致。
  • 函数不返回错误。圈选规则是纯过滤,没有 I/O,也不应该因为某个用户数据脏就整体失败;脏数据被跳过,由上游对账发现。

测试:任一命中、时间边界与排除优先

测试要覆盖四个容易回归的语义:标签任一命中、空标签放行、活跃时间边界、排除优先。另外还要验证排序和入参不被修改。

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

package reach

import (
	"reflect"
	"testing"
)

func TestSelectAnyTagMatches(t *testing.T) {
	users := []User{
		{ID: "u1", Tags: []string{"vip"}, LastActive: 100},
		{ID: "u2", Tags: []string{"trial", "new"}, LastActive: 100},
		{ID: "u3", Tags: []string{"churn_risk"}, LastActive: 100},
	}
	got := Select(users, Rule{Tags: []string{"vip", "trial"}})
	want := []string{"u1", "u2"}
	if ids := userIDs(got); !reflect.DeepEqual(ids, want) {
		t.Fatalf("Select ids = %v; want %v", ids, want)
	}
}

func TestSelectEmptyTagsMatchesAll(t *testing.T) {
	users := []User{
		{ID: "u2", Tags: []string{"trial"}, LastActive: 50},
		{ID: "u1", Tags: nil, LastActive: 100},
	}
	got := Select(users, Rule{})
	if ids := userIDs(got); !reflect.DeepEqual(ids, []string{"u1", "u2"}) {
		t.Fatalf("Select ids = %v; want [u1 u2]", ids)
	}
}

func TestSelectActiveSince(t *testing.T) {
	users := []User{
		{ID: "u1", LastActive: 99},
		{ID: "u2", LastActive: 100},
		{ID: "u3", LastActive: 101},
	}
	got := Select(users, Rule{ActiveSince: 100})
	if ids := userIDs(got); !reflect.DeepEqual(ids, []string{"u2", "u3"}) {
		t.Fatalf("Select ids = %v; want [u2 u3]", ids)
	}
	if ids := userIDs(Select(users, Rule{})); len(ids) != 3 {
		t.Fatalf("ActiveSince=0 ids = %v; want all users", ids)
	}
}

func TestSelectExcludeWins(t *testing.T) {
	users := []User{
		{ID: "u1", Tags: []string{"vip"}, LastActive: 100},
		{ID: "u2", Tags: []string{"vip"}, LastActive: 100},
	}
	got := Select(users, Rule{
		Tags:        []string{"vip"},
		ActiveSince: 100,
		Exclude:     map[string]bool{"u1": true},
	})
	if ids := userIDs(got); !reflect.DeepEqual(ids, []string{"u2"}) {
		t.Fatalf("Select ids = %v; want [u2]", ids)
	}
}

func TestSelectSortsAndKeepsInput(t *testing.T) {
	users := []User{
		{ID: "u3", Tags: []string{"vip"}, LastActive: 100},
		{ID: "u1", Tags: []string{"vip"}, LastActive: 100},
		{ID: "u2", Tags: []string{"vip"}, LastActive: 100},
	}
	snapshot := append([]User(nil), users...)
	got := Select(users, Rule{Tags: []string{"vip"}})
	if ids := userIDs(got); !reflect.DeepEqual(ids, []string{"u1", "u2", "u3"}) {
		t.Fatalf("Select ids = %v; want sorted [u1 u2 u3]", ids)
	}
	if !reflect.DeepEqual(users, snapshot) {
		t.Fatalf("Select mutated input: %v", users)
	}
}

func TestSelectCopiesTagsAndDropsEmptyID(t *testing.T) {
	users := []User{
		{ID: "", Tags: []string{"vip"}, LastActive: 100},
		{ID: "u1", Tags: []string{"vip"}, LastActive: 100},
	}
	got := Select(users, Rule{Tags: []string{" vip "}})
	if len(got) != 1 || got[0].ID != "u1" {
		t.Fatalf("Select = %+v; want only u1", got)
	}
	got[0].Tags[0] = "changed"
	if users[1].Tags[0] != "vip" {
		t.Fatalf("result tags alias input: %v", users[1].Tags)
	}
}

func TestSelectNoMatchReturnsEmpty(t *testing.T) {
	got := Select([]User{{ID: "u1", Tags: []string{"new"}}}, Rule{Tags: []string{"vip"}})
	if got == nil || len(got) != 0 {
		t.Fatalf("Select = %v; want non-nil empty slice", got)
	}
}

func userIDs(users []User) []string {
	ids := make([]string, len(users))
	for i, u := range users {
		ids[i] = u.ID
	}
	return ids
}

TestSelectAnyTagMatches 验证 vip 和 trial 是“任一命中”,不是交集。TestSelectEmptyTagsMatchesAll 固定空规则放行全部用户的语义。TestSelectActiveSince 把边界放在 LastActive == ActiveSince,确认是大于等于而不是大于;同时验证 ActiveSince=0 不限制。TestSelectExcludeWins 让被排除用户同时满足标签和活跃条件,确认排除优先。

TestSelectSortsAndKeepsInput 用乱序输入验证输出升序,并对比调用前后的输入快照。TestSelectCopiesTagsAndDropsEmptyID 验证空 ID 被丢弃,以及修改结果里的 Tags 不会影响入参。TestSelectNoMatchReturnsEmpty 确认无命中时返回非 nil 的空切片,避免调用方把 nil 当成错误。

运行命令与真实输出

在 webapp-lab 目录执行:

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

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

=== RUN   TestSelectAnyTagMatches
--- PASS: TestSelectAnyTagMatches (0.00s)
=== RUN   TestSelectEmptyTagsMatchesAll
--- PASS: TestSelectEmptyTagsMatchesAll (0.00s)
=== RUN   TestSelectActiveSince
--- PASS: TestSelectActiveSince (0.00s)
=== RUN   TestSelectExcludeWins
--- PASS: TestSelectExcludeWins (0.00s)
=== RUN   TestSelectSortsAndKeepsInput
--- PASS: TestSelectSortsAndKeepsInput (0.00s)
=== RUN   TestSelectCopiesTagsAndDropsEmptyID
--- PASS: TestSelectCopiesTagsAndDropsEmptyID (0.00s)
=== RUN   TestSelectNoMatchReturnsEmpty
--- PASS: TestSelectNoMatchReturnsEmpty (0.00s)
PASS
ok  	webapp/internal/reach	0.231s

七条用例全部通过。它们固定的是单次圈选的行为;分批大小、通道容量和队列隔离属于发送编排,需要在上层用配置和监控约束。

小结

圈选规则要能逐条翻译成字段和比较方式,标签是任一命中,排除优先,空条件要明确语义。触达要按下游容量分批,营销与事务分开,否则一次“全量”就会把正常通知挤到两小时后。

Select 只做确定性过滤:不改入参、结果排序、脏数据跳过。下一节讨论用户被触达之后,怎样判断回流到底来自哪一次活动。

阅读导航:上一节:目录与学习路径 · 下一节:9.2 召回链路与转化归因 。

常见错误与边界

  • 把“会员和试用”写成标签交集。运营说的是两类人,代码却要求同时满足,人数骤降且不报错。
  • 空标签被当成“匹配不到”。新用户没有标签,会被静默排除;空规则应明确为不限制。
  • 排除名单最后才做。分批、重试、人工补发都可能绕过二次过滤,排除必须前置。
  • 用 > 而不是 >= 判断活跃时间。边界当天的用户会被漏掉,活动人数对不上。
  • 直接排序入参。调用方后续还要用原始顺序对账,Select 应输出新切片。
  • 全量一次入队。下游吞吐低于入队速度时,队列会堆到不可控,重试进一步放大。
  • 营销与事务共用队列。营销峰值会延迟订单通知,事故影响从“推送慢”升级为“交易不可用”。
  • 只按人数分批,不按通道容量分批。八百万切八批仍然可能每批都超过下游承受能力。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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