单个SMTP连接池扛不住突发流量,因其本质是阻塞式短连接,每次发信需重复DNS解析、TLS握手与认证,易触发超时或限频错误;真正有效的是预建复用的长连接池,而Python标准库无原生支持,需用aiosmtplib配合asyncio.Semaphore按服务商独立构建异步连接池。

为什么单 SMTP 连接池扛不住突发流量
单个 smtplib.SMTP 实例本质是阻塞式、无复用的短连接,每发一封邮件都得重新 connect() → starttls() → login() → sendmail() → quit()。并发一高,DNS 解析超时、TLS 握手失败、认证被限频这些错误就集中爆发,典型报错是 socket.timeout 或 smtplib.SMTPAuthenticationError。
真正起作用的是连接池 —— 预建并复用一批已认证的长连接,跳过重复握手开销。Python 标准库没提供原生池,必须自己封装或选对库。
- 别用
smtplib.SMTP直接循环发:它不是为并发设计的 - 避免手动维护
list存一堆SMTP对象:容易漏quit()导致端口耗尽 - 不要把所有账号塞进一个池:不同邮箱服务商(如 Gmail / QQ / 自建 Postfix)的速率限制、TLS 要求、认证方式差异很大,混用必出错
用 aiosmtplib + asyncio.Semaphore 构建轻量级异步池
同步池(如 smtp-pool 类库)在 Web 服务里会阻塞 event loop;而 aiosmtplib 是 asyncio 原生支持的 SMTP 客户端,配合 asyncio.Semaphore 控制并发数,才是现代 Python 邮件服务的合理组合。
关键点在于:每个 SMTP 服务单独配一个池,而不是全局一个池。
import asyncio
import aiosmtplib
from email.mime.text import MIMEText
<h1>每个服务商独立配置,不混用</h1><p>SMTP_CONFIGS = {
"gmail": {
"host": "smtp.gmail.com",
"port": 587,
"username": "user@gmail.com",
"password": "app_password_here",
"pool_size": 5
},
"qq": {
"host": "smtp.qq.com",
"port": 587,
"username": "user@qq.com",
"password": "smtp_password_here",
"pool_size": 8
}
}</p><p>class SMTPPool:
def <strong>init</strong>(self, config):
self.config = config
self.semaphore = asyncio.Semaphore(config["pool_size"])
self._pool = []</p><pre class="brush:php;toolbar:false;">async def acquire(self):
await self.semaphore.acquire()
# 复用现有连接,或新建
if self._pool:
return self._pool.pop()
return aiosmtplib.SMTP(
hostname=self.config["host"],
port=self.config["port"],
use_tls=False
)
async def release(self, client):
try:
await client.quit()
except Exception:
pass
self._pool.append(client)
self.semaphore.release()
注意:aiosmtplib.SMTP 的 send_message() 必须在已 connect() 和 starttls() 后调用;实际使用中需封装 connect + login 到 acquire() 流程里,示例省略细节但逻辑不能跳过。
故障转移怎么写才不丢信
单纯“换一个 SMTP 服务器重试”是危险的:可能造成重复投递(比如第一台已成功发出去,第二台又发一遍),或静默丢信(重试次数设太低,网络抖动就失败)。
可靠做法是:**失败后不立即切换,而是标记该 SMTP 节点临时不可用(例如 60 秒),同一封邮件只走一个确定的节点;下一封再按权重/健康度选新节点**。
- 用
asyncio.Lock保护节点状态字典,避免并发修改 - 健康检查不能只 ping 端口:要模拟完整流程 ——
connect→ehlo→auth→quit - 权重不能硬编码在配置里:应根据最近 5 分钟的成功率动态调整,比如
qq成功率降到 80% 以下,权重自动减半 - 永远保留一个兜底通道(如本地 Postfix relay),哪怕慢也要确保不丢
自建 SMTP 服务(如 Postal / Mailu)接入池时的坑
自建服务和公有云邮箱最大的区别是:它默认不带速率限制,但极容易因并发过高被上游拒收(表现为 451 或 421 错误),而且日志里往往只显示 “Connection reset by peer” 这种模糊信息。
必须做三件事:
- 在
config/postal.yml中显式设置smtp_server.proxy_protocol: true,否则负载均衡器转发后,REMOTE_ADDR全是内网 IP,无法做基于来源的限流 - 给每个自建节点配独立域名(如
smtp1.yourdomain.com),DNS 轮询 + 客户端本地缓存会导致部分请求永远打不到健康节点 - PostgreSQL 连接数要预留余量:Postal 的
postal send命令本身也抢 DB 连接,max_connections至少设为 SMTP 池大小 × 3
真实运维中,最难调的不是代码,而是各 SMTP 服务商对 “异常行为” 的定义不一致 —— 同样的发信节奏,Gmail 可能限频,QQ 邮箱却完全放行,这种差异只能靠持续采集响应码+延迟数据来动态适配。











