短信服务这块,我陆陆续续接手的项目里几乎都绕不开验证码、通知、营销类消息。一开始大家都在群里喊“短信发不出去”“审核怎么又失败了”“用户收不到验证码”,问题五花八门。踩过几次坑之后,我把自己做阿里云短信业务的完整流程整理了一遍,从账号配置到代码落地再到回执追踪,一篇讲清楚。这篇文章适合后端开发、独立开发者和刚接手短信业务的运维同学,照着做基本能少走一大半弯路。
1. 业务场景与整体设计
1.1 短信在业务里到底扮演什么角色
短信服务最核心的价值只有一个:触达确定性。跟站内信、App推送、微信模板消息比,短信不需要对方在线、不需要下载客户端、不需要关注公众号,只要手机号能用就能收到。所以它在业务里通常承担三个角色:
- 安全验证:登录验证码、注册验证码、找回密码、支付确认、设备绑定。这类场景对到达率和时效要求最高,通常要求秒级送达。
- 状态通知:订单发货、物流更新、预约提醒、工单处理结果。这类消息对延迟没那么敏感,但需要保证不丢、可查。
- 营销触达:活动促销、优惠券提醒、召回唤醒。这类场景发送量大,对成本敏感,而且受运营商通道限制最严格。
我做过的模拟项目X里,短信主要用在登录验证码和订单通知上。登录验证码走的是“发送即焚”策略:存缓存、设过期、定时清理。订单通知则更多依赖回执追踪来确认用户真的收到了。
1.2 为什么选阿里云短信而不是自建通道
早期有些团队会自建短信网关,或者直接找第三方短信代理(不是网络代理,是通道代理商)接入。自建网关意味着要跟运营商谈通道,个人和中小公司基本没这个能力,通道质量、到达率、审核规范全都是坑。第三方代理质量参差不齐,有的价格低但到达率感人,有的到达率还行但接口不稳定,出了问题连个技术支持都找不到。
阿里云短信比较稳的点在于:
- 接口标准:国内站API版本固定,SDK多语言齐全,有什么问题查文档、提工单都方便。
- 通道质量:三网(移动、联通、电信)覆盖,发送高峰期基本不会因为通道拥塞而大面积延迟。
- 合规兜底:签名、模板审核机制帮你把内容合规这个问题提前卡住,至少在平台侧不会出现明显违规的发送。
- 成本透明:按条计费,签名和模板审核免费,发起营销活动前可以估算成本。
从个人开发者到企业项目,阿里云短信基本是“先用起来再说”的最优解。等业务量大了、有特殊通道需求了,再考虑跟运营商直连或者接入多厂商容灾也不迟。
1.3 最小可上线的短信业务架构
先别一上来就把MQ、分布式事务全安排上。一个真正最小可用的短信业务,其实只需要四块:
- 接入层:业务服务(登录/注册/下单)调用短信服务模块。
- 缓存层:存验证码、控制发送频率、做幂等。
- 发送模块:封装短信API调用,统一管理签名、模板、参数。
- 回执模块:接收短信状态报告,做失败重试和对账。
如果团队刚起步,验证码直接存Redis就行。回执可以先用HTTP回调,不用急着上消息队列。只有当发送量大了、多环境多应用都要消费回执的时候,才考虑用队列解耦。
这个架构的关键点在于:发送和业务解耦。不要把短信API的细节散落在各个业务代码里,后面要加缓存、限流、统计的时候,没有统一出口会非常痛苦。
2. 前置工作:账号、权限、签名与模板
2.1 RAM子账号与AccessKey的最佳实践
很多人一上手就用主账号的AccessKey调短信接口,图省事。这个习惯我看到一次就想拦一次。主账号AK权限是全部资源,一旦泄露,不只是短信费用被刷的问题,整个云账号的资源都可能被操作。
正确做法是在RAM控制台创建一个子账号,只授权短信服务的权限。最小权限策略里,常用的权限点大概包括:
- dysms:SendSms:发送短信
- dysms:QuerySendDetails:查询发送详情
- dysms:QuerySmsSignList:查询签名列表
- dysms:QuerySmsTemplateList:查询模板列表
我一般还会在代码里给AK单独做配置管理,不写死在代码仓库里,本地开发走环境变量,线上走密钥管理服务。AK泄漏之后第一时间去RAM里禁用、轮换,这个流程也必须在文档里写明。
2.2 签名申请的类型与材料
短信签名相当于你的“发件人名称”,用户看到短信时最先注意到的就是它。签名申请要按实际业务场景选类型:
- 网站:需要已备案的网站域名
- A需要在应用商店有上架记录,提供应用名称和商店截图
- 公众号或小程序:需要公众号或小程序认证截图
- 企业:需要营业执照等企业资质
签名内容通常是业务品牌名或网站名,比如“某电商平台”“某应用提醒”。个人申请时如果签名跟账号主体资质对不上,很容易被驳回。
我踩过的坑是这样的:最早给模拟项目X申请签名时,名字里带了“验证码”三个字,结果被驳回。官方要求签名不能包含“测试”“验证码”这类通用词,签名要代表实际业务主体。后来把签名改成业务名称才通过。
2.3 模板编写规范与审核要点
模板是发送内容的大纲,写法直接影响审核效率和发送成功率。模板里如果有变量,需要明确变量名和示例,比如验证码模板:
【模X提醒】您的验证码为${code},请在${time}分钟内有效。为保障账户安全,请勿泄露给他人。模板审核最容易被驳回的几个点:
- 变量内容问题:变量不能传网页链接、IP地址、微信号、手机号、QQ群等。模板里写“${url}”让用户去点击,基本必驳。
- 内容敏感词:营销类模板里带促销、优惠幅度、加群等敏感字眼,会被要求修改措辞。
- 缺少退订说明:营销短信必须带“回复TD退订”这类字样,否则审核不通过。这一点很多新手会漏。
- 模板与原签名不匹配:签名是A品牌,模板内容却是B业务的,会被判定为冒用。
模板审核不通过时,控制台会给出具体的驳回原因,照着改一般能过。不要反复提交一模一样的模板,系统会有记录,重复提交反而更慢。
2.4 常用的测试手段
正式发送之前,建议先在控制台或者用测试签名、测试模板调试。阿里云短信控制台里可以申请“测试签名”和“测试模板”,限定测试手机号使用,它不会真的下发到普通用户,也不会扣费。用这套东西跑通整个链路之后,再申请正式签名和模板。
如果项目比较急,可以先临时用一个已经通过审核的通用模板做开发联调,等业务自己的签名、模板下来之后再切换。但要注意,线上切换前一定要发真实手机号验证一遍,别想当然以为“接口能通就行”。
3. 发送实操:API调用与代码落地
3.1 请求参数逐个拆解
发送短信的核心接口是SendSms。这里把关键参数拆开看:
| 参数 | 类型 | 说明 |
|---|---|---|
| PhoneNumbers | String | 接收短信的手机号码,支持一次发给多个,最多建议一次1000个 |
| SignName | String | 短信签名名称,来自控制台审核通过的签名 |
| TemplateCode | String | 模板CODE,形如SMS_123456 |
| TemplateParam | String | 模板变量对应的JSON串,例如{"code":"123456"} |
| SmsUpExtendCode | String | 上行短信扩展码,一般用不到 |
| OutId | String | 外部流水扩展字段,方便业务侧关联查询 |
TemplateParam是新手出错率最高的地方。它是一个JSON字符串,如果模板里有${code}、${time}两个变量,那这个参数必须像这样写:
{"code":"123456","time":"5"}注意JSON串本身在传参时是字符串,不要转义多一层。很多“发送失败、变量不匹配”的问题都是这里出的。
3.2 Java SDK发送验证码的完整代码
下面是我在实际项目里跑通的Java写法,用的是通用API请求方式,只依赖core包,适用于大部分SDK版本:
import com.aliyuncs.DefaultAcsClient; import com.aliyuncs.IAcsClient; import com.aliyuncs.CommonRequest; import com.aliyuncs.CommonResponse; import com.aliyuncs.http.MethodType; import com.aliyuncs.profile.DefaultProfile; public class SmsSendUtil { private static final String REGION_ID = "cn-hangzhou"; private static final String ACCESS_KEY_ID = System.getenv("ALIYUN_AK_ID"); private static final String ACCESS_KEY_SECRET = System.getenv("ALIYUN_AK_SECRET"); public static boolean sendVerifyCode(String phone, String code) { try { DefaultProfile profile = DefaultProfile.getProfile( REGION_ID, ACCESS_KEY_ID, ACCESS_KEY_SECRET); IAcsClient client = new DefaultAcsClient(profile); CommonRequest request = new CommonRequest(); request.setSysMethod(MethodType.POST); request.setSysDomain("dysmsapi.aliyuncs.com"); request.setSysVersion("2017-05-25"); request.setSysAction("SendSms"); request.putQueryParameter("PhoneNumbers", phone); request.putQueryParameter("SignName", "模X提醒"); request.putQueryParameter("TemplateCode", "SMS_123456"); request.putQueryParameter("TemplateParam", "{\"code\":\"" + code + "\"}"); request.putQueryParameter("OutId", "verify_" + System.currentTimeMillis()); CommonResponse response = client.getCommonResponse(request); String data = response.getData(); // 这里用JSON解析data,判断"Code"字段是否为"OK" // OK表示SendSms请求已受理,但不代表短信一定送达 return data != null && data.contains("\"Code\":\"OK\""); } catch (Exception e) { // 记日志、做异常告警 return false; } } }这里要特别强调一个误区:SendSms返回Code=OK只是说明阿里云接受了你的发送请求,不代表用户已经收到、更不代表用户能看到。真正确认送达必须看短信状态报告。
3.3 响应解析与BizId的作用
发送成功后,返回值里会带一个BizId字段,它是这次发送请求的唯一业务标识。后续用QuerySendDetails查询发送详情时,需要把BizId传进去。所以发送成功之后,建议把手机号、签名、模板、BizId、请求时间都存一份日志或记录表,后面排查“用户说没收到”时全靠它。
下面是用QuerySendDetails查询详情的简化示意:
CommonRequest queryRequest = new CommonRequest(); queryRequest.setSysMethod(MethodType.POST); queryRequest.setSysDomain("dysmsapi.aliyuncs.com"); queryRequest.setSysVersion("2017-05-25"); queryRequest.setSysAction("QuerySendDetails"); queryRequest.putQueryParameter("PhoneNumber", phone); queryRequest.putQueryParameter("SendDate", "20240101"); queryRequest.putQueryParameter("PageSize", "10"); queryRequest.putQueryParameter("CurrentPage", "1"); queryRequest.putQueryParameter("BizId", bizId);查询结果里能看到每条短信的发送状态、运营商回执码、送达时间。真到排查环节,这条链路是救命用的。
3.4 把发送逻辑封装成独立模块
调用短信API只是第一步,真正的工程化是要把它封装成业务侧统一调用的模块。我的习惯是定义一个通用的发送接口:
- 入参只有手机号、业务场景、模板占位符map
- 模块内部根据场景映射签名和模板CODE
- 发送前先做校验:手机号格式、变量完整性、是否在发送频率内
- 发送后把记录写进日志表
好处是,如果后面从国内短信切到国际短信,或者接入第二个厂商做容灾,只需要替换模块内部实现,上层业务代码一行都不用改。
我在模拟项目X里就是照着这个思路做的:发送验证码、订单通知、营销短信全部走同一个模块,线上跑了半年,几乎没有因为短信代码问题返工。
3.5 业务侧的幂等与防刷逻辑
短信不是“只管发出去就行”,业务侧至少要加两道保险:
第一道是防刷。验证码接口如果裸奔,分分钟会被脚本刷爆,短信费用直接起飞。我在项目里通常这么处理:
- 同一个手机号60秒内不允许重复发送验证码。
- 同一个IP一小时内的验证码请求次数做上限。
- 前端在点击“获取验证码”前,先过图形验证码或滑块验证。
- 登录场景再结合历史行为做风控,异常设备直接拉黑。
第二道是缓存与过期。验证码生成后写入Redis并设置有效期,一般是5分钟。发送请求里带上同一个请求ID做幂等,防止用户连点时发出多条短信。
具体到代码里,验证码发送前的频率检查大概是:
String sendKey = "sms:verify:" + phone; Boolean first = redis.setIfAbsent(sendKey, "1", Duration.ofSeconds(60)); if (!Boolean.TRUE.equals(first)) { throw new BizException("请勿频繁获取验证码"); } // 生成4位或6位随机码 String code = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 999999)); // 存缓存,5分钟过期 redis.set("verify:code:" + phone, code, Duration.ofMinutes(5));这里有个细节:setIfAbsent本身是原子操作,用同一个key做60秒去重,比先get再set稳妥得多。高并发下用非原子操作会出现“同时放行”的问题,短信就发重了。
4. 回执接收与状态追踪
4.1 为什么必须关注回执
短信发出去之后,运营商链路是异步的。短信可能被手机系统拦截、被运营商风控、因为签名问题被拒收。如果业务只关心SendSms的返回值,那用户“收不到验证码”的问题永远是黑盒。
回执机制就是让阿里云把短信的最终发送状态推送给你。拿到回执之后,业务才能做三件事:
- 对账:确认每条短信确实送达。
- 告警:如果一批短信大面积发送失败,立刻报警。
- 补偿:针对失败状态,做非敏感的替代触达。
4.2 控制台配置回执消息
在短信控制台的“回执消息”里,可以配置短信状态报告和上行消息的接收方式。两种方式:
- MNS消息队列:阿里云把回执消息推送到你自己的消息队列里,业务侧消费队列消息。适合发送量大的场景,消息不丢、可重试。
- HTTP推送:阿里云直接POST回执到你的回调接口。适合中小项目,配置简单。
HTTP推送需要注意几点:
- 回调地址必须是公网可访问的HTTPS地址。
- 阿里云会通过签名方式校验推送来源,推荐在代码里对推送内容做验签或来源IP校验。
- 回调接口要做幂等处理,同一个回执消息可能会推送多次。
我实际用的是HTTP推送。上线前在配置里填好地址,然后找控制台的“测试回执”功能模拟一条回执,确认接口能通、数据能解析,再放量。
4.3 回执数据结构
不同触点的回执字段略有差异,状态报告的消息内容大体长这样:
{ "phone_number": "13800138000", "send_status": "1", "err_code": "DELIVRD", "template_code": "SMS_123456", "biz_id": "1234567890", "out_id": "verify_1620000000000", "send_time": "2024-01-01 10:00:00", "report_time": "2024-01-01 10:00:02" }- send_status为1表示成功,为2表示失败。
- err_code是运营商返回的状态码,比如DELIVRD是送达,其它错误码需要查表。
- biz_id和out_id用于和你的发送记录关联。
收到回执后,更新业务记录里的短信状态字段,前端就能展示“验证码已发送”或“验证码发送失败”。这个体验很多人忽略,但对用户来说感知非常直接。
上行消息,也就是用户回复短信的内容,结构类似,有content、send_time、dest_code等字段。如果业务需要做“回复短信退订”或者“回复关键词查询”,就需要处理上行消息。
4.4 失败重试与补偿策略
回执显示发送失败时,不要无脑重发。要先看错误码:
- 如果是手机号问题(关机、停机、空号),重发没意义。
- 如果是运营商限流,可以等一段时间再补发。
- 如果是签名/模板问题,重发也会失败,需要修配置。
重试策略我建议这样:
- 第一次失败,间隔30秒重试一次。
- 第二次失败,间隔5分钟重试一次。
- 仍失败则不再自动重试,转人工或换其它触达方式。
有一个容易漏掉的问题:短信验证码发送失败后,如果重试,必须重新生成验证码并更新缓存,否则用户拿着第一次的验证码,跟新发的对不上,体验更差。
5. 常见故障与排查速查表
5.1 验证码收不到怎么定位
用户说“收不到验证码”时,按下面顺序排查:
- 先在自己手机上看是否收到了。如果自己也收不到,直接看控制台的发送记录和回执。
- 查SendSms的返回。如果连Code=OK都没有,看错误码是什么。
- 查回执。回执里send_status是2,说明到了运营商侧但没送达,看err_code。
- 查手机拦截设置。被拦截的短信一般不会进运营商回执,需要让用户翻垃圾短信。
- 查运营商通道问题。某个运营商段长时间送达率下降,大概率是通道故障,这种只能等。
最实用的一招:把短信发送记录和回执记录都入库,支持按手机号和时间查。没有这个记录表,用户反馈问题时你只能干瞪眼。
5.2 高频错误码速查表
| 错误码 | 含义 | 处理方式 |
|---|---|---|
| isv.MOBILE_NUMBER_ILLEGAL | 手机号非法 | 校验号段,拦截测试号 |
| isv.SMS_SIGNATURE_ILLEGAL | 签名不存在或未审核 | 检查签名是否通过审核 |
| isv.SMS_TEMPLATE_ILLEGAL | 模板不存在或未审核 | 检查模板CODE是否正确 |
| isv.INVALID_JSON_PARAM | JSON格式错误 | 检查TemplateParam是否合法JSON |
| isv.TEMPLATE_PARAMS_ILLEGAL | 模板变量不匹配 | 核对变量名是否一致 |
| isv.BUSINESS_LIMIT_CONTROL | 发送频率超限 | 检查同一号码的发送频次 |
| isv.AMOUNT_NOT_ENOUGH | 账户余额不足 | 充值,设置余额预警 |
| isp.FLOW_LIMIT | 系统限流 | 降低发送频率,稍后重试 |
| isp.RAM_PERMISSION_DENY | RAM权限不足 | 给子账号授权短信权限 |
遇到不认识的错误码,优先去官方错误码文档查,别自己猜。这里只列最常见的几个,很多朋友栽在isv.INVALID_JSON_PARAM上,查了半天参数名,结果是JSON串里多转义了一个花括号。
5.3 审核不通过案例集
边界案例最容易被驳回。我整理几个典型的:
- 签名带了“通知”“提醒”这类业务属性词:签名只放品牌名。
- 模板变量支持传链接:审核必驳,改成固定文案或者用短链服务包装后再发。
- 营销短信没有退订:必须带“回T退订”“回复TD退订”等清晰退订指引。
- 内容跟企业资质不符:模板内容要和签名主体业务强相关。
改完之后重新提交,一般审核周期是几个小时内。如果很急,可以提工单加急,但前提是前面几轮提交记录里没有反复提交相同内容。
5.4 频率限制的应对
阿里云对短信发送本身有频率限制策略,尤其在验证码场景。业务侧在高峰期如果单号码、单IP瞬时请求过多,会触发isv.BUSINESS_LIMIT_CONTROL。
应对方式是做业务层的队列化和削峰。高峰期把发送请求先放进队列,消费端按固定速率调用短信API。这样既避免触发频率限制,又不会因为瞬时流量把短信费用冲高。
但别走到另一个极端:验证码场景本身对时效有要求,队列堆积超过30秒用户就会不耐烦。所以队列消费速度要压测,不能拍脑袋定。
6. 运维、监控与成本控制
6.1 余额预警与监控
短信是按量计费,账户欠费时所有发送会直接失败。这个故障一旦发生,影响面是所有用户都收不到验证码,绝对算得上P0事故。我在多个项目里都遇到过类似问题,深有体会。
控制台自带按量预警,还可以在云监控里配置自定义告警:
- 账户余额低于某阈值,立刻报警。
- 单日短信费用超过预算阈值,报警。
- 某个模板的发送失败率超过5%,报警。
- 每分钟发送量突增,可能有刷单风险,报警。
告警渠道建议接电话和短信,邮件和钉钉在深夜打不醒人。
6.2 日志与链路追踪
短信日志至少要记录这些字段:
| 字段 | 说明 |
|---|---|
| request_id | 阿里云返回的请求ID |
| phone | 手机号 |
| scene | 业务场景 |
| template_code | 模板CODE |
| biz_id | 发送业务ID |
| out_id | 业务侧流水号 |
| request_time | 请求时间 |
| report_time | 回执时间 |
| status | 最终状态 |
这个表就是短信业务的账本。运维排障、成本核算、业务审计都靠它。我用过比较省事的方式:业务日志直接打到日志服务里,再配几个统计报表,不用自建数据库。
6.3 降低短信成本的几条路
短信费在业务量上来之后是一笔实打实的开支。省钱不是砍功能,而是把每一分钱花在刀刃上:
- 验证码优先用短信,但非核心通知(如站内信优先级高的场景)可以切到App推送或微信公众号模板消息。
- 营销短信做好人群筛选,定向发送,别搞广撒网。
- 短信文案里的变量数量影响计费长度,超过字数上限会按多条计费。文案精简,既省钱又降低通道拒收概率。
- 对到达率不敏感的场景,可以选相对的“低价通道”,但要用回执数据做质量监控,便宜不代表不能选。
最后分享一个我踩过几次坑之后的体会:短信这个业务,看代码十分钟能跑通,但“线上稳”靠的是把前置审核、发送频率、回执追踪、余量告警这些细节全做到位。尤其是回执,很多人上线一个月都没看过一条回执数据,出了问题第一反应是“阿里云没发”,其实通道早就把失败状态推回来了,只是没人接。所以不管项目大小,建议把回执配置这一步放到和发送代码同等重要的位置。真等线上出了“用户收不到验证码”的投诉再补,代价至少是好几倍。