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,所有发送被静默拦截,排查时没有任何错误日志。 - 时钟回拨被忽略。历史时间晚于当前时间时继续计数,频控会少算,退订后的补发也可能重复。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。