直接用go sendEmail()易致panic、静默失败、协程爆炸和无法反馈结果;应改用带缓冲channel(如mailChan := make(chan MailTask, 100))配合固定worker协程消费,实现可控异步、错误重试与状态可追溯。

为什么直接用 go sendEmail() 不够用
在 Gin 处理 HTTP 请求时,如果直接用 go sendEmail() 启动协程发邮件,看似“非阻塞”,但实际存在几个隐性问题:协程可能因 panic 崩溃导致邮件静默失败;没有统一错误收集机制;大量并发请求会无节制地创建协程,容易耗尽内存或触发 SMTP 限流;更关键的是——你无法感知发送是否成功,也无法给客户端返回可靠状态。
用带缓冲的通道做任务队列更稳妥
真正可控的非阻塞发送,是把邮件任务塞进一个有容量限制的通道,再由固定数量的后台 worker 协程消费。这样既避免协程爆炸,又能集中处理失败重试和日志。
-
mailChan := make(chan MailTask, 100)—— 缓冲区设为 100,防止突发流量压垮内存 - 启动 3 个 worker:
for i := 0; i - 每个
MailTask至少包含To、Subject、Body和一个done chan error用于回传结果(可选) - Gin handler 中只做
mailChan ,立刻返回 202 Accepted,不等发送完成
注意 smtp.SendMail 的超时与上下文取消
Go 标准库的 smtp.SendMail 不接受 context.Context,硬写超时容易卡死。正确做法是用 net.DialTimeout 自建连接,再传给 smtp.Client:
conn, err := net.DialTimeout("tcp", "smtp.example.com:587", 10*time.Second)
if err != nil {
return err
}
client, _ := smtp.NewClient(conn, "smtp.example.com")
defer client.Quit()
// ... 认证、发送逻辑否则一旦网络抖动,worker 协程就挂住,整个通道消费停滞。
别忘了 recover + 日志 + 重试退避
worker 协程必须包一层 defer func() { if r := recover(); r != nil { log.Printf("mail worker panic: %v", r) } }(),否则 panic 会让该协程退出,通道积压任务无人处理。
- 单次发送失败建议记录完整错误(含
smtp: 554 Transaction failed这类具体码) - 重试最多 2 次,间隔用
time.Sleep(time.Second 避免雪崩 - 永久失败的任务应写入 DB 或发告警,不能丢弃
通道本身不保证投递,它只是解耦手段。真正的可靠性靠的是 worker 的健壮性和失败后的可观测性。


















