直接用smtp.sendmail会崩在群发场景,因其为同步阻塞调用,每封邮件需新建连接、认证、传输、关闭,导致qps低、失败重试难控制、一卡全卡;真实业务需拆解为http收任务→持久化队列→工作协程消费→可观测性兜底。

为什么直接用 smtp.SendMail 会崩在群发场景
因为 smtp.SendMail 是同步阻塞调用,每发一封邮件都得建连接、认证、传输、关闭——QPS 上不去,失败时重试逻辑难控制,一卡全卡。真实业务里,你发 5000 封邮件,哪怕只有 1% 失败,也得手动捞日志、补发、对账。
真正要解决的不是“怎么发邮件”,而是“怎么可靠地调度、排队、重试、监控”。所以必须拆:HTTP 接口收任务 → 持久化队列 → 工作协程消费 → 可观测性兜底。
- 别把邮件内容直接塞进内存队列(如
chan *EmailTask),进程重启就丢 - 别用纯内存缓存做去重(比如 map[string]bool),多实例部署时失效
-
net/smtp默认超时是 0(无限等待),必须显式设timeout: 30 * time.Second
用 github.com/hibiken/asynq 做持久化任务队列
Redis 背书的异步任务库,比自建 MySQL 队列表更轻、更适配邮件这类短耗时+需重试的场景。它自动处理失败重入队、延迟投递、并发限制,且支持 Web UI 查看任务状态。
关键配置点:
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
- 初始化 client 时,
asynq.RedisClientOpt{Addr: "localhost:6379", Password: "", DB: 0}必须和你的 Redis 实例一致 - 定义任务类型用字符串常量,比如
const TaskTypeSendEmail = "email:send",别拼错,否则 worker 找不到 handler - 任务 payload 用 JSON 序列化,字段必须导出(首字母大写),例如
type EmailPayload struct { To string `json:"to"` Subject string `json:"subject"` Body string `json:"body"` }
发送任务示例:
task := asynq.NewTask(TaskTypeSendEmail, payload, asynq.Queue("email"), asynq.MaxRetry(3))<br>_, err := r.client.Enqueue(task) 注意这里没设 asynq.ProcessIn(2 * time.Minute) —— 群发不需要延迟,除非你要做定时发送。Worker 中如何安全调用 SMTP 并避免被封 IP
单个 worker 并发太高,Gmail 或 Outlook 会直接限流甚至拉黑 IP。必须做两层节流:队列级并发 + SMTP 连接池级速率控制。
- 启动 worker 时传
asynq.ServerOption{Concurrency: 5},即最多 5 个任务并发执行 - SMTP 客户端不能每次 new,要用
net/smtp+ 连接复用:自己封装一个SMTPClient结构体,内嵌*smtp.Client,加锁复用;或更简单——用gomail库的gomail.NewDialer并设dialer.DialTimeout = 10 * time.Second - 每封邮件发完后
time.Sleep(1 * time.Second),这是最朴素但有效的防封策略(尤其对免费邮箱) - 捕获具体错误:如果
err.Error()包含"554 5.7.1 Message rejected due to spam content",就该停发并告警,而不是重试
HTTP 接口层怎么设计才不丢任务
用户调用 POST /api/v1/email/batch 提交 1000 个收件人,接口返回 202 Accepted,不代表邮件已发出,只代表“已入队”。这是契约,也是高可用的前提。
- 请求体必须校验
To格式(用mail.ParseAddress)、长度(单封邮件Body不超过 10MB)、频率(同一 IP 5 分钟内最多 50 次提交) - 入队前先写一条记录到 PostgreSQL 的
email_jobs表(含id,status,created_at,total_count),这样即使 Redis 故障,也能靠 DB 对账 - 不要在 HTTP handler 里做任何耗时操作,包括 JSON 解析后的结构体转换——这些都交给 worker 做
- 返回值只给
{"job_id": "job_abc123"},别返回 “发送成功”,那是 worker 的事
容易被忽略的是幂等性:同一个 job_id 提交两次,第二次应该直接返回已有记录,而不是重复入队。用 Redis 的 SET job_id ... NX EX 3600 最快。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










