「回声」的 iOS 推送,静默失败了三天
「回声」的推送服务有一个健康检查接口:它会向 APNs 发一条测试消息,只要拿到 200 就认为渠道正常。这个检查每天跑一次,三天里一直显示绿色。与此同时,iOS 用户的评论回复、审核结果、支付提醒全都没有到达,客服工单开始堆积。
真正的原因很普通:APNs 的生产证书过期了。推送服务在建立连接时被拒绝,错误被适配层捕获后写进了日志,但发送队列把这条消息标记为“已投递”,因为适配层只判断了“有没有抛出未处理的异常”,没有判断“渠道返回的是不是成功”。健康检查用的测试消息走的是另一条开发证书通道,所以一直正常。
三天后,一位 iOS 用户在其他平台反馈“你们的推送是不是坏了”,团队才开始查。此时已经积累了大量未送达的事务通知,其中一部分是审核结果,用户以为自己的内容还在审核中,反复提交,产生了重复内容。
这个事故的核心不是证书过期,而是失败没有被显式处理。发送失败后,系统既没有重试,也没有降级,更没有告警,只是把消息丢掉了。推送是一个跨外部系统的链路,失败是常态,问题在于失败之后做什么。
先区分可重试与不可重试
不是所有失败都值得重试。渠道超时、连接被重置、服务端 5xx 通常是暂时性的,可以重试;证书过期、token 无效、包体超限、用户关闭通知则是确定性的,重试多少次都不会成功。把这两类失败混在一起,重试会变成无效流量,甚至触发渠道限流。
| 失败类型 | 典型错误 | 是否重试 | 建议动作 | 用户影响 |
|---|---|---|---|---|
| 暂时性网络失败 | 超时、连接重置、503 | 是 | 指数退避后重试 | 延迟收到 |
| 渠道限流 | 429、配额耗尽 | 是 | 按渠道建议时间退避 | 延迟收到 |
| 凭证失效 | 证书过期、密钥错误 | 否 | 告警并切换备用凭证 | 完全收不到 |
| Token 失效 | BadDeviceToken、410 Gone | 否 | 清理 token,标记设备不可达 | 该设备收不到 |
| 包体或字段超限 | PayloadTooLarge、参数错误 | 否 | 拒绝发送并修正模板 | 该条收不到 |
| 用户关闭通知 | 权限拒绝、订阅未授权 | 否 | 降级到站内信或短信 | 换通道收到 |
这张表决定了重试策略的边界。重试只对前两类有意义,而且必须有上限和退避。无上限重试会在渠道故障时制造重试风暴,把一次外部故障放大成内部雪崩。固定间隔重试也不合适,渠道限流时固定间隔只会继续撞墙。
退避策略通常用指数退避加抖动:第一次等 1 秒,第二次 2 秒,第三次 4 秒,每次加一点随机抖动,避免大量客户端在同一时刻重试。重试上限一般设 2 到 4 次,超过之后进入降级流程。事务通知可以多试几次,营销通知应该尽早放弃,因为过期的营销消息没有价值。
重试状态要持久化
重试不能只放在内存里。进程重启、发布、扩容都会丢失内存中的重试队列,消息就永远消失了。把重试任务写入持久化队列,记录通知 ID、已尝试次数、下次尝试时间和最后一次错误,消费者按时间取出执行。这样即使服务重启,重试也能继续。
持久化也带来去重问题。同一条通知可能被多个消费者同时取出,必须在发送前用通知 ID 做幂等,或者用队列的可见性超时保证同一时刻只有一个消费者处理。幂等键应该由业务生成,而不是由渠道返回的 message ID 生成,因为渠道调用失败时根本没有 message ID。
重试记录还要能回答“为什么失败”。只存一个布尔值不够,至少要有错误码、错误信息和渠道名称。证书过期和 token 失效的处理方式完全不同,如果日志里只有“发送失败”,排查时只能靠猜。
失败之后要降级,而不是静默丢弃
降级的本质是换一条通道完成同一个业务目标。推送失败后,站内信是最常用的兜底:用户下次打开应用能看到;短信适合支付、安全等强事务;邮件适合账单和长文通知。降级不是把所有消息都发一遍,而是按业务重要性选择通道。
| 业务类型 | 主通道 | 第一降级 | 第二降级 | 是否允许最终丢弃 |
|---|---|---|---|---|
| 支付、安全 | APNs / FCM | 短信 | 站内信 | 不允许 |
| 审核结果 | APNs / FCM | 站内信 | 邮件 | 不允许 |
| 评论回复 | APNs / FCM | 站内信 | 无 | 可以,但需聚合 |
| 营销活动 | APNs / FCM | 无 | 无 | 允许 |
降级链路要有明确的终点。支付通知如果短信也失败,必须进入人工告警,而不是悄悄结束。营销通知则应该允许直接丢弃,因为它的时效性强,补发反而打扰用户。把“是否允许最终丢弃”写成配置,比在代码里散落 if-else 更容易审计。
降级还要避免重复打扰。主通道超时但实际已经送达的情况并不少见,直接降级到短信会让用户收到两条内容相同的消息。可行的做法是给降级加一个短延迟,并优先检查客户端回执;如果没有回执再降级。这个延迟对事务通知可以短一些,对互动通知可以长一些。
失败场景:证书过期,三天没人发现
回到那次事故,完整的失败链路是这样的:推送服务调用 APNs,连接被拒绝,适配层返回错误;发送队列的 worker 捕获错误后,把任务标记为完成,理由是“已经处理过,不需要再次投递”;监控只看队列积压,队列没有积压,所以没有告警;健康检查走开发证书,和生产证书不是同一条链路,所以一直绿色。
三个环节各自都“没有报错”,合起来就是静默失败。第一,适配层没有把渠道错误向上传递,而是吞掉了。第二,队列没有区分“成功完成”和“失败完成”,失败任务被当成成功确认。第三,监控只覆盖了内部指标,没有覆盖“用户是否收到”这个外部结果。
后果是连锁的。审核结果没送达,用户重复提交;支付提醒没送达,用户以为支付失败,重复付款;评论回复没送达,互动率下降。更麻烦的是,三天的消息已经过期,补发会让用户一次性收到大量旧通知,只能筛选出仍然有效的事务通知逐条补发。
修正方案有四个动作。第一,适配层把渠道错误分类后返回,不允许吞错。第二,队列区分成功、可重试失败和不可重试失败,不可重试失败直接进入降级。第三,健康检查使用生产凭证和生产链路,并增加“最近一小时成功发送量”的告警。第四,建立降级链路,事务通知在推送失败后自动转站内信或短信。
用 Plan、Attempt 和 Next 固定降级顺序
降级逻辑最容易写成一串 if-else:主渠道失败就试备用,备用失败就发短信,短信失败就写站内信。分支一多,顺序和上限就说不清楚。下面的代码把降级计划抽象成 Plan,把已发生的尝试抽象成 Attempt,由 Next 统一决定下一步。它不负责真正发送,只负责回答“下一次该发给谁”。
// file: internal/push/fallback.go
// tier: L1
// verify: go test ./internal/push/
package push
import "fmt"
// Attempt 记录一次已经发生的投递结果。Reason 只用于日志和排障,
// 不参与路由决策。
type Attempt struct {
Channel Channel
OK bool
Reason string
}
// Plan 描述一条通知的主渠道和备用渠道。MaxAttempts 包含主渠道那次尝试。
type Plan struct {
Primary Channel
Fallback Channel
MaxAttempts int
}
// Next 根据已发生的 attempts 返回下一次应该投递的渠道。
// 返回 false 表示不再重试:要么已经成功,要么尝试次数用完。
func Next(p Plan, attempts []Attempt) (Channel, bool, error) {
if p.Primary == "" {
return "", false, fmt.Errorf("push: plan has empty primary channel")
}
if p.Fallback == "" {
return "", false, fmt.Errorf("push: plan has empty fallback channel")
}
if p.Primary == p.Fallback {
return "", false, fmt.Errorf("push: primary and fallback are both %q", p.Primary)
}
if p.MaxAttempts <= 0 {
return "", false, fmt.Errorf("push: MaxAttempts must be positive, got %d", p.MaxAttempts)
}
if len(attempts) == 0 {
return p.Primary, true, nil
}
if len(attempts) >= p.MaxAttempts {
return "", false, nil
}
if attempts[len(attempts)-1].OK {
return "", false, nil
}
if len(attempts) == 1 {
return p.Fallback, true, nil
}
return "", false, nil
}
Next 只做三件事:校验计划、判断是否还有尝试机会、按顺序返回主渠道或备用渠道。它不 sleep,不调用渠道,也不修改 attempts。把“决定”和“执行”分开,测试就可以只验证决策,不需要 mock 网络。
MaxAttempts 包含主渠道那次尝试,这个语义必须在注释里写清楚。如果把它理解成“额外重试次数”,主渠道失败后就会多试一次备用,实际发送次数比配置多一。配置和代码对不上,是降级系统最常见的 bug 来源之一。
Next 遇到最后一次尝试成功时返回 false,而不是继续返回备用渠道。这个判断放在 len(attempts) >= MaxAttempts 之后,因为尝试次数用尽时无论如何都不应该再发。顺序很重要:先判断上限,再判断成功,最后才判断是否需要降级。
测试:主备顺序、成功终止和上限
降级测试要覆盖四类行为:空尝试返回主渠道;主渠道失败后返回备用;任意一次成功之后不再继续;尝试次数用尽后停止。此外还要覆盖非法计划,包括主备相同、渠道为空和上限非正。下面的测试按这五类组织。
// file: internal/push/fallback_test.go
// tier: L1
// verify: go test ./internal/push/
package push
import "testing"
func TestNextStartsWithPrimary(t *testing.T) {
p := Plan{Primary: APNs, Fallback: WebPush, MaxAttempts: 2}
got, ok, err := Next(p, nil)
if err != nil {
t.Fatal(err)
}
if !ok || got != APNs {
t.Fatalf("Next(nil) = %q, %v; want apns, true", got, ok)
}
}
func TestNextFallsBackAfterPrimaryFailure(t *testing.T) {
p := Plan{Primary: APNs, Fallback: WebPush, MaxAttempts: 2}
attempts := []Attempt{{Channel: APNs, OK: false, Reason: "BadDeviceToken"}}
got, ok, err := Next(p, attempts)
if err != nil {
t.Fatal(err)
}
if !ok || got != WebPush {
t.Fatalf("Next = %q, %v; want webpush, true", got, ok)
}
}
func TestNextStopsAfterSuccess(t *testing.T) {
p := Plan{Primary: APNs, Fallback: WebPush, MaxAttempts: 3}
attempts := []Attempt{{Channel: APNs, OK: true}}
got, ok, err := Next(p, attempts)
if err != nil {
t.Fatal(err)
}
if ok || got != "" {
t.Fatalf("Next = %q, %v; want no further attempt", got, ok)
}
}
func TestNextStopsAtMaxAttempts(t *testing.T) {
p := Plan{Primary: APNs, Fallback: WebPush, MaxAttempts: 2}
attempts := []Attempt{
{Channel: APNs, OK: false, Reason: "timeout"},
{Channel: WebPush, OK: false, Reason: "410 Gone"},
}
got, ok, err := Next(p, attempts)
if err != nil {
t.Fatal(err)
}
if ok || got != "" {
t.Fatalf("Next = %q, %v; want exhausted", got, ok)
}
oneTry := Plan{Primary: APNs, Fallback: WebPush, MaxAttempts: 1}
got, ok, err = Next(oneTry, attempts[:1])
if err != nil {
t.Fatal(err)
}
if ok || got != "" {
t.Fatalf("MaxAttempts=1 Next = %q, %v; want exhausted", got, ok)
}
}
func TestNextRejectsInvalidPlan(t *testing.T) {
cases := []Plan{
{Primary: "", Fallback: WebPush, MaxAttempts: 2},
{Primary: APNs, Fallback: "", MaxAttempts: 2},
{Primary: APNs, Fallback: APNs, MaxAttempts: 2},
{Primary: APNs, Fallback: WebPush, MaxAttempts: 0},
}
for _, p := range cases {
if _, _, err := Next(p, nil); err == nil {
t.Fatalf("want error for plan %+v", p)
}
}
}
TestNextFallsBackAfterPrimaryFailure 验证主渠道失败后的降级顺序。TestNextStopsAfterSuccess 验证成功之后不再继续,避免用户收到主渠道和备用渠道各一条。TestNextStopsAtMaxAttempts 同时覆盖“两次用尽”和“上限为 1”的情况,后者是配置里最容易写错的边界。
TestNextRejectsInvalidPlan 把四类非法计划放在一个表里。主备相同是特别危险的一种:如果不报错,降级会在两个相同渠道之间来回,用户可能收到重复消息。提前在计划校验阶段拒绝,比在发送阶段靠日志发现要可靠。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/push/ -run 'TestNext' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestNextStartsWithPrimary
--- PASS: TestNextStartsWithPrimary (0.00s)
=== RUN TestNextFallsBackAfterPrimaryFailure
--- PASS: TestNextFallsBackAfterPrimaryFailure (0.00s)
=== RUN TestNextStopsAfterSuccess
--- PASS: TestNextStopsAfterSuccess (0.00s)
=== RUN TestNextStopsAtMaxAttempts
--- PASS: TestNextStopsAtMaxAttempts (0.00s)
=== RUN TestNextRejectsInvalidPlan
--- PASS: TestNextRejectsInvalidPlan (0.00s)
PASS
ok webapp/internal/push 0.268s
五个用例全部通过。0.00s 只说明决策函数本身很快,真实降级还要等退避时间、调用备用渠道、检查回执。证书过期这类不可重试错误,应该在第一次失败后就进入降级,而不是等到 MaxAttempts 用尽。
小结
推送失败是常态,静默丢弃才是事故。处理失败的第一步是分类:暂时性错误可以重试,确定性错误必须降级或告警。第二步是给重试加上限和退避,避免把外部故障放大成内部雪崩。第三步是为不同业务选择降级通道,并明确哪些通知允许最终丢弃。
Plan、Attempt 和 Next 把降级顺序变成一个可测试的决策函数。它不解决“怎么发”,但能保证“下一次发给谁、还要不要发”有唯一答案。下一章讨论索引与业务库的同步,那是另一个“两套系统如何保持一致”的问题。
阅读导航:上一节:10.2 通知聚合与频控 · 下一节:11.1 索引与业务库的同步 。
常见错误与边界
- 把渠道错误吞掉,只记录“已处理”。队列以为成功,用户永远收不到,事故只能靠用户反馈发现。
- 对所有错误都重试。证书过期、token 失效重试多少次都不会成功,只会制造无效流量。
- 重试没有上限。渠道故障时重试风暴会把一次外部故障放大成内部雪崩。
- 重试没有退避。固定间隔重试在渠道限流时只会继续撞墙,应该用指数退避加抖动。
- 主备渠道配置成同一个。降级会在两个相同渠道之间来回,用户可能收到重复消息。
- 降级到短信却不做去重。主通道超时但实际已送达时,用户会收到两条内容相同的消息。
- 事务通知失败后直接丢弃。支付、安全、审核结果必须有降级通道或人工告警。
- 健康检查只测内部队列,不测生产凭证和真实链路。队列没积压不代表用户能收到。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。