做后端开发的迟早都会碰到短信接口对接的需求。注册验证码、登录通知、订单提醒、异常告警……短信通道看着简单真要在一个 Spring Boot 项目里稳稳当当跑起来还是有不少细节容易翻车。这篇文章就把我实际对接短信服务的完整流程写出来从选型到写代码从异步处理到回调对接再到常见错误码排查尽量让刚接触短信开发的同事能照着落地。1. 短信接口开发的整体思路与方案选型1.1 短信在业务中的定位与核心需求短信功能在绝大多数项目里都属于小功能、强依赖的存在。代码量不大但一旦出了问题直接影响用户注册、登录、支付验证等核心链路。所以在动手写代码之前先要搞清楚业务上到底需要什么。最常见的短信场景有这几类验证码短信注册、登录、找回密码、二次验证特点是高并发、短时效。通知类短信订单状态变化、物流提醒、还款提醒特点是量大、不要求秒达。营销类短信活动推广、优惠券发放特点是需要提前报备模板限制较多。告警类短信系统异常、服务器负载过高特点是频率低但必须保证送达。不同场景对短信服务的要求不一样。验证码要求发送速度快、到达率高营销短信对模板审核和发送时间有严格限制告警短信则更关注稳定性和通道冗余。我在项目里通常会把短信能力统一封装成一个模块而不是散落在各个业务代码中后面接新场景只需要加模板和调用点维护成本会低很多。1.2 为什么选 Spring Boot 而不是其他框架如果项目本身是 Spring Boot那集成短信服务其实没有太多纠结的必要。Spring Boot 的核心优势就是自动化配置和生态整合短信 SDK 的初始化、连接池管理、配置绑定、异步执行这些能力都能直接复用。Spring Boot 与短信集成相关的几个关键优势配置绑定通过ConfigurationProperties把短信服务商的密钥、签名、模板等配置统一映射成 Java 对象。依赖管理短信服务商提供的 Java SDK 大多都能直接引入不需要手动处理传递依赖。异步能力Async加线程池就能解决发送短信阻塞主线程的问题。可测试性Spring 的依赖注入让 Mock 外部短信服务变得非常方便单元测试和联调测试都能独立进行。对比以前在 Servlet 里手动管理连接和线程池的老做法Spring Boot 把这些基础设施都托底了开发者只需要关注业务逻辑本身。这也是我现在不管对接哪个第三方服务都优先用 Spring Boot 做承载的原因。1.3 主流短信服务商与核心概念国内常用的短信服务商有阿里云短信、腾讯云短信、华为云短信还有一些第三方聚合平台。功能上大同小异都提供发送接口、状态回执、模板管理、签名审核。选型时我主要看三点单价、到达率、审核速度。单价影响成本到达率影响用户体验审核速度则决定了功能上线节奏。对接前必须理解几个核心概念不然代码会写得很迷茫AccessKey ID 与 AccessKey Secret服务商颁发的访问凭证相当于你调用短信接口的账号密码必须放在服务端保管。短信签名显示在短信开头的【xxx】代表发送主体需要企业资质或个人身份认证后申请审核通过才能使用。短信模板短信正文内容比如您的验证码为${code}5分钟内有效。变量部分用占位符表示需要审核。模板参数调用发送接口时传入的具体变量值比如把code替换成123456。回执服务商发送短信后会把最终状态成功、失败、发送中推送到你的回调接口用于追踪送达结果。这些概念在不同服务商里叫法不同但底层逻辑一致。理解了这套模型换任何一家服务商都只是换 SDK 和配置的事。2. 环境准备与项目依赖配置2.1 Spring Boot 项目初始化新建一个 Spring Boot 项目我习惯直接用 Spring Initializr 初始生成Spring Boot 版本选稳定版2.7.x 或 3.x 均可视团队基础而定本文示例以 2.7 为主。如果项目里还没有 Web 依赖需要引入spring-boot-starter-web因为后面的回调接口需要接受 HTTP 请求。除了 Web 依赖通常还会用到这些dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency短信 SDK 我以阿里云短信为例做法经典 SDK 的依赖如下dependency groupIdcom.aliyun/groupId artifactIdaliyun-java-sdk-core/artifactId version4.6.3/version /dependency dependency groupIdcom.aliyun/groupId artifactIdaliyun-java-sdk-dysmsapi/artifactId version2.2.1/version /dependency这里先说明一点阿里云现在主推新版的dysmsapi20170525SDK代码风格更现代但经典 SDK 仍然在用且网上资料多。我下面的示例用经典 SDK 展示核心逻辑方便理解发送流程。生产环境如果在意长期维护建议使用官方最新 SDK原理完全一致。2.2 短信服务商配置项设计短信相关的配置不要直接写在业务代码里而是统一放到application.yml用独立的配置类绑定。这样可以做到不同环境开发、测试、生产使用不同配置避免改代码。我的做法是新建一个SmsProperties配置类import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; Component ConfigurationProperties(prefix sms.aliyun) public class SmsProperties { private String accessKeyId; private String accessKeySecret; private String signName; private String templateCode; private String endpoint dysmsapi.aliyuncs.com; private String regionId cn-hangzhou; // getter / setter 省略 }然后在application.yml中配置sms: aliyun: access-key-id: your-access-key-id access-key-secret: your-access-key-secret sign-name: 你的签名 template-code: SMS_123456789 endpoint: dysmsapi.aliyuncs.com region-id: cn-hangzhou一份配置对应一个服务商如果项目里同时接了多个短信服务商例如一个做验证码、一个做营销那可以在sms下再加aliyun、tencent等子节点配置类也拆成多个。这样做的好处是切换服务商时只改配置不动代码。2.3 配置文件与密钥安全把 AccessKey Secret 明文写在application.yml里在开发环境图省事还行生产环境绝不能这么干。密钥一旦泄露别人就能随意调用你的短信接口轻则短信费用爆表重则被用于诈骗短信发送。我实践下来比较稳妥的办法生产环境的密钥放入配置中心比如 Nacos、Apollo 或 Spring Cloud Config并开启权限管控。如果项目没接配置中心至少用环境变量注入配置项写成${SMS_ACCESS_KEY_SECRET}。开启服务商提供的 IP 白名单限制只允许生产服务器出口 IP 调用。定期轮换密钥不要怕麻烦泄露后的代价比轮换成本高得多。再补充一个细节如果短信 SDK 内部会输出调试日志注意别把密钥打到日志里。有些版本在 Debug 级别会打印请求内容这也要在日志配置里关掉或者屏蔽。3. 短信发送核心代码实现3.1 统一接口与返回模型设计短信发送不能只围绕调一次 API来设计还要考虑业务侧怎么调用、怎么判断成功、失败之后怎么办。我习惯先定义一个统一的发送接口和返回模型让上层业务不感知具体短信服务商。先定义统一的返回模型public class SmsResult { private boolean success; private String requestId; private String code; private String message; public static SmsResult ok(String requestId) { SmsResult result new SmsResult(); result.setSuccess(true); result.setRequestId(requestId); result.setCode(OK); return result; } public static SmsResult fail(String code, String message) { SmsResult result new SmsResult(); result.setSuccess(false); result.setCode(code); result.setMessage(message); return result; } // getter / setter 省略 }这里的关键是success字段不能只依赖服务商返回的code OK因为网络异常时可能根本拿不到响应。所以后面的发送逻辑要区分服务商返回失败和调用过程抛异常两种情况。服务接口可以设计成这样public interface SmsSender { SmsResult sendVerifyCode(String mobile, String code); SmsResult sendTemplateSms(String mobile, String templateCode, MapString, String params); }接口粒度按业务场景拆分比较好。比如sendVerifyCode内部固定使用验证码模板业务方不需要关心模板编码sendTemplateSms则开放模板参数能力给那些灵活变化的通知场景使用。这样既保证了常用路径最简单又保留了扩展能力。3.2 发送验证码的核心逻辑以阿里云经典 SDK 为例发送验证码的核心逻辑如下import com.aliyuncs.DefaultAcsClient; import com.aliyuncs.IAcsClient; import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest; import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse; import com.aliyuncs.profile.DefaultProfile; import com.aliyuncs.profile.IClientProfile; import com.fasterxml.jackson.databind.ObjectMapper; Service public class AliyunSmsSender implements SmsSender { private final SmsProperties properties; private final ObjectMapper objectMapper; public AliyunSmsSender(SmsProperties properties, ObjectMapper objectMapper) { this.properties properties; this.objectMapper objectMapper; } Override public SmsResult sendVerifyCode(String mobile, String code) { MapString, String params new HashMap(); params.put(code, code); return sendTemplateSms(mobile, properties.getTemplateCode(), params); } Override public SmsResult sendTemplateSms(String mobile, String templateCode, MapString, String params) { try { IClientProfile profile DefaultProfile.getProfile( properties.getRegionId(), properties.getAccessKeyId(), properties.getAccessKeySecret()); DefaultProfile.addEndpoint( properties.getRegionId(), properties.getRegionId(), Dysmsapi, properties.getEndpoint()); IAcsClient client new DefaultAcsClient(profile); SendSmsRequest request new SendSmsRequest(); request.setPhoneNumbers(mobile); request.setSignName(properties.getSignName()); request.setTemplateCode(templateCode); request.setTemplateParam(objectMapper.writeValueAsString(params)); SendSmsResponse response client.getAcsResponse(request); if (OK.equals(response.getCode())) { return SmsResult.ok(response.getRequestId()); } return SmsResult.fail(response.getCode(), response.getMessage()); } catch (Exception e) { // 记录异常日志 return SmsResult.fail(EXCEPTION, e.getMessage()); } } }这段代码有几个细节要留意DefaultProfile.addEndpoint这步很容易被忽略Region 配错了会一直报InvalidRegionId。templateParam必须是 JSON 字符串且键名要与模板中的变量名完全一致多传、少传都可能报模板参数错误。每次发送都重新创建IAcsClient在低并发下没问题但高并发场景最好把 Client 做成单例或复用后面会说到线程安全问题。3.3 异步发送、重试与线程池短信发送耗时一般在几百毫秒到几秒如果在用户请求线程里同步调用注册接口的 RT 会被拖慢。尤其是验证码场景用户点击获取验证码后等待响应体验会很明显。比较合理的方案是异步发送。发送接口先做基础参数校验手机号格式、模板是否存在然后丢进线程池执行接口立即返回发送中。注意这里要区分提交成功和发送成功业务侧不要误解。Spring Boot 里启用Async很简单EnableAsync Configuration public class AsyncConfig { Bean(smsTaskExecutor) public Executor smsTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(200); executor.setThreadNamePrefix(sms-task-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }线程池参数要根据业务量来定。比如每秒可能需要发送 20 条验证码每条耗时 300ms一个线程每秒最多处理 3~4 条那核心线程至少需要 6~8 个。这里CallerRunsPolicy是我刻意选的兜底策略线程池满了之后发送任务由调用线程执行而不是直接丢弃。短信不能丢慢一点总比收不到好。异步发送方法可以这样写Async(smsTaskExecutor) public void sendVerifyCodeAsync(String mobile, String code) { SmsResult result sendVerifyCode(mobile, code); if (!result.isSuccess()) { // 这里考虑是否需要重试下面说明 log.error(短信发送失败: mobile{}, code{}, result{}, mobile, code, result); } }重试机制不要无脑加。短信是收费的同一验证码重复发三次可能就是三笔费用。我建议只对系统异常和明确的限流错误做重试对参数错误签名错误这种问题重试也没用。重试次数控制在 2 次以内间隔可以用简单的固定延迟 1 秒或者引入 Spring Retry 做退避。如果对时序敏感可以结合 Redis 做短信发送次数限制比如同号一分钟一次、一天十次。这里还有一个实战经验验证码本身的生成和存储建议放到 Redis 中设置 5 分钟过期。发送短信之后把验证码 set 到 Redis校验时再 get 出来比对。异步发送场景下要特别注意用户可能在短信还没真正发出时就来校验因为已经是异步了校验能否成功取决于验证码存储逻辑收到短信和存储验证码是两个独立环节要设计好先后顺序。我在项目中是先存储验证码再异步发送短信这样即使发送失败后续流程也能感知到。4. 回调接收、日志与监控4.1 短信状态回执回调接口短信发送后运营商最终是否成功送达需要通过服务商的回执系统获知。服务商会把状态推送到你配置的回调 URL。这个回调接口很关键但因为平时不是主链路很多项目直接忽略了导致发送失败也无法感知。回调接口一般就是一个 POST 接口接收服务商推送的 JSON 数据。以阿里云为例回执内容包含phone_number、success、err_code、out_id等字段。我们需要在自己的系统里用一个接口接收RestController RequestMapping(/api/sms/callback) public class SmsCallbackController { PostMapping(/aliyun) public String aliyunCallback(RequestBody MapString, Object payload) { // 记录原始回调数据 log.info(阿里云短信回执: {}, payload); // 解析关键字段 String phone String.valueOf(payload.get(phone_number)); Boolean success Boolean.valueOf(String.valueOf(payload.get(success))); String errCode payload.get(err_code) null ? : String.valueOf(payload.get(err_code)); // 更新短信发送记录表 // 返回值固定为 success服务商才认为推送成功 return success; } }这里必说的坑有两个回调接口必须幂等。服务商可能因为网络问题重复推送同一条回执所以更新发送记录时要按业务唯一键比如消息 ID做幂等判断。返回内容必须按服务商要求来。很多服务商要求返回success字符你返回 JSON 或 200 以外的状态码它就会认为推送失败然后反复重推。此外回调接口的地址必须是公网可访问的。本地开发调试可以用内网穿透工具辅助但生产环境一定放在正式域名的路径下并只允许服务商来源 IP 访问避免被恶意刷接口。4.2 日志埋点与监控大盘短信模块的日志非常重要因为出了问题很难靠用户反馈定位。每一个发送动作都要有唯一流水号建议用UUID或者数据库自增 ID 作为bizId贯穿发送请求 - 服务商响应 - 状态回执全流程。常见的日志埋点有发送前记录手机号脱敏、模板编码、参数摘要、时间戳。发送后记录服务商返回码、返回消息、耗时。回调时记录回执状态、错误码。异常时记录异常堆栈、请求上下文。生产上可以把短信日志单独打到独立文件方便日志采集和检索。比如使用 Logback 配置单独的 loggerlogger namesmsLogger levelINFO additivityfalse appender-ref refSMS_FILE/ /logger如果公司有监控平台还可以把关键指标上报比如每分钟发送量、成功率、失败率、平均耗时。我在项目里用的方案是按业务指标封装了一个SmsMetrics类内部对接 Micrometer 或直接打印结构化日志由运维平台采集。当发送成功率低于阈值时自动告警一般能提前发现问题。4.3 异常分类与兜底降级外部短信服务不可能永远稳定。我们要在代码里预设好各种异常下的兜底策略。我习惯把异常分成三类业务异常手机号格式错误、模板缺少参数、签名不匹配。这类异常直接返回给业务方不重试。服务商限流比如isv.BUSINESS_LIMIT_CONTROL。这类异常可以做指数退避重试但要控制次数。网络异常连接超时、读取超时、DNS 解析失败。这类异常最不能忽视因为用户可能已经流失了需要靠回执或补偿机制处理。兜底降级的方案根据业务重要性可以有不同级别记录日志人工介入补发。延迟后重试一次。切换备用短信服务商。将发送失败的手机号写入失败表由定时任务扫描补发。我之前在一个支付通知场景里就遇到过短信通道半夜宕机的情况。后来加了失败表 定时补偿机制短信发送失败后记录到sms_fail_record表每五分钟扫描一次未成功的记录重新发送并限制单号补发次数。这样即使通道抖动也能保证通知不丢。5. 高频问题排查与性能调优5.1 错误码速查表对接短信接口最耗时间的就是排查错误码。我把常见的错误码和解决方案整理成一个速查表遇到问题直接对照。错误码含义解决方案OK发送成功无需处理isv.SMS_SIGNATURE_ILLEGAL签名不存在或未审核通过检查签名名称确认在服务商后台已审核通过isv.SMS_TEMPLATE_ILLEGAL模板不存在或未审核通过检查模板编码确认模板内容与调用参数一致isv.MOBILE_NUMBER_ILLEGAL手机号格式错误检查手机号是否符合 11 位数字且以 1 开头isv.TEMPLATE_MISSING_PARAMETERS模板参数缺失检查模板变量和实际传入的 JSON 参数是否一致isv.BUSINESS_LIMIT_CONTROL触达频次限制或业务限流降低发送频率等待限流解除或申请提升阈值isv.AMOUNT_NOT_ENOUGH账户余额不足充值或配置余额告警isv.INVALID_PARAMETERS参数格式非法逐个检查请求字段尤其是模板参数 JSONisp.RAM_PERMISSION_DENYRAM 子账号权限不足在 RAM 控制台授权短信接口权限SYSTEM_ERROR服务商系统异常间隔几秒后重试有一个容易被忽视的问题isv.BUSINESS_LIMIT_CONTROL不只是频控也可能是内容命中敏感词或者手机号被列入黑名单。遇到这个错误码时最好去服务商控制台看具体原因不要盲目重试。5.2 并发限制与性能优化短信接口在服务商侧是有 QPS 限制的默认可能只有 100 QPS 甚至更低。如果你的业务瞬时发送量超过限制就会收到限流错误。优化手段可以从几个方向入手第一复用客户端连接。经典 SDK 的IAcsClient是线程安全的可以设计成单例 Bean不要每次发送都创建。我有一次为了省事在 Service 里 new 了个 Client结果压测时连接被大量创建和销毁超时率和资源占用都很高。第二批量发送能力。不少服务商提供批量发送接口一次可以传多个手机号。但要注意批量接口也有上限通常是 1000 个号码/次。如果业务量特别大需要分批处理。第三削峰填谷。比如秒杀场景下验证码请求瞬间暴涨可以用消息队列把发送请求削峰下游按固定速率消费平滑调用服务商接口。这样做虽然短信到达会有延迟但避免了被限流导致大批量失败。第四内存与 GC 调优。短信发送线程池如果过深内存里堆积的任务太多会导致 GC 压力。可以通过监控队列深度来动态调整线程池参数不要让队列无限堆积。5.3 测试环境省钱与防骚扰技巧短信是一条一扣费测试环境如果直接调真实接口一天下来可能烧掉不少钱而且容易骚扰到真实用户。我见过最尴尬的案例同事用正式环境配置在测试环境联调结果验证码发到了用户手机上造成严重的用户投诉。测试环境的规范做法有几种使用 Mock 服务代替真实短信。Spring Boot 项目里可以写一个Profile(test)的MockSmsSender返回固定成功结果。如果必须真实发送优先使用服务商提供的测试签名和测试模板部分厂商对测试模板不收费或收费很低。维护一个测试手机号白名单只有白名单号码才允许调用真实发送。在发送逻辑里加一个全局开关比如sms.enabledfalse时只打印日志不调用接口。集成测试时还可以配合 Mockito 直接 mock 掉SmsSender的调用快速验证业务逻辑而不用真的去连服务商。等到部署到 staging 环境再打开真实通道做一次冒烟测试。6. 实战中的一些额外心得最后再分享几个我在实际项目里摸索出来的点。关于密钥管理我一直强调要用配置中心和环境变量这里再说一个细节AccessKey 不要提交到 Git 仓库。即使你后来把它删了历史提交记录里还是有。万一仓库是公开的密钥就等于泄露了。正确做法是第一道防线用环境变量第二道防线配合服务商的 RAM 子账号给短信权限单独做一个最小权限账号而不是直接用主账号。关于回调验签服务商推送回执时一般会带签名参数目的是保证数据来自官方。但很多开发者在回调接口里完全没验签存在安全隐患。伪造的回执可能导致业务误判短信状态比如把失败当成功。所以接回调一定要看服务商文档按它规定的算法校验签名至少也要校验来源 IP。关于代码结构短信模块在项目里往往是横切的很多地方都要用。我建议把SmsSender接口、实现类、配置类、回调 Controller 都放在独立的sms包下不要和业务代码混在一起。这样以后升级服务商或调整签名策略影响面可控。做短信功能最重要的不是把代码跑通而是把失败路径想清楚。短信发送是一个外部依赖很重的链路网络抖动、服务商故障、配置错误都有可能发生。只要在代码里把日志、回执、重试、降级这四件事做好这个模块基本就能稳定运行了。希望这篇实战记录能帮你少踩几个坑。