批量发邮件的防重设计,别让同一个人收到两封一模一样的信
在 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 的执行链条深入推敲,我发现了一条极其隐蔽的**“死亡缝隙”**。
当时的执行序列是这样的:
- 领取任务,标记为
sending; - 建立 TLS,调用远程 SMTP 发送邮件(耗时约 800ms);
- 远程 SMTP 返回
250 OK(关键节点:邮件已经在公网飞了!); - 同步连接 IMAP 服务器,把发出的邮件追加保存到本地的“已发送文件夹”(耗时约 2~3s);
- 终于完事,把本地数据库状态从
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 ]- 出站前落盘(Pipelined In-flight): 在发起任何网络连接之前,必须先将状态置为
smtp_inflight,并持久化生成好的全局唯一Message-ID。只要任务越过这条红线,任何自动恢复逻辑严禁将其放回队列; - 慢操作移出主事务: SMTP 只要返回
250 OK,必须在同一事务内立即把状态固化为smtp_accepted!后续的 IMAP 存箱、UI 提醒全部丢给后台异步事件去跑,把关键窗口从 3 秒瞬间缩窄到 2 毫秒以内; - Idempotency-Key 意图锚定: 每一个发送动作必须由前端在发起意图时携带唯一的
Idempotency-Key。哪怕网络抖动导致重试到达,后端直接查表返回当前状态,绝不二次投递。
什么是可靠的 Message-ID?不要依赖 SMTP 服务器自动生成 ID。必须在客户端由自己生成符合 RFC 5322 标准的唯一消息标识(如
<uuid@yourdomain.com>)。这样即使发生异常,未来在向 IMAP 服务器检索“刚才这封信到底发出去了没有”时,才有唯一可信的指纹。
总结
在编写涉及真实物理副作用(扣款、发信、发短信、物理设备控制)的代码时,永远不要抱有任何侥幸心理:
- 区分防并发与防重复: 锁只能保证互斥,幂等键(Idempotency Key)才能保证语义唯一;
- 警惕框架层默认的“善意”重试: 对任何非幂等写接口,全局自动重试无异于定时炸弹;
- 副作用发生与状态落盘的窗口越小越好: 远程成功与本地确认之间的耗时越长,系统坠毁时留下幽灵的概率就越高;
- 诚实面对不确定性: 当遭遇无法判定的边缘断网时,大方地把状态展示为“发送状态待确认”,把最终裁决权留给用户,远比自作主张的野蛮重发体面得多。