不能在 Gin handler 中同步发邮件,因 SMTP 是网络 I/O 密集型操作,耗时 300ms–2s,易导致 HTTP 请求阻塞、超时、goroutine 堆积;smtp.SendMail 无 context 支持、无重试、无日志,且邮箱解析和中文编码易出错;应使用带缓冲 channel + 固定消费者实现可控异步发送。

直接在 Gin handler 里调用 smtp.SendMail 或 gomail.DialAndSend 发邮件,会导致 HTTP 请求阻塞、超时、并发失控,甚至拖垮整个服务——这不是“非阻塞”,只是把问题藏得更深。
为什么不能在 handler 里同步发邮件
SMTP 是网络 I/O 密集型操作,一次成功发送常耗时 300ms–2s;Gin 默认 HTTP 超时通常为 30 秒,但用户可接受的等待上限往往只有 1–2 秒。一旦并发请求增多,goroutine 堆积、连接池打满、响应延迟飙升,前端看到的就是 504 Gateway Timeout 或长时间白屏。
-
smtp.SendMail不接受context.Context,无法优雅中断卡死连接 - 错误全靠返回值,没重试、没日志、没告警,失败即静默丢弃
- 多个收件人用
strings.Split(to, ";")拼接,遇到含逗号或空格的邮箱地址会解析错 - 中文 Subject 不做
=?UTF-8?B?...编码,部分邮件客户端显示为乱码或被拒收
用带缓冲 channel 实现可控异步发送
真正解耦的关键不是“扔进 goroutine”,而是引入容量限制和固定消费者——避免协程爆炸,同时保留失败可观测性。
- 定义任务结构体:
type MailTask struct { To, Subject, Body string; Done chan - 初始化带缓冲通道:
mailChan := make(chan MailTask, 100),防止突发流量压爆内存 - 启动固定数量 worker(如 3 个):
for i := 0; i - handler 中只做
mailChan ,立刻返回202 Accepted - 每个 worker 必须包
defer func() { if r := recover(); r != nil { log.Printf("mail worker panic: %v", r) } }(),否则 panic 后通道积压无人消费
SMTP 连接必须带超时且手动管理
标准库 smtp.SendMail 内部用 net.Dial,不支持上下文取消,网络抖动时协程永久挂起。必须自己建连接并控制生命周期。
- 用
net.DialTimeout("tcp", "smtp.example.com:587", 10*time.Second)替代默认 dial - 创建
smtp.Client后,务必defer client.Quit(),否则连接泄漏 - 认证失败、554 Transaction failed、421 Service not available 等错误要原样记录,不能只打印
err.Error() - 单次失败最多重试 2 次,间隔用
time.Sleep(time.Second * 1)和time.Second * 3,避免雪崩
验证码等关键邮件必须同步 + context 控制
不是所有邮件都适合异步:用户注册/找回密码的验证码,业务语义要求“发送成功才允许下一步”,必须同步执行,但绝不能裸调用。
- 用
context.WithTimeout(ctx, 5*time.Second)包裹整个发送流程 - 若使用
gomail.v2,调用前设d.Timeout = 5 * time.Second - Subject 必须编码:
"=?UTF-8?B?" + base64.StdEncoding.EncodeToString([]byte("【验证码】")) + "?=" - 正文换行符必须是
\r\n,不是\n;含 emoji 或特殊符号时,Content-Transfer-Encoding设为base64更稳妥 - 发送失败时,区分临时错误(如网络超时)和永久错误(如邮箱格式非法),前者可返回 503 并建议重试,后者应返回 400 并提示用户检查邮箱
通道本身不保证投递,它只是解耦手段;真正的可靠性来自 worker 的 recover 机制、错误分类、重试退避和失败落库——这些细节漏掉任意一环,异步邮件就会变成线上事故温床。











