《互联网产品的服务端架构·交易、增长与平台化》9.3 退订、频控与合规

用户在 App 内退订了营销消息,短信却仍在发送,最终收到投诉。文章说明退订为什么是合规要求而不是体验优化、频控为什么要分渠道、事务通知与营销通知为什么要走不同队列,并给出 Allowed 与 AllowSend 的 Go 实现、边界测试和真实运行输出。

App 内退了订,短信还在发

一位用户在 App 的设置页关掉了“营销短信”和“营销推送”两个开关。第二天,他仍然收到一条召回短信。用户把截图发到客服,问“我明明退了订,为什么还在发”。客服查到短信通道的订阅状态仍是“已订阅”,只能道歉并手工加入黑名单。

事后复盘发现,设置页只写入了推送通道的偏好表,短信通道的退订记录在另一张表里,两张表之间没有同步。短信供应商还有一份自己的 suppression list,需要每天凌晨批量上传。用户上午退订,最快也要第二天凌晨才生效。三个系统各自都“按配置工作”,合在一起却违反了用户的明确意愿。

退订问题经常被当成体验优化:少发一条没关系,多发一条最多被骂。但在短信、外呼等渠道,退订是合规要求,发送方需要能证明自己在用户拒绝后停止了营销触达。一次漏发是体验问题,一次该停没停是合规问题。

退订是合规要求,不是体验优化

把退订做对,核心不是多一个开关,而是把“用户意愿”变成所有发送路径都必须查询的权威状态。任何一个渠道绕过它,整套机制就失效了。

事项正确做法常见错误
退订粒度按用户加渠道记录一个总开关覆盖所有渠道
偏好顺序追加事件,最后一条生效覆盖写,丢失历史
与频控关系先查退订,再看频控频控有余量就发送
通知类型事务与营销分队列、分开关营销退订连订单通知一起停
同步延迟发送前实时查权威状态依赖供应商每日批量同步
审计保留退订时间、渠道、来源只存一个布尔值

“最后一条生效”要求偏好表是追加式的事件日志,而不是一行覆盖写。用户可能先退订、后又主动订阅,也可能在不同设备上操作。只有保留顺序,才能回答“现在到底是什么状态”。如果表里只有 opt_out 布尔值,没有更新时间,多端并发写入时结果取决于谁最后落库,排查时无法还原。

退订与频控的优先级必须写死。正确顺序是先调用 Allowed 判断是否退订,再调用 AllowSend 判断频控。反过来先算频控,虽然最终也会被退订拦住,但中间可能已经占用了发送配额、触发了模板渲染,甚至在异步链路里漏掉退订检查。退订检查应该尽量靠近发送动作,并且失败关闭。

事务通知和营销通知必须分开。用户退订营销短信,不影响验证码、订单状态、账单提醒等事务通知;反过来,事务通知的模板和发送通道也不应该被营销活动复用。两者共用队列的后果在 9.1 已经出现过:营销峰值把订单通知压到两小时后。共用退订开关的后果更严重:用户为了停止营销,连必要的交易信息也收不到。

同步延迟是退订失效的头号原因。设置页写库成功,不代表短信通道已经知道。发送前实时查询权威偏好表,或者通过变更流在秒级内更新本地缓存,是更可靠的做法。供应商侧的 suppression list 只能作为最后一道兜底,不能作为唯一状态。

失败场景:退订状态只同步了推送通道

回到开头的用户。他在设置页关闭两个开关,前端调用的是推送服务的偏好接口;短信服务的偏好表没有对应记录,默认值是“允许”。发送任务查询短信偏好表,得到“允许”,于是继续发送。供应商的 suppression list 每天凌晨同步一次,用户在同步前仍然会收到消息。

第二个变种是“退订了营销,但活动被标记为事务”。运营为了让召回短信绕过退订,把活动类型改成“服务通知”。代码只看活动类型,不看模板内容和用户意愿,于是营销消息走了事务通道。这种绕过一旦成为惯例,退订开关就名存实亡。

第三个变种是频控窗口算错。用户在一个小时内收到三条营销短信,规则配置是“每小时最多两条”,但代码用的是左闭右开窗口,边界上的发送没有计数,实际发了三条。用户投诉后,日志里每条都显示“未超限”。窗口边界必须在代码里固定,并且有测试覆盖。

Go 实现:Allowed 与 AllowSend 分开

Allowed 回答“用户是否还愿意接收这个渠道的营销消息”,AllowSend 回答“这个渠道现在还能不能再发一条”。两个问题分开,发送链路才能把退订检查放在频控之前,并且在任意一步失败时停下来。

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

package reach

import (
	"errors"
	"fmt"
)

var (
	// ErrInvalidCapWindow 表示频控窗口不是正数。
	ErrInvalidCapWindow = errors.New("reach: cap window must be positive")
	// ErrInvalidCapMax 表示频控上限为负数。
	ErrInvalidCapMax = errors.New("reach: cap max must not be negative")
	// ErrClockRegression 表示历史发送时间晚于当前时间,通常意味着时钟回拨
	// 或上游事件乱序,继续计数会得到不可信的结果。
	ErrClockRegression = errors.New("reach: send time is later than now")
)

// Pref 是用户对一个渠道的订阅偏好。同一用户同一渠道可能有多条记录,
// 以切片中最后一条为准;OptOut 为 true 表示明确退订。
type Pref struct {
	UserID  string
	Channel string
	OptOut  bool
}

// Cap 是单渠道的频控配置。WindowSeconds 是统计窗口,Max 是窗口内允许的
// 最大发送次数;Max 为 0 表示不限制次数。窗口是左开右闭区间
// (now-WindowSeconds, now],与归因的闭区间刻意不同。
type Cap struct {
	WindowSeconds int64
	Max           int
}

// Allowed 判断用户在某个渠道是否仍可接收营销消息。没有任何偏好记录时
// 默认允许;同一用户同一渠道只看最后一条 Pref,后写入的偏好覆盖先写入的。
//
// 发送链路必须先调用 Allowed,再调用 AllowSend。退订是合规要求,优先级
// 高于频控:用户已经退订时,即使频控仍有余量也不能发送。
func Allowed(prefs []Pref, userID, channel string) bool {
	allowed := true
	for _, p := range prefs {
		if p.UserID == userID && p.Channel == channel {
			allowed = !p.OptOut
		}
	}
	return allowed
}

// AllowSend 判断在当前时间 now 是否还能向用户发送一条营销消息。history
// 是该用户在该渠道上的历史发送时间,顺序不限;窗口内的每一次发送都计数,
// 不区分成功与失败,避免失败重试绕过频控。
//
// 返回错误的情况有三类:窗口非正数、上限为负数、历史时间晚于 now。最后
// 一类对应时钟回拨或事件乱序,必须让调用方停下来,而不是静默少算。
func AllowSend(history []int64, now int64, c Cap) (bool, error) {
	if c.WindowSeconds <= 0 {
		return false, fmt.Errorf("%w: %d", ErrInvalidCapWindow, c.WindowSeconds)
	}
	if c.Max < 0 {
		return false, fmt.Errorf("%w: %d", ErrInvalidCapMax, c.Max)
	}
	for _, at := range history {
		if at > now {
			return false, fmt.Errorf("%w: %d > %d", ErrClockRegression, at, now)
		}
	}
	if c.Max == 0 {
		return true, nil
	}

	lower := now - c.WindowSeconds
	sent := 0
	for _, at := range history {
		if at > lower && at <= now {
			sent++
		}
	}
	return sent < c.Max, nil
}

实现里有几个关键点:

  • Allowed 默认返回 true。没有偏好记录表示用户没有明确拒绝,而不是默认退订。
  • 同一用户同一渠道只看最后一条 Pref。切片顺序就是事件顺序,后写入的偏好覆盖先写入的;退订后再订阅也能正确恢复。
  • 退订优先于频控,但这个优先级由调用顺序保证:先 Allowed,再 AllowSend。Allowed 返回 false 时,不应再调用频控,更不应发送。
  • AllowSend 的窗口是左开右闭 (now-WindowSeconds, now]。恰好落在 now-WindowSeconds 上的发送不计入,避免窗口滑动时同一条记录被重复计算。
  • Max=0 表示不限制次数,但仍然会检查时钟回拨。配置为不限不代表可以忽略乱序数据。
  • 历史时间晚于 now 返回 ErrClockRegression。时钟回拨或上游事件乱序时,继续计数会少算发送量,必须让调用方停下来。
  • 窗口非正数、上限为负数都返回错误。负上限如果不报错,sent < Max 永远为 false,所有发送都会被静默拦截。

这套函数不负责存储偏好、同步供应商或重试。它只保证:给定一份偏好和一份发送历史,判断是确定的、可测试的。

测试:最后一条偏好、半开窗口与时钟回拨

退订和频控的测试要覆盖六个语义:默认允许、最后一条偏好生效、用户与渠道隔离、零上限不限、窗口左开右闭、时钟回拨报错。

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

package reach

import (
	"errors"
	"testing"
)

func TestAllowedDefaultsToTrue(t *testing.T) {
	if !Allowed(nil, "u1", "sms") {
		t.Fatal("Allowed with no prefs = false; want true")
	}
}

func TestAllowedLastPrefWins(t *testing.T) {
	prefs := []Pref{
		{UserID: "u1", Channel: "sms", OptOut: false},
		{UserID: "u1", Channel: "sms", OptOut: true},
		{UserID: "u1", Channel: "sms", OptOut: false},
	}
	if !Allowed(prefs, "u1", "sms") {
		t.Fatal("last pref opt-in = false; want true")
	}

	prefs = append(prefs, Pref{UserID: "u1", Channel: "sms", OptOut: true})
	if Allowed(prefs, "u1", "sms") {
		t.Fatal("last pref opt-out = true; want false")
	}
}

func TestAllowedScopesUserAndChannel(t *testing.T) {
	prefs := []Pref{
		{UserID: "u1", Channel: "sms", OptOut: true},
		{UserID: "u1", Channel: "push", OptOut: false},
		{UserID: "u2", Channel: "sms", OptOut: false},
	}
	if Allowed(prefs, "u1", "sms") {
		t.Fatal("u1 sms = true; want opted out")
	}
	if !Allowed(prefs, "u1", "push") {
		t.Fatal("u1 push = false; want allowed")
	}
	if !Allowed(prefs, "u2", "sms") {
		t.Fatal("u2 sms = false; want allowed")
	}
}

func TestAllowSendZeroMaxIsUnlimited(t *testing.T) {
	history := []int64{999, 999, 999}
	ok, err := AllowSend(history, 1000, Cap{WindowSeconds: 60, Max: 0})
	if err != nil {
		t.Fatalf("AllowSend = %v, want nil", err)
	}
	if !ok {
		t.Fatal("Max=0 = false; want true")
	}
}

func TestAllowSendWindowIsHalfOpen(t *testing.T) {
	// 窗口 (940, 1000]:940 不计,941 和 1000 都计。
	history := []int64{940, 941, 1000}
	ok, err := AllowSend(history, 1000, Cap{WindowSeconds: 60, Max: 3})
	if err != nil {
		t.Fatalf("AllowSend = %v, want nil", err)
	}
	if !ok {
		t.Fatal("sent=2 max=3 = false; want true")
	}
	if ok, _ := AllowSend(history, 1000, Cap{WindowSeconds: 60, Max: 2}); ok {
		t.Fatal("sent=2 max=2 = true; want false")
	}
}

func TestAllowSendIgnoresOldSends(t *testing.T) {
	history := []int64{100, 939, 950}
	ok, err := AllowSend(history, 1000, Cap{WindowSeconds: 60, Max: 1})
	if err != nil {
		t.Fatalf("AllowSend = %v, want nil", err)
	}
	if ok {
		t.Fatal("one send in window max=1 = true; want false")
	}
}

func TestAllowSendRejectsClockRegression(t *testing.T) {
	history := []int64{1000, 1001}
	_, err := AllowSend(history, 1000, Cap{WindowSeconds: 60, Max: 1})
	if !errors.Is(err, ErrClockRegression) {
		t.Fatalf("AllowSend = %v; want ErrClockRegression", err)
	}
}

func TestAllowSendRejectsBadConfig(t *testing.T) {
	if _, err := AllowSend(nil, 1000, Cap{WindowSeconds: 0, Max: 1}); !errors.Is(err, ErrInvalidCapWindow) {
		t.Fatalf("window=0 = %v; want ErrInvalidCapWindow", err)
	}
	if _, err := AllowSend(nil, 1000, Cap{WindowSeconds: 60, Max: -1}); !errors.Is(err, ErrInvalidCapMax) {
		t.Fatalf("max=-1 = %v; want ErrInvalidCapMax", err)
	}
}

TestAllowedDefaultsToTrue 固定“无记录即允许”。TestAllowedLastPrefWins 先退订再订阅,确认最后一条生效;随后追加一条退订,确认状态再次翻转。TestAllowedScopesUserAndChannel 验证一个渠道的退订不会影响另一个渠道,也不会影响其他用户。

TestAllowSendZeroMaxIsUnlimited 验证 Max=0 时即使历史里已有多次发送也返回 true。TestAllowSendWindowIsHalfOpen 把发送放在窗口起点、起点后一秒和 now,确认起点不计、后两者都计。TestAllowSendIgnoresOldSends 验证窗口外的历史不占配额。TestAllowSendRejectsClockRegression 和 TestAllowSendRejectsBadConfig 覆盖时钟回拨、零窗口和负上限。

运行命令与真实输出

在 webapp-lab 目录执行:

go test ./internal/reach/ -run '^Test(Allowed|AllowSend)' -count=1 -v

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

=== RUN   TestAllowedDefaultsToTrue
--- PASS: TestAllowedDefaultsToTrue (0.00s)
=== RUN   TestAllowedLastPrefWins
--- PASS: TestAllowedLastPrefWins (0.00s)
=== RUN   TestAllowedScopesUserAndChannel
--- PASS: TestAllowedScopesUserAndChannel (0.00s)
=== RUN   TestAllowSendZeroMaxIsUnlimited
--- PASS: TestAllowSendZeroMaxIsUnlimited (0.00s)
=== RUN   TestAllowSendWindowIsHalfOpen
--- PASS: TestAllowSendWindowIsHalfOpen (0.00s)
=== RUN   TestAllowSendIgnoresOldSends
--- PASS: TestAllowSendIgnoresOldSends (0.00s)
=== RUN   TestAllowSendRejectsClockRegression
--- PASS: TestAllowSendRejectsClockRegression (0.00s)
=== RUN   TestAllowSendRejectsBadConfig
--- PASS: TestAllowSendRejectsBadConfig (0.00s)
PASS
ok  	webapp/internal/reach	0.242s

八条用例全部通过。它们固定的是单机判断逻辑;跨系统同步、供应商 suppression list 和退订审计需要在上层用变更流与对账保证。

小结

退订是合规要求,必须按用户和渠道记录,以最后一条偏好为准,并且在所有发送路径上先于频控检查。事务通知与营销通知要分开,供应商同步只能兜底,不能当权威状态。

Allowed 和 AllowSend 把“愿不愿意收”和“还能不能发”拆开,窗口边界和时钟回拨都有明确错误。下一章从用户触达转向平台能力:应用、权限与配额。

阅读导航:上一节:9.2 召回链路与转化归因 · 下一节:10.1 应用、权限与配额 。

常见错误与边界

  • 退订只写一个总开关。用户退掉营销短信,连订单通知也收不到;或者退了一个渠道,其他渠道照发。
  • 偏好表覆盖写。没有事件顺序,多端操作后无法还原“最后一条”是什么。
  • 先查频控再查退订。频控通过就发送,退订检查在异步链路里被跳过。
  • 依赖供应商每日同步。用户上午退订,第二天凌晨才生效,中间仍会收到消息。
  • 把营销活动标记成事务通知绕过退订。模板和内容仍是营销,合规风险不会因为标签改变而消失。
  • 频控窗口边界写错。左闭右开会让边界发送漏计,用户实际收到的条数超过配置。
  • 负上限不校验。sent < -1 永远为 false,所有发送被静默拦截,排查时没有任何错误日志。
  • 时钟回拨被忽略。历史时间晚于当前时间时继续计数,频控会少算,退订后的补发也可能重复。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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