不能直接用smtp.sendmail同步发验证码,因其阻塞式i/o会卡住http请求,缺乏超时控制、重试机制、并发限流和配额隔离,易导致注册登录流程失败或通道被封;应构建含限流、队列、重试、监控的微服务。

Go 语言本身不内置邮件队列能力,高可用的验证码发送微服务必须自己组装「限流 + 队列 + 重试 + 监控」四层逻辑,不能只调用 net/smtp 发完就了事。
为什么不能直接用 smtp.SendMail 同步发验证码
同步调用会把 HTTP 请求阻塞在 SMTP 连接、TLS 握手、远程响应等环节,一旦邮件服务商抖动(如腾讯企业邮箱返回 421 或超时),你的 API 就卡住,用户注册/登录流程直接失败。更危险的是:没有并发控制时,短时间大量请求可能触发服务商限频,导致整个通道被封。
-
smtp.SendMail是阻塞式 I/O,无法设置 per-request 超时(只能设全局net.DialTimeout) - 无内置重试策略,SMTP 错误码如
451 Requested action aborted或421 Service not available需要指数退避重发 - 无法隔离不同业务方的发送配额(比如 A 业务突发 1000 验证码,不应挤占 B 业务的通道)
用 worker pool + channel 实现轻量级内存队列
对中小规模(日均百万级以下)场景,不需要引入 Redis 或 Kafka——用 Go 原生 channel 搭配固定数量 worker 即可扛住峰值,且避免外部依赖带来的单点故障。
关键设计点:
- 定义结构体
VerificationTask,含to、code、templateID、createdAt、retryCount - 用带缓冲的
chan *VerificationTask(如make(chan *VerificationTask, 1000))做入队入口,防止生产者阻塞 - 启动固定数目的 goroutine 消费该 channel,每个 worker 内部封装 SMTP 发送 + 错误分类处理逻辑
- 失败任务按错误类型分流:网络类(
net.OpError)立即重入队列;认证类(535 Authentication failed)记录告警并丢弃
示例片段:
for task := range taskCh {
if err := sendViaSMTP(task); err != nil {
if isTransientError(err) && task.retryCount <h3>如何让服务真正「高可用」:健康检查与平滑扩缩容</h3><p>高可用不是靠多起几个实例,而是让每个实例能自证状态、能被安全下线、能应对上游突发流量。</p>
- 暴露
/healthz接口,不仅检查 HTTP server 是否存活,还要探测:len(taskCh)是否持续 >90% 缓冲上限、最近 1 分钟 SMTP 成功率是否 retryCh 是否堆积 - 接收
SIGHUP信号时,停止接收新任务(关闭taskCh的写端),但继续消费已入队任务,直到taskCh和retryCh均为空才退出 - 使用
sync.WaitGroup管理所有 worker goroutine,确保Shutdown()不遗漏正在发信的协程 - 配置项必须支持热加载:比如 SMTP 密码、发信域名、重试间隔,通过
fsnotify监听 YAML 文件变更
容易被忽略的三个细节
实际部署后最容易出问题的地方,往往不在主流程里:
- SMTP 认证密码若硬编码或从环境变量读取,重启服务时密码变更会导致所有实例瞬间断连——应走 Vault 或 K8s Secret 挂载 + 定期轮换
- 未设置
time.AfterFunc清理过期验证码任务(如 10 分钟未发送成功的),retryCh会长期堆积死信,OOM - 所有日志必须打上
taskID(用uuid.NewString()生成),否则线上排查某条验证码没发出去时,无法串联 SMTP 日志、HTTP 日志、重试日志
验证码服务的可靠性,最终取决于你对「失败」的预设有多细——不是有没有重试,而是重试几次、隔多久、什么错误不重试、重试失败后通知谁。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











