邮件短信通知模块重构复盘:从SMTP协议到高可靠发送链路
最近在梳理我们几个后端服务的通知链路邮件和短信发送这两条通道又被我重新推倒做了一遍。坦白讲单看某个发送功能接一个服务商、调一个接口半天就能跑通但真把邮件和短信发送放进生产环境要考虑的维度完全不一样送达率、失败重试、频控限流、模板审批、费用核算、超时控制……每一项都可能让线上通知变得不靠谱。这篇文章是我做完这轮重构之后的完整复盘从设计思路、协议原理到最终落地的代码结构都会拆开讲适合正在设计用户通知模块、或者邮件和短信发送总是出各种小问题的开发同学看完可以直接照着搭一版。1. 先把需求和边界想清楚1.1 邮件和短信两条完全不同的通道很多同学把它们当成同一个事情——“不就是往一个地址发一段文本嘛”。这个认知是后续所有坑的起点。邮件走的是 SMTP 协议本质是存储转发发送方把信投到对方邮件服务器对方服务器再决定放进收件箱还是垃圾箱。这个过程通常要几秒到几分钟而且没有人能向你承诺“一定到”收件方服务器策略、DNS 配置、反垃圾评分都会影响最终结果。短信走的是运营商网络服务商把内容通过运营商通道推到用户手机秒级到达只要手机信号正常基本都能收到但每一条都要付费内容长度也被限制得很死。从业务视角看邮件的优势是便宜、内容承载量大、可以带附件和排版适合订单详情、账单、周报这类信息密集型场景短信的优势是强触达、实时性高适合验证码、支付提醒、系统告警这类必须立刻看到的消息。把这两条通道塞进同一个“发送”接口里不是不行但你必须意识到它们的失败模式、成本模型、合规要求差异巨大处理逻辑应该分开只是入口可以统一。1.2 典型场景决定了方案选型我习惯把通知需求先按场景分类再决定用哪条通道。只有四类场景会反复出现在几乎每个业务里验证码类登录、注册、改密码、二次校验。短信优先级最高邮件可以作为没有手机号或短信不可达时的备用通道。这里的核心是时效性和验证逻辑不是内容丰富度。交易状态类下单、支付成功、发货、退款。邮件承载详细说明短信只推送关键节点而且短信内容往往需要模板化、不能自由发挥。监控告警类服务异常、任务失败、指标超阈值。邮件和短信都可以通常短信给值班人员邮件留档给整个团队还要考虑渠道故障时的互相兜底。营销触达类活动通知、召回、订阅推送。邮件量大需要用独立的发送域名和 IP 池养信誉短信在这里反而要克制频控和退订机制必须做好否则投诉率上来了会被服务商限制发送。这四个场景对延迟、成本、送达率的诉求完全不同。我见过最典型的反面教材是有人把所有通知都走短信一个月短信费用比服务器费用还高然后为了省钱又改成全部走邮件结果验证码收不到用户疯狂投诉。正确做法是在模块设计阶段就把通道属性暴露出来让调用方根据场景选。1.3 统一通知模块的最小边界既然要统一得先划清边界。我最终沉淀下来的接口极其简单一个发送入口接收四个参数——通道类型、模板标识、接收人、模板变量。业务方只需要说“给这个手机号发验证码变量是”或者“给这个邮箱发订单通知变量是订单号”至于邮件怎么拼 HTML、短信怎么满足签名要求、失败了怎么重试全部由模块内部消化。模块内部至少要有四层模板层、发送层、队列层、可观测层。模板层管内容发送层管协议对接队列层管异步和重试可观测层管日志和费用。这四层互相独立换短信服务商的时候只改发送层加新模板只动模板层排查问题只看可观测层。剩下的原则是不要把业务逻辑写进发送模块不要把发送参数散落在各个业务代码里不要让调用方关心“邮件和短信的技术差异”。2. 邮件发送从 SMTP 到一封能落地的邮件2.1 SMTP 投递链路是怎么跑的应用发邮件通常不做整个 MTA 的工作而是把信交给一个 SMTP 服务端。以自建或云邮箱服务为例投递的链路大概是你的程序通过 SMTP 协议连接发信服务器完成登录后提交邮件发信服务器根据收件人域名查 DNS 的 MX 记录找到收件人邮箱所在的接收服务器接收服务器根据收件人地址投递到具体邮箱并触发收件人的垃圾邮件过滤规则。这意味着一个很反直觉的事实你调用 sendmail 成功返回只代表你的发信服务器接收了这封信不代表收件人真的看见了它。后面每一跳都可能出问题接收方把信拒收、丢进垃圾箱、或者因为策略原因延迟处理。所以做邮件发送的同学要有“投递成功不等于送达”的基本觉悟监控的指标也要围绕真实送达率来设计。SMTP 有两个常用端口需要说清楚。465 端口是隐式 SSL程序一连接就建立 TLS 加密通道简单直接587 端口是显式 STARTTLS先明文握手再升级加密。很多邮箱服务商的授权码机制要求使用加密连接代码里最稳的是直接走 465 端口。25 端口通常被云服务器默认封禁只适合内网或自建邮件服务器的场景外部发信尽量不要碰它。连接超时时间必须设置否则发信服务端网络抖动时你的业务线程会卡在 socket 上出不来。2.2 第一封邮件用标准库快速验证连通性先别急着上框架和队列第一步一定是验证发信服务端的连通性和账号配置。我用 Python 写过一个最小验证脚本几十行就能把整条链路打通import smtplib from email.mime.text import MIMEText from email.header import Header smtp_host smtp.你的发信域名.com smtp_port 465 username no-reply你的发信域名.com password 这个账号专用的授权码 msg MIMEText(你的订单已支付成功点击查看详情, _subtypehtml, _charsetutf-8) msg[Subject] Header(订单支付成功通知, utf-8) msg[From] username msg[To] 接收测试邮箱example.com with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout10) as server: server.login(username, password) server.sendmail(username, [msg[To]], msg.as_string())两个细节值得注意。一是主题和正文都显式声明了 UTF-8 编码主题里如果有中文不包一层 Header 很容易在某些邮箱客户端显示成乱码。二是 timeout 参数我见过很多生产事故是 socket 默认超时时间太长发信服务商不可用时一个发送调用能把 Tomcat 的连接线程池拖垮直接把 timeout 收紧到 10 秒以内配合后文说的队列业务侧几乎感知不到故障。2.3 模板化、附件与内容组织邮件有一个短信不具备的优势内容承载量大。但内容大不代表你可以把 HTML 拼在业务代码里。我踩过的坑是业务方反复改邮件样式每次都改动业务代码然后打包上线一周能发三次版本。后来我把邮件模板统一收敛到模板目录按模板标识命名正文里的动态部分用占位符标记比如order_id、amount、pay_time。程序从模板文件读取再用字符串替换或者模板引擎渲染业务代码只负责传变量。from string import Template template Template(h3支付成功/h3p订单号${order_id}/p) html template.safe_substitute({order_id: 20250101001})附件是个隐藏很深的问题。日志文件和 PDF 账单这类业务附件还好最怕的是附件文件名里有用户输入内容比如把用户名直接拼进文件名一旦包含特殊字符接收方解析就可能出问题。我现在统一用 MIMEBase 构造附件文件名强制做转义大小超过 10MB 就直接拒绝并告警。还要强调一点附件里的内容往往是用户隐私邮件本身也要走加密传输就算发信服务商支持在页面上查看历史邮件也要确认日志里不记录正文和附件内容。2.4 送达率优化SPF、DKIM、收件人策略邮件发出去了不代表进了收件箱。我在真实的业务里经常看到这样一个场景邮件发送状态全部成功用户却收不到后来发现全部被收件方服务器当垃圾邮件拒了。这里核心原因是发信域的 DNS 上没有配置 SPF 和 DKIM 记录收件方服务器无法确认这封信确实来自声称的发信域。SPF 的作用是声明哪些 IP 允许以你的域名发信DKIM 是给邮件做数字签名DMARC 则告诉收件方“验证不通过时怎么办”。这三者不是可选项是邮件送达率的基本盘。如果用的是商用邮件推送服务它们通常会分配独立的发送域和回信地址你只要按服务商要求配置对应的 DNS 记录即可。如果不配你会发现测试环境发到主流邮箱时通通进垃圾箱而且这个结果在换邮箱服务商之后依然存在因为分数已经被拉低了。实践经验还有几条事务性邮件和营销邮件不要用同一个发送域否则营销邮件的投诉率会影响交易邮件的送达新域名发信要有一段“预热期”逐步提高发送量不要第一天就发上万封定期监测退信率、投诉率、垃圾箱命中率任何一项异常都要立刻暂停营销类邮件的发送。另外如果数量不大我建议保留一份真实收件人的测试名单每次模板改动后用测试名单发一遍并检查排版和编码这类回归测试对送达率意义很大。3. 短信发送接服务商 API 的正确姿势3.1 短信服务商到底在卖什么短信服务商的本质是帮你打通运营商通道。运营商网络有短信中心、计费系统、下行通道普通开发者没法直接对接运营商服务商把这些东西封装成 HTTP API同时承担签名报备、模板审核、状态回执这些合规和运维工作。你按条数付钱服务商对内容、发送量、频率都有管控这是整个行业在防诈骗压力下的必然形态。选服务商时我会看几个硬指标通道稳定性尤其重要节假日的发送成功率和速度状态回执能力能否实时把“已送达/未送达”推给我签名和模板审核周期审核慢会直接拖累业务上线单条价格在其次因为省下来的每一分钱都有可能变成投诉和故障。还有一个很容易被忽略的点服务商 A 在某个运营商的通道质量好服务商 B 在另一个运营商质量好所以有条件的话接两家的接口做通道级双发或者故障切换这在监控告警场景里特别有用。3.2 签名、模板和内容变量的合规细节短信内容不能像邮件一样随便写。每条短信必须有报备过的签名模板里动态变量部分要用占位符这里的模板不是前端界面那种模板而是平台审核过的内容框架。举个例子验证码短信的模板可能是【某应用】您的验证码是${code}${minutes}分钟内有效请勿泄露。其中【某应用】是签名${code}${minutes}是变量固定文案部分一个字都不能改。有些服务商允许变量填任意文本但建议在代码中限制变量的长度和字符集否则用户填写的数据可能触发服务商的拦截策略最常见的例子就是变量里带了链接或营销词汇整条短信被判为营销甚至反诈拦截。对接服务商 API 的时候有三件事必须检查一是请求参数里必须有模板标识和模板变量不要直接传完整短信内容二是接收人的号码格式通常要求 11 位手机号前面不要带 86 或 86三是服务商返回的提交状态只是“已受理”真正到没到要看后续的状态回执后面我会专门说这个坑。3.3 鉴权机制与请求签名实现短信服务的 API 一般用 accessKey 和 secretKey 做鉴权签名算法通常是把请求参数排序拼接后加上时间戳再用 HMAC 类算法算出一个摘要。这个机制的目的是防重放、防参数篡改。我贴一段通用实现细节以你用的服务商文档为准import hashlib import hmac import time def build_sign(secret_key: str, params: dict) - str: message .join(f{k}{params[k]} for k in sorted(params)) return hmac.new( secret_key.encode(utf-8), message.encode(utf-8), hashlib.sha256 ).hexdigest() params { phone: 13800001234, template_id: SMS_2025001, template_var: {code:123456,minutes:5}, timestamp: str(int(time.time())), } params[sign] build_sign(secret_key, params)有几个容易踩的细节参数必须先排序再拼接顺序不同签名结果就不同时间戳要和服务商服务器时间对齐偏差太大会被拒绝本地时钟如果不同步优先启用 NTP 同步secretKey 绝对不要出现在日志、异常堆栈或者前端代码里更不要直接写在配置文件提交到仓库我用的是环境变量加密钥管理服务密钥单独加密存储。3.4 验证码业务的状态机与频控策略验证码是短信场景里最高频、也最容易被人刷的业务。我的标准状态机是这样的收到发送请求后先检查该手机号过去 60 秒内是否已发送过已发送就直接返回“请勿重复请求”然后生成 6 位数字验证码只在短时间内有效通常 5 到 10 分钟存到缓存里的是验证码的哈希值和过期时间而不是明文最后才调用短信接口并记录一条发送日志。验证的时候同样要防暴力破解。连续输错 5 次就作废当前验证码并触发冷却同一手机号一天之内允许的验证码发送次数也有限额超了就拒绝。我见过一个业务因为没做频控凌晨被脚本用户拿一批手机号刷验证码一晚上把一个月短信预算烧光了。另外验证码尽量用数字而不要用字母中文短信里字母大小写的识别率在部分场景会有问题短信内容里加一句“请勿泄露”既是合规需要也是降低用户转发风险的手段。4. 把可靠性做上去队列、重试与可观测性4.1 为什么不能在业务请求线程里同步发送这是我认为整个通知模块最重要的一条设计决策。短信服务商 API 的响应时间从几百毫秒到几秒不等邮件发信服务商更慢尤其是在网络波动或者服务商拥堵时。如果在用户注册、下单、支付回调的主流程里同步调用发送接口用户的请求延迟就被第三方服务延迟绑架了结算回调里一个短信超时可能影响整个支付流程。正确做法是异步化业务代码把通知任务写入一个持久化的任务表发送线程池或者调度任务从表中拉取任务去发送。异步化之后发送失败还能重试发送记录也自然沉淀成了审计日志。很多人问这一步能不能简单用内存队列我的建议是尽量不要。进程重启内存队列就丢了通知任务一旦丢失用户收不到验证码、收不到发货通知这种问题排查起来极其痛苦不如一开始就用带持久化的任务表。4.2 一张数据库表当队列简单还可靠如果你的通知量在每天几万到几十万条这个量级完全不需要上消息队列一张业务表就够了。这是我一直在用的做法表结构大致是这样CREATE TABLE notify_task ( id BIGINT AUTO_INCREMENT PRIMARY KEY, channel TINYINT NOT NULL COMMENT 1邮件 2短信, template_key VARCHAR(64) NOT NULL, receiver VARCHAR(128) NOT NULL, payload TEXT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待发送 1发送成功 2失败待重试 3终态失败 4已取消, retry_count INT NOT NULL DEFAULT 0, next_retry_at DATETIME NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY idx_status_next (status, next_retry_at) ) COMMENT通知发送任务表;发送 Worker 的逻辑非常简单查出status0 且 next_retry_atnow的任务逐条把状态改成“发送中”防止多个 Worker 重复消费然后按通道调用对应的发送器。发送成功置为终态失败则增加重试次数并计算下一次重试时间。重试策略我使用指数退避1 分钟、5 分钟、30 分钟、2 小时、8 小时最多重试 5 次超过次数进入终态失败队列由告警通知人工处理。为什么这么设计因为瞬时故障通常很快恢复重试间隔太短会扩大压力间隔太长会让用户等太久上限不能无限扩大否则一个已注销的手机号会不断被发送骚扰。这种方案唯一的弱点是轮询频率。任务量大时用定时的批量扫描没问题任务量小时可以结合业务事件直接入队唤醒。等规模真的超过几张表的承载能力再平滑迁移到专门的消息队列也不迟业务上一层抽象的发送接口完全不用改。4.3 渠道降级与人工补偿有了任务表渠道降级就变得很自然。我的告警流程是短信优先、邮件兜底短信通道连续失败率超过阈值时把目标值班人员的任务自动转成邮件发送同时在告警系统里标记“短信通道异常已降级为邮件”。对用户侧的验证码场景不建议自动把短信验证码转成邮件验证码因为两个通道的安全策略不同但可以让用户在登录页主动选择“收不到短信改用邮件验证”。人工补偿也是必须的。任务表里终态失败的任务不要删保留给人工排查。我加了一个简单的管理页面按手机号、邮箱、时间范围查询发送记录支持单条重发。有一次用户投诉没收到发货短信客服通过这个页面发现短信服务商状态回执显示“号码拒收”原来是用户把营销短信全部拦截了这种情况重发多少次都没用得引导用户把签名加入白名单。没有记录页面的项目这类问题只能靠技术翻日志效率差别非常大。4.4 日志、指标体系与费用核算通知模块的可观测性有个容易搞错的重点发送接口返回成功不代表送达。拿短信来说服务商返回“提交成功”可能最终状态回执是“未送达”邮件也一样投递服务器接收了不一定进收件箱。所以核心指标至少要有三层请求层看提交成功率和耗时分布回执层看真实送达率和失败分类业务层看用户实际行为比如验证码的提交成功率、邮件内链接的点击率。我把每次发送的关键字段都打进日志任务ID、通道、模板标识、接收人脱敏后的值、服务商返回的消息ID、状态回执、耗时。有了这些用户反馈没收到时从查询任务表开始一路查到服务商回执通常几个SQL就能定位故障点。费用核算也建议按天汇总记录每个通道的发送条数和费用估算和财务账单对照。短信费用最怕的是失控设定值班告警当发信量或费用环比突增超过 50% 时立刻告警这能精准抓住刷接口和误配置问题。5. 线上问题排查实录5.1 邮件发出去了但收件人一直看不到这类问题的第一刀永远切在“发送日志里状态是什么”。如果发信服务商返回失败重点检查账号权限、发信额度、收件地址格式如果返回成功但客户说没收到先让客户查垃圾箱再看退信回执。我排过最经典的案子某业务邮件一直正常某天开始大量客户投诉收不到查发送日志全部成功后来发现是发信域 DNS 的 MX 记录被运维改了收件方服务器根本找不到接收方。DNS 类问题优先级最高因为它的影响面是整个域名而不仅仅是某一次的邮件内容。如果只有部分用户收不到优先怀疑收件方策略。重点排查内容里是否包含“发票”“链接”“折扣”这类触发词以及发信 IP 的声誉是否下降。我处理过一次投诉某个模板里放了一个正常链接但因为域名从未发过信被接收方当成了钓鱼邮件处理。最终方案是换用服务商提供的专用发信域名老域名用来发低频率的事务性邮件分轨道管理。5.2 短信提交成功但用户收不到短信提交成功但用户收不到90% 的情况看状态回执就能定位。回执里常见的失败原因有关机、停机、号码不存在、内容被运营商拦截、用户手机设置了骚扰拦截。前三种是号码本身问题重发没有意义拦截问题需要引导用户操作或者简化内容。短信内容里的链接是最容易被拦截的有些通道对链接有额外的报备要求所以纯验证码场景最好不带任何 URL。有一次我们短信延迟到了十几分钟排查后发现是服务商通道容量不足运营商在高峰时段对某类签名做了优先排队。那次之后我做了双通道策略正常走主服务商延迟超过阈值自动切换备通道。短信通道的能力是有天花板的别太迷信所谓的多通道调度先把主备通道、灰度和切换机制做出来比什么都管用。5.3 重复发送重试机制的经典副作用只要用了失败重试就一定会有重复发送的可能。因为“发送后响应超时但实际已发送”这种情况无法通过响应来判断是否成功重试自然会把同一条消息再发一遍。用户收到两条验证码短信虽然不会造成严重事故但体验很糟糕而且营销邮件的重复发送还可能导致账号被投诉。解法是幂等控制和去重检查。任务表方案天然有优势Worker 领取任务时把状态置为处理中发送完成后根据服务商返回的消息ID做记录在业务入口还可以设计一个去重键比如“手机号模板业务单号”同一业务单号的验证码在几分钟内只发一条。补充一句验证码有效期和发送次数做去重时要以业务语义为准不要单纯以时间为准同一用户连续点击重发是合理的同一笔订单触发两次通知才是不合理的。5.4 测试环境与生产环境的差距我自己栽过一个大跟头测试环境里邮件、短信全部正常一上线立刻翻车。原因特别低级——测试环境我用的是一套测试用的发信账号和服务商测试通道生产环境用的另一套两边配置的模板内容不同上线代码里模板标识还停留在测试值导致生产环境调用时模板不存在。这个教训让我把配置校验写进了启动流程应用启动时加载配置文件并连接各个渠道做一次探测模板标识不存在就直接启动失败宁可不让服务起来也不要带着错误配置跑。短信在测试环境还有一个特殊性模拟器和部分测试通道不会真正下发短信所以要有专门的测试手机号白名单打上标记后走后端的模拟短信通道在日志里打印完整的发送内容。这样既保证测试人员能看到效果又不会把钱浪费在真发上。邮件测试也是同理我在测试环境默认把收件人统一替换成测试邮箱并在邮件标题上加上[TEST]前缀防止测试邮件混进生产用户。最后的一点个人体会这套邮件和短信发送模块重构完跑了大半年我最深的体会是通知模块看起来简单实际上考验的是全局工程能力从网络协议、服务商对接到队列、重试、监控、费用每一个环节都藏着一堆细节。我踩过最多坑的地方不是发送本身而是失败后的处理和排查链路。如果你现在正要动手做我建议第一步不是什么花哨的架构而是先把“发送记录表”建起来让每一次邮件和短信发送都有迹可循。有了这张表后续的队列、重试、降级、统计都是水到渠成的事。做技术很多时候就是这样把基础设施打牢比堆砌功能重要得多。