「回声」用户一天收到 15 条推送,然后关掉了通知
「回声」在大促前做了一轮增长实验。运营把“关注的人发了新文章”“你关注的话题有新讨论”“你的文章被收藏”“限时会员折扣”四类消息全部打开,希望提升回访。活动第一天,一位活跃用户从早上九点到晚上十点,陆续收到 15 条推送:每隔半小时就有一条,内容有重复的文章、有已经读过的评论、还有两条一模一样的优惠提醒。
第二天,这位用户在设置里关掉了全部通知。更糟的是,他还在应用商店留了一条评论:“别推了,再推卸载。”这条评论被其他用户点赞顶到了前面。运营看到的是推送打开率下降,技术看到的是 APNs 和 FCM 的成功率仍然很高,两边都没意识到问题出在“发得太多”,而不是“发得出去”。
通知系统有两个独立的指标:到达率和打扰度。10.1 讨论的是如何让消息到达;这一节讨论如何让消息值得到达。聚合解决“多条合成一条”,频控解决“一天最多几条”。两者都不是渠道层能做的,必须在业务发送之前完成。
聚合先要回答:按什么维度合并
“聚合”听起来是一个动作,实际上要先选维度。按用户聚合,是把同一个用户在一段时间内收到的多条通知合成一条摘要;按会话聚合,是把同一个评论串、同一个群聊里的多条消息合成一条;按内容聚合,是把同一篇文章的点赞、收藏、评论合成一条“你的文章有新互动”。三种维度的合并键、时间窗口和文案都不同。
| 聚合维度 | 合并键 | 典型窗口 | 适合的消息 | 主要风险 |
|---|---|---|---|---|
| 按用户 | 用户 ID | 5 到 30 分钟 | 多条低优先级互动 | 摘要过长,用户不知道具体发生了什么 |
| 按会话 | 会话 ID | 1 到 5 分钟 | 评论串、群聊、私信 | 把不同话题硬合成一条,语义错位 |
| 按内容 | 内容 ID | 10 到 60 分钟 | 点赞、收藏、评论等互动 | 内容作者看到大量重复摘要 |
| 不聚合 | 通知 ID | 无 | 支付、安全、审核结果 | 事务通知被误合并,用户漏看关键信息 |
表里的窗口是案例假设值,实际取值要看业务节奏。私信和评论串要求实时,窗口不能太长;点赞和收藏可以容忍几分钟延迟,换来的是一条更干净的摘要。事务通知通常不该聚合,因为每一条都对应一个用户需要独立确认的事实。
选择维度时,先问“用户看到摘要后能不能理解发生了什么”。如果答案是否定的,这个聚合就不该做。例如把“张三评论了你的文章”和“李四点赞了你的文章”合成“你的文章有 2 条新互动”,用户点进去还要自己找是哪两条,省下的通知条数变成了额外的认知成本。
聚合状态存在哪里
聚合需要状态:某个用户在当前窗口内已经攒了哪些事件。这个状态可以放在内存、Redis 或消息队列的本地窗口里,选择取决于丢失的代价。丢一条点赞聚合,最多是少一条摘要;丢一条支付通知,用户可能以为支付失败而重复付款。所以事务通知通常不走聚合,或者聚合状态必须持久化并支持重放。
在 Redis 里做聚合,常见结构是“用户 ID + 窗口起始时间”作为 key,value 保存事件计数和最后一条事件的内容。写入时用原子操作累加,读取时判断窗口是否结束。这里要小心过期时间:key 过期太早,窗口还没结束状态就没了;过期太晚,内存会被大量不活跃用户占满。较稳妥的做法是给 key 设置略大于窗口的 TTL,并由定时任务清理空 key。
聚合还要考虑顺序。用户先收到“张三评论”,再收到“李四点赞”,摘要里的顺序应该和事件发生顺序一致,而不是和消费顺序一致。事件自带发生时间,聚合时按时间排序,避免因为消息队列重试导致摘要里“先点赞后评论”的错乱。
频控要区分营销与事务通知
频控不是“每个用户每天最多 N 条”这么简单。营销通知和事务通知的用户预期完全不同。营销通知是“我希望你感兴趣”,用户关掉它不会影响核心功能;事务通知是“你必须知道”,比如支付成功、审核结果、安全提醒,用户关掉它可能造成实际损失。把两者塞进同一个计数器,会出现“因为营销发多了,导致支付通知被拦掉”的严重问题。
| 通知类型 | 用户预期 | 默认频控 | 免打扰 | 失败后的处理 |
|---|---|---|---|---|
| 营销 | 可选、可关 | 每天 1 到 2 条 | 严格遵守 | 直接丢弃,不降级到短信 |
| 互动 | 与我相关 | 每会话每小时若干条 | 可配置 | 聚合成摘要,次日再发 |
| 事务 | 必须知道 | 不按条数限制 | 不适用 | 降级到站内信或短信 |
| 安全 | 立即知道 | 不限制 | 不适用 | 多通道并发,确保至少一条到达 |
频控的粒度也要分开。营销通知按“用户 + 天”限流,互动通知按“用户 + 会话 + 小时”限流,事务通知按“业务 ID”去重而不是按条数限流。一个支付订单可能产生“支付成功”“发货”“签收”多条事务通知,它们都应该发,但不能因为同一个订单重复发送同一条。
频控还需要一个“全局上限”兜底。即使每类通知都合规,一个用户仍可能同时收到营销、互动、事务、安全四类消息。全局上限负责防止极端情况,比如系统故障导致重试风暴。这个上限应该设得足够高,只拦截异常流量,不拦截正常业务。
频控计数放在哪一层
最外层的网关可以做粗粒度限流,但它不知道消息类型,只能按用户或 IP 计数。业务层知道消息类型,却可能被多个服务调用,计数不统一。可行的做法是:业务层在发送前调用统一的频控服务,频控服务按“用户 + 类型 + 窗口”维护计数;网关只做异常流量保护,不承担业务频控。
频控服务返回的不应该只是“允许/拒绝”,还应该返回“建议延迟到什么时候”。营销通知被限流时可以顺延到第二天,互动通知可以等窗口结束再聚合,事务通知则不应该被延迟。把处理建议和判定结果一起返回,调用方才能做出符合业务语义的决定。
计数的一致性要求不高,允许少量超发。用户偶尔多收到一条营销通知,比频控服务不可用时全部拒绝要好。因此频控计数可以用 Redis 的近似计数,甚至允许在故障时降级为本地计数。关键是事务通知不能被营销计数误伤,这要靠类型隔离而不是靠计数精度。
失败场景:大促没有频控,用户直接把通知关了
回到「回声」那次大促。运营配置了四条营销规则:会员折扣、限时优惠券、作者激励、话题活动。每条规则单独看都合理,每天最多一条;但系统没有全局频控,四条规则各自计数,叠加起来就是每天四条营销通知。再加上互动通知和系统通知,活跃用户一天收到十几条。
更严重的是重试逻辑。大促当天渠道拥堵,部分推送超时,服务端自动重试三次。重试没有去重,同一条优惠提醒被重复发送。用户看到的不是“四条营销”,而是“两条一样的优惠提醒 + 一条会员折扣 + 一条作者激励 + 若干互动”,体验接近骚扰。
后果在第二天显现:一批活跃用户关闭了通知权限,营销打开率断崖式下跌,连事务通知的到达率也受到影响,因为用户关的是整个应用的通知。运营以为是文案不够吸引人,技术以为是渠道质量下降,实际上问题出在发送策略。
修正分三层。第一层是去重:同一条通知用稳定 ID 去重,重试不增加新的通知。第二层是聚合:同一用户短时间内的多条互动合成摘要。第三层是频控:营销通知按用户每天限一条,并且所有营销规则共享一个全局计数。三层都做完,才谈得上“精准触达”。
用 Template 和 Render 固定聚合后的文案边界
聚合之后,多条事件变成一条摘要,文案会变长,变量会变多。如果模板没有声明变量、没有长度校验,聚合反而会制造新的问题:摘要超出渠道限制被截断,或者变量缺失导致“某某 等 3 人”变成空字符串。下面的代码把模板声明、变量替换和长度校验放在同一个入口。
// file: internal/push/template.go
// tier: L1
// verify: go test ./internal/push/
package push
import (
"fmt"
"regexp"
"unicode/utf8"
)
// Template 是运营配置的原始文案。Vars 必须列出标题和正文里
// 允许出现的全部占位符,Render 不接受未声明的变量。
type Template struct {
ID string
Title string
Body string
Vars []string
}
// Message 是完成变量替换后的最终文案。
type Message struct {
Title string
Body string
}
const (
maxTitleRunes = 64
maxBodyRunes = 200
)
var placeholder = regexp.MustCompile(`\{\{([a-zA-Z0-9_]+)\}\}`)
// Render 用 vars 替换 {{name}} 占位符,并检查声明、缺参和长度。
// 错误信息里带占位符名字,运营可以直接定位是哪一条文案配置错了。
func Render(t Template, vars map[string]string) (Message, error) {
declared := make(map[string]bool, len(t.Vars))
for _, name := range t.Vars {
declared[name] = true
}
for _, field := range []string{t.Title, t.Body} {
for _, m := range placeholder.FindAllStringSubmatch(field, -1) {
name := m[1]
if !declared[name] {
return Message{}, fmt.Errorf("push: template %q uses undeclared placeholder %q", t.ID, name)
}
}
}
title, err := fill("title", t.ID, t.Title, vars)
if err != nil {
return Message{}, err
}
body, err := fill("body", t.ID, t.Body, vars)
if err != nil {
return Message{}, err
}
if n := utf8.RuneCountInString(title); n > maxTitleRunes {
return Message{}, fmt.Errorf("push: template %q title is %d chars, limit %d", t.ID, n, maxTitleRunes)
}
if n := utf8.RuneCountInString(body); n > maxBodyRunes {
return Message{}, fmt.Errorf("push: template %q body is %d chars, limit %d", t.ID, n, maxBodyRunes)
}
return Message{Title: title, Body: body}, nil
}
func fill(part, id, text string, vars map[string]string) (string, error) {
var missing string
out := placeholder.ReplaceAllStringFunc(text, func(m string) string {
name := m[2 : len(m)-2]
v, ok := vars[name]
if !ok {
if missing == "" {
missing = name
}
return m
}
return v
})
if missing != "" {
return "", fmt.Errorf("push: template %q missing %s variable %q", id, part, missing)
}
return out, nil
}
聚合场景下,Vars 的作用被放大了。摘要模板可能包含 actor、count、target 三个变量,缺任何一个都不能发送。Render 先检查模板里出现的占位符是否都在 Vars 里声明,再检查调用方是否提供了全部变量。两个检查分开,是因为它们对应不同的责任方:未声明是运营配置错误,缺变量是业务代码错误。
长度校验用 utf8.RuneCountInString 而不是 len,原因在 10.1 已经说过:中文标题按字节算会误判。聚合后的摘要很容易超过 200 字,尤其是“张三、李四、王五等 8 人评论了你的文章”这种模板。与其让渠道截断,不如在渲染时直接拒绝,让业务方改成“等 8 人”的短摘要。
测试:缺变量、未声明和超长都必须报错
模板测试不能只测“正常替换”。聚合场景最容易出问题的是缺变量和超长,因此测试要覆盖四个方向:正常替换、缺变量、未声明占位符、长度边界。下面的测试用 strings.Contains 检查错误信息里是否带变量名,因为线上排查时需要一眼看出是哪个变量。
// file: internal/push/template_test.go
// tier: L1
// verify: go test ./internal/push/
package push
import (
"strings"
"testing"
)
func TestRenderReplacesDeclaredVars(t *testing.T) {
tpl := Template{
ID: "comment-reply",
Title: "{{actor}} 回复了你",
Body: "在《{{title}}》下:{{excerpt}}",
Vars: []string{"actor", "title", "excerpt"},
}
got, err := Render(tpl, map[string]string{
"actor": "小林",
"title": "周末骑行路线",
"excerpt": "这段路我也走过",
})
if err != nil {
t.Fatal(err)
}
if got.Title != "小林 回复了你" {
t.Fatalf("title = %q", got.Title)
}
if got.Body != "在《周末骑行路线》下:这段路我也走过" {
t.Fatalf("body = %q", got.Body)
}
}
func TestRenderRejectsMissingVariable(t *testing.T) {
tpl := Template{
ID: "order-paid",
Title: "订单已支付",
Body: "金额 {{amount}} 元",
Vars: []string{"amount"},
}
_, err := Render(tpl, map[string]string{})
if err == nil {
t.Fatal("want error for missing variable")
}
if !strings.Contains(err.Error(), "amount") {
t.Fatalf("error %q does not name the missing variable", err)
}
}
func TestRenderRejectsUndeclaredPlaceholder(t *testing.T) {
tpl := Template{
ID: "promo",
Title: "限时活动",
Body: "{{discount}} 折",
Vars: []string{"amount"},
}
_, err := Render(tpl, map[string]string{"discount": "8"})
if err == nil {
t.Fatal("want error for undeclared placeholder")
}
if !strings.Contains(err.Error(), "discount") {
t.Fatalf("error %q does not name the undeclared placeholder", err)
}
}
func TestRenderEnforcesLengthLimits(t *testing.T) {
vars := map[string]string{}
base := Template{ID: "length", Title: "标题", Body: "正文"}
ok := base
ok.Title = strings.Repeat("标", maxTitleRunes)
ok.Body = strings.Repeat("文", maxBodyRunes)
if _, err := Render(ok, vars); err != nil {
t.Fatalf("boundary values should pass: %v", err)
}
longTitle := base
longTitle.Title = strings.Repeat("标", maxTitleRunes+1)
if _, err := Render(longTitle, vars); err == nil {
t.Fatal("want error for overlong title")
}
longBody := base
longBody.Body = strings.Repeat("文", maxBodyRunes+1)
if _, err := Render(longBody, vars); err == nil {
t.Fatal("want error for overlong body")
}
}
TestRenderReplacesDeclaredVars 用一条评论回复模板验证正常路径,标题和正文都包含多个占位符。TestRenderRejectsMissingVariable 和 TestRenderRejectsUndeclaredPlaceholder 分别覆盖“调用方没传”和“运营没声明”两种情况,它们的错误信息都带变量名。
TestRenderEnforcesLengthLimits 先验证边界值可以通过,再分别验证标题和正文超一个字符会失败。边界值测试很重要:如果实现把 > 写成 >=,正好 64 字的标题会被误拒,而普通测试可能永远发现不了。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/push/ -run 'TestRender' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestRenderReplacesDeclaredVars
--- PASS: TestRenderReplacesDeclaredVars (0.00s)
=== RUN TestRenderRejectsMissingVariable
--- PASS: TestRenderRejectsMissingVariable (0.00s)
=== RUN TestRenderRejectsUndeclaredPlaceholder
--- PASS: TestRenderRejectsUndeclaredPlaceholder (0.00s)
=== RUN TestRenderEnforcesLengthLimits
--- PASS: TestRenderEnforcesLengthLimits (0.00s)
PASS
ok webapp/internal/push 0.271s
四个用例全部通过。0.00s 只代表单次渲染很快,不代表聚合链路很快;真实聚合还要读 Redis 状态、排序事件、生成摘要,再走渠道发送,任何一段都可能成为瓶颈。
小结
通知聚合和频控解决的是“发多少”和“什么时候发”。聚合按用户、会话或内容选择合并维度,频控按营销、互动、事务、安全选择不同策略。两者都不能在渠道层完成,因为渠道只负责投递,不理解业务语义。
Template 和 Render 是聚合结果落地前的最后一道校验:变量必须声明、必须提供、长度必须可控。它不决定聚合维度,也不决定频控阈值,但能保证聚合出来的摘要不会因为缺变量或超长而变成垃圾消息。下一节讨论推送失败后的降级,那是“发不出去怎么办”的问题。
阅读导航:上一节:10.1 渠道、模板与到达率 · 下一节:10.3 推送失败与降级 。
常见错误与边界
- 把营销通知和事务通知放进同一个频控计数器。营销发多了会误伤支付、安全等必须送达的消息。
- 只按单条规则限流,没有全局上限。四条营销规则各自合规,叠加起来仍然可能一天四条。
- 聚合窗口过长。点赞摘要延迟半小时才到,用户已经打开过应用,通知失去意义。
- 聚合窗口过短。窗口太短等于没有聚合,用户仍然会被连续打扰。
- 重试不去重。渠道超时后的重试会重复发送同一条通知,用户看到的是“系统卡了”。
- 用一套模板适配所有聚合场景。评论串和点赞摘要的语义不同,变量和文案都应该分开配置。
- 摘要不设长度上限。聚合人数越多文案越长,最终被渠道截断,用户看到半句话。
- 把频控拒绝当成失败重试。被频控拦截的营销通知应该丢弃或顺延,而不是立刻重试,否则会放大流量。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。