☰
Spring Boot短信接口工程化接入:异步化、回执与监控实战
2026/10/8 4:11:23 网站建设 项目流程

很多Java团队做短信接入,最典型的问题就是把“能发出去”当成了“已完成”。我帮人看过不少项目代码,Controller 里 new 一个平台 SDK,AccessKey 直接写在 application.yml,业务代码里到处散落着 sendSms 调用,发送结果不处理,回执回调也不接。今天这篇总结,就是围绕Java 短信接口在Spring Boot项目里的接入整个链路来写:从平台选型开始,到抽象层设计、异步化、回执处理、监控告警,再到我会亲身踩进去过的那些坑。文章偏工程实践,不写“Hello World 级”的内容,适合正在做或准备做短信平台的 Java 开发,尤其是项目已经跑了几年、开始被短信这个“小功能”反噬的团队。

1. 短信接入不是写个“发短信 Demo”那么简单:先想清楚几个决定架构的问题

很多人一开始都觉得,短信接入是件小事:申请一个账号、复制一段官方示例代码、填上 AccessKey,然后调一下,短信就发出去了。但等业务量上来,问题全浮出来了:换了平台要改十几个地方、验证码接口响应从几十毫秒变成 400 毫秒、半夜短信平台限流导致注册失败、回执丢了不知道用户到底收到没有。这些问题的根源,基本都在动手写代码之前就埋下了。

1.1 业务需求分层:验证码、通知、营销,三种短信三种玩法

设计短信模块之前,第一步不是选平台,而是把业务需求拆清楚。我习惯把所有短信场景分成三类:

  • 验证码/安全类:时效性强、发送频率受控、对成功率要求极高。一般是用户登录、注册、异地登录提醒。这类消息通常需要一分钟以内送达,而且必然伴随大量并发(比如开抢、活动预热)。
  • 业务通知类:发货通知、订单状态变更、航班变动。这类消息对时效性要求略低,但也要求稳定可靠,不能大面积丢失。一般走“模板 + 业务参数”的方式,消息内容和发送记录需要完整保存。
  • 营销推广类:促销短信、会员关怀。量大、频率高,但单条失败影响有限,经常需要异步批量发送,并且要严格遵循平台频控规则,不然会被被封掉发送权限。

这三种需求,对短信模块的抽象边界、发送链路、监控指标要求完全不一样。如果你一开始就把它们全部塞进同一个发送方法,后面一定会出现“为了给营销消息加定时发送,结果验证码接口也跟着变慢”的怪现象。

1.2 平台选型:云厂商、运营商直连、国际通道、私有化网关怎么挑

平台选型是接入前最容易拍脑袋决定的事情。常见的短信通道有两类路径:一类是直接对接运营商的企业短信网关,例如移动云MAS、电信天翼云、联通云信这类平台,它们会提供 HTTP 接口,走签名和模板报备,适合对成本敏感、业务量稳定、不依赖云生态的企业;另一类是通过阿里云、腾讯云这类云计算厂商的短信服务,本质上是它们帮你去对接运营商,你付一个聚合后的单价,换来的是更稳定的接口和更快的接入速度。

下面这张对比表是我在项目选型时经常给团队看的:

对比维度云厂商短信服务运营商直连网关国际短信通道自建短信网关
接入成本很低,SDK/API 齐全中等,需要走报文协议较高,资质要求严格高,需要自己对接运营商
稳定性高,自带流控和容灾依赖本地运营商线路按国家/区域波动完全自己扛
审核复杂度低,模板和签名在线报备中,部分平台有本地审核高,需要合同、资质高,需要实运营商合同
适合场景绝大多数中大型业务大促型、自有通道成本可控跨境电商、出海业务运营商深度合作团队

对大多数Spring Boot项目来说,我更推荐云厂商的短信服务起步,原因是它把“发送”封装成REST接口,模板管理、签名校验、发送统计都是现成的。等你觉得单条成本敏感了,再考虑在抽象层后面叠加一个运营商直连的实现,做到双通道容灾。这里的核心思路是:选型不要把平台和代码绑死,不要把“当前用谁”变成“只能是谁”。

1.3 平台 SDK 不能成为业务代码的边界

我见过一种非常典型的写法:在 UserService 里直接用阿里云SDK,然后在另一个 OrderService 里又用腾讯云SDK。这样做的后果是,业务层与底层短信通道直接耦合。等你想从阿里云切到腾讯云,会发现需要改的不仅是发送调用,而是整个业务逻辑。

正确的方向是,让业务代码只依赖一个“发短信”这个动作,不依赖任何具体平台。你可以先定义一个方法叫send(SmsRequest request),业务层只管传模板编码和参数;至于这个方法底层是走阿里云还是运营商网关,是同步还是异步,是走HTTP还是走消息队列,完全不应该被业务感知。这就是典型的依赖倒置思想,也是本文后面所有设计的基础。

2. Spring Boot 项目里如何设计短信抽象层:从 SDK 裸调到统一发送服务

这一章我直接给出我在生产环境里验证过的抽象层设计。目标很简单:不管底层接几个平台,业务代码的调用永远只有一行,而且接口设计得足够清晰,新接手的人不用翻文档也能明白。

2.1 一个只属于你自己的SmsSender接口

核心就是三个对象:SmsRequest(请求参数)、SmsResponse(发送结果)、SmsSender(发送接口)。代码大概长这样:

public interface SmsSender { SmsResponse send(SmsRequest request); } public class SmsRequest { private String phoneNumber; // 手机号,纯数字格式,+86 统一去掉 private String templateCode; // 模板编码,比如 "VERIFY_CODE_TEMPLATE" private Map<String, String> params; // 模板变量,例如 {"code":"123456","minutes":"5"} private String bizTraceId; // 业务跟踪ID,用于回调与日志串联 private Integer priority; // 0 高优先级(验证码),1 普通(通知),2 低(营销) // 省略 getter / setter / builder } public class SmsResponse { private boolean success; // 是否发送成功(指被平台受理) private String providerMsgId; // 平台侧的发送ID,回执要用 private String errorCode; // 失败时的平台错误码 private String errorMessage; // 失败时的平台错误信息 private long costMillis; // 发送耗时,方便日志统计 // 省略 getter / setter / builder }

为什么不用 Map 当参数?因为我发现只要用 Map,项目里就会出现“团队各写各的 key”,有人传mobile,有人传phone,运行起来才发现模板变量配不上。用SmsRequest统一约束后,字段名、类型都收敛了,模板参数内部再序列化成一个 JSON 字符串传给平台,天然隔离了业务与平台差异。

2.2 把平台细节关进适配器里:一个接口,多个实现

有了SmsSender接口后,每个短信平台就是一个适配器。比如阿里云的实现类大概是这样的:

@Component @ConditionalOnProperty(name = "sms.provider", havingValue = "aliyun") public class AliyunSmsSender implements SmsSender { private final SmsProperties smsProperties; private SmsClientV1 client; @PostConstruct public void init() { Config config = new Config(); config.setAccessKeyId(smsProperties.getAccessKeyId()); config.setAccessKeySecret(smsProperties.getAccessKeySecret()); config.setEndpoint(smsProperties.getEndpoint()); this.client = new SmsClientV1(config); } @Override public SmsResponse send(SmsRequest request) { long start = System.currentTimeMillis(); try { SendSmsRequest req = new SendSmsRequest(); req.setPhoneNumbers(request.getPhoneNumber()); req.setTemplateCode(request.getTemplateCode()); req.setTemplateParam(JSON.toJSONString(request.getParams())); // 这里可以从配置中心读取签名名称 req.setSignName(smsProperties.getSignName(request.getTemplateCode())); SendSmsResponse resp = client.sendSms(req); boolean success = "OK".equals(resp.getCode()); return SmsResponse.builder() .success(success) .providerMsgId(resp.getRequestId() + "|" + resp.getBizId()) .errorCode(resp.getCode()) .errorMessage(resp.getMessage()) .costMillis(System.currentTimeMillis() - start) .build(); } catch (Exception e) { return SmsResponse.builder() .success(false) .errorCode("EXCEPTION") .errorMessage(e.getMessage()) .costMillis(System.currentTimeMillis() - start) .build(); } } }

你可能会问,为什么客户端在@PostConstruct里初始化而不是每次请求 new 一个?因为短信SDK内部有 HTTP 连接池,重复创建客户端会反复建立连接,导致性能恶化,严重的时候会出现大量TIME_WAIT连接。用 Spring 管理客户端生命周期后,连接池复用的问题就解决了。

每个平台的适配器都实现同一个SmsSender。将来要加腾讯云,就新建TencentSmsSender;要加云MAS,就新建ChinaMobileSmsSender。业务层一点不动,只改配置sms.provider。

2.3 发送结果必须是“业务结果”,不是平台异常

上面代码里注意一个细节:我没有让平台抛出的异常直接向上传播,而是捕获之后包装成SmsResponse返回。这个设计是我踩了很多坑才确定的。

短信发送这个动作,本质上不是你自己的系统在操作数据库,而是一次外部服务调用。外部服务超时、限流、参数校验失败,都是日常事件,不是“系统bug”。如果把这些异常都向上抛给业务层,业务层就不得不写大量 try-catch 区分“这条短信到底发出去没”,很容易把业务逻辑搞乱。

更关键的是,业务层想要的答案其实很简单:到底是“已经受理成功”还是“明确失败了”。所以我推荐所有平台适配器都遵循这样一条规则:

  • 网络超时、平台返回“未知状态”这类不确定结果,用success=false加一个特殊错误码UNKNOWN,由上层决定是否重试;
  • 平台明确返回失败(比如手机号不在白名单、模板参数错误),同样用success=false返回平台的错误码,但这时候上层不能盲目重试,因为每次都必然失败。

重试策略这个我在后面专门讲,这里先明确一点:SmsResponse 的职责是表达结果,不是表达异常,异常只是结果的一种来源。

2.4 配置统一收口:密钥和账号别散落在代码里

我见过很多项目的短信配置是这样的:阿里云的 AccessKey 写在application.yml,腾讯云的 SDK AppID 写在代码常量类,云MAS 的账号在数据库里。等出了问题要排查,第一件事就是到处翻配置。

正确的做法是集中管理:

sms: provider: aliyun aliyun: access-key-id: ${SMS_ALIYUN_AK_ID} access-key-secret: ${SMS_ALIYUN_AK_SECRET} endpoint: dysmsapi.aliyuncs.com sign-name: ${SMS_ALIYUN_SIGN_NAME} tencent: secret-id: ${SMS_TENCENT_SECRET_ID} secret-key: ${SMS_TENCENT_SECRET_KEY} sdk-app-id: ${SMS_TENCENT_APP_ID} sign-name: ${SMS_TENCENT_SIGN_NAME}

然后定义一个SmsProperties配置类,用@ConfigurationProperties绑定。这样不管接多少个平台,配置都集中在同一个前缀sms下面。密钥走环境变量,连配置中心都不用上,就能做到环境隔离。

提示:任何短信平台的 AccessKey 都属于高敏感凭证,不要提交到 Git。生产环境务必放在配置中心、KMS 或者容器平台的 secret 机制里,并定期轮换。

3. 异步化改造:把“发送短信”从接口耗时里摘出去

短信发送最大的性能坑是同步阻塞。云厂商的短信接口虽然以 HTTP 为主,但一次请求通常要经过你的服务、云厂商网关、运营商网关,最后才到达短信中心。本地网络好、平台压力小的时候,单次发送耗时 200-300ms;一旦遇到平台侧流控或接口抖动,耗时很容易超过 1 秒。如果登录接口的业务代码里同步调一次短信,那么登录接口的最长耗时就直接被拖到这个数字。

3.1 同步调用隐藏的连锁反应

假设你的系统平均每秒有 50 次短信发送请求,每次同步等待 300ms,那么同一时刻大约有 15 个线程被“发送短信”这个动作占着。按照 Tomcat 默认 200 线程来计算,这 15 个线程本身不算多。但问题是短信发送经常是瞬时突增——比如晚上 8 点的限量抢购,1000 个人同时注册或登录,这 1000 次发送会瞬间把线程池占满。这时候别说是短信服务,整个应用的其他请求都会被阻塞,因为线程资源被短信等待消耗掉了。

异步化要解决的就是这个问题:发送动作提交后立刻返回,真正往平台推数据放到另一个线程池或者消息队列里去执行。

3.2 线程池隔离:验证码和营销消息不能共用资源池

异步发送如果只用一个全局线程池,还是有问题。比如某次营销任务一次性发了 10 万条短信,把线程池塞满了,结果用户此刻正在登录,验证码短信排在了营销短信后面,这种事故我真实见过。

所以线程池要按优先级隔离。我现在这个项目里用了两个线程池:

@Configuration public class SmsThreadPoolConfig { @Bean("smsHighPriorityExecutor") public ThreadPoolTaskExecutor smsHighPriorityExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(16); executor.setQueueCapacity(1000); executor.setThreadNamePrefix("sms-high-"); // 关键:验证码类不允许丢任务,用 CallerRunsPolicy executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } @Bean("smsNormalPriorityExecutor") public ThreadPoolTaskExecutor smsNormalPriorityExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(5000); executor.setThreadNamePrefix("sms-normal-"); // 营销消息允许丢弃,不要拖垮主流程 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); executor.initialize(); return executor; } }

线程池参数怎么定?我一般按短信平台允许的 QPS 倒推。假设平台单账号 QPS 上限是 100,每个请求处理耗时约 0.2s,那么一个线程每秒最多处理 5 个请求,理论上 20 个线程就能打满平台的 100 QPS。但我不建议“尽量多开线程”,因为开太多线程只会让平台触发限流。核心线程数一般等于平台 QPS 上限乘以单请求耗时,最大线程数不要超过它的 1.5 倍,剩下的靠队列缓冲。

3.3 消息队列削峰:注册高峰时不把平台打满

线程池能解决“线程被占用”的问题,但解决不了“瞬时流量超过平台 QPS 上限”的问题。比如运营平台限制单账号 QPS 为 100,你凌晨注册高峰期突然涌入 500 次验证码请求,如果直接同步打到平台,平台会截断一部分请求,用户就收不到短信。

削峰的方式有两种。第一种是简单的线程池限速,通过信号量控制发送速率,这种方式适合单机部署;第二种是引入消息队列,比如把发送任务丢进 MQ,消费者按固定速率拉取后交给短信发送器,适合多实例部署。我建议绝大多数场景用第二种,因为短信这个场景天然对延迟有一定容忍度——验证码通常 1 分钟内有效,只要不是排太长队,异步就能扛住。

在 Spring Boot 里我不想再引入额外组件的情况下,也可以用 Spring Event 加定时消费的方式,但坦白讲,业务量到了“需要削峰”这个级别,Redis 队列或 MQ 是更现实的选择。如果你用 Redis 做队列,注意要做任务幂等,因为消费端重启会带来重复消费。

3.4 别做“看起来异步”的假异步

Spring 的@Async注解用起来很方便,但有三个隐藏的坑,几乎每个后端团队都会踩一遍:

  • @Async默认线程池是SimpleAsyncUnregisterAsyncExecutor,这种线程池每次任务都会创建新线程,完全没法控制并发,生产环境必须自己定义线程池并指定@Async("smsHighPriorityExecutor")。
  • @Async标注的方法不能被同类内部调用,因为 Spring 代理只在外层调用时生效,同类内部this.send()会绕过代理,变成同步执行。
  • @Async方法如果丢给一个独立的离线程,异常不会自动传回主线程。所以异步方法内部必须先捕获所有异常,保证至少把日志记录下来。

我的习惯是,在短信这个场景里不完全依赖@Async,而是把“发送任务”明确建模成一个对象,提交给指定的线程池执行:

public class SmsSendTask implements Runnable { private final SmsSender smsSender; private final SmsRequest request; @Override public void run() { SmsResponse resp = smsSender.send(request); // 统一写日志、埋点、记录发送结果 } }

这样做的好处是,后续引入 MQ、上线重试机制,只需要改任务消费者那一层,发送器本身完全不用动。

4. 状态回执、重试与幂等:短信生产环境的“事后处理”链路

很多人觉得短信“提交成功”就等于“用户收到了”。这在工程上是不成立的。短信平台接口返回“请求接受成功”,只能说明这条短信被网关受理,不代表手机已经收到。手机号停机、手机信号弱、短信中心处理异常,都会导致下发失败。所以短信接入真正成熟的标志,是能处理回执(DLR)。

4.1 短信发送的完整生命周期

完整链路是这样的:

  1. 业务系统调用发送接口,平台受理,返回消息 ID;
  2. 平台把短信推送给运营商通道;
  3. 运营商短信中心下发到手机;
  4. 手机收到后,短信中心生成回执(Delivery Report),上报给平台;
  5. 平台把回执回调给你提供的 URL 地址。

所以一条短信最终有四种真实状态:发送中、成功、失败、未知。要做到“知道用户到底收到没有”,就必须接回执回调。

4.2 回执回调的验签与幂等:重点工程

Spring Boot 项目接收短信平台回调时,我推荐的做法是单独建一个 Controller,路径和正常业务接口分开,专门做三件事:

  • 验签:平台一般会用签名算法(例如 MD5 + 密钥)对回调内容签名,你必须在入口处校验签名,防止别人伪造回调;
  • 幂等:同一个消息的回执平台可能推送多次,你不能因为收到两次成功就更新两次状态,要用消息 ID 做唯一索引;
  • 状态维护:数据库里维护短信记录表,核心字段包括provider_msg_id、mobile、template_code、send_status(SENDING/SENT/FAILED)、callback_time、callback_payload等。

回调接口核心逻辑类似这样:

@PostMapping("/sms/callback") public String handleCallback(@RequestBody String payload, @RequestHeader("X-Signature") String signature) { if (!signatureService.verify(payload, signature)) { return "FAIL"; } SmsCallback callback = callbackParser.parse(payload); smsRecordService.updateStatus(callback.getMsgId(), callback.getStatus()); return "SUCCESS"; }

注意,回调处理要快,不要在回调接口里发 MQ、写日志同步刷盘。收到回调后第一时间更新数据库状态并返回 SUCCESS 给平台,后续的实时推送、告警、通知走监听器去异步处理。

4.3 重试策略不是越猛越好:分级处理才是对的

短信发送失败后要不要重试?要,但不能无脑重试。我的经验是分情况:验证码和通知类消息,失败后立即重试一次,间隔 10-20s 再重试一次,仍然失败就进入人工告警;营销消息不做重试,因为促销短信晚到几分钟基本没意义,重试只会增加平台限流风险。

重试不是“原样丢回线程池再跑一次”,而是要有退避策略。用指数退避的话大概就是这样:

重试次数延迟间隔说明
10s立即重试一次,捕获偶发网络抖动
230s等待平台短暂流控恢复
35min给平台和运营商处理留时间
更长时间不推荐短信语义不适合超长时间延迟

重试过程中最怕的是重复发送。业务系统里的重试,本质上是同一逻辑执行多次,所以要保证幂等。我在写短信模块时,会给每条短信生成一个bizId(业务侧订单号 + 手机号 + 模板编码 + 当前时间戳的哈希值),发送前先去 Redis 查bizId是否已存在;如果存在且成功过,直接返回上一次的结果,避免用户收到两条一模一样的短信。

4.4 验证码频控:防轰炸是做短信平台绕不开的需求

验证码发送类接口,如果不做频控,很容易被刷接口的人用来“短信轰炸”自己的用户,也容易被对手拿来消耗你的短信预算。所以验证码发送前必须有三级频控:

  • 同一手机号:1 分钟内最多 1 条,10 分钟内最多 3 条,24 小时内最多 5 条;
  • 同一 IP:10 分钟内最多 10 条,超过就走滑块验证;
  • 全局总 QPS:按平台配额设置熔断值。

在 Spring Boot 里我常用 Redis 计数器实现,核心是 INCR + EXPIRE。为了省一次网络往返,也可以考虑用 Lua 脚本,代码思路大概是:

local key = KEYS[1] local limit = tonumber(ARGV[1]) local ttl = tonumber(ARGV[2]) local current = redis.call('incr', key) if current == 1 then redis.call('expire', key, ttl) end if current > limit then return 0 end return 1

被频控拦截的请求要返回明确提示,比如“操作过于频繁,请稍后再试”,让前端展示给用户。另外,验证码本身在使用后要及时失效,不能一直留在 Redis 里等待可能的别人冒用。

5. 监控、限流与链路追踪:优雅接入的另一半功夫

短信发送不像操作数据库,你看不到一条 SQL 执行了多少毫秒。它跨了四个系统,任何一个环节出问题都会表现为“用户说没收到短信”。没有监控的短信模块,等于在裸奔。

5.1 日志里必须透出的几个关键字段

从接入短信平台第一天起,我就强制要求所有短信日志必须结构化,一条日志至少包含这些字段:

smsProvider=aliyun bizTraceId=c8f2a01e234 phoneMasked=138****1234 templateCode=USER_LOGIN_CODE providerMsgId=123456789 status=SUCCESS costMs=312 errorCode=EXCEPTION errorMsg=Read timed out

手机号在日志里必须脱敏,这是数据安全的红线。发送记录的日志,建议除了控制台输出,还要落一张独立的短信日志表,方便运营人员查询“为什么这个人没收到短信”。现在很多团队用logback做异步 appender,打日志开销很小,完全没必要为了节省一点磁盘而省掉这些信息。

5.2 Spring Boot Actuator 与 Admin:把短信指标暴露出来

Spring Boot 项目最方便的监控方式是引入spring-boot-starter-actuator,配合 Micrometer 把自定义指标暴露出来。我会注册这些指标:

@Bean public MeterBinder smsMetrics(SmsRecordMapper mapper) { return meterRegistry -> { Gauge.builder("sms.send.total", mapper::countTodaySend) .description("今日短信发送总量") .register(meterRegistry); Gauge.builder("sms.send.success.rate", () -> mapper.countTodaySuccess() * 1.0 / Math.max(mapper.countTodaySend(), 1L)) .description("今日短信成功率") .register(meterRegistry); }; }

同时用@Timed注解给发送方法加耗时统计,这样就能在 Grafana 里看到短信发送服务的 P95、P99 耗时曲线。生产环境配合 Spring Boot Admin 也很实用,它可以直接展示健康检查和指标数据,团队不用搭一套完整监控平台也能快速看到短信模块的健康状况。

我特别建议关注两类指标:发送成功率(近 10 分钟失败率超过 20% 就要告警)和回执率(回执成功数 / 发送受理数)。回执率低于警戒线时,说明平台或运营商通道可能出现了大范围下发延迟,这是单靠“发送成功”看不出的问题。

5.3 traceId:从业务到回执的完整链路

短信模块另一个容易忽略的是链路追踪。用户发起的登录请求里有一个 traceId,但短信回调接口又是另一条链路,两者如果不做关联,出了问题根本对不上账。

我现在的做法比较简单:在SmsRequest里通过参数传递 traceId,然后每条短信在发出去之前,同时记录“业务请求的 traceId”和“平台回执的 providerMsgId”。回执回来后,通过providerMsgId关联到短信记录表,再反查到业务请求的 traceId。这样从“用户说没收到短信”到“业务日志里的发送动作”到“回执日志里的失败原因”,一条线全部串起来。

5.4 平台故障与降级熔断

第三方短信平台也不是永远稳定的。我自己遇到过某平台凌晨大规模升级导致接口超时,所有短信请求都在等超时释放。如果项目里没有降级方案,这种小概率事件立刻演变成了业务主链路不可用。

所以在短信发送这一层,我会加一个超时和熔断两层保护:

  • 发送器设置合理的连接超时和读超时,一般 3 秒连接,5 秒读超时,避免一个慢平台拖垮整个服务;
  • 对平台连续失败次数做熔断。如果 30 秒内失败率超过 80%,就自动进入降级状态:验证码消息改为备用通道(比如切换另一家平台),营销消息直接丢弃或转定时重发;
  • 所有的高可用方案,都必须在压测环境里演练过“主平台彻底挂掉”的场景,不是配置了熔断就万事大吉。

6. 我在生产环境真实踩过的一些短信坑

最后聊几个我在生产环境里真实踩过的坑。这些坑多到让我一度怀疑“发短信”这个功能是不是被诅咒了,每一条都不是官方文档会告诉你的,但每一个都值得你记住。

6.1 模板和签名审核的时间差

短信模板和签名在云厂商平台需要审核,但审核不是即时的。我第一次接平台时,注册账号当天就把模板提交了,以为第二天就能上线,结果模板一直处于“审核中”,因为签名资质材料还有问题。等资质通过,模板审核又是半天起步。

解决方案很简单:提前申请签名和模板,至少留出 2 个工作日。尤其注意,营销类模板审核比通知类更严格,不能用“面向所有用户”这种太泛的模板描述。模板内容里不能让参数值完全替代整条消息,比如“您的验证码是${code},请勿泄露”比“${content}”好审得多。

6.2 手机号格式校验的边界

很多人觉得手机号校验就是写个正则^1[3-9]\d{9}$,问题是短信场景不是只有大陆 11 位手机号。跨境电商项目里,用户可能是 +852、+886、+65 的号码。还有用户的手机号前缀可能带 86、有空格、有中划线。

我在接入短信模块时统一做了一个PhoneNumberNormalizer,负责把号码标准化:去掉 +、空格、中划线,自动补 86,然后在发送前再根据平台要求拼回。你不能让业务系统把带空格的号码传给平台,平台只会回复“invalid mobile number”。

6.3 时间戳、随机数和接口幂等:同一请求发两次

阿里云、腾讯云的短信 API 一般以 templateCode + phoneNumber 判断重复发送,但是这个幂等窗口很短,而且你完全依赖平台做幂等并不可靠。有一次我们的网络超时重试机制写得太激进,同一笔验证码在 10 秒内被提交了三次,结果用户在同一分钟内收到三条一模一样的短信,直接被投诉了。

从那以后,我在所有短信请求里强制带上bizId,并且在本地(Redis)做发送前的幂等校验。同时平台侧的请求参数里,随机数不要用也会影响去重,如果是 HTTP 接口,请保证每次请求都有可以让平台识别的唯一值。

6.4 中文编码和长短信分割

这个坑相当隐蔽。短信内容超过一定字节数后会被运营商自动分割成多条计费或发送,不同网关对“长短信”的处理方式不一样。有些平台按 70 个汉字一条计费,长短信按 67 个汉字拆分;如果你的 content 里包含了特殊字符(例如 emoji、换行符、全角符号),字节数计算和中文编码很容易算错。还有 HTTP 请求里如果没指定charset=UTF-8,服务端收到乱码,最后用户收到的就是一堆问号。

我现在的做法是:模板内容一律用 UTF-8,参数里禁止 emoji;发送之前用平台的预览接口看一遍实际内容,确认签名和模板前缀占的字数不会导致超限。

6.5 HTTP 连接池与慢网关

短信 SDK 内部一般都有自己的 HTTP 连接池配置,但如果你用的是运营商直连 HTTP 接口,就需要自己管理连接池。我曾经在一次大促前压测时发现,短信发送模块的 Tomcat 线程全被阻塞了,看 JVM 线程 dump,全是java.net.SocketInputStream.socketRead0,最后定位到是 HTTP 客户端没有设置连接池和合理的超时时间,每次发送都新建 TCP 连接,TIME_WAIT 堆积后系统连不上外部地址。

给通过 HTTP 直连短信平台的项目一个一般性建议:使用 Apache HttpClient 或 OkHttp,连接池最大连接数按平台 QPS 上限调整,并设置连接池空闲回收。像这样的配置:

sms: http: connection-timeout: 3000 socket-timeout: 5000 max-connections: 200 max-per-route: 100 idle-connection-timeout: 60s

6.6 回调 URL 可达性与鉴权

回调接口如果搭建在内网环境,短信平台的外网服务器是访问不到的。我的一个项目曾经把回调地址配成http://localhost:8080/sms/callback,平台那边当然推送失败,结果所有短信回执都丢了,用户没收到短信也没人知道。上线回调接口前,一定要用平台提供的测试推送功能先试通,并确认回调接口是被外网代理转发可达的。回调地址还需要处理 HTTPS、鉴权、白名单。有些平台会从固定 IP 推送回调,你可以在安全组里只放行这些 IP。

最后说一个我自己的习惯:不管接哪一家短信平台,我都会先花半天时间把“回执回调 + 幂等 + 失败状态机”这一套链路做通再做发送功能。短信这个场景里,发送永远是简单的那一半,真正值钱的工程能力都在这些看不见的地方。希望这篇总结能帮你把 Spring Boot 项目的短信接入做得更稳,少踩一些我已经替你踩过的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询