《互联网产品的服务端架构·内容、社交与实时互动》10.1 渠道、模板与到达率

从「回声」一条发布成功通知用户却没看到写起:区分发送成功与真实到达,拆解 APNs、FCM、Web Push、小程序订阅消息在包体、图片和深链上的差异,给出 Capability、Of 的 Go 实现、表驱动测试与真实运行输出,并梳理模板裁剪和到达率统计的常见错误。

「回声」的作者说,文章发布了却没人知道

「回声」是一个内容社区。作者写完一篇文章,点击发布,系统会给他的关注者发一条通知:某某发布了新文章。产品经理在需求文档里写得很简单:发布成功后调用推送服务,把标题和摘要发出去。

上线第一周,数据看板上的“推送成功率”是 99.2%,看起来一切正常。但作者群里不断有人问:为什么我发布之后,粉丝说完全没收到提醒?运营抽查了十个粉丝,其中三个人的手机通知栏里确实没有这条消息,而服务端日志显示 APNs 和 FCM 都返回了 200。

排查后发现,问题分散在三个地方。第一,有人的 iOS 应用在后台被系统限制,APNs 接受了请求,但设备没有拉起通知。第二,有人的 Android 机型把应用归入“不常用应用”,FCM 消息进了待投递队列,第二天才出现。第三,有人的小程序只订阅了“评论回复”,根本没有订阅“新文章”,服务端却把消息发到了订阅消息接口,接口返回成功,用户端不会展示。

这三件事指向同一个结论:服务端记录的“发送成功”,和用户真正“看到”之间,隔着渠道能力、系统策略和用户授权三层。到达率必须从“渠道回了什么”和“用户后来做了什么”两边一起算,不能只看接口返回码。

到达率不是“发出去了”,而是“用户看到了”

先把几个容易混的指标分开。发送量是服务端调用渠道接口的次数;受理量是渠道返回成功、承诺会尝试投递的数量;展示量是客户端真正把通知画到通知栏或会话列表的次数;点击量是用户点开通知的次数。到达率通常指展示量除以发送量,而点击率是点击量除以展示量。把受理量当展示量,是所有“成功率很高但用户没感觉”的根源。

不同渠道对这些指标的可见程度完全不同。APNs 只在设备回执里给出部分信息,FCM 有 delivery receipt 但依赖客户端上报,Web Push 的 Push API 只能知道推送服务是否接收,小程序订阅消息则受一次性订阅和模板字段限制。服务端不能假设所有渠道都能提供同等粒度的回执。

先把四个口径写成埋点

术语不落到埋点上,团队对“到达率”的理解就会各说各话。发送量在服务端调用渠道前打点,带上模板 ID、渠道、用户 ID 和批次号;受理量在渠道返回成功时打点,记录渠道返回的 message ID;展示量由客户端在通知真正展示时上报,带上同一条 message ID;点击量由客户端在用户点开时上报。四个点用同一个 trace ID 串起来,才能算清楚每一步的损耗。

这里有一个现实约束:客户端上报可能丢失,也可能延迟。服务端不能把“没有收到展示回执”直接等同于“没有展示”,否则弱网用户的到达率会被系统性低估。更稳妥的做法是把展示回执当作异步信号,先用渠道受理量做实时监控,再用客户端回执做离线校准,两者分别出报表。

埋点还要区分“首次发送”和“重试发送”。同一条通知可能因为超时被重试三次,如果每次重试都记一条发送量,到达率的分母会虚高。正确做法是给通知分配稳定的 notification ID,发送量按 ID 去重,尝试次数单独记录。这样既能看整体到达率,也能看单条通知的重试成本。

渠道优先级不等于到达率

产品上常有一个误解:iOS 用户走 APNs,Android 用户走 FCM,渠道选对了,到达率就应该高。实际上渠道只是必要条件,不是充分条件。用户可能关闭了通知权限,可能把应用放进了后台限制名单,可能长时间没有打开应用导致推送 token 失效。这些状态都不在渠道接口的返回里。

因此,渠道选择逻辑和到达率统计必须解耦。选择逻辑回答“这条消息应该走哪个渠道”,统计逻辑回答“走这个渠道之后用户有没有看到”。前者可以在发送前决定,后者只能在发送后观察。把两者混在一个“推送成功”字段里,就会得到那个 99.2% 的假象。

更细一点,渠道优先级应该由业务语义决定,而不是由到达率决定。交易通知优先保证可达,可以走 APNs 加短信兜底;营销通知优先控制打扰,应该尊重用户的免打扰设置。到达率是结果指标,不是路由策略本身。

渠道单条包体上限图片深链典型限制模板策略
APNs4 KB支持支持后台刷新受系统调度标题短、摘要完整、可带跳转参数
FCM4 KB支持需客户端解析厂商通道可能二次裁剪正文控制在两行内,跳转放在 data
Web Push2 KB不支持支持浏览器权限随时可被撤销纯文本,链接必须完整可点
小程序订阅消息1 KB不支持不支持必须用户逐次订阅,字段有长度限制只放关键字段,长文改为站内信

表中的数字是本书配套 lab 采用的案例假设值,不是各平台当前文档的精确上限。真实项目应以接入时渠道文档为准,并把这些值做成配置而不是散落在代码里的常量。表的用途不是背数字,而是提醒你:四个渠道的能力形状不同,模板不能一套通发。

“图片”和“深链”看起来是产品功能,实际上会反过来改变文案结构。支持图片的渠道可以把摘要压缩成一句引导语,让图片承担信息;不支持图片的渠道必须把关键信息写进正文。支持深链的渠道可以只写“点击查看”,不支持深链的小程序则要把用户需要的信息直接放在模板字段里,因为用户点进去也未必能找到对应页面。

渠道能力为什么要建模成显式结构

很多团队把渠道差异写成 if-else:if channel == "apns" { ... } else if channel == "fcm" { ... }。短期能跑,长期会出三类问题。新增渠道时忘记改所有分支;某个分支复制粘贴时漏掉一个限制;测试只覆盖最常用的那个渠道。把能力抽成 Capability 结构,路由和裁剪逻辑都读同一份数据,差异才有唯一事实来源。

建模的粒度也值得讨论。包体上限、图片、深链是最小集合,还可以加入“是否支持声音”“是否支持分组”“是否需要用户逐次授权”。字段不是越多越好,每加一个字段,就要有对应的模板分支和测试。这个 lab 只保留三个直接影响文案的字段,其余能力留在渠道适配层内部。

模板必须按渠道裁剪,而不是发出后祈祷

模板系统的常见写法是:运营配置一套标题和正文,服务端做变量替换,然后原样发给所有渠道。这个写法在 APNs 和 FCM 上可能勉强能用,到了 Web Push 和小程序就会出问题。Web Push 的包体更小,小程序的字段长度和订阅次数都受限。模板不是“一段文本”,而是“在某个渠道约束下可渲染的一段文本”。

裁剪有两种做法。第一种是运营为每个渠道各配一套模板,服务端按渠道选择;第二种是运营只配一套主模板,服务端按渠道能力做截断、去图、替换链接。第一种可控但维护成本高,第二种省人力但容易截出半句话。折中的做法是:主模板负责语义,渠道变体负责长度和结构,服务端只做校验,不做创造性改写。

校验必须发生在发送之前。标题超过渠道限制、正文超过限制、模板里出现未声明的占位符、变量缺失,都应该在渲染阶段报错,而不是让渠道去截断。渠道截断不会告诉你它截在了哪里,用户看到的可能正好是“点击下方链接查”这种半截话。

模板变体如何组织

如果选择“为每个渠道各配一套模板”,要避免四份模板各自演化。可行的组织方式是:主模板保存语义和变量声明,渠道变体只覆盖标题前缀、正文字段和跳转方式。所有变体共享同一份变量列表,渲染前用主模板做一次声明校验,再用变体做长度校验。这样运营改文案时只改一处语义,渠道差异仍然可控。

如果选择“一套模板加服务端裁剪”,必须把裁剪规则写成可测试的函数。例如“正文超过 80 字时截断并追加省略号”“不支持图片时移除图片字段并保留文字摘要”。裁剪函数要返回是否发生了裁剪,发送记录里保存这个标记。没有标记的截断等于静默改写,事后无法解释用户为什么看到半句话。

无论哪种方式,模板都不应该在发送时临时拼接 HTML 或 JSON。模板只负责文本和变量,序列化交给渠道适配层。模板里出现渠道专属的 JSON 字段,意味着模板和渠道耦合了,下一节做聚合时会很难复用。

一次渲染要回答的四个问题

渲染一条通知,至少要回答四个问题:模板声明了哪些变量;调用方是否提供了全部变量;渲染后的标题和正文有多长;目标渠道能否承载这个长度。前三个问题与渠道无关,第四个问题依赖 Capability。把前三个问题放进模板层,第四个问题放进渠道适配层,职责就清楚了。

长度校验用字符数而不是字节数。中文在 UTF-8 里通常占三个字节,按字节限制会把一条正常的中文标题误判为超长;但渠道的包体限制又是按字节算的。这两个口径都要保留:模板层限制“用户可读的字符数”,渠道层限制“序列化后的字节数”。本节的 lab 先实现前者,后者留给渠道适配层。

失败场景:300 字模板发进小程序,被截成半句话

「回声」有一次做“作者激励计划”,运营写了一套 300 字的模板:开头恭喜作者入选,中间说明流量扶持规则,结尾附上活动页面入口。这套模板在站内信里展示正常,运营就把它配置成了全渠道通用。

当天晚上,小程序用户收到的订阅消息只有前半句:“恭喜你入选本期作者激励计划,本次扶持将”。后面的规则和入口全部消失。用户点开消息,跳转的是小程序首页,因为模板里根本没有配置页面路径。客服收到大量询问:到底扶持什么、去哪里看规则、是不是诈骗。

技术上的原因很直接:小程序订阅消息的正文有长度限制,渠道把超长部分截断了;同时模板里的活动链接是一个 HTTP URL,而小程序订阅消息不支持这种深链,服务端也没有把它转换成小程序页面路径。更严重的是,服务端把渠道返回的“受理成功”记成了“发送成功”,报表上看不出任何异常。

这次事故的后果不只是几条通知难看。第一,用户对活动真实性的信任下降,客服成本上升。第二,运营误以为活动已经触达,后续没有补发站内信。第三,模板系统没有留下“被裁剪”的记录,事后无法统计影响范围。修正方案有三步:模板按渠道拆分配置;渲染阶段做长度和占位符校验;发送结果区分“受理”和“展示”,并对超长模板直接拒绝而不是交给渠道截断。

事故后的排查顺序

这类问题事后排查时,不要先怀疑渠道故障。按“模板、订阅、权限、系统策略”的顺序查,通常最快。先看模板渲染后的实际标题和正文长度,再看用户是否订阅了对应消息类型,然后看客户端通知权限是否开启,最后才看系统后台限制和渠道回执。这个顺序的依据是:越靠近服务端的环节越容易验证,越靠近用户的环节越难复现。

排查时还要区分“单个用户”和“一批用户”。单个用户收不到,多半是权限、token 或订阅状态问题;一批用户同时收不到,才更可能是模板、渠道配置或服务端代码问题。把这两个场景混在一起查,会在错误的层面浪费大量时间。

最后要留下可回放的记录:模板 ID、渲染后的标题和正文、目标渠道、渠道返回的 message ID、客户端展示回执。没有这些记录,事故只能靠用户描述来还原,而用户描述通常只记得“没收到”,记不住当时的系统状态。

用 Capability 和 Of 固定渠道边界

下面的代码来自本书配套的 webapp-lab。它把四个渠道的能力写成一张表,Of 负责查表和校验。未知渠道直接报错,包体上限非正也直接报错。这样路由层拿到的永远是一个合法的 Capability,不需要在每个调用点重复判断。

// file: internal/push/channel.go
// tier: L1
// verify: go test ./internal/push/

package push

import "fmt"

// Channel 是推送渠道的稳定标识,直接用于日志、路由和降级配置。
type Channel string

const (
	APNs        Channel = "apns"
	FCM         Channel = "fcm"
	WebPush     Channel = "webpush"
	MiniProgram Channel = "miniprogram"
)

// Capability 描述一个渠道在包体、图片和深链上的硬约束。
// MaxPayloadKB 以 KB 计,是服务端裁剪模板前必须知道的边界。
type Capability struct {
	MaxPayloadKB     int
	SupportsImage    bool
	SupportsDeepLink bool
}

var capabilities = map[Channel]Capability{
	APNs:        {MaxPayloadKB: 4, SupportsImage: true, SupportsDeepLink: true},
	FCM:         {MaxPayloadKB: 4, SupportsImage: true, SupportsDeepLink: false},
	WebPush:     {MaxPayloadKB: 2, SupportsImage: false, SupportsDeepLink: true},
	MiniProgram: {MaxPayloadKB: 1, SupportsImage: false, SupportsDeepLink: false},
}

// Of 返回渠道能力。未知渠道、包体上限非正的配置都直接报错,
// 让错误在启动或路由阶段暴露,而不是等线上裁剪时才发现。
func Of(c Channel) (Capability, error) {
	cap, ok := capabilities[c]
	if !ok {
		return Capability{}, fmt.Errorf("push: unknown channel %q", c)
	}
	if cap.MaxPayloadKB <= 0 {
		return Capability{}, fmt.Errorf("push: channel %q has non-positive MaxPayloadKB %d", c, cap.MaxPayloadKB)
	}
	return cap, nil
}

这段实现有两个刻意的选择。第一,能力表是包级私有变量,外部只能通过 Of 读取,避免调用方绕过校验直接改表。第二,Of 同时校验“渠道存在”和“配置合法”,因为一个存在于表里但包体上限为 0 的渠道,和未知渠道一样不可用。把两种错误都放在同一个入口,启动自检只需要遍历渠道列表调用一次 Of。

MaxPayloadKB 用 KB 而不是字节,是为了让配置更易读。真正计算包体时,渠道适配层仍然要序列化后按字节比较,不能把 KB 当成精确值。这个字段的语义是“上限的量级”,不是“可以精确填满的配额”。

测试:四种渠道的能力必须彼此不同

能力表最容易退化成复制粘贴。四个渠道写了四行,其中两行一模一样,测试却只检查“没有报错”。下面的测试除了检查合法性,还用 map[Capability]Channel 检查四种能力是否重复。Capability 只包含可比较字段,因此可以直接作为 map 的键。

// file: internal/push/channel_test.go
// tier: L1
// verify: go test ./internal/push/

package push

import "testing"

func TestOfReturnsDistinctCapabilities(t *testing.T) {
	seen := make(map[Capability]Channel)
	for _, c := range []Channel{APNs, FCM, WebPush, MiniProgram} {
		cap, err := Of(c)
		if err != nil {
			t.Fatalf("Of(%q): %v", c, err)
		}
		if cap.MaxPayloadKB <= 0 {
			t.Fatalf("Of(%q) MaxPayloadKB = %d, want positive", c, cap.MaxPayloadKB)
		}
		if other, ok := seen[cap]; ok {
			t.Fatalf("channels %q and %q share capability %+v", other, c, cap)
		}
		seen[cap] = c
	}
	if len(seen) != 4 {
		t.Fatalf("got %d distinct capabilities, want 4", len(seen))
	}
}

func TestOfRejectsUnknownChannel(t *testing.T) {
	if _, err := Of(Channel("sms")); err == nil {
		t.Fatal("want error for unknown channel")
	}
	if _, err := Of(Channel("")); err == nil {
		t.Fatal("want error for empty channel")
	}
}

func TestOfRejectsNonPositivePayload(t *testing.T) {
	old := capabilities[MiniProgram]
	capabilities[MiniProgram] = Capability{MaxPayloadKB: 0}
	defer func() { capabilities[MiniProgram] = old }()

	if _, err := Of(MiniProgram); err == nil {
		t.Fatal("want error for zero MaxPayloadKB")
	}
}

TestOfReturnsDistinctCapabilities 同时验证三件事:每个渠道都能查到、包体上限为正、四个能力互不相同。最后一条是这套测试的核心。如果以后有人把 Web Push 改成和 FCM 一样,测试会明确指出是哪两个渠道撞了。

TestOfRejectsNonPositivePayload 临时修改包级变量,并在 defer 里恢复。它验证的是“配置坏了要报错”,而不是“渠道不存在”。这两个错误路径在生产上的表现不同:未知渠道通常是代码或配置写错,非正上限通常是配置中心推了一个坏值。日志里能区分它们,排障会快很多。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/push/ -run 'TestOf' -count=1 -v

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

=== RUN   TestOfReturnsDistinctCapabilities
--- PASS: TestOfReturnsDistinctCapabilities (0.00s)
=== RUN   TestOfRejectsUnknownChannel
--- PASS: TestOfRejectsUnknownChannel (0.00s)
=== RUN   TestOfRejectsNonPositivePayload
--- PASS: TestOfRejectsNonPositivePayload (0.00s)
PASS
ok  	webapp/internal/push	0.322s

PASS 表示三个用例全部通过,ok 行末尾是包路径和本次耗时。0.00s 是单个用例的耗时,不代表真实推送的端到端延迟;真实链路还包含模板渲染、渠道 HTTP 调用、设备回执和客户端上报。

小结

到达率的分母是“服务端决定发送”,分子是“用户真正看到”。中间隔着渠道能力、系统调度和用户授权。服务端能控制的是模板形状和渠道选择,不能控制系统和用户,所以必须把“受理成功”和“展示成功”分开记录。

Channel、Capability 和 Of 解决的是渠道边界问题:每种渠道能承载什么,必须在代码里有唯一、可测试的答案。模板裁剪、长度校验和回执统计都建立在这个答案之上。下一节会讨论通知聚合与频控,那是“发多少”的问题;这一节先保证“发得出去、发得正确”。

到达率还是一个需要长期维护的指标。渠道平台会调整包体限制和回执字段,用户会更换设备、重装应用、关闭权限,模板也会不断新增。服务端能做的,是把渠道能力配置化、把模板校验前置、把发送和展示分开记录。这三件事不会直接提高到达率,但会让到达率的变化可解释。

如果只能记住一句话:不要用“接口返回成功”回答“用户有没有看到”。前者是服务端的动作,后者是用户的结果,中间那段路需要单独的观测和兜底。

从成本角度看,为到达率做埋点不是额外开销,而是避免重复发送的前提。不知道哪些用户已经看到,就只能靠加大发送量来碰运气,最终既打扰用户,也推高渠道费用。

阅读导航:上一节:目录与学习路径 · 下一节:10.2 通知聚合与频控 。

常见错误与边界

  • 把渠道接口返回 200 当成用户已看到。受理、投递、展示是三个不同阶段,报表必须分开统计。
  • 用一套模板通发所有渠道。包体、图片和深链能力不同,至少要为小程序和 Web Push 准备短模板。
  • 在发送后依赖渠道截断超长文案。截断位置不可控,用户看到半句话比不发送更伤信任。
  • 只按字节校验中文长度。字节限制适合渠道适配层,用户可读长度应按字符数校验,两者都要保留。
  • 把渠道能力写死成 if-else。新增渠道时容易漏分支,能力表加 Of 才能保证唯一事实来源。
  • 允许包体上限为 0 的渠道上线。配置错误应该在启动自检或路由阶段报错,而不是静默发送空消息。
  • 用未声明的占位符拼模板。渲染阶段必须拒绝未声明变量,否则上线后才会发现变量永远为空。
  • 拿单元测试耗时估算推送延迟。真实延迟由渠道网络、设备状态和系统调度决定,单元测试只验证逻辑。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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