Backend

批量发邮件的防重设计,避免同一个人收到两封相同的邮件

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 的执行链路往下看,我发现了一个容易忽略的时间窗口。

当时的执行顺序是:

  1. 领取任务,标记为 sending;
  2. 建立 TLS 连接,调用远程 SMTP 发送邮件(约 800ms);
  3. 远程 SMTP 返回 250 OK。此时邮件已经发出。
  4. 同步连接 IMAP 服务器,把发出的邮件保存到本地的“已发送”文件夹(约 2 到 3 秒);
  5. 完成后,把本地数据库中的状态从 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 ]
  1. 出站前落盘(Pipelined In-flight): 在发起任何网络连接之前,先把状态置为 smtp_inflight,并持久化生成好的全局唯一 Message-ID。任务越过这一步之后,任何自动恢复逻辑都不允许把它放回队列;
  2. 慢操作移出主事务: SMTP 返回 250 OK 后,立即在同一事务中把状态固化为 smtp_accepted。之后的 IMAP 存档和界面提示交给后台异步事件处理,把关键窗口从约 3 秒缩短到 2 毫秒以内;
  3. Idempotency-Key 标记发送意图: 每个发送动作都由前端在发起时携带唯一的 Idempotency-Key。即使网络抖动导致请求重试,后端也会直接查表返回当前状态,不会再次投递。
什么是可靠的 Message-ID?

不要依赖 SMTP 服务器自动生成 ID,应由客户端生成符合 RFC 5322 的唯一消息标识,例如 <uuid@yourdomain.com>。这样即使发生异常,之后在 IMAP 服务器上查找“这封信是否已经发出”时,才有唯一可靠的依据。

总结

在编写涉及真实副作用(扣款、发信、发短信、控制物理设备)的代码时,应该假设任何环节都可能失败:

  1. 区分防并发与防重复: 锁只能保证互斥,幂等键(Idempotency Key)才能保证语义上的唯一性;
  2. 注意框架默认的自动重试: 对任何非幂等的写接口,全局自动重试都可能造成重复写入;
  3. 缩短副作用与状态落盘之间的窗口: 远程操作成功与本地确认之间的时间越长,系统崩溃后留下错误状态的概率越高;
  4. 对不确定的结果保持诚实: 遇到无法判断结果的断网情况,把状态显示为“发送状态待确认”,由用户决定后续操作,比自动重发更稳妥。

评论