《互联网产品的服务端架构·内容、社交与实时互动》10.3 推送失败与降级

从「回声」APNs 证书过期导致 iOS 推送静默失败三天写起:区分可重试与不可重试的失败,说明重试上限、退避和主备渠道降级为何必须显式建模,给出 Plan、Attempt、Next 的 Go 实现、表驱动测试与真实运行输出,并总结失败降级中的常见错误。

「回声」的 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 失效重试多少次都不会成功,只会制造无效流量。
  • 重试没有上限。渠道故障时重试风暴会把一次外部故障放大成内部雪崩。
  • 重试没有退避。固定间隔重试在渠道限流时只会继续撞墙,应该用指数退避加抖动。
  • 主备渠道配置成同一个。降级会在两个相同渠道之间来回,用户可能收到重复消息。
  • 降级到短信却不做去重。主通道超时但实际已送达时,用户会收到两条内容相同的消息。
  • 事务通知失败后直接丢弃。支付、安全、审核结果必须有降级通道或人工告警。
  • 健康检查只测内部队列,不测生产凭证和真实链路。队列没积压不代表用户能收到。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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