1. 为什么我要自己封装一套群发短信方案
那个下午我到现在还记得,系统刚上线,运营说要给全量注册用户发一条版本升级通知,当时代码里写着的是for循环逐条调用服务商的单发接口。程序跑了两个多小时,跑到三分之一被服务商限频,一堆报错堆在日志里,用户手机上短信隔了很久才稀稀拉拉收到。事后运营一脸无辜:不就是发个短信吗?也就是从那天起,我决定认认真真把短信发送能力当做一个独立的基础服务来做,而不是"调个接口而已"。
1.1 业务需求比"发短信"复杂得多
先别急着写代码。你接到"群发短信"需求时,第一件事不是找服务商,而是先把需求拆开。因为"发短信"这三个字背后至少有四种完全不同的场景:
验证码短信:单条为主,单次请求要求快、到达率高,通常60秒内必须到达,否则用户就点"重新获取"了。它对接口时延极其敏感,属于整个短信系统里优先级最高的一类。
通知短信:比如订单状态变化、物流更新、系统告警,单条批量下发,允许一定的排队延迟,但不能丢。这类消息量大,出问题时用户感知强,必须保证稳定送达。
营销短信:比如活动推广、优惠券提醒,单次下发量很大,动辄几万到几十万条,对到达时间没有秒级要求,但对限频、内容审核和退订处理非常敏感,发不好就要被投诉。
告警短信:量小但非常关键,必须保证不因批量任务把通道堵死,否则系统真出故障时,告警短信反而发不出去,那就成了连环事故。
这四种场景对短信接口的调用方式、重试策略、优先级要求完全不同。如果你只是拿着服务商提供的一个SendSms方法到处调用,后面一定会被需求追着打。
1.2 直接用服务商API去循环发送的痛点
我把最初"for循环调接口"踩过的坑整理了一下,基本就这几个:
- 串行发送太慢:一条短信即使接口返回只要100毫秒,一万条也要1000秒,也就是接近17分钟。这还没算网络抖动和限频后的退避等待。
- 失败消息难以追踪:循环里一条发送失败直接抛异常,你不知道它属于哪一批、用户手机号是多少、服务商返回什么错误码,靠翻日志根本没法定位。
- 没有重试机制:服务商返回"业务限流"或"触发拦截"时,如果不想办法重试,这批用户就永远收不到短信,而且连原因都查不出来。
- 无去重能力:批量任务里同一手机号可能出现两次,营销短信多发一遍用户会投诉,验证码多发一遍问题更大,甚至可能被服务商当成异常行为。
- 没有统一模板和签名管理:业务人员直接在代码里拼短信内容,乱改格式,最后被服务商拦截时你根本不知道哪个模板出了问题。
所以,真正的短信接口集成方案,核心不是"调通一个接口",而是把发送链路做成一个可监控、可重试、可统计的完整流程。标题里说的"批量发送短信接口集成方案",本质上就是围绕这条链路来设计的。
2. 选型阶段:短信服务商与接口方案的取舍
聊方案之前必须先解决一个问题:用哪家的短信接口?这个选择直接决定了后面封装层的抽象方式和工作量。
2.1 主流服务商的接口风格对比
我自己实际用过并维护过三套短信服务商SDK,体验差异还是挺明显的:
| 维度 | 国内主流云厂商A | 国内主流云厂商B | 国际服务商C |
|---|---|---|---|
| 接口风格 | RPC风格,JSON/XML参数 | HTTP API,参数风格相近 | REST风格,认证简单 |
| 模板审核 | 必须预审模板与签名 | 必须预审,流程较快 | 无需预审,但风险自担 |
| 短信类型 | 验证码/通知/营销 | 验证码/通知/营销 | 通用消息 |
| 限频控制 | 默认QPS不高,可申请提升 | 默认有频率限制,可调 | 按账户信誉动态调整 |
| 回执能力 | 有异步状态报告 | 有异步状态报告 | 有,且回执维度更细 |
我的建议是:如果你的业务对到达率有硬要求,直接选主流云厂商,别贪便宜用杂牌通道。你省下的每一分钱,都会在"用户投诉收不到短信"时加倍还回去。国内的短信通道市场很成熟,但低价通道往往意味着路由不稳定、回执延迟甚至丢回执,而这些恰好都是批量发送最不能接受的。
另外还要确认一个容易被忽略的点:服务商是否支持企业资质认证。个人开发者想申请签名和模板非常困难,很多短信接口权限要求必须有企业主体。所以选型时要把公司资质情况一起纳入判断,否则方案设计得再好,第一步就卡住了。
2.2 为什么最终选择"队列+服务商API"的组合
选型时我对比过两条路:一条是完全依赖服务商提供的批量发送接口,一次提交一个文件或一个大数据包;另一条是用本地队列+服务商单发接口自己控制节奏。
批量发送接口确实省事,但短板也很明显:你提交之后只能等最终报告,中间无法细粒度控制优先级;批量接口一旦限频,整个批次都得撤回重排;而且不同服务商的批量接口格式千差万别,做抽象层的成本反而更高。
最后我的方案是:上层统一走本地队列,按业务场景设定优先级和速率,底层把"单发接口"和"批量接口"都封装成同一套发送器。正常情况下用小批量聚合发送,某个业务需要冲量时再切到批量接口。这样既保留了灵活性,又让上层业务不用关心底层通道差异。
这里有一个关键认知:短信本身就是异步的。你调用接口拿到"OK",只代表服务商接收成功了,不代表用户手机收到了。真正的发送结果要通过服务商的回执,也就是状态报告异步回调才知道。所以你的集成方案里,必须有一个模块专门处理回执,否则你永远不知道真实发送成功率,也不知道那些"提交成功但没送达"的短信到底浪费了多少预算。
3. 批量发送的核心链路拆分
把整条链路拆开看,从左到右依次是:业务请求层、数据清洗层、调度层、发送器、回执处理层。每一层只做一件事,上下层之间通过数据结构和队列解耦。
3.1 发送前的数据准备:手机号校验与号码段处理
批量发送最容易翻车的第一步不是接口,而是数据。数据不干净,后面的所有优化都白搭。
我先是写了一个手机号清洗函数,主要做三件事:
- 格式校验:国内手机号为11位数字,以1开头,第二位通常是3、5、6、7、8、9。用正则做一次粗筛,把明显不合法的号码剔除掉。这一步能挡掉大量"看起来像号码"的脏数据,比如带横杠的、含空格的、位数不对的。
- 号码去重:同一批任务里相同手机号只保留一条。用集合或Redis Set在批量前做一次去重,成本很低,收益很高,因为重复发送不只是浪费钱,还容易让用户反感。
- 黑名单过滤:对已退订用户、投诉过的号码、测试号码单独维护名单,发送前过滤掉。这块我在后文还会细说,但请记住它是刚需,不是可选项。
这一步容易被忽略,但它在整个链路里性价比最高。几万条数据里哪怕有5%的脏数据,清理之后都能帮你在上游省下几百上千条的短信费用,同时避免触发服务商的风控。你可以把这个清洗逻辑做成独立服务,每次批量任务进来先排队清洗,洗完了才允许进入发送队列。
3.2 内容模板与签名:被拦截最多的环节
国内主流服务商基本都要求你提前把短信模板和签名提交审核。审核通过后,发送时服务商会校验模板变量和签名是否匹配,不匹配直接拒绝。
我的经验是:模板设计要保守,把变化的部分作为变量占位。比如"您的验证码是${code},${minute}分钟内有效",而不是在代码里拼接一整段话。这样有两个好处:一是审核容易通过,二是批量内容里变量替换更可控,不容易出现某条短信因为特殊字符被服务商当成"异常内容"拦截。
签名方面,签名是【】里的那段名称,它会直接暴露在用户手机上。签名要和你的企业主体、模板内容匹配,不要试图在这里做任何"绕过审核"的操作,那是纯属找麻烦。实际维护中,如果你有多个业务线,建议每个业务线申请独立的签名,这样一旦某一个签名被投诉封禁,其他业务线还能正常运行,不会全军覆没。
3.3 批量调度这一层的设计
调度层是整个群发方案的核心。它的职责是:把一个大任务拆成可并发执行的小批次,同时控制整体速率,避免触发服务商限频。
我采用的是"分批+并发+节流"的组合思路:
- 分批:每批按分片大小,比如500条,切分。一批提交完不等待回执,继续提交下一批。
- 并发:同时提交的批次数量受信号量控制,比如最多同时跑8个批次。这样即使底层是单发接口,也能实现高吞吐。
- 节流:通过令牌桶或简单的sleep控制每秒提交的总条数,把QPS压在服务商允许范围内。别小看这一步,很多线上事故就是从这里开始的。
这样设计后,你并不需要依赖服务商提供"批量发送接口"才能高效群发,用单发接口也能打出一万多条/分钟的量,关键是节奏控制好。调度层还应该支持"暂停"和"优先级调整",否则运营临时要求"先把某部分人发掉"时,你只能干瞪眼。
3.4 发送结果与状态回执的处理思路
发送器收到服务商响应后,只能判断"提交成功"。真实的到达情况要通过回执回调获知。
我的做法是:发送记录入库时生成一个msgId,服务商回调时带上msgId和status字段,回执处理服务再去更新对应记录的状态。注意回执可能延迟几分钟到几小时,所以最终的成功率统计要以"任务启动后N小时"为截止时间,而不是任务刚提交完就算。
这里我吃过一次亏:某次营销任务发完后,我看提交成功率99%,心里美滋滋,结果第二天运营说用户没收到。一查,回执那里静默丢了30%的"送达失败",很多号码是空号或关机,而回执处理程序当时有个bug,把这一类都当成"发送失败但未处理"。后来我统一改成:回执处理必须落库、必须可查、必须和提交记录关联,缺一不可。
4. 工程实现:从单条到并发群发的代码实战
下面是我在实际项目中沉淀出来的精简版实现,语言用Python,但思路可以平移到任何语言。重点不是抄代码,而是理解每一层为什么要这么设计。
4.1 核心发送器抽象
先把服务商差异挡在门外。我定义一个SendChannel接口,里面只暴露两个方法:单条发送和批量发送。这样上层业务永远不感知具体服务商。
from abc import ABC, abstractmethod from dataclasses import dataclass from typing import List, Optional @dataclass class SmsMessage: phone: str template_code: str params: dict sign_name: str msg_id: Optional[str] = None class SendChannel(ABC): @abstractmethod def send_single(self, msg: SmsMessage) -> dict: """返回服务商的原始响应""" @abstractmethod def send_batch(self, msgs: List[SmsMessage]) -> List[dict]: """批量发送,返回每条消息的提交结果"""具体服务商只要继承这个接口实现即可。换供应商时只改工厂类,上层代码完全不用动。我给这个抽象层加了两层保障:一是超时控制,每个HTTP请求必须有timeout,否则一个慢接口能拖垮整个线程池;二是签名校验,服务商返回的数据结构如果不符合预期,直接抛错,不往下传脏数据。
4.2 消费者与线程池并发控制
发送任务的消费端我用了ThreadPoolExecutor加信号量,把"并发批次"和"批次内条数"两个参数独立出来:
import threading from concurrent.futures import ThreadPoolExecutor, as_completed class SmsDispatcher: def __init__(self, channel: SendChannel, max_workers: int = 8, batch_size: int = 500): self.channel = channel self.batch_size = batch_size self.executor = ThreadPoolExecutor(max_workers=max_workers) self.semaphore = threading.Semaphore(max_workers) def dispatch(self, messages: List[SmsMessage]): chunks = [messages[i:i + self.batch_size] for i in range(0, len(messages), self.batch_size)] futures = [] for chunk in chunks: self.semaphore.acquire() future = self.executor.submit(self._send_chunk, chunk) futures.append(future) for future in as_completed(futures): future.result() self.semaphore.release()_send_chunk内部再根据服务商能力决定走批量还是循环单发。原则很简单:并发数量足够多时,单发接口也能顶住很大的量,关键是别在循环里加过重的日志和网络重试,否则日志本身就会拖垮性能。
一个我踩过的坑是:并发数开太大,比如20个线程,每个线程发送间隙都不控制,结果服务商在5秒内收到上千条提交,直接触发限频。后来我把并发和节流绑在一起——线程只负责提交,全局再挂一个令牌桶,每秒只放行固定次数,整体反而更稳定。
4.3 失败重试与退避策略
发送失败的错不能一刀切。我把错误码分成三类,每一类的处理方式完全不同:
- 可重试错误:限流、网络超时、签名解析临时故障、系统繁忙。这类错误等一会儿再发大概率能成。
- 不可重试错误:手机号格式错误、模板变量不匹配、签名不存在。这类错误重试多少次都一样,直接标记为失败并人工排查。
- 强制失败:内容含敏感词、触发运营商拦截。这类要立刻停止该模板继续发送,否则越试越糟,甚至可能连累整个签名被封。
重试用指数退避,每次间隔分别为1秒、2秒、4秒,最大3次。加一个random抖动,防止所有任务同时重试又撞在一起:
import random import time def retry_send(func, channel, msg, retries=3): for attempt in range(retries): try: return func(channel, msg) except RetryableError: if attempt < retries - 1: time.sleep(2 ** attempt + random.uniform(0, 1)) return {"status": "failed", "msg_id": msg.msg_id}这里有个细节:重试的任务要放回队列尾部,而不是立刻重新调用。因为发送通道刚被限流时,大批重试任务同时进来会让情况更糟。放回队列后,让调度层按当前速率自然消费,反而平滑很多。
4.4 回执回调接口
用FastAPI起一个回执接收服务。注意回调地址必须是服务商可以外网访问的URL,且要校验来源身份,否则容易被人伪造回执刷库。
from fastapi import FastAPI, Request app = FastAPI() @app.post("/sms/receipt") async def receive_receipt(request: Request): data = await request.json() msg_id = data.get("msg_id") status = data.get("status") # 校验签名,更新发送记录状态 update_send_status(msg_id, status) return {"code": 0, "msg": "ok"}回执服务一定要做好幂等处理。同一个msgId的回执可能因为网络重试被推送多次,你的更新逻辑必须是"用后到的覆盖先到的"这种简单策略,或者去重后只处理第一次。否则统计出来的数据一会儿一变,排查问题时人都要疯了。
5. 高并发群发最容易踩的坑
这部分我在生产环境里真的一个个撞过,拿出来说比什么理论都管用。
5.1 频率限制与并发参数的平衡
每家服务商都有单位时间接口调用上限,有的是每分钟2000条,有的是按QPS算。很多人以为"并发开大=发得快",其实服务商那边的限频才是真瓶颈。你开再多的线程,超过限额的请求全被拒绝,甚至可能被临时封禁接口权限。
我做压测时的做法是:先按服务商文档给上限的60%估算并发和每秒条数,然后逐步上调,观察延迟和失败率。如果失败率开始抬头,立刻回退到上一档。频率参数要写在配置中心里,不要写在代码里,因为服务商随时可能调整通道策略,写死在代码里改起来太痛苦。
另外一个经验是:不同短信类型的限频是分开算的。验证码通道和营销通道通常有独立的QPS配额,所以做方案时可以拆成两套配置,验证码走验证码的速率,营销走营销的速率,互不干扰。
5.2 手机号格式与海外号码
国内手机号校验我上面提过了。但做跨境业务时要注意:服务商API的phone字段要么只接受国内11位号,要么要求带上+86前缀。很多线上事故就是业务把国际号当成国内号传上去,被服务商静默丢弃或报格式错误。
如果你要支持国际短信,最稳妥的做法是在数据清洗层判断号码归属:国内号走国内通道,国际号走国际通道,不要混在一个批次里提交,否则有一个号码格式错误,整个批次可能都要重发。号码归属判断我用的是手机号前缀匹配规则,配合运营商公布号段表,每季度更新一次,基本够用。
5.3 特殊号段与黑名单处理
虚拟运营商号码、携号转网过来的号码,在运营商侧的到达率会低一些,这不一定是你接口的问题。我自己遇到的真实案例是:某个批次全量发出后,回执显示20条发送失败,全是同一虚拟号段。后来在清洗层单独给这类号码标了优先级,错误回执处理时走人工确认流程,避免误判成通道故障。
退订和投诉用户一定要维护黑名单。营销短信被用户回复"TD"退订后,如果下次又给他发了,轻则投诉,重则服务商封你的模板和签名。这块没有捷径,只能老老实实在发送前过滤。我还建议对黑名单做分级:硬退订的永久过滤,临时投诉的观察一段时间后自动转普通用户,避免误伤正常活跃用户。
5.4 内容审核与合规红线
很多开发者第一次做群发时容易忽视:内容不是你想发就能发的。国内通道对营销内容有一套非常严格的审核机制,模板里不能有夸大宣传的词汇,必须写清楚退订方式,推广短信还必须错开休息时段。这些规则不是为了为难人,而是为了整个行业生态能长期运转。
这里不建议任何人想着绕过审核或者伪造签名去发垃圾短信,成本极高。通道一封,影响的不是一批任务,而是整个业务系统所有依赖短信的场景,包括登录验证码、告警通知,全都会跟着停摆。技术方案做得再好,合规不过关,全白搭。营销短信至少要做到:提供退订方式、不轰炸、不伪装成通知短信、目标用户是主动订阅过的。把这些做对,你的发送成功率也会更高,因为投诉少了,服务商给你的通道质量也会维持得更好。
6. 压测与线上验证
方案写完,最后一步是验证。我把验证流程分成三档,每一档有明确的目的,不会直接上生产乱发。
6.1 小规模验证跑通
第一档:100条以内的测试任务,用测试签名和测试模板,验证发送链路是否通、回执是否正常入库、失败重试是否正确。这个阶段重点看日志,不发真实用户。
我会特意构造一些异常数据,比如空手机号、位数不对的号码、不存在的模板变量,看清洗层和发送器是否按预期处理。这一步做得越细,线上事故越少。测试手机号我固定用几个指定的号段,并且把测试模板和线上模板分开,避免混淆。
6.2 压测数据与性能指标
第二档:5000条真实号码的小规模群发,压测时记录几个关键指标:
| 指标 | 观测值 | 说明 |
|---|---|---|
| 提交耗时 | 平均80ms/批 | 网络与服务商接口延迟 |
| 总耗时 | 5000条约90秒 | 受并发批次与限频共同影响 |
| 提交成功率 | 99.6% | 剩余为限频与网络抖动 |
| 最终到达率(24h回执) | 97.8% | 未到多为空号与关机 |
| 回执回传延迟 | 中位数10秒内 | 高峰时段会拉长 |
这个数据仅供参考,不同服务商和时段差异很大,但你可以拿它当基线去对比自己调参前后的效果。注意压测要和线上错峰,不然压测短信和真实业务短信抢通道,两边都会被限频拖慢。
6.3 我的实践体会:把成功交给回执,把失败交给重试
这么多场景做下来,我最深的体会就两句:提交接口返回成功不代表短信真的到了用户手机,真正的成功要等回执来确认;而发送失败时也别慌,先分清错误类型再决定重不重试,比盲目重试一万次有用得多。
如果让我给刚做短信集成的朋友一个建议,那就是从一开始就按"统一抽象、异步化、可观测"这三个原则来设计。不要为了省事把代码写死在一个服务商的SDK里,也不要迷信某个"批量接口"能一劳永逸。短信这种场景,真正的复杂度不在接口文档里,而在海量数据、频率控制、回执处理和那些永远不可能一次考虑全的边界情况中。
最后再分享一个实用小技巧:把测试手机号固定用几个指定的号段,测试模板和线上模板分开,在配置中心里加一个"测试模式"开关。打开后发送器会把所有请求转发到测试通道并打印完整报文。这样每次联调、排查问题都会轻松很多——短信这东西,看不见摸不着,调试成本本来就高,能省一点是一点。