一、一封信卡住,整个注册流程就废了
我站上出过一次很典型的事故。那年双十一前后某个上午,注册量突然归零,后台日志里全是「邮件发送失败」。用户点了「发送验证码」,页面转了三秒,返回一个五百。查下来不是代码坏了,而是邮件服务商的接口在那天上午响应变慢,从 200 毫秒涨到 8 秒,我的同步发送代码又没有任何超时设置,PHP-FPM 的工作进程就这么被一个个挂在那里等。五分钟内,二十个工作进程全被占满,整个站点连首页都打不开了。
这是一封邮件杀了一台服务器。事后复盘,问题根本不在邮件服务商,而在于我把一个外部、不可控、可能很慢的动作,放在了用户请求的同步路径上。这次之后我把站上所有发信的地方都改成了入队,用一张 MySQL 表做队列,一个常驻的 worker 慢慢发。这篇文章就写这套东西:为什么不用 Redis、表怎么设计、重试怎么做、哪些信不能入队、以及上线后我踩过的坑。
二、为什么是 MySQL 队列,而不是 Redis 或现成的消息队列
先说结论再讲理由:日发送量在十万封以内、只有一台机、不想多一个中间件的场景,用 MySQL 表做队列是最省事的选择。我评估过几个方案:
Redis 的 LIST 或 Stream。性能当然好,但引入一个内存服务意味着一份额外的运维负担:重启后数据在不在取决于持久化配置、内存满了会 OOM、要单独做监控。我这台机器上 Redis 已经装着做缓存了,但把「不能丢的数据」放进去,我始终不太放心。
RabbitMQ / Kafka。对个人站是明显的过度设计。装起来容易,出问题时排查成本高,而且它们解决的问题(多消费者、复杂路由、削峰)我基本用不上。
MySQL 表。优势是事务一致性天然具备——注册和发信记录可以放在同一个事务里,要么都成功要么都失败,不会出现「用户建了但信没发」。而且备份、监控、查询全都是现成的:用 SELECT 就能看到队列积压情况,不需要学新的工具。劣势是并发消费要靠行锁,量大了会有热点。但我的场景一天最多几万封,这个量级对 InnoDB 来说完全不是问题。
关键判据是:你能接受的发送延迟是多少。验证邮件、找回密码这类,用户期望是十几秒内到,队列的轮询间隔设 2 秒完全够。如果要求毫秒级的实时通知,那才需要真正的消息中间件——但发邮件本身就不可能毫秒到达,这个需求在邮件上不存在。
三、队列表的设计
先看结构。这张表我用了三年,改过两次字段,整体还算稳定:
CREATE TABLE mail_queue (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
to_addr VARCHAR(255) NOT NULL,
to_name VARCHAR(64) NOT NULL DEFAULT '',
subject VARCHAR(255) NOT NULL,
body MEDIUMTEXT NOT NULL,
is_html TINYINT NOT NULL DEFAULT 1,
template VARCHAR(32) NOT NULL DEFAULT '', -- 模板名,便于统计和重发
status TINYINT NOT NULL DEFAULT 0, -- 0待发 1发送中 2成功 3失败待重试 4永久失败
attempts TINYINT UNSIGNED NOT NULL DEFAULT 0,
next_run INT UNSIGNED NOT NULL, -- 下次可执行时间
locked_until INT UNSIGNED NOT NULL DEFAULT 0,
locked_by VARCHAR(32) NOT NULL DEFAULT '',
last_error VARCHAR(512) NOT NULL DEFAULT '',
message_id VARCHAR(128) NOT NULL DEFAULT '',
created_at INT UNSIGNED NOT NULL,
sent_at INT UNSIGNED NOT NULL DEFAULT 0,
KEY idx_pick (status, next_run, id),
KEY idx_created (created_at),
KEY idx_to (to_addr, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个字段值得单独解释:
next_run 与 status 组成的复合索引是核心。取任务的查询永远是「status 是待发或待重试,且 next_run 已经到了,按 id 正序取 N 条」,这个索引让查询走覆盖扫描,不会因为表里有几十万条历史数据变慢。
locked_until 是防止重复发送的关键。worker 取任务时把它锁几秒(我设 60 秒,够一次 SMTP 往返),别的 worker 就不碰。这比「取出来就删」安全:如果进程被 kill,锁过期后任务会重新可见,不会丢;代价是极端情况下可能重复发一次,这个可以接受。
attempts 与 last_error 是排障的唯一线索。没有这两个字段,队列出问题你只会看到「有人说没收到邮件」,无从下手。有了它们,一条 SQL 就能分出「全部失败」和「个别失败」:
SELECT status, COUNT(*) FROM mail_queue
WHERE created_at > UNIX_TIMESTAMP() - 86400
GROUP BY status;正文存 MEDIUMTEXT 而不是存模板名加参数。我最初为了省空间只存模板名和变量,worker 里再渲染。结果改了一次模板文案,队列里积压的旧信全部按新模板发出去了,内容对不上(比如活动日期已经过了)。存最终 HTML 虽然占空间,但「发出去的就是当时生成的那封」这个性质太重要了,不能省。清理任务会在 30 天后删除成功的记录,实际占用并不大。
四、入队的正确姿势:和业务放在同一个事务里
入队不是一个 INSERT 那么简单,它必须和业务写入绑在一起。以注册为例:
$db->beginTransaction();
try {
$uid = createUser($email, $password);
$html = renderTemplate('welcome', ['name' => $name, 'link' => $activeUrl]);
enqueue($db, $email, $name, '欢迎注册', $html, 'welcome');
$db->commit();
} catch (Throwable $e) {
$db->rollBack();
throw $e;
}这样做的意义是:要么用户建好了且信进了队列,要么两件事都没发生。如果入队失败,用户会看到注册失败并重试,不会出现「账号建好了但收不到激活信,只能找客服」的僵尸账号。
反过来说,不要为了让入队绝对成功而把入队放在事务外。我见过一种写法是先 commit 用户,再 try 入队,catch 里只记个日志。这看起来更稳,实际上是把不一致变成了永久状态——用户永远收不到那封信用来激活。
还有一个细节:入队时别在请求线程里渲染太重的模板。如果模板里要做复杂的图片内联或者查询统计数据,那部分开销仍然在用户请求路径上。我的做法是模板渲染很轻(只做变量替换),需要重计算的内容要么预先生成好,要么推迟到 worker 里做——但注意上一节说的原则,变量值必须在入队时就确定,worker 只做拼装。
速率限制也要在入队时做。同一个邮箱一小时内最多三封,同一 IP 一天最多二十封,这类判断放在入队函数里,超过就直接拒绝入队并返回「请稍后再试」。放在 worker 里做的话,垃圾请求已经进了队列,白白占用发送额度,还会拖累送达率。
五、worker 的取任务逻辑
取任务是我在这套系统上改过最多次的地方,最终定下来的写法是这样:
-- 1) 抢占:把候选行标记为自己的
UPDATE mail_queue
SET status = 1, locked_until = UNIX_TIMESTAMP() + 60, locked_by = ?
WHERE id IN (
SELECT id FROM (
SELECT id FROM mail_queue
WHERE status IN (0,3) AND next_run <= UNIX_TIMESTAMP()
AND locked_until <= UNIX_TIMESTAMP()
ORDER BY id LIMIT 20
) t
)
AND status IN (0,3);
-- 2) 取回自己抢到的
SELECT * FROM mail_queue
WHERE status = 1 AND locked_by = ? AND locked_until >= UNIX_TIMESTAMP();关键点有三个。第一,UPDATE ... WHERE id IN (SELECT ...) 里套了一层派生表,这是为了绕开 MySQL「不能在 UPDATE 的子查询里引用同一张表」的限制,同时也是让 ORDER BY ... LIMIT 真正生效。
第二,抢占时不要用 SELECT ... FOR UPDATE 后逐条处理。那样会把行锁持有到整个发送过程结束,一次 SMTP 往返三秒,二十条就是六十秒的事务,别的 worker 全部阻塞。我的写法是把「标记」和「发送」彻底分开:标记是一个毫秒级事务,发送在事务外进行。
第三,靠 locked_by 认领,不靠「我记得我取了哪些 id」。worker 崩溃重启后,只要锁过期,这些行会自然回到可领取状态。用进程内数组记录已领取的 id 是常见的错误做法,进程一挂就全丢了(或者锁永远不放,任务卡死)。
worker 本身的循环很简单,重点是别让它跑成一个会泄漏内存的常驻进程:
while (true) {
$jobs = pickJobs(20);
foreach ($jobs as $job) {
sendOne($job); // 内部 try/catch,异常只记录
}
if (count($jobs) < 20) usleep(2000000); // 没活了睡 2 秒
}我用的是 systemd 管这个进程,配 Restart=always 和 RestartSec=5,并且在循环里每处理五百封主动 exit(0) 一次,让 systemd 拉一个新的起来——这样即使有轻微的内存增长,也会被定期重置。这比在循环末尾手动 unset 变量可靠得多。同时配 MemoryMax=256M,一旦涨到 256M 就被杀掉重启,不会拖垮整机。
六、重试策略:分清「暂时性」和「永久性」失败
这是我花时间最多、收益也最大的一块。最初我写的是失败就重试三次,间隔五分钟——结果一个写错的收件地址被重试了三次,一次发送失败触发两次 SMTP 往返,白白拉低了发送域的声誉,最后进了对方的黑名单。
正确的做法是按 SMTP 响应码分类:
4xx 是暂时性失败,比如 421 服务不可用、450 邮箱暂时不可达、451 本地错误、452 存储空间不足。这类应该退避重试,间隔可以按 2 分钟、10 分钟、30 分钟、2 小时递增,最多五次。超过就转成永久失败。
5xx 是永久性失败,比如 550 邮箱不存在、551 用户不在本地、552 超出配额、553 地址格式非法。这类绝对不要重试,直接标记永久失败并记录原因。550 硬退(hard bounce)重试越多,你的域名信誉掉得越快,这是发信这件事上最常见的自毁行为。
连接层错误(连不上、超时、TLS 握手失败)要当成暂时性失败,但重试间隔要长一些,因为这类往往是服务商侧的问题,短时间重试没有意义。
实现上的关键点:SMTP 库抛出的异常里通常带着响应码,但不同库的包装不一样。我的做法是先看异常是否属于「连接类」(Swift_TransportException 之类),再尝试从消息文本里正则提取三位数字的响应码,提取不到就按暂时性处理——宁可多试一次,也不要因为解析失败把一封正常的信丢掉。
另外,next_run 的退避要加随机抖动。比如第二次重试定在 10 分钟,实际写 next_run = now + 600 + rand(0, 120)。不加抖动的话,一批同时失败的信会在同一秒一起重试,瞬间对 SMTP 服务商形成冲击,反而更容易被限流。这是分布式系统里通用的经验,在单机上也一样成立。
七、哪些邮件不能走队列
队列不是万能的,有几类必须区别对待:
极少数需要同步返回结果的邮件。比如某些「发送测试邮件」的后台功能,管理员点了之后要立刻看到「成功」或具体报错。这种可以直接同步发,但要加 3 秒超时,并且只允许管理员触发。
营销类批量邮件不要混在这个队列里。一百封验证码和一封群发促销在同一个队列里,worker 会被大任务占满,验证码延迟到几分钟后,用户早就关掉页面了。我的做法是营销邮件走单独的表和单独的 worker 进程,甚至连发送服务商都分开——用独立子域,出了投递问题也不影响主域的验证邮件。
「发送即丢弃」的通知不要入库。比如有人访问了我的页面这种统计类通知,量大、失败无所谓。这类我干脆不入队,或者在入队后设 next_run 很短、失败只试一次。别让无价值的信占用队列和额度。
还有一个容易被忽略的:不要在入队时就生成退订链接的 token 后立刻写库,而是让 token 可以重复使用。因为队列重试时邮件正文是固定的,如果 token 是一次性的且已经在第一次发送时用掉了(比如记录在某个校验表里),重试发出去的那封里面的链接就是死的。这类细节只有真的遇到过才会想到。
八、监控:攒够了看三个数就够
队列的监控不需要仪表盘,三个数就能覆盖九成的故障:
第一,积压量。待发信件的条数。稳定在几十以内是正常的,突然涨到几千说明 worker 挂了或者发送通道出问题了。这个数可以直接用 SQL 取,五分钟一次,超过阈值就给我发个提醒(对,用另一条通道,别用同一套邮件系统发告警)。
第二,最近一小时的失败率。
SELECT
SUM(status IN (0,1,3)) AS pending,
SUM(status = 2 AND sent_at > UNIX_TIMESTAMP() - 3600) AS ok1h,
SUM(status IN (4) AND created_at > UNIX_TIMESTAMP() - 3600) AS fail1h
FROM mail_queue;fail1h / (ok1h + fail1h) 超过 20% 就该起床了。这个指标比「发送总数」有意义得多,因为总量下降可能是流量下降,失败率上升一定是链路出了问题。
第三,卡在「发送中」超过五分钟的条数。这是我加的最有用的一个告警。正常情况下 status = 1 的行最多存在几秒,如果有一批行卡在这个状态超过五分钟,说明 worker 在发送中途被杀,或者处理某封信时死循环了。锁会在 60 秒后过期、任务会被重新领取,但如果每封都卡住(比如对方 SMTP 每次都超时),就会看到一个持续的非零值。用这条 SQL 一眼看出:
SELECT COUNT(*) FROM mail_queue
WHERE status = 1 AND locked_until < UNIX_TIMESTAMP() - 300;有了这三个数,再加上日志里每个任务的 message_id(真正的 SMTP Message-ID,不是自己的自增 ID),排查一个「用户说没收到」的问题只需要一分钟:查这张表看状态是成功还是失败、失败的原因是什么、服务商返回的 ID 是多少,然后拿那个 ID 去服务商后台查投递轨迹。这比翻日志快太多了。
九、清理与容量
成功的记录留 30 天,永久失败的留 90 天(排查问题需要),发送中的绝不删。清理放在 worker 的空闲分支里做,每次删五百条,避免一次删除大事务锁表:
DELETE FROM mail_queue
WHERE status = 2 AND sent_at < UNIX_TIMESTAMP() - 2592000
ORDER BY id LIMIT 500;注意别用 DELETE FROM ... WHERE created_at < X 一把梭。历史数据八个月积累下来几十万行,一条 DELETE 会产生一个巨大的事务,binlog 一下子涨到几个 G,还可能把从库延迟拖到十几分钟。分批删、每批之间睡 100 毫秒,是唯一安全的做法。
容量上做个估算:一封带模板的验证信正文大约 4KB,一天一万封就是 40MB,30 天 1.2GB。这对小站的磁盘来说要关注但不用恐慌,前提是清理任务真的在跑。我踩过的坑就是清理代码写好了但那条分支从来没被触发(因为 if (count($jobs) < 20) 的条件在高峰期永远不成立),一个月后表涨到 6GB 才发现。后来改成按时间触发(每跑完五十轮清理一次),与队列是否空闲无关。
十、总结:慢一点没关系,别同步等
这套东西的全部价值,可以用一句话概括:把不可控的外部调用从用户请求路径上移走。邮件、短信、第三方支付回调、图片处理、PDF 生成,凡是「需要等一个外部服务」的动作,都值得这样拆一次。
上线三年,它挡住的最大一次事故是某邮件服务商整晚挂了六小时。那晚队列积压到一万两千封,站点其他功能完全正常,服务商恢复后 worker 用四十分钟把所有信发完。如果还是当年那个同步发送的写法,那六小时里站点会彻底不可用。
几个回头看最有价值的决策:入队与业务同事务、按响应码分类重试(5xx 绝不重试)、锁用超时而非删除、以及那三个监控数字。至于用了什么中间件、队列表有多少字段,其实都不重要。重要的是你脑子里清楚:哪些动作可以等,哪些动作必须立刻失败。