最近在梳理我们几个后端服务的通知链路,邮件和短信发送这两条通道又被我重新推倒做了一遍。坦白讲,单看某个发送功能,接一个服务商、调一个接口,半天就能跑通;但真把邮件和短信发送放进生产环境,要考虑的维度完全不一样:送达率、失败重试、频控限流、模板审批、费用核算、超时控制……每一项都可能让线上通知变得不靠谱。这篇文章是我做完这轮重构之后的完整复盘,从设计思路、协议原理到最终落地的代码结构都会拆开讲,适合正在设计用户通知模块、或者邮件和短信发送总是出各种小问题的开发同学,看完可以直接照着搭一版。
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("你的订单已支付成功,点击查看详情", _subtype="html", _charset="utf-8") msg["Subject"] = Header("订单支付成功通知", "utf-8") msg["From"] = username msg["To"] = "接收测试邮箱@example.com" with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10) 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>支付成功</h3><p>订单号:${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 的逻辑非常简单:查出status=0 且 next_retry_at<=now的任务,逐条把状态改成“发送中”,防止多个 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]前缀,防止测试邮件混进生产用户。
最后的一点个人体会
这套邮件和短信发送模块重构完跑了大半年,我最深的体会是:通知模块看起来简单,实际上考验的是全局工程能力,从网络协议、服务商对接,到队列、重试、监控、费用,每一个环节都藏着一堆细节。我踩过最多坑的地方不是发送本身,而是失败后的处理和排查链路。如果你现在正要动手做,我建议第一步不是什么花哨的架构,而是先把“发送记录表”建起来,让每一次邮件和短信发送都有迹可循。有了这张表,后续的队列、重试、降级、统计都是水到渠成的事。做技术很多时候就是这样,把基础设施打牢,比堆砌功能重要得多。