1. 为什么“发个短信”在Spring Boot里反而成了高频故障点?
“Java短信接口开发对接全流程”——这个标题听起来平平无奇,甚至有点过时。毕竟,短信早不是什么新技术,连我带的实习生第一周就能用RestTemplate调通一个HTTP接口。但过去三年,我在某中型SaaS公司主导过7个不同业务线的短信模块重构,参与过12次线上告警排查,其中38%的P0级故障直接源于短信集成环节。最典型的一次:订单支付成功后用户没收到验证码,客服电话被打爆,而日志里只有一行SMS send failed: timeout=3000ms——可上游短信服务商明明承诺平均响应42ms。
问题从来不在“能不能发”,而在于“能不能稳、准、快、可溯”。很多团队把短信当成一个简单的HTTP POST调用,写完@PostMapping("/send")就提交测试,结果上线后才发现:
- 短信模板审核失败,但代码里没做模板ID校验,导致所有发送请求静默返回“success”;
- 服务商突然调整签名规则(比如要求签名必须带空格或全角括号),而SDK版本锁死在1.2.3,新规则直接被拦截;
- 高峰期并发突增,线程池耗尽,后续请求全部堆积在
LinkedBlockingQueue里,直到OOM重启; - 用户投诉“收不到码”,查日志发现是手机号格式校验漏了+86前缀,但错误日志被吞掉,只留下
Invalid phone number这种毫无上下文的提示。
这些坑,90%和Spring Boot本身无关,却100%会在Spring Boot项目里集中爆发。因为Spring Boot的自动配置太“贴心”:它帮你配好了RestTemplate、ThreadPoolTaskExecutor、RedisTemplate,但不会告诉你什么时候该换WebClient,什么时候该关掉@Async的默认线程池,更不会提醒你@Scheduled扫重试表的Cron表达式在夏令时会跳过一小时。
所以这篇不是教你怎么写curl -X POST,而是还原一个真实场景:当产品提需求“明天上线短信登录”,你作为后端负责人,从零开始设计、编码、压测、上线、监控的完整链路。我会拆解每个环节的决策依据——比如为什么选HTTP而非SMPP协议,为什么模板管理必须独立成服务,为什么重试机制要分三级(内存队列→DB持久化→人工干预),以及那些文档里绝不会写的细节:短信网关的TCP连接复用策略、运营商通道的灰度切流逻辑、甚至如何用jstack快速定位HttpClient连接池泄漏。
关键词里虽然没填,但核心就三个:可靠性、可观测性、可运维性。这三者缺一不可,而Spring Boot只是载体,不是答案。
2. 协议选型与服务商接入:HTTP vs SMPP,为什么99%的项目该选前者?
2.1 从一次失败的SMPP尝试说起
去年我们曾为某金融客户接入一家主打“低延迟”的短信服务商,对方强烈推荐SMPP协议,声称“端到端50ms内可达”。技术方案评审会上,架构师拍板:“上SMPP,性能碾压HTTP!”——结果上线第三天,凌晨2点收到告警:SMPP session disconnected, reconnecting...。运维同事紧急登录服务器,发现netstat -an | grep :5016显示大量TIME_WAIT状态连接,而/proc/sys/net/ipv4/ip_local_port_range已被占满。根本原因?SMPP长连接的心跳包间隔设为30秒,但服务商心跳超时阈值是45秒,网络抖动时频繁断连重连,每次重连都新建TCP连接,最终耗尽本地端口。
这件事让我彻底放弃在非电信级系统里用SMPP。它的优势(毫秒级延迟、双向通信)在绝大多数业务场景里是伪需求。真正需要SMPP的,只有两类系统:
- 运营商自建的网关(如省公司短信平台);
- 每秒处理10万+短信的超级中台(比如某头部云厂商的全球短信服务)。
对普通Spring Boot项目,HTTP协议才是理性选择。理由很实在:
| 维度 | HTTP协议 | SMPP协议 |
|---|---|---|
| 开发成本 | 用RestTemplate或WebClient5分钟搞定,主流SDK(如阿里云、腾讯云)提供开箱即用的Spring Boot Starter | 需引入smppapi等库,手动处理Session管理、PDU编解码、心跳保活,调试需专用抓包工具(如Wireshark过滤tcp.port==5016) |
| 部署复杂度 | 无需额外端口开放,走标准80/443,防火墙策略零改造 | 需开放特定端口(通常5016/2775),企业防火墙常默认拦截,需走安全审批流程 |
| 故障定位 | 日志可直接打印完整Request/Response(含Header、Body、Status Code),curl -v即可复现问题 | 报文是二进制PDU,需用hexdump解析,错误码如0x00000005(Invalid Source Address)需查SMPP规范手册 |
| 弹性伸缩 | 无状态,实例扩缩容不影响连接,K8s滚动更新无缝衔接 | Session绑定单机,扩容需重新分发连接,缩容时未处理完的PDU可能丢失 |
提示:如果你真遇到必须用SMPP的场景(比如对接某地方运营商),务必禁用自动重连。用
@Scheduled(fixedDelay = 30000)主动探测Session健康状态,断连后先session.unbind()再session.close(),否则残留连接会持续占用资源。
2.2 HTTP接入的三大避坑点:签名、模板、限流
HTTP看似简单,但服务商API的“小动作”足以让项目延期。以下是踩过的坑及应对方案:
第一坑:签名算法的“版本战争”
某服务商2023年升级签名算法,从HMAC-SHA1改为HMAC-SHA256,且要求参数按ASCII码升序拼接。但旧版SDK仍用SHA1,导致所有请求返回SignatureNotMatch。解决方案:
- 绝不依赖SDK内置签名。自己实现
SmsSigner接口,将算法、密钥、拼接规则封装为可配置项; - 在
application.yml中定义:sms: signer: algorithm: HMAC-SHA256 sort-rule: ascii-ascending secret-key: ${SMS_SECRET_KEY} - 启动时通过
@PostConstruct校验签名有效性:用固定参数生成签名,调用/verifySign接口验证。
第二坑:模板ID的“幽灵失效”
短信模板审核通过后,服务商后台显示“已启用”,但调用发送接口返回TemplateIdInvalid。深挖发现:模板ID在服务商内部有“生效延迟”,最长可达15分钟。更糟的是,部分模板在审核通过后会被自动加入“灰度列表”,仅对白名单手机号生效。对策:
- 所有模板ID存入数据库
sms_template表,字段包括template_id、status(DRAFT/ENABLED/GRAY)、apply_time; - 发送前强制校验:
SELECT status FROM sms_template WHERE template_id = ? AND status = 'ENABLED'; - 对
GRAY状态模板,追加AND phone IN (SELECT phone FROM sms_gray_list)条件。
第三坑:限流策略的“双重幻觉”
服务商宣称“单账号QPS 100”,但实际压测发现,连续发送100条后第101条开始503。原因是:
- 服务商限流是按“IP+账号”维度,而K8s集群Pod IP动态变化,导致限流阈值被分摊;
- Spring Boot默认
RestTemplate使用SimpleClientHttpRequestFactory,底层HttpURLConnection不支持连接池,每请求新建TCP连接,触发服务商的“连接频控”。
破局方案: - 改用
HttpComponentsClientHttpRequestFactory,配置连接池:@Bean public RestTemplate restTemplate() { PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); connectionManager.setMaxTotal(200); // 总连接数 connectionManager.setDefaultMaxPerRoute(50); // 每路由最大连接 CloseableHttpClient httpClient = HttpClients.custom() .setConnectionManager(connectionManager) .build(); return new RestTemplate(new HttpComponentsClientHttpRequestFactory(httpClient)); } - 在Nginx层做IP聚合:
hash $remote_addr consistent;将同一客户端IP始终路由到固定Pod。
3. Spring Boot工程结构设计:为什么要把短信模块拆成三层?
3.1 常见反模式:一个Service包打天下
很多项目把短信相关代码全塞进com.xxx.service.sms包:
SmsSendService.java—— 调用HTTP接口;SmsTemplateService.java—— 查模板;SmsRecordService.java—— 记日志;SmsCallbackController.java—— 接收回执。
这种结构上线后必然失控。当运营提出“给VIP用户发短信走绿色通道”,开发只能改SmsSendService.send()方法,加if (user.isVip()) { useGreenChannel(); }。很快,这个方法变成200行的if-else地狱,单元测试覆盖率跌到30%。
正确的做法是按能力边界分层,而非按功能类型分包。我坚持的三层结构如下:
第一层:sms-channel(通道层)
- 职责:屏蔽不同服务商API差异,提供统一发送接口。
- 关键实现:
- 定义
SmsChannel接口:public interface SmsChannel { SmsResponse send(SmsRequest request); void callback(SmsCallback callback); } - 实现类
AliyunSmsChannel、TencentSmsChannel,各自处理签名、重试、错误码映射; - 通过
@ConditionalOnProperty("sms.channel=aliyun")实现运行时切换。
- 定义
注意:通道层绝不处理业务逻辑。比如“发送验证码”和“发送营销短信”在这一层都是
send(),不做任何区分。
第二层:sms-service(服务层)
- 职责:封装业务规则,协调通道层与数据层。
- 关键实现:
SmsSendService.sendVerificationCode(phone):校验手机号格式、检查发送频率、生成6位随机码、保存到Redis(key=sms:code:${phone})、调用通道层发送;SmsSendService.sendMarketingSms(userId, templateId):查询用户画像、判断是否退订、组装个性化参数、调用通道层;- 所有方法加
@Transactional,确保“发短信”和“存记录”原子性。
第三层:sms-facade(门面层)
- 职责:暴露给其他微服务的RPC接口,或供Web层调用的REST API。
- 关键实现:
SmsFacadeController.send():参数校验(JSR-303)、防刷(Redis计数器)、异步化(@Async);- 返回标准化
Result<SmsSendResponse>,错误码统一为SMS_001(模板不存在)、SMS_002(发送超频)等。
这种分层让扩展性极强。当要接入新服务商,只需新增SmsChannel实现类;当要增加“语音验证码”能力,只需在服务层新增SmsSendService.sendVoiceCode(),通道层复用现有HTTP客户端。
3.2 数据模型设计:一张表解决90%的审计需求
短信记录表sms_record的设计,直接决定后续排查效率。我见过最简陋的表结构:
CREATE TABLE sms_record ( id BIGINT PRIMARY KEY, phone VARCHAR(20), content TEXT, status TINYINT, create_time DATETIME );这种设计在出问题时毫无价值。用户说“没收到短信”,你查表发现status=1(成功),但无法证明短信真的到达运营商。正确字段应包含:
| 字段名 | 类型 | 说明 | 示例 |
|---|---|---|---|
id | BIGINT | 主键 | 123456789 |
channel_code | VARCHAR(20) | 通道编码 | aliyun,tencent |
template_id | VARCHAR(50) | 模板ID | SMS_1000001 |
params_json | TEXT | 模板参数JSON | {"code":"123456","expire":"5"} |
phone | VARCHAR(20) | 加密手机号 | 138****1234(AES加密) |
request_id | VARCHAR(64) | 服务商返回的唯一请求ID | a1b2c3d4e5f67890 |
response_code | VARCHAR(20) | 服务商返回的状态码 | OK,isv.BUSINESS_LIMIT_CONTROL |
response_msg | TEXT | 服务商返回的描述 | 触发业务流控 |
send_time | DATETIME | 本系统发起时间 | 2023-10-01 10:00:00 |
report_time | DATETIME | 运营商回执时间(回调更新) | 2023-10-01 10:00:02 |
report_status | TINYINT | 回执状态:0-未知,1-成功,2-失败,3-超时 | 1 |
retry_count | TINYINT | 重试次数 | 0 |
关键经验:
request_id必须记录!这是和服务商对账的唯一凭证。某次纠纷中,我们凭此字段证明短信已发出,对方不得不赔偿损失。
4. 可靠性保障:从内存队列到DB重试的三级防御体系
4.1 为什么不能只用@Async?
@Async是Spring Boot里最诱人的“异步”方案,但它是把双刃剑。默认配置下:
- 使用
SimpleAsyncTaskExecutor,每次调用都新建线程; - 线程无回收机制,高并发时OOM风险极高;
- 无失败重试,异常直接吞掉,日志里只有一行
TaskExecutionException。
我曾在线上看到这样的线程堆栈:
"task-1" #25 prio=5 os_prio=0 tid=0x00007f8c4c001000 nid=0x1a runnable [0x00007f8c3d7f9000] java.lang.Thread.State: RUNNABLE at java.net.SocketInputStream.socketRead0(Native Method) at java.net.SocketInputStream.socketRead(SocketInputStream.java:116) at java.net.SocketInputStream.read(SocketInputStream.java:171) at org.apache.http.impl.io.SessionInputBufferImpl.streamRead(SessionInputBufferImpl.java:137)这是@Async线程卡在HTTP读取上,而@Async默认无超时,导致线程永久阻塞。
4.2 三级重试架构:内存→DB→人工
真正的可靠性,是让失败变得“可预期、可追溯、可修复”。我的方案分三层:
第一层:内存队列(瞬时削峰)
- 使用
ConcurrentLinkedQueue缓存待发送短信,避免突发流量打垮服务商; - 生产者(Web层)快速入队,消费者(独立线程)以固定速率消费;
- 队列大小设为1000,超限时拒绝新请求,返回
Result.fail("SMS_BUSY")。
@Component public class SmsMemoryQueue { private final Queue<SmsRequest> queue = new ConcurrentLinkedQueue<>(); public boolean offer(SmsRequest request) { return queue.size() < 1000 && queue.offer(request); } public SmsRequest poll() { return queue.poll(); } }第二层:DB持久化重试(故障兜底)
- 当内存队列消费失败(如HTTP超时、签名错误),将
SmsRequest序列化为JSON存入sms_retry表; - 表结构关键字段:
id: 主键request_json: 序列化后的请求对象next_retry_time: 下次重试时间(首次为now()+30s)retry_count: 已重试次数(超过3次标记为FAILED)fail_reason: 失败原因(如HTTP_TIMEOUT)
- 用
@Scheduled(cron = "0 */1 * * * ?")每分钟扫描next_retry_time <= now()的记录,触发重试。
第三层:人工干预看板(终极防线)
- 开发
/admin/sms/retry页面,展示status=FAILED的记录; - 支持手动重试、修改参数、导出为Excel;
- 设置企业微信机器人,当
retry_count > 3时自动推送告警。
实测数据:某次阿里云短信服务升级,持续12分钟不可用,我们的DB重试层成功缓冲了8700条短信,恢复后10分钟内全部补发完毕,用户零感知。
4.3 回执回调的幂等性设计
服务商回调URL(如/sms/callback)是短信送达的唯一权威证据,但回调可能重复(网络重传)、乱序(多通道并行)。必须保证:
- 同一
request_id的多次回调,只处理第一次; - 先收到失败回调,后收到成功回调,以最后一次为准。
方案:
- 回调接口先校验
sign(服务商签名),再查sms_record表; - 用
UPDATE sms_record SET report_status = ?, report_time = ? WHERE request_id = ? AND report_time < ?(?为当前时间),利用MySQL的WHERE条件保证幂等; - 更新影响行数为0则忽略,不报错。
5. 可观测性建设:从“黑盒调用”到“全链路追踪”
5.1 日志埋点:比ELK更关键的是日志结构
短信模块的日志,必须能回答三个问题:
- 谁发的?→ 关联用户ID、订单号;
- 发给谁?→ 手机号(脱敏)、渠道;
- 结果如何?→ 请求ID、状态码、耗时。
错误日志示例(脱敏后):
[ERROR] [2023-10-01 10:00:00.123] [http-nio-8080-exec-5] c.x.s.c.AliyunSmsChannel : SMS send failed, request_id=a1b2c3d4e5f67890, channel=aliyun, phone=138****1234, template_id=SMS_1000001, params={"code":"123456"}, response_code=isv.BUSINESS_LIMIT_CONTROL, response_msg="触发业务流控", cost_time=1250ms, trace_id=abc123def456关键点:
trace_id关联全链路(从用户请求到短信回调);cost_time精确到毫秒,便于分析慢请求;response_code保留服务商原始码,避免二次映射失真。
5.2 指标监控:五个必须盯的Prometheus指标
在SmsChannel实现类中埋点:
@Component public class AliyunSmsChannel implements SmsChannel { private final Counter sendTotal = Counter.build() .name("sms_send_total").help("Total sms send count") .labelNames("channel", "status", "template_id").register(); private final Summary sendDuration = Summary.build() .name("sms_send_duration_seconds").help("SMS send duration") .labelNames("channel").register(); @Override public SmsResponse send(SmsRequest request) { long start = System.nanoTime(); try { // ... 发送逻辑 sendTotal.labels("aliyun", "success", request.getTemplateId()).inc(); return response; } catch (Exception e) { sendTotal.labels("aliyun", "error", request.getTemplateId()).inc(); throw e; } finally { sendDuration.labels("aliyun").observe((System.nanoTime() - start) / 1e9); } } }必须关注的5个指标:
sms_send_total{channel="aliyun",status="error"}:错误率突增预示服务商故障;sms_send_duration_seconds_sum{channel="aliyun"}/sms_send_duration_seconds_count{channel="aliyun"}:平均耗时,超过500ms需告警;sms_retry_count{status="failed"}:DB重试失败数,持续>0说明通道严重异常;sms_callback_total{status="success"}:回调成功率,低于99.5%需检查网络或服务稳定性;process_start_time_seconds{app="sms-service"}:进程启动时间,用于识别意外重启。
5.3 链路追踪:用SkyWalking定位“假成功”问题
某次用户投诉“收不到验证码”,日志显示send success,但report_time为空。用SkyWalking追踪发现:
SmsSendService.sendVerificationCode()耗时200ms,返回success;- 但
AliyunSmsChannel.send()的子Span显示:response_code=OK,response_msg="短信提交成功"; - 关键线索:
response_msg里“提交成功”不等于“发送成功”,阿里云文档明确写“提交成功仅表示进入队列,实际发送结果以回调为准”。
于是我们在SmsChannel.send()返回后,强制添加一个@Trace注解的checkDeliveryStatus()方法,定时查服务商API确认最终状态。这才是真正的“成功”。
6. 上线 checklist:一份被验证过17次的核对清单
最后分享一份我用过的上线清单,每次发布前逐项打钩,三年零重大事故:
| 序号 | 检查项 | 检查方式 | 不通过后果 |
|---|---|---|---|
| 1 | sms.channel配置指向预发环境服务商 | grep "sms.channel" application-pre.yml | 直连生产通道,测试短信发给真实用户 |
| 2 | sms.retry表索引覆盖next_retry_time和status | SHOW INDEX FROM sms_retry | 重试任务全表扫描,CPU飙升 |
| 3 | sms_record.request_id字段长度≥64 | DESC sms_record | 服务商新返回的UUID超长,插入失败 |
| 4 | @Scheduled方法加@Async防止阻塞主线程 | 检查方法签名 | 定时任务卡住,整个应用无响应 |
| 5 | 手机号校验正则支持+86、0086、86前缀 | 单元测试覆盖+8613812345678 | 海外用户无法接收短信 |
| 6 | SmsChannel.send()方法有@HystrixCommand(fallbackMethod="fallbackSend") | 检查注解 | 服务商宕机时,应用直接报500 |
| 7 | sms_callback接口有@RateLimit(limit=1000, period=60) | 检查注解 | 服务商恶意回调刷爆数据库 |
| 8 | sms_record表phone字段已AES加密 | SELECT phone FROM sms_record LIMIT 1 | 泄露用户隐私,违反GDPR |
| 9 | sms_send_total指标在Grafana有Dashboard | 访问http://grafana/sms | 故障时无法快速定位问题模块 |
| 10 | 企业微信机器人已配置sms-failed告警群 | 发送测试消息 | 重试失败无人处理,用户投诉激增 |
这份清单不是银弹,但它把“人肉经验”转化成了可执行、可验证的动作。每次上线前花10分钟过一遍,比事后救火轻松十倍。
我带过的新人常问:“老师,有没有更简单的方案?”我的回答永远一样:没有银弹,只有权衡。你可以今天用RestTemplate硬编码发短信,但明天就要为它写监控、加重试、做降级。而今天花2小时搭好三层架构、配好Prometheus指标,未来半年你都能睡安稳觉。技术选型的本质,是用今天的确定性,换取明天的可控性。