Junie's Blog

批量发邮件的防重设计,别让同一个人收到两封一模一样的信

全文共 2122预计阅读 8 分钟

在 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 或特定数据库锁的边缘 case,我写了个高并发脚本模拟 20 个线程暴力并发争抢,结果没有任何一条任务被重复领跑。

既然 Worker 抢锁没毛病,那邮件到底是怎么发出两遍的?

我退回最基础的一步,做了一个极简的测试:在没有并发的情况下,对同一个“审核并发送”接口连续顺序调两次 POST。

啪!两封邮件毫无阻拦地全发了出去!

通俗比喻:防两个人同时抢,不等于防同一个人点两次

很多人常把“并发控制”和“幂等性”搞混:

  • 并发控制:就像电影院检票口装了个单人旋转闸机,防的是“两个人拿着同一张票同时挤进去”;
  • 幂等性:防的是“一个人检票进去坐下了,5 分钟后他又拿着同一张票走出来重新刷了一次,系统居然又放他领了一份爆米花”。

数据库抢锁只能防前者,根本管不住后者!

并发控制 vs 幂等性

这是很多初中级工程师最容易混淆的概念:

  • 并发控制(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~3s);
  5. 终于完事,把本地数据库状态从 sending 改为 sent

看懂这个致命的漏洞了吗?

在第 3 步和第 5 步之间,存在长达两三秒的真空期!

如果在这两三秒里,用户关闭了软件、电脑盖上了盖子、或者进程由于 OOM 重启了,本地数据库里记录的状态依然是 sending

系统重启后,守护进程巡检数据库,发现有一条任务处于 sending 状态且超期未完成,它会怎么想?

“哎呀,刚才肯定异常中断了,这封信没发出去,我得帮用户重新加回队列(Re-queue)重新发!”

于是,下一轮轮询重新把它捡起来,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 存箱、UI 提醒全部丢给后台异步事件去跑,把关键窗口从 3 秒瞬间缩窄到 2 毫秒以内;
  3. Idempotency-Key 意图锚定: 每一个发送动作必须由前端在发起意图时携带唯一的 Idempotency-Key。哪怕网络抖动导致重试到达,后端直接查表返回当前状态,绝不二次投递。
什么是可靠的 Message-ID?

不要依赖 SMTP 服务器自动生成 ID。必须在客户端由自己生成符合 RFC 5322 标准的唯一消息标识(如 <uuid@yourdomain.com>)。这样即使发生异常,未来在向 IMAP 服务器检索“刚才这封信到底发出去了没有”时,才有唯一可信的指纹。

总结

在编写涉及真实物理副作用(扣款、发信、发短信、物理设备控制)的代码时,永远不要抱有任何侥幸心理:

  1. 区分防并发与防重复: 锁只能保证互斥,幂等键(Idempotency Key)才能保证语义唯一;
  2. 警惕框架层默认的“善意”重试: 对任何非幂等写接口,全局自动重试无异于定时炸弹;
  3. 副作用发生与状态落盘的窗口越小越好: 远程成功与本地确认之间的耗时越长,系统坠毁时留下幽灵的概率就越高;
  4. 诚实面对不确定性: 当遭遇无法判定的边缘断网时,大方地把状态展示为“发送状态待确认”,把最终裁决权留给用户,远比自作主张的野蛮重发体面得多。

评论