批量发邮件的防重设计,避免同一个人收到两封相同的邮件
1955 字约 7 分钟
摘要群发或自动化邮件系统里,发送失败可以重试,但同一个重要收件人收到两封相同的邮件会造成明显的负面影响。一次偶发的重复投递,促使我们重新划分了系统中有副作用的环节。
在 Auto Email Sender 这款用于联系导师和学术交流的桌面工具里,我们对发信可靠性的要求比电商下单还要高。
原因是:在学术圈,给一位素未谋面的教授发自荐信,如果因系统故障连续发出两封一模一样的邮件,收件人很可能会认为发信人轻率、缺乏分寸。
“发不出去了报个错就行,同一封信给同一个教授投递两次,那就是实打实的失礼。”
某天,线上出现了几起异常反馈:部分收件人的收件箱里有两封内容完全相同的邮件,时间戳只差几秒。
查看日志后发现,大部分邮件都正常,只有少数任务出现了重复投递。这并不是客户端在“已发送”箱里显示了两个副本,而是 SMTP 服务器确实收到了两次发送请求。
怀疑 Worker 并发抢任务
批量发信由后台的异步 Worker 池轮询数据库来领取任务。遇到重复消费,第一反应自然是:是不是有两个 Worker 同时抢到了同一条待发记录?
我先检查了领取任务的 SQL:
UPDATE email_tasks
SET status = 'sending', updated_at = NOW()
WHERE id = :id AND status = 'pending';这是基于乐观锁的状态流转。只有受影响行数为 1 时才算抢到任务;另一个并发进来的 Worker 拿到的受影响行数一定是 0,会直接放弃。
为了排除 SQLite 或特定数据库锁的边缘情况,我写了一个脚本,用 20 个线程模拟高并发抢占,结果没有任何一条任务被重复领取。
既然抢锁没有问题,邮件为什么会发两遍?
我退回到最基础的一步,做了一个简单的测试:在没有并发的情况下,对同一个“审核并发送”接口顺序调用两次 POST。
结果两封邮件都成功发出了。
通俗比喻:防两个人同时抢,不等于防同一个人点两次很多人会把“并发控制”和“幂等性”混为一谈:
- 并发控制:好比电影院检票口的单人闸机,防的是两个人拿着同一张票同时挤进去;
- 幂等性:防的是一个人检票进场、坐下,五分钟后拿着同一张票出来再刷一次,结果系统又发了一份爆米花。
数据库的抢锁只能防前一种情况,管不住后一种。
并发控制与幂等性这两个概念很容易混淆:
- 并发控制(Concurrency Control): 解决的是“同一时刻,两个人能否同时做这件事”;
- 幂等性(Idempotency): 解决的是“无论间隔多久,同一个请求调用一次和调用十次,产生的副作用是否相同”。
抢锁只能防并发,不能防带状态的重复请求。
前端的自动重试
用户只点了一次发送按钮,接口为什么会被调用两次?
查看前端的网络封装层后发现:为了改善弱网下的体验,Axios 拦截器配置了全局重试,只要网络抖动或 TCP 连接被重置,就自动重发请求。
这个设计用在只读的 GET 请求上没有问题,但用在会产生外部副作用的 POST /api/send 上就会出错:
T1: 前端 POST 请求到达后端
T2: 后端成功连上外部 SMTP 服务器,把邮件数据完整交给了网关
T3: 后端准备返回 HTTP 200 给前端,但此时局域网刚好抖动,TCP RST!
T4: 前端认为“网络出错,请求肯定没发成功”,自动发起重试(第二次 POST)!
T5: 后端收到第二次请求,再次建立 SMTP 连接,第二封邮件就发出去了不要直接把网络报错理解为服务端没有执行在分布式系统里,“请求超时或连接中断”不代表服务端没有执行。它往往只表示服务端执行了,但没能把结果返回给你。
前端防抖、区分请求方法是第一步。但仅靠前端禁用按钮和取消重试还不够,因为后端随时可能重启或崩溃。
卡在 sending 状态的任务
前端的问题理清之后,批量任务在后台静默运行时为什么也会偶发重复?批量任务并没有按钮让用户点击。
顺着 Worker 的执行链路往下看,我发现了一个容易忽略的时间窗口。
当时的执行顺序是:
- 领取任务,标记为
sending; - 建立 TLS 连接,调用远程 SMTP 发送邮件(约 800ms);
- 远程 SMTP 返回
250 OK。此时邮件已经发出。 - 同步连接 IMAP 服务器,把发出的邮件保存到本地的“已发送”文件夹(约 2 到 3 秒);
- 完成后,把本地数据库中的状态从
sending改为sent。
问题出在第 3 步和第 5 步之间:有长达两三秒的空档。
如果在这段时间里,用户关闭了软件、合上了笔记本,或者进程因 OOM 重启,本地数据库里这条任务的状态仍然是 sending。
系统重启后,守护进程巡检数据库,发现有一条任务长时间停留在 sending 状态。它会认为:
“刚才肯定是异常中断了,这封信没发出去,我得重新放回队列。”
于是下一轮轮询会重新领取这条任务,SMTP 服务器再次收到指令,第二封信就这样被补发了。
放弃 Exactly-once,采用 At-most-once
SMTP 协议本身没有分布式事务的支持,所以“绝对只发一次(Exactly-once)”在这里做不到。
假设 SMTP 网关刚收下邮件,下一毫秒网络断开,你并不知道邮件最终是否发出。此时只有两种选择:
- At-least-once(至少一次): 宁可重发,也不漏发。发营销邮件可以接受,但在学术通信中会造成不好的影响;
- At-most-once(最多一次): 宁可停下来等待人工确认,也不多发哪怕一封。
我们选择了 At-most-once。
为此,我们重新设计了发信的状态流转,设置了三道边界:
[ prepared ] ──> [ smtp_inflight ] ──> [ smtp_accepted ] ──> [ sent ]
│
(异常中断/失联)
↓
[ confirming / unconfirmed ]- 出站前落盘(Pipelined In-flight): 在发起任何网络连接之前,先把状态置为
smtp_inflight,并持久化生成好的全局唯一Message-ID。任务越过这一步之后,任何自动恢复逻辑都不允许把它放回队列; - 慢操作移出主事务: SMTP 返回
250 OK后,立即在同一事务中把状态固化为smtp_accepted。之后的 IMAP 存档和界面提示交给后台异步事件处理,把关键窗口从约 3 秒缩短到 2 毫秒以内; - Idempotency-Key 标记发送意图: 每个发送动作都由前端在发起时携带唯一的
Idempotency-Key。即使网络抖动导致请求重试,后端也会直接查表返回当前状态,不会再次投递。
什么是可靠的 Message-ID?不要依赖 SMTP 服务器自动生成 ID,应由客户端生成符合 RFC 5322 的唯一消息标识,例如
<uuid@yourdomain.com>。这样即使发生异常,之后在 IMAP 服务器上查找“这封信是否已经发出”时,才有唯一可靠的依据。
总结
在编写涉及真实副作用(扣款、发信、发短信、控制物理设备)的代码时,应该假设任何环节都可能失败:
- 区分防并发与防重复: 锁只能保证互斥,幂等键(Idempotency Key)才能保证语义上的唯一性;
- 注意框架默认的自动重试: 对任何非幂等的写接口,全局自动重试都可能造成重复写入;
- 缩短副作用与状态落盘之间的窗口: 远程操作成功与本地确认之间的时间越长,系统崩溃后留下错误状态的概率越高;
- 对不确定的结果保持诚实: 遇到无法判断结果的断网情况,把状态显示为“发送状态待确认”,由用户决定后续操作,比自动重发更稳妥。