欢迎光临
我们一直在努力

短信发不出去的时候,谁来告诉你:注册链路上的失败告警设计

目录

一、先厘清失败语义:什么算“发送异常”

二、架构:异步解耦是告警可靠性的前提

三、完整 Go DEMO

3.1 错误分类与告警级别

3.2 告警分发器与日志

3.3 邮件通知器

3.4 组装与运行

四、生产环境必须补充的四件事

五、回到问题本身


用户注册时点击“获取验证码”,页面提示“发送成功”,但短信始终没有到达。这个用户大概率不会重试,而是直接关掉页面。更糟的是,如果短信通道整体故障,成百上千个注册会在同一时间段内静默失败,而项目管理者对此一无所知,直到客服工单堆积才后知后觉。

短信发送是注册链路上最脆弱的环节:它依赖第三方服务商,受到运营商限流、签名审核、模板报备、网络抖动等多重外部因素制约。发送失败本身不是最可怕的——失败之后无人知晓才是。本文围绕“短信发送异常时记录日志并通知项目管理者”这一需求,给出一个可直接落地的 Go 实现方案。

一、先厘清失败语义:什么算“发送异常”

在写任何代码之前,必须把“异常”拆开。短信发送失败至少有四种性质完全不同的情况,它们对告警策略的要求截然不同:

第一类,业务性失败。 手机号格式错误、用户当日获取验证码次数超限、模板参数缺失。这类失败是用户或请求本身的问题,发送重试没有意义,也不应该惊动项目管理者——给一个输错号码的用户触发 P0 告警,只会制造噪音。

第二类,通道性失败。 短信服务商返回限流错误、签名未审核、账户余额不足、模板被拒。这类失败指向配置或资源问题,往往持续存在且影响所有后续请求,是告警的核心目标。

第三类,网络性失败。 调用短信 API 超时、连接被重置。可能是瞬时抖动,也可能意味着下游服务已经不可用。需要重试,但如果重试后仍然失败,必须告警。

第四类,不可归因失败。 拿到了非预期的响应,错误码不在已知分类中。这类失败最少见,但也最值得警惕,应当直接告警。

一个实用的分类策略是:调用方只负责把错误“归类”,告警层根据类别决定是否触发、触发到哪个通道。 业务性失败写日志即可,通道性失败立即告警,网络性失败在重试耗尽后告警,不可归因失败无条件告警。此外,如果同一类失败在短时间内集中爆发,即使单次属于“可重试”类别,也应当升级为告警信号。

二、架构:异步解耦是告警可靠性的前提

最直接的做法是在 SendSMS 返回错误的那一行紧接着发邮件。但这会把注册接口的响应时间绑定到 SMTP 服务器的握手耗时上,而且如果邮件发送本身失败,连错误日志都可能来不及写。

正确的结构是发送与告警解耦:短信发送发生在注册请求的同步路径中(或紧随其后的异步 Worker 中),失败信息被投递到一条独立的告警通道;告警消费者异步地完成日志落盘和通知发送。这样,短信通道的故障不会拖慢注册接口,告警通道的故障也不会阻断错误日志的记录。

整体链路:

注册请求 → 生成验证码 → SMS Sender(分类错误)
│
┌────────┴────────┐
│ 成功:返回 │
│ 失败:投递告警 │
└────────┬────────┘
▼
AlertDispatcher(缓冲通道)
│
┌────────┴────────┐
▼ ▼
结构化日志落盘 告警通知(邮件/Webhook)
│
去重 + 冷却

告警分发器持有缓冲通道和后台 goroutine。Dispatch 是非阻塞投递:如果通道已满,记录一条降级日志(“告警队列已满,告警被丢弃”),而不是阻塞调用方。去重和冷却是告警层必须具备的能力,否则一次持续五分钟的短信通道故障可能产生数千封告警邮件,把项目管理者的邮箱和告警系统一起打垮。

三、完整 Go DEMO

以下代码仅依赖标准库,可直接运行。短信发送部分用模拟 Sender 代替真实 SDK,但错误分类、异步分发、结构化日志、邮件告警和冷却去重的结构是完整可用的。

3.1 错误分类与告警级别

type FailureKind string

const (
FailureBusiness FailureKind = "business" // 不告警
FailureChannel FailureKind = "channel" // 立即告警
FailureNetwork FailureKind = "network" // 重试耗尽后告警
FailureUnknown FailureKind = "unknown" // 无条件告警
)

type SendError struct {
Kind FailureKind
Code string
Message string
Cause error
}

func (e *SendError) Error() string { return e.Code + ": " + e.Message }
func (e *SendError) Unwrap() error { return e.Cause }

func (e *SendError) ShouldAlert() bool {
return e.Kind != FailureBusiness
}

func (e *SendError) Severity() string {
switch e.Kind {
case FailureChannel, FailureUnknown:
return "P0"
case FailureNetwork:
return "P1"
default:
return "P3"
}
}

短信 Sender 的实现只做一件事:调用服务商,把返回的错误映射成上述分类。以下模拟实现中,特定手机号触发不同类别,便于演示:

type SMSSender interface {
Send(ctx context.Context, phone, code string) error
}

type mockSender struct{}

func (m *mockSender) Send(ctx context.Context, phone, code string) error {
switch phone {
case "13800000001":
return &SendError{Kind: FailureChannel, Code: "isv.BUSINESS_LIMIT_CONTROL",
Message: "短信服务商限流"}
case "13800000002":
return &SendError{Kind: FailureNetwork, Code: "timeout",
Message: "调用短信 API 超时", Cause: context.DeadlineExceeded}
case "13800000003":
return &SendError{Kind: FailureBusiness, Code: "isv.MOBILE_NUMBER_ILLEGAL",
Message: "手机号格式非法"}
default:
return nil
}
}

3.2 告警分发器与日志

Alert 承载一次告警所需的全部上下文。Dispatch 只做非阻塞投递;真正的日志写入和通知发送在后台 goroutine 中完成。

type Alert struct {
Time time.Time
Phone string
Kind FailureKind
Severity string
Code string
Message string
Cause error
}

type Dispatcher struct {
ch chan Alert
logger *slog.Logger
notifier Notifier
seen map[string]time.Time // key -> 最近一次告警时间
mu sync.Mutex
cooldown time.Duration
}

func NewDispatcher(buf int, logger *slog.Logger, notifier Notifier, cooldown time.Duration) *Dispatcher {
return &Dispatcher{
ch: make(chan Alert, buf),
logger: logger,
notifier: notifier,
seen: make(map[string]time.Time),
cooldown: cooldown,
}
}

func (d *Dispatcher) Start(ctx context.Context) {
go func() {
for {
select {
case <-ctx.Done():
return
case a := <-d.ch:
d.handle(a)
}
}
}()
}

func (d *Dispatcher) Dispatch(a Alert) {
select {
case d.ch <- a:
default:
// 队列满:降级记录,绝不阻塞短信发送路径
d.logger.Error("alert queue full, alert dropped",
"phone", maskPhone(a.Phone), "code", a.Code)
}
}

func (d *Dispatcher) handle(a Alert) {
// 1. 结构化日志:无论是否告警,失败事实必须落日志
d.logger.Error("sms send failed",
"time", a.Time.Format(time.RFC3339),
"phone", maskPhone(a.Phone),
"kind", string(a.Kind),
"severity", a.Severity,
"code", a.Code,
"message", a.Message,
"cause", errStr(a.Cause),
)

// 2. 去重与冷却
key := string(a.Kind) + ":" + a.Code
d.mu.Lock()
last, ok := d.seen[key]
if ok && time.Since(last) < d.cooldown {
d.mu.Unlock()
d.logger.Warn("alert suppressed by cooldown", "key", key)
return
}
d.seen[key] = time.Now()
d.mu.Unlock()

// 3. 异步通知
if err := d.notifier.Notify(context.Background(), a); err != nil {
d.logger.Error("alert notification failed", "err", err)
}
}

注意冷却的粒度为 Kind + Code:同一类失败在冷却窗口内只通知一次,但不同错误码各自独立。这样通道限流和余额不足不会互相抑制,而持续的同一种故障不会反复轰炸。

3.3 邮件通知器

type Notifier interface {
Notify(ctx context.Context, a Alert) error
}

type EmailNotifier struct {
Host, Port, From, Password string
To []string
}

func (n *EmailNotifier) Notify(ctx context.Context, a Alert) error {
subject := fmt.Sprintf("[%s] 短信发送异常: %s", a.Severity, a.Code)
body := fmt.Sprintf(
"时间: %s\\n手机号: %s\\n类别: %s\\n错误码: %s\\n描述: %s\\n原因: %s\\n",
a.Time.Format("2006-01-02 15:04:05"),
maskPhone(a.Phone), a.Kind, a.Code, a.Message, errStr(a.Cause),
)
msg := "Subject: " + subject + "\\r\\n" +
"From: " + n.From + "\\r\\n" +
"To: " + strings.Join(n.To, ",") + "\\r\\n" +
"Content-Type: text/plain; charset=UTF-8\\r\\n\\r\\n" + body

auth := smtp.PlainAuth("", n.From, n.Password, n.Host)
addr := n.Host + ":" + n.Port
return smtp.SendMail(addr, auth, n.From, n.To, []byte(msg))
}

实际项目中,用 jordan-wright/email 之类的库替代裸 smtp.SendMail 会更稳健,支持 HTML 正文、TLS 和附件,但标准库实现足以说明结构。项目管理者不一定时刻盯着邮箱,通常还会叠加企业微信或钉钉 Webhook 作为第二通道——Notifier 接口允许多个实现组合,MultiNotifier 依次调用即可。

3.4 组装与运行

func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
notifier := &EmailNotifier{
Host: "smtp.example.com", Port: "587",
From: "alert@example.com", Password: "***",
To: []string{"pm@example.com"},
}
dispatcher := NewDispatcher(256, logger, notifier, 2*time.Minute)
dispatcher.Start(context.Background())

sender := &mockSender{}

phones := []string{"13800000001", "13800000002", "13800000003", "13800000004"}
for _, p := range phones {
err := sender.Send(context.Background(), p, "123456")
if err == nil {
logger.Info("sms sent", "phone", maskPhone(p))
continue
}
var se *SendError
if errors.As(err, &se) && se.ShouldAlert() {
dispatcher.Dispatch(Alert{
Time: time.Now(), Phone: p, Kind: se.Kind,
Severity: se.Severity(), Code: se.Code,
Message: se.Message, Cause: se.Cause,
})
} else {
logger.Warn("sms send rejected (no alert)",
"phone", maskPhone(p), "err", err)
}
}
time.Sleep(500 * time.Millisecond) // 等待异步告警完成
}

运行结果:第一个手机号触发通道限流,产生 P0 告警;第二个触发超时,产生 P1 告警;第三个是业务性失败,只记 Warn 日志,不告警;第四个成功。如果连续多次发送同一错误码的失败,冷却机制会让只有第一次真正发出邮件。

四、生产环境必须补充的四件事

上述 DEMO 是可运行的骨架,但直接上线前还有四个工程化缺口。

第一,告警通道自身的失败处理。 邮件发送也可能失败——SMTP 不可达、凭据过期。handle 中记录通知失败是正确的,但还需要一个后备通道。常见做法是在 MultiNotifier 中让邮件和 Webhook 互为备份,或在通知连续失败时降级写入本地文件,由外部采集器兜底。

第二,重试与告警的关系。 本文的 DEMO 没有引入发送重试。如果加入指数退避重试,告警应当发生在重试耗尽之后,而不是首次失败时。否则一条瞬时的网络抖动就会触发 P1 告警,违背了“网络性失败重试后告警”的原则。重试本身应当带 jitter,并且只在网络性和部分通道性错误(如限流)上进行。

第三,trace ID 贯穿上下文。 告警邮件里只有手机号和错误码,项目管理者无法快速关联到具体的注册请求。在 Alert 中增加 TraceID 字段,并在日志和邮件正文中一并输出,能把排查路径从“猜”变成“追”。

第四,告警收敛的持久化。 内存中的 seen map 在进程重启后丢失,可能导致重启后立刻对同一持续故障重新告警。对于单实例部署,这可以接受;多实例或需要严格收敛时,冷却状态应当放入 Redis 等共享存储,以 Kind:Code 为 key 设置 TTL。

五、回到问题本身

短信发送失败不是一个“异常处理”问题,而是一个可观测性设计问题。发送失败的信息如果只存在于被 if err != nil 吞掉的那一行,项目管理者就永远处于盲区。本文的方案把失败拆成类别、把日志与通知解耦到异步通道、用冷却机制控制告警音量,并用一个 Notifier 接口保留了向邮件之外通道扩展的能力。

值得强调的一点是:业务性失败不告警,并不是“不记录” 。所有失败都应当写入结构化日志,只是通知的对象和紧迫性不同。日志回答“发生了什么”,告警回答“谁需要现在行动”。把两者混为一谈,要么让管理者被噪音淹没,要么让关键故障在日志海洋里沉底。短信注册链路的可靠性,最终取决于这个区分是否被认真对待。

赞(0)
未经允许不得转载:171主机测评 » 短信发不出去的时候,谁来告诉你:注册链路上的失败告警设计
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址