短信接口开发实战:从供应商接入到稳定发送服务的完整指南
说到短信接口开发技巧很多人第一反应是“不就是调一个HTTP接口发条文本嘛”但真的要把一套能长期稳定跑的定制短信发送服务做出来踩过的坑远比你想象的多。我最早接短信需求时也这么想结果被签名审核、状态回调、限流重试、乱码拆条轮番折腾了一遍才意识到这活儿本质上不是“发短信”而是“做一个可靠的消息投递系统”。这篇文章我就把这几年的实战经验整理一下覆盖从需求梳理、供应商接入、发送链路、定制能力到监控踩坑的完整过程适合正在做短信验证码、通知提醒、运营触达这类服务的小团队和个人开发者参考。1. 短信接口开发前先盘点这三件容易被忽略的事很多人直接跳到申请账号、看文档、跑Demo却忽略了前面最关键的几步。我接过几个项目有给内部系统做验证码通知的也有给运营活动做批量短信的凡是后续改起来痛苦的基本都是因为在动手前没把需求盘清楚。1.1 不要先选厂商先写清楚自己的发送需求选短信供应商之前我会先整理一张需求清单把五个问题写明白消息类型是什么、量级有多大、什么时候发、发给谁、要不要状态报告。这五点直接影响后面的技术选型和架构设计。消息类型决定了通道选择。验证码、通知、营销短信在平台侧往往走不同的签名和模板规则审核标准不一样价格也不一样。量级决定并发模型。我会用“峰值并发 瞬时消息数 ÷ 期望发送窗口秒数”这个公式粗算比如运营导入5万条希望10分钟内发完峰值并发就是50000÷600≈84条/秒。这个数字出来后才能判断是单机同步循环就够还是必须上队列和批量worker。发送时机和接收对象容易被忽略。如果只是用户点了“获取验证码”后实时发直接同步调用还行但如果是定时催缴、每日提醒、活动触达就必须考虑本地任务队列。接收对象也决定了要不要做手机号管理、去重、黑名单过滤。这些不在前期定清楚后面改表结构、改调用方式都很折腾。1.2 签名和模板申请是第一个隐形门槛短信不是想发就能发的主流供应商都要求先申请签名和内容模板审核通过后才能调用接口。我之前有一次自认为接口调通了结果测试手机号收到的是乱码短信排查半天才发现是模板审核时用的变量名和接口传参不一致平台把变量当成了普通文本。签名通常用【】包裹体现企业或产品品牌不能随便写。如果是个体开发者很多平台对签名资质有额外要求申请前一定要把营业执照、应用名称这些材料准备好。模板审核最麻烦的是变量规则比如验证码模板写成“您的验证码为${code}5分钟内有效请勿泄露”接口传的TemplateParam就应该是{code:123456}两边字段名必须完全一致。营销类模板还要避开诱导性词汇内容里别出现“免费领取”这类容易被拒的词。经验是同一个业务场景尽量多准备几套模板比如验证码场景准备“登录验证”“修改信息验证”两套因为有些平台会要求场景与模板内容匹配一个模板不可能覆盖所有入口。1.3 号码来源与频次限制决定架构复杂度号码来源看起来是个业务问题其实直接决定技术复杂度。如果手机号都是从自己系统里来比如注册用户、下单用户那发送服务只需要处理“已知用户通知”如果号码来自运营导入的Excel、外部渠道甚至用户自行提交的表单就必须有接收人管理、去重和黑名单机制。频次限制也是架构设计的重要输入。很多供应商在单个号码上有限流60秒只能发一条、一天最多N条。这类限制如果依赖供应商报错才有感知用户体验已经很差了。正确做法是在自己的发送服务里提前做频次控制用Redis计数器或数据库记录每个号码最近发送时间和累计次数在真正调接口前就拦截下来。这个能力必须提前规划到架构里而不是等收到用户投诉后再补。2. 接口协议与SDK封装把多供应商差异挡在业务层外面短信供应商虽然很多但HTTP接口大同小异基本逃不开“鉴权参数 手机号 签名 模板 变量 业务ID”这组结构。可别小看这一层如果直接把供应商SDK散落到业务代码里后期换个供应商或者做多通道容灾时会被各种API差异折腾到崩溃。2.1 HTTP接口一般长什么样响应结构有什么坑大多数平台提供的HTTP发送接口长这样假设用Python的requests调核心参数大致如下import requests import json resp requests.post( https://sms.example.com/api/send, data{ access_key_id: ..., access_key_secret: ..., phone_numbers: 13800138000, sign_name: 某公司, template_code: SMS_000001, template_param: json.dumps({code: 123456}), out_id: order_20240101_000001, }, timeout(3, 5), # 连接超时3秒读超时5秒 ) body resp.json()典型响应是这样的{ Code: OK, Message: OK, RequestId: F5D2D5E7-1A2B-3C4D-5E6F-7A8B9C0D1E2F, BizId: 1234567890 }这里最大的坑是HTTP返回CodeOK只代表平台已经受理并不代表短信真的发到用户手机上了。可很多人第一次接入时都把“CodeOK”当作发送成功导致出了问题后连自己的发送记录都是错的。正确理解是CodeOK表示“进管道成功”最终状态要看状态报告回调。另外响应里的RequestId和BizId必须存下来后面和状态报告对账全靠它们。2.2 统一发送抽象层怎么设计我会在业务层和供应商SDK之间加一个薄薄的抽象层核心就一个方法class SmsSender(ABC): abstractmethod def send(self, phone: str, template_code: str, params: dict, sign_name: str, out_id: str) - SendResult: 发送短信返回内部统一结果 pass class SendResult: success: bool # 是否被平台成功受理 provider_success: bool # 供应商受理结果 request_id: str # 供应商请求ID biz_id: str # 平台业务ID error_code: str # 供应商返回错误码 error_message: str raw: dict # 原始响应方便排查每个供应商写一个实现类。关键点是成功与失败的语义要统一供应商返回的“调用成功但业务失败”也要映射成明确结果不能直接抛异常。超时更不能任由底层SDK长时间阻塞连接超时和读超时必须显式设置否则线程池会被拖垮。还有一个容易被忽略的字段是out_id也就是业务侧自定义ID。我在发验证码时会把它设置成订单号或用户ID这样状态报告回调回来时能直接关联到业务记录不用再去反查手机号。2.3 为什么我不建议在业务代码里直接调用第三方SDK直接调SDK看起来能省几步但后期维护成本很高。首先供应商SDK更新频繁一旦某个版本宣布停止维护所有散落在各处的调用点都要跟着改其次各供应商SDK的线程模型不一样有的内部用连接池有的每次请求都新建连接在高并发下表现差异很大出了问题很难定位。有一次我们接到供应商升级通知要求所有调用方在限定时间内切换新签名算法。当时业务代码里有七八处直接调SDK的地方到处改签名、改校验逻辑改了整整一个下午。后来另一组同事用的统一封装层只需要改一个模块半天就完成了。打那以后我定了个规矩任何供应商接入都必须先包一层业务层永远只知道“自己的发送接口”不知道“某云厂商SDK”。3. 一条短信从本地入库到状态报告的完整旅程真正稳的发送服务不是业务代码里直接调发送接口就完事而是把“发送”当成一个有状态的过程来管理。我现在的做法是所有发送请求先落库再异步发送再通过状态报告闭环。这个链路一开始可能觉得绕但对账、补发、排障时你就知道它有多重要。3.1 引入本地消息表给发送动作增加“确认机制”可以类比成寄快递你把包裹交给快递员并不等于对方签收快递面单上的单号才是追踪凭证。短信也一样HTTP调用返回OK只是“收了件”最后是否妥投要看状态报告。所以我会建一张发送消息表核心字段包括CREATE TABLE sms_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, msg_key VARCHAR(64) NOT NULL, -- 幂等键业务侧生成 phone VARCHAR(20) NOT NULL, template_code VARCHAR(32) NOT NULL, template_param TEXT NOT NULL, sign_name VARCHAR(64) NOT NULL, out_id VARCHAR(64), status TINYINT NOT NULL DEFAULT 0, -- 0待发送 1已受理 2成功 3失败 4超时 provider_code VARCHAR(32), request_id VARCHAR(64), biz_id VARCHAR(64), retry_count INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL );有了这张表进程重启、超时、重复回调都能通过msg_key和status兜底。发送前先按msg_key查一下如果已经有记录且处于“已受理”状态就不再重复调用避免用户收到两条一模一样的验证码。3.2 批量发送和并发参数的估算方式批量通知是另一个典型场景。某次运营活动需要给几万个用户发通知如果直接写个for循环同步调接口单线程要跑很久而且很容易触发供应商的账户级限流。正确做法是把发送任务拆成批次丢进队列由多个worker消费同时用限速器控制整体QPS。我一般这样估算并发参数假设供应商限制单账号QPS为50连接池最大连接数设为20每批从数据库捞出100条消息由5个worker并发处理每个worker每批间隔100ms整体QPS就能控制在接近50以内。参数不是固定的需要拿生产环境小流量验证。一个比较实用的配置参考如下参数推荐起始值说明单批拉取条数100太大容易占用内存太小导致DB请求频繁单条消息发送超时5秒供应商一般3秒内返回超时要考虑网络问题消费者线程数5先小后大观察QPS和错误率再调批次间隔100ms用于平滑限速避免瞬时冲高单号码频次限制60秒1条/每天5条具体按业务场景定营销和验证码不同3.3 状态报告处理与重试策略状态报告是供应商异步回调过来的通过预设的webhook地址推送。常见状态码有DELIVRD成功、UNDELIV失败、REJECTD拒绝等。回调处理接口要做两件事先验签再更新本地消息状态。验签不能省否则伪造回调可以直接把发送状态改成成功。回调本身必须是幂等的。同一个状态报告因为网络重试可能会推两次处理函数里要按request_id或biz_id去重只有第一次能更新状态。重试策略我也慢慢总结出了一套原则网络超时或连接失败可以重试指数退避最多两次供应商返回明确业务错误比如签名不存在、模板不匹配绝不重试要告警人工检查状态报告标记失败时是否补发要看业务容忍度验证码可以补一次营销短信绝对不能补。4. 定制短信发送服务的核心扩展点“定制”两个字听着抽象落到代码上无非是几个扩展点模板变量、接收人管理、退订偏好、定时发送和频率控制。把这些模块做好了你就可以在同一个发送服务上支撑很多不同业务方而不是每个业务各搞一套。4.1 模板变量替换与内容长度截断的实际处理使用正规短信平台时内容是平台侧按模板和变量拼装的本地不需要真正拼出完整文本。但如果你用的是通用文本通道或者需要在发送前做字数校验、敏感词过滤、统计计费条数就必须在本地做内容装配和长度计算。模板变量的替换要小心中文和特殊字符。变量值里有{}、等字符时有的平台会对模板参数做特殊解析建议先把变量值做URL编码或HTML转义。长度截断更要谨慎一条中文短信到底算多少字符不同通道不一样有些按70字符计费长短信按67字符拆分签名还要占位置。最稳妥的方式是拿目标通道的文档和测试号码实际验证不要只凭经验。我一般在消息入库前做一次内容长度预判超长就不允许入库直接返回“内容过长”的提示而不是等发送后被平台截断。4.2 接收人管理和退订/偏好的落地定制发送服务一定会碰到“有些人不该发”的场景。我的方案是维护一张黑名单表包含用户主动退订、被投诉、或者历史上被标记为无效的号码。所有发送任务在真正调接口之前先做一次黑名单和退订表过滤。退订的落地比想象中麻烦。有的平台支持短信上行用户回复“TD”退订平台会通过上行消息推给你有的平台不支持只会在状态报告里透出某种标记。早期我把退订逻辑写在发送服务里结果每次用户退订都要改业务代码。后来改成集中式的退订名单服务发送前统一过滤各业务方不用再各自维护。事实上有一些用户会同时存在于多个供应商的退订名单里分散维护非常容易漏。用户偏好设置也类似比如免打扰时段、只接收某类消息这些都不应该影响发送核心代码。发送服务暴露一个“是否允许发送”的判断入口由上层把用户偏好和本次消息类型传进来就行。4.3 定时发送和个性化频率控制定时发送我用消息表里的send_at字段实现worker只消费到期的消息配合Redis做一个分布式锁防止多节点同时捞到同一批数据。这样加一个定时任务批次很简单也不用引入复杂的调度中间件。频率控制我个人强烈建议放在发送服务内部而不是靠供应商的报错兜底。用Redis就是一段很简单的逻辑import redis r redis.Redis(hostredis.example.com, port6379, decode_responsesTrue) def check_freq_limit(phone: str, scene: str, max_per_minute: int 1) - bool: key fsms:freq:{scene}:{phone} count r.incr(key) if count 1: r.expire(key, 60) return count max_per_minute这个方案逻辑简单但在Redis故障时要设置降级策略不然会把正常用户也拦截了。个性化方面我还会在模板变量里拼接用户姓名、订单号等上下文信息实际发送前用日志把拼装结果打印出来方便用户反馈“收不到”或“内容不对”时快速定位。5. 实战半年后总结的踩坑清单与监控红线最后这部分都是我用真金白银换来的经验。很多坑不跑量根本不会出现等出现时往往已经在影响线上用户所以要提前预防。5.1 中文乱码、长短信拆分、状态延迟这三个问题中文乱码最容易在导入环节出现。某次运营人员上传号码文件内容里带着短信文案我直接用通用读文件方式读取结果模板变量里的中文全部变成乱码。原因是没有按UTF-8编码解析文件。从那以后我规定所有外部文件导入统一显式指定编码并且在入库前做一轮内容校验。长短信拆分问题发生在群发场景。用户收到一条超长短信平台按多条计费状态报告里会出现多个子状态必须在回调处理逻辑里把同一个长短信的子状态归并否则发送成功率统计会严重失真。状态延迟问题则是“不耐心”造成的。最开始我们在状态报告没到达时默认失败马上补发结果用户收到重复短信。后来规定状态等待时间必须大于供应商承诺的延迟上限宁可晚一点确认结果也不能盲目补发。5.2 接口连接池耗尽与供应商限流的排查有一次业务量翻倍发送服务后端接口突然出现大量超时。排查后发现罪魁祸首是每次发短信都新建一个HTTP客户端底层TCP连接根本没复用数据库、后端线程全部被拖满。修复方法是把HTTP客户端全局复用配置连接池和最大空闲时间问题立刻缓解。供应商限流则是另一个坑。表现是该发送请求返回类似Throttling的错误码但它不是系统故障而是并发超限。我们曾经遇到过账户级限流一个业务做活动把整账号QPS占满其他业务的短信全部排队延迟。后来把所有业务拆到不同签名和子账号下按业务维度分别监控才彻底解决。5.3 关键监控指标和告警阈值参考短信服务没有监控等于裸奔。我现在的监控面板上固定看这几个指标推荐给同样在做短信服务的团队指标建议告警阈值说明受理成功率低于99%触发告警平台返回OK才算受理成功送达成功率低于95%持续5分钟告警根据状态报告统计真实体验指标等待队列深度超过1万告警积压过多会影响实时消息时效单号码发送频率超过场景阈值告警防止人为误操作或数据异常回调堆积数量超过5分钟未消费告警状态报告处理服务卡住会导致对账混乱供应商错误码分布非OK错误码突增告警模板审核过期、欠费等问题能提前发现日志方面我会给每条短信打一个消息ID把手机号、模板、变量、供应商响应、状态报告全部串起来做成结构化日志。这样用户说“我没收到验证码”时只需要查一条日志就能看到当前到了哪个环节。5.4 灰度切换供应商的验证步骤换供应商不能“一把梭”。我习惯先在配置中心加一个灰度比例按1%、10%、50%分步切换同时把新供应商的送达率和旧供应商做对比。白名单手机号灰度也很有效把测试人员和业务方的手机号强制切到新通道在真实环境里验证文案、变量和状态回调。切换前要重点核对几个点长短信拆分规则是否一致、状态报告字段含义是否一致、退订消息能不能正常回流、签名和模板是否已经在平台审核通过。如果新供应商送达率明显低于当前通道或者状态报告延迟超过约定时限要立刻切回旧的留好逃生通道。做了这么久短信服务我最大的体会是真正难的不是把短信“发出去”而是让它“可追踪、可重试、可核对、可灰度”。先接回调再铺量先上监控再迭代。最后分享一个小技巧把所有发送结果和状态报告通过消息队列同步到数据仓库隔一段时间做一次送达率和业务转化分析这时候你会看到很多单靠直觉发现不了的问题比盲目调并发参数有效得多。