Spring Boot短信接口工程化接入:异步化、回执与监控实战
很多Java团队做短信接入最典型的问题就是把“能发出去”当成了“已完成”。我帮人看过不少项目代码Controller 里 new 一个平台 SDKAccessKey 直接写在 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 MapString, 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 区分“这条短信到底发出去没”很容易把业务逻辑搞乱。更关键的是业务层想要的答案其实很简单到底是“已经受理成功”还是“明确失败了”。所以我推荐所有平台适配器都遵循这样一条规则网络超时、平台返回“未知状态”这类不确定结果用successfalse加一个特殊错误码UNKNOWN由上层决定是否重试平台明确返回失败比如手机号不在白名单、模板参数错误同样用successfalse返回平台的错误码但这时候上层不能盲目重试因为每次都必然失败。重试策略这个我在后面专门讲这里先明确一点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 短信发送的完整生命周期完整链路是这样的业务系统调用发送接口平台受理返回消息 ID平台把短信推送给运营商通道运营商短信中心下发到手机手机收到后短信中心生成回执Delivery Report上报给平台平台把回执回调给你提供的 URL 地址。所以一条短信最终有四种真实状态发送中、成功、失败、未知。要做到“知道用户到底收到没有”就必须接回执回调。4.2 回执回调的验签与幂等重点工程Spring Boot 项目接收短信平台回调时我推荐的做法是单独建一个 Controller路径和正常业务接口分开专门做三件事验签平台一般会用签名算法例如 MD5 密钥对回调内容签名你必须在入口处校验签名防止别人伪造回调幂等同一个消息的回执平台可能推送多次你不能因为收到两次成功就更新两次状态要用消息 ID 做唯一索引状态维护数据库里维护短信记录表核心字段包括provider_msg_id、mobile、template_code、send_statusSENDING/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 条同一 IP10 分钟内最多 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 日志里必须透出的几个关键字段从接入短信平台第一天起我就强制要求所有短信日志必须结构化一条日志至少包含这些字段smsProvideraliyun bizTraceIdc8f2a01e234 phoneMasked138****1234 templateCodeUSER_LOGIN_CODE providerMsgId123456789 statusSUCCESS costMs312 errorCodeEXCEPTION errorMsgRead 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 请求里如果没指定charsetUTF-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: 60s6.6 回调 URL 可达性与鉴权回调接口如果搭建在内网环境短信平台的外网服务器是访问不到的。我的一个项目曾经把回调地址配成http://localhost:8080/sms/callback平台那边当然推送失败结果所有短信回执都丢了用户没收到短信也没人知道。上线回调接口前一定要用平台提供的测试推送功能先试通并确认回调接口是被外网代理转发可达的。回调地址还需要处理 HTTPS、鉴权、白名单。有些平台会从固定 IP 推送回调你可以在安全组里只放行这些 IP。最后说一个我自己的习惯不管接哪一家短信平台我都会先花半天时间把“回执回调 幂等 失败状态机”这一套链路做通再做发送功能。短信这个场景里发送永远是简单的那一半真正值钱的工程能力都在这些看不见的地方。希望这篇总结能帮你把 Spring Boot 项目的短信接入做得更稳少踩一些我已经替你踩过的坑。