ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java短信SDK接入与生产加固实践

Java短信SDK接入与生产加固实践

1. 短信接口是整个系统里最不像技术问题的技术问题

做 Java 后端的人迟早会碰上一件事:产品经理在需求评审上说"就加一个验证码,很简单"。然后你打开 SDK 文档,看着SendSmsRequest这个类,心里想的是"这不就三行代码吗"。真正把 Java 版本的短信服务 SDK 接进去、上线跑上几个月以后你会发现,这条链路的代码量确实很小,但它牵扯的东西比很多核心业务模块都多——涉及外部网络、涉及钱、涉及用户体验,而且出问题的时候往往是在半夜。这篇东西不打算复述官方文档,而是把我在多个项目里接 Java 短信 SDK 的完整路径摊开:从依赖引入、客户端初始化、发送封装,到错误码分层、幂等设计、限流降级和监控埋点,把每一步为什么这么做讲清楚。

先说清楚范围。这里讨论的"短信服务 SDK 的 Java 版本",指的是主流云服务商提供的那套 HTTP 客户端封装库,核心能力就三件事:调用发送接口、解析返回结构、附带签名鉴权。它不是什么重量级中间件,本质是一个带重试和序列化的 HTTP 客户端。但正因为"本质简单",大多数人接的时候很随意:直接在主线程里new一个客户端,发送方法裸调,返回值打印一下就完事。这种写法在测试环境永远不会出问题,因为测试环境不会触发流控、不会出现签名不匹配、不会因为一条长短信被拆成三条计费。

我见过一个挺典型的场景:某公司的登录模块在活动期间被刷验证码,单机 QPS 打满,短信接口开始返回触发流控的错误码,业务代码没做任何判断,直接把异常吞掉,用户界面显示"验证码已发送",实际上一条都没发出去。排查的时候日志里全是同一句send failed,没有错误码、没有手机号脱敏后的追踪 ID、没有耗时统计,只能靠猜。这个坑的根本原因不是短信 SDK 难用,而是接入的时候把"调用"当成了"集成"。调用只要求你写出一行能跑通的代码,集成要求你考虑失败、重复、延迟、成本和可观测性。

1.1 为什么这条链路总在非工作时间暴露问题

短信是典型的强外部依赖,它跨越了你和运营商之间的整条链路:你的应用 → SDK 的 HTTP 客户端 → 云服务商网关 → 运营商通道 → 用户手机。任何一段抖动都会以"发送失败"或"延迟到达"的形式反映到你的业务层。而这条链路的稳定性特点是"平时很稳,波动很突然",白天工作时段运营商通道资源相对宽松,夜间批量任务和营销短信集中下发,通道压力大,系统级错误出现的概率明显上升。

更麻烦的是,很多团队把短信发送放在同步链路里。用户点击"获取验证码",请求线程一直阻塞等待短信接口返回,读超时设成了默认值。一旦上游网关慢下来,你的 Tomcat 线程池会以肉眼可见的速度被占满,然后整个登录接口跟着挂掉——短信发不出去是小事,把整个应用的线程资源拖死才是大事。我在一个项目里就吃过这个亏,压测的时候没模拟短信接口超时,上线后遇到一次网关抖动,二十几个线程卡在 read 上,接口平均响应从 80ms 涨到 3 秒。

所以这篇文章的第一个观点很明确:接短信 SDK 的第一步不是写发送代码,而是想清楚它在你的系统拓扑里处于什么位置。它应该是可失败、可降级、可异步、可观测的边缘依赖,而不是一个同步阻塞的关键路径节点。后面的所有章节,无论是超时配置、异常映射还是限流降级,都是围绕这个定位展开的。

1.2 三个反直觉的特性决定了代码的写法

短信发送有三点和普通内部 RPC 调用完全不同的特性,理解了这三点,很多设计选择就变得顺理成章。

第一,它是有成本的。每发一条都计费,长短信按条拆分计费。这意味着你不能像调内部服务那样无脑重试,一次网络抖动触发的盲目重试可能产生双倍费用,更糟糕的是用户会收到两条一模一样的验证码,体验和风控都会出问题。

第二,它是不可撤回的。数据库写错了可以改,消息发错了可以补偿,短信发出去就收不回来了。所以发送之前的校验必须做足:手机号格式、模板参数合法性、频率限制、内容合规。所有这些校验都应该在调用 SDK 之前完成,把无效请求挡在外面,既能省钱又能减少无效调用。

第三,它的返回结果不等于送达结果。发送接口返回成功,只代表云服务商网关受理了这个请求,不代表用户手机收到了。真正的送达状态要靠回执(上行/下行报告)异步推回来。如果你的业务逻辑强依赖"用户一定收到",那就得把回执链路也接上,这是很多项目遗漏的部分。

2. 动手之前先把四个模型概念对齐:凭证、签名、模板、回执

很多人接 SDK 卡住,不是因为 Java 代码写不出来,而是因为没搞明白短信服务的四个核心概念之间的关系。这四个东西在控制台里分散在不同菜单,在 SDK 里又分别对应不同的字段,第一次接触很容易混。

概念控制台里的位置SDK 字段是否必填常见误解
访问凭证账号安全/密钥管理AccessKeyId / AccessKeySecret必填用主账号密钥图省事
签名短信签名管理SignName必填以为可以随便填
模板短信模板管理TemplateCode + TemplateParam必填把整段内容当参数传
回执消息回执/事件订阅回调地址可选以为返回 OK 就是送达

2.1 凭证管理:别用主账号密钥,这是最容易埋雷的地方

SDK 初始化需要一对密钥,通常叫 AccessKeyId 和 AccessKeySecret。我在不止一个项目里看到过有人直接拿主账号密钥写进配置文件,然后提交到了代码仓库。这个习惯的风险等级很高:主账号密钥往往拥有全量权限,一旦泄露,损失的不只是短信费用。

正确的做法是走子账号(RAM 用户)路线,创建一个专用于短信服务的子账号,只授予短信发送相关的权限策略,然后把这对密钥注入到配置中心或环境变量里,绝不进代码仓库。密钥的存放位置我一般按环境区分:开发环境用配置中心的加密配置项,测试和生产用 KMS 或配置中心的加密能力,容器化部署的话直接用 Secret 挂载成环境变量。

有一个细节容易被忽略:密钥轮换。密钥不是设一次就永远不管的东西,尤其是人员流动之后。我的做法是在配置里同时支持主密钥和备用密钥两个字段,SDK 客户端初始化时优先读主密钥,发送返回鉴权类错误时自动切到备用密钥并打告警。这样轮换的时候可以先把新密钥写进备用位,观察一段时间无异常后再把主密钥替换掉,实现无停机切换。

2.2 签名与模板:上线前的两道审批,别等到发布前才想起

签名是你发给用户的那条短信开头的【某某系统】,模板是预先审核过的内容格式。这两个东西都需要提前提交审核,审核需要时间,而且模板内容有明确的规范限制。我遇到过最尴尬的情况是:功能开发完了,联调也过了,发布前一天才发现模板还没审核通过,硬生生拖了两天。

模板的变量用占位符表示,发送时通过 TemplateParam 传一个 JSON 字符串。这里有两个高频错误。第一个是把整个短信内容塞进参数里,比如传{"content":"您的验证码是123456"},这会被直接拒绝,因为模板是固定的,变量只能填内容片段。第二个是 JSON 转义问题,参数值里包含引号、反斜杠、中文特殊符号的时候,手工拼 JSON 字符串极容易出错,一定要用 JSON 序列化库生成,不要用字符串拼接。

提示:验证码类模板的变量只放纯数字,不要放任何其他字符。营销或通知类模板的变量内容也要避免出现可能触发内容审核的词,否则会出现"模板审核通过但发送时被拦截"的情况。

另外要提醒的是签名的数量限制和命名规范。多数平台对单个账号下的签名数量有上限,签名的名称也有格式要求,通常要求能体现业务归属。我的建议是按业务线划分签名,而不是按项目划分,否则签着签着就超限了,而且用户手机上看到的签名五花八门也不利于品牌统一。

2.3 回执:发送成功和送达成功是两回事

发送接口返回的成功,语义是"请求已被受理"。用户手机有没有收到,取决于后续的通道投递。回执机制就是把这个投递结果异步推回给你,通常是通过配置一个回调地址,云服务商在状态变化时 POST 过来。

回执要不要接?取决于业务对送达结果的敏感度。验证码场景一般不接,用户没收到会自己点重发。但如果是重要的通知类短信(比如订单状态变更、审批结果),业务可能需要对未送达的号码做二次触达,这时候回执就必须接。

接回执有两个工程上的注意点。第一,回调接口必须做幂等,同一条消息可能被推送多次。第二,回调接口必须快速返回,不要在回调里做耗时业务逻辑,正确做法是把回调数据落库或丢进消息队列,异步处理。第三,回调接口要能防伪造,多数平台提供了签名校验机制,这个校验一定要开,否则任何人都能往你的回调地址灌假数据。

3. 第一行代码:依赖引入与客户端初始化

概念对齐了,开始写代码。这一节讲的是从零跑通第一条短信的完整路径,包括依赖、配置、初始化和调用,以及那些文档里不太强调但实际很关键的参数。

3.1 依赖引入与版本选择的取舍

Java SDK 通常以 Maven 依赖的形式提供,坐标以厂商官方文档为准。引入的时候有一个常见纠结:用最新版还是用稳定版。我的经验是跟随官方推荐版本,不要自己升到最新。短信 SDK 的版本迭代经常涉及底层 HTTP 客户端的替换(比如从某个 HTTP 库换到另一个),大版本升级可能带来传递依赖冲突,尤其是在你已经引入了多个网络库的项目里。

<!-- 坐标以官方文档为准,这里仅示意结构 --> <dependency> <groupId>com.example.sms</groupId> <artifactId>sms-sdk-java</artifactId> <version>x.y.z</version> <exclusions> <!-- 如果项目已统一使用某 HTTP 客户端,可以排除 SDK 自带的以减小包体 --> <exclusion> <groupId>com.example.http</groupId> <artifactId>http-client</artifactId> </exclusion> </exclusions> </dependency>

排除传递依赖这件事要慎重,SDK 内部如果直接调用了被排除库的类,运行时就会抛 NoClassDefFoundError。稳妥的做法是先不排除,用mvn dependency:tree看看有没有版本冲突,有冲突再针对性处理。我在一个老项目里就因为盲目排除了一个 JSON 库,导致 SDK 内部序列化参数时报错,排查了半天才发现是依赖被剥掉了。

3.2 客户端初始化:超时和连接池是重点

客户端对象应该全局单例,不要每次发送都 new 一个。SDK 内部维护着 HTTP 连接池,频繁创建销毁客户端等于放弃了连接复用,在高并发下会显著增加延迟和文件句柄占用。正确的做法是用 Spring 的@Bean或者静态单例持有它。

@Configuration public class SmsClientConfig { @Value("${sms.access-key-id}") private String accessKeyId; @Value("${sms.access-key-secret}") private String accessKeySecret; @Value("${sms.endpoint}") private String endpoint; @Bean(destroyMethod = "shutdown") public SmsClient smsClient() { ClientConfig config = new ClientConfig() .setAccessKeyId(accessKeyId) .setAccessKeySecret(accessKeySecret) .setEndpoint(endpoint) // 连接超时:建连阶段,建议 2-3 秒 .setConnectTimeoutMillis(3000) // 读超时:等响应的阶段,建议 5 秒左右 .setReadTimeoutMillis(5000) // 连接池上限,按你的并发量估 .setMaxIdleConnections(50) .setMaxConnections(200) // 开启自动重试要谨慎,见下一节 .setAutoRetry(false); return new SmsClient(config); } }

这几个参数值得展开说。连接超时设短一点,因为建连失败通常是网络层面的问题,等再久也没用,快速失败对业务更友好。读超时设长一点,但也不能太长,5 秒是个比较平衡的值——太短会误杀正常但稍慢的请求,太长会把调用线程拖住。连接池上限要根据你的实际并发估算:假设峰值每秒 200 条,每条平均耗时 200ms,那么并发连接数大约是 200 × 0.2 = 40,留两倍余量设成 80 到 100 比较合适。

setAutoRetry(false)这一项是我踩过坑之后的固定选择。SDK 自带的自动重试通常是针对网络异常配置的,但短信是外部副作用操作,重试意味着可能重复发送。宁可自己在业务层做可控的重试,也不要让 SDK 在背后悄悄重试。后面第 5 章会讲重试的正确边界。

3.3 跑通第一条短信与返回结构的解读

public SendResult send(String phone, String templateCode, String paramJson, String bizId) { SendSmsRequest request = new SendSmsRequest() .setPhoneNumbers(phone) .setSignName(signName) .setTemplateCode(templateCode) .setTemplateParam(paramJson) // 业务流水号,用于对账和追踪,强烈建议传 .setOutId(bizId); SendSmsResponse response = smsClient.sendSms(request); return SendResult.builder() .requestId(response.getRequestId()) .bizId(response.getBizId()) .code(response.getCode()) .message(response.getMessage()) .build(); }

返回结构里有四个字段值得关注。Code是业务状态码,OK代表受理成功,其余都是失败,具体错误码见第 5 章。Message是描述信息,给人看的,不要用它做程序判断。RequestId是本次请求的唯一标识,出问题找云服务商排查时必须提供这个。BizId是这条短信在服务端的回执 ID,接了回执链路的话,回执推送里会带上这个 ID,用它来关联发送记录和送达结果。

提示:日志里一定要打 RequestId 和 BizId,但手机号必须脱敏。常见做法是保留前三后四,中间用星号替代,或者干脆只打手机号的哈希值。手机号属于敏感信息,明文落盘在合规上是有问题的。

4. 把裸调用封装成可维护的发送服务

直接调 SDK 的代码写起来最快,但三个月后维护起来最痛苦。我见过的所有短信相关的事故复盘里,几乎都有一条共性:发送逻辑散落在各个业务模块,每个地方对返回码的处理方式都不一样,有的判断了,有的没判断,有的判断了但把错误吞掉了。所以第二步是把发送能力收拢成一个服务层。

4.1 校验前置:把无效请求挡在调用之前

调用 SDK 之前应该做一轮完整的本地校验,包括手机号格式、模板参数、频率限制、黑名单。这些校验的意义不只是防止报错,更重要的是省钱——每一次无效调用都是白花花的费用。

手机号校验我一般分两层。第一层是格式层,用正则判断位数和号段,这个成本极低。第二层是归一化,去掉空格、横线、加号前缀、国家码前缀,统一成纯数字格式。我遇到过用户从通讯录复制号码带上了不可见字符,导致发送失败,日志里看起来号码一模一样,实际上是编码不同。归一化的时候顺手把这类字符过滤掉。

private static final Pattern CN_MOBILE = Pattern.compile("^1[3-9]\\d{9}$"); private String normalize(String raw) { if (raw == null) return null; String s = raw.replaceAll("[\\s\\-+()]", "") .replaceAll("\\u00A0", "") // 不换行空格 .replaceAll("\\uFEFF", ""); // BOM if (s.startsWith("86") && s.length() == 13) { s = s.substring(2); } return CN_MOBILE.matcher(s).matches() ? s : null; }

模板参数的校验同样重要。验证码类的参数必须是纯数字,长度和模板里定义的位数一致;通知类的参数要检查长度,因为参数过长会导致短信被拆成多条长短信,费用翻倍,用户体验也差。我一般会在服务层设一个参数总长度的硬上限,超过直接拒绝并打日志,这样可以及时发现调用方传了异常内容。

4.2 异常映射:把三种失败区分开

SDK 调用可能产生三类失败,这三类的处理方式完全不同,混在一起处理是很多 bug 的根源。

第一类是业务校验失败,比如手机号非法、模板不存在、签名不匹配、余额不足。这类失败重试也没用,必须把明确的错误原因反馈给调用方,让它去修正参数或提示用户。

第二类是触发流控,也就是短时间内发送频率超限。这类失败是有可能自愈的,等一会儿再发可能就成功了,但绝不能立刻重试,立刻重试只会让情况更糟。

第三类是系统级失败,包括网络超时、服务端 5xx、网关异常。这类失败是唯一值得重试的类别,但必须有次数上限和退避策略。

public enum SmsErrorType { BIZ_ERROR, // 参数/配置问题,不重试 RATE_LIMITED, // 流控,延迟后再试 SYSTEM_ERROR, // 网络/服务端问题,可有限重试 UNKNOWN } public SmsErrorType classify(String code) { if (code == null) return SmsErrorType.UNKNOWN; if ("OK".equalsIgnoreCase(code)) return SmsErrorType.UNKNOWN; if (code.startsWith("isv.")) { // 业务侧错误,多数不可重试 return code.toLowerCase().contains("limit") ? SmsErrorType.RATE_LIMITED : SmsErrorType.BIZ_ERROR; } if (code.startsWith("isp.")) { return SmsErrorType.SYSTEM_ERROR; } return SmsErrorType.UNKNOWN; }

把错误分类逻辑独立出来,好处是后续所有调用方都复用同一套判断,不会出现有的地方重试有的地方不重试。这个分类方法建议配单元测试,把每个错误码对应的分类固定下来,将来错误码有新增时至少能保证已有逻辑不被改坏。

4.3 发送结果对象:给上层一个稳定的契约

不要让业务层直接接触 SDK 的 Response 对象。这有两层原因:一是 SDK 对象字段多、含义杂,业务层用不上;二是将来换服务商的时候,如果业务层到处引用了 SDK 类型,迁移成本会非常高。

我通常定义这样一个结果对象:

public class SendResult { private boolean success; // 是否成功受理 private String requestId; // 请求追踪 ID private String bizId; // 回执关联 ID private SmsErrorType errorType; // 错误分类 private String errorCode; // 原始错误码 private String errorMessage; // 原始错误描述 private long costMillis; // 本次调用耗时 }

这个对象里我特意加了耗时字段。短信接口的响应时间分布很有参考价值,正常情况下 P99 应该在几百毫秒内,如果某段时间整体拉长了,往往意味着上游网关在承压,是个早期预警信号。把耗时打进日志,配合日志平台做聚合,比事后追查要省事得多。

5. 错误码逐个拆:从返回字符串到可执行动作

错误码是短信集成里信息密度最高的部分。文档里通常给了一张大表,但实际做的时候真正重要的不是背下来,而是知道每个错误码背后应该采取什么动作。这一节按我实际遇到的频率排个序。

5.1 高频错误码与处置动作对照

错误码(示意)含义触发原因处置动作
OK受理成功正常记录 bizId,等待回执
isv.MOBILE_NUMBER_ILLEGAL号码非法格式错误、空号段不重试,提示用户检查
isv.SMS_TEMPLATE_ILLEGAL模板不合法模板 ID 错、未审核、已下线不重试,告警,检查配置
isv.SMS_SIGNATURE_ILLEGAL签名不合法签名未审核或拼写不一致不重试,告警
isv.BUSINESS_LIMIT_CONTROL触发流控单号码或全局频率超限延迟重试,检查限流配置
isv.AMOUNT_NOT_ENOUGH余额不足账户欠费不重试,立即告警到人
isp.SYSTEM_ERROR服务端异常上游系统抖动有限次重试

这张表里最值得说的是BUSINESS_LIMIT_CONTROL和AMOUNT_NOT_ENOUGH。流控错误的坑在于它有可能是单号码级别的,也可能是全局级别的,返回的错误码是同一个,但处理方式完全不同。判断方法很简单:如果同一时刻所有号码都失败,那就是全局流控,需要检查自己的账号配额和发送速率;如果只有某一个号码反复失败,那就是针对这个号码的频控。我的做法是在限流逻辑里对号码维度单独计数,发送失败时先查本地计数,能快速定位是哪一类。

余额不足这个错误是最容易造成"静默失败"的。因为它是业务错误,很多代码逻辑会把它归到"不重试"的类别里,然后打一行日志就结束了。结果就是账户欠费三天,所有验证码都发不出去,直到有用户投诉才被发现。我的建议是:余额不足、签名非法、模板非法这三个错误码必须触发即时告警,通过钉钉、企业微信或者邮件推到值班人,而不是只写日志。

5.2 排查一条发送失败的正确顺序

线上反馈"收不到验证码"的时候,很多人的第一反应是去翻应用日志,但更高效的做法是从外往里查。

第一步,看发送接口的返回码。这是最快的一步,直接决定问题在上游还是在下游。如果返回码不是 OK,按上表处置,基本十分钟内能定位。如果返回 OK,说明请求已经被受理,问题在投递环节,继续下一步。

第二步,用 RequestId 或 BizId 去服务商控制台查发送记录。控制台能看到这条短信的完整状态流转:已提交、已发送、已送达、发送失败。如果控制台显示"发送成功"但用户说没收到,那问题在终端侧,可能是手机号输错了、手机装了拦截软件、或者用户手机欠费停机。

第三步,如果控制台显示"发送失败"且原因是空号或停机,这就是业务数据问题,需要在注册或下单环节加强对号码有效性的校验。

这个顺序看起来简单,但实际排查的时候经常被跳过。我见过有同事直接从"检查代码逻辑"开始查,查了两个小时,最后发现是模板被下线了,返回码早就写清楚了。

5.3 重试的边界到底在哪

重试的设计有三条原则,每一条都是踩坑换来的。

第一,只对系统级错误重试。业务错误重试是纯粹的浪费,还会放大日志噪音。流控错误可以重试,但必须延迟,而且要区分是单号码频控还是全局频控——单号码频控重试没有意义,用户等一会儿自己会点重发。

第二,重试次数必须有硬上限。我一般设 2 次,最多 3 次。每次重试间隔用指数退避,比如 200ms、600ms、1.8s。总耗时控制在读超时的两倍以内,避免线程被长时间占用。

第三,重试必须有幂等保护。这一条最容易被忽略。如果第一次请求实际上已经成功,只是响应在网络中丢失了,你的重试会导致用户收到两条短信。解决办法是给每次业务发送分配一个唯一的 OutId,并且在下游做去重——但短信服务端一般不会基于 OutId 去重,所以更实际的做法是在应用层做:重试之前先查一下本地发送记录,如果这条业务流水已经标记为成功,就不再重试。

public SendResult sendWithRetry(SmsCommand cmd) { SendResult last = null; long backoff = 200L; for (int i = 0; i <= MAX_RETRY; i++) { last = doSend(cmd); if (last.isSuccess()) return last; if (last.getErrorType() != SmsErrorType.SYSTEM_ERROR) return last; if (i == MAX_RETRY) break; sleepQuietly(backoff); backoff *= 3; } return last; }

6. 生产加固:幂等、限流、异步、降级

跑通了、封装了、错误码也处理了,代码看起来没问题了。但在真实流量下,还差四道加固。这一节讲的是从"能用"到"敢在大促期间用"的距离。

6.1 幂等设计:从业务流水号到发送记录

幂等的核心是给每一次"用户意图"分配一个唯一的业务标识,然后保证同一个标识只会真正触发一次发送。这个标识通常由业务侧生成,比如"登录验证码 + 用户 ID + 时间窗口"的组合哈希,或者直接用一个全局唯一的请求 ID。

落地方式是在发送前先查一次记录表,发送成功后写入记录。这张表要能支撑高频写入,字段包括业务标识、手机号哈希、模板码、状态、RequestId、BizId、创建时间。状态分为发送中、成功、失败,发送中的记录要设过期时间,避免因为进程崩溃导致记录一直卡在中间状态。

这里有个细节值得注意:状态表不能用手机号做唯一键,要用业务标识做唯一键。因为同一个手机号在不同业务场景下发送是允许的,比如一分钟内既能收到登录验证码又能收到支付验证码,用手机号做唯一键会误杀。

6.2 限流:三层限流缺一不可

短信的限流要做三层,少一层都可能出问题。

第一层是账号级限流,防止整体用量超过配额导致被平台限速。这一层通常用令牌桶实现,速率按你的套餐额度和业务峰值设置。

第二层是场景级限流,登录验证码、注册验证码、通知类短信分别设置不同的速率上限。这样即使某一类场景被刷,也不会影响其他场景。这层是防止"单点故障扩散"的关键。

第三层是用户级限流,同一个手机号在时间窗口内的发送次数限制,这层既是成本控制也是风控。常见的规则是:同号码 1 分钟内最多 1 条,1 小时内最多 5 条,24 小时内最多 10 条。

// 用户级限流示意,用本地缓存或 Redis 计数器都行 public boolean allowSend(String phone) { String keyMinute = "sms:min:" + md5(phone); String keyHour = "sms:hour:" + md5(phone); String keyDay = "sms:day:" + md5(phone); if (counter.get(keyMinute) >= 1) return false; if (counter.get(keyHour) >= 5) return false; if (counter.get(keyDay) >= 10) return false; counter.incrementAll(keyMinute, keyHour, keyDay); return true; }

用 Redis 做计数器的时候要注意过期时间的设置,以及原子性。用INCR加EXPIRE两步操作会有竞态,推荐用 Lua 脚本或者带过期参数的原子命令。

6.3 异步化:把发送从用户请求线程里挪出去

这个改造对用户体验的提升是最直接的。原来的流程是用户点"发送验证码",请求线程同步调用短信接口,用户要等接口返回才能看到"已发送"提示。改造后变成:请求线程只做校验、限流、生成任务、投递到线程池或消息队列,立刻返回;后台消费者实际调用 SDK 发送。

异步化之后,短信接口的抖动就不会传导到用户请求上了。但异步也带来了新的问题:发送失败怎么办?答案是把发送记录的状态维护好,失败时按策略重试或标记失败,用户点"重新发送"的时候能看到真实状态。

线程池的配置要单独隔离,不要和业务的公共线程池混用。核心线程数按发送峰值估算,队列要有界,拒绝策略用 CallerRuns 或者直接丢弃并打日志——绝不能用无界队列,那等于把内存当缓冲,出问题是迟早的。

6.4 降级与多通道容灾

单一服务商总有不可用的时候,尤其是大促期间。降级方案有两个层次。

轻量级的做法是配置降级开关,当短信发送失败率超过阈值(比如连续 1 分钟失败率超过 30%),自动切换到降级模式:验证码场景改为纯语音播报或者干脆在页面上给出明确提示"短信通道繁忙,请稍后再试",避免用户在那里干等。

重量级的做法是双服务商容灾,同时接入两家的 SDK,主通道失败时自动切到备用通道。这个方案的工程成本不低,因为两家的模板、签名都要各自申请,返回码体系也不同,需要做一层统一的抽象。但对短信强依赖的业务来说,这是必要的投入。实现上可以定义一个SmsProvider接口,两个实现类分别包装两家的 SDK,用一个路由策略决定走哪个通道。

7. 验证码之外:批量通知、可观测性与成本

接完主链路,剩下的是一些容易被忽略但影响很大的工程细节。这一节讲三块:批量发送的正确姿势、监控指标怎么设计、成本怎么抠。

7.1 批量发送:不要用循环单发

通知类短信经常是批量场景,比如给一批用户发活动通知。最常见的错误写法是 for 循环调用单发接口,一千个用户就是一千次 HTTP 请求,耗时不说,还容易触发账号级流控。

SDK 一般提供了批量发送接口,支持一次传入多个号码(用逗号分隔),但要注意三个限制。第一,单次批量有号码数量上限,通常是几百到一千个,超过要自己分批。第二,批量发送的模板参数是共享的,如果每个用户的内容不同,就不能用批量接口,只能在应用层并发单发。第三,批量接口的返回是全成功或全失败,不提供逐个号码的结果,需要单独查询。

所以实际做法是:内容相同的场景用批量接口,分批调用,每批之间加一点间隔避免瞬时压力;内容不同的场景用单发,但要控制并发度,用一个固定大小的线程池或者信号量限流,不要把下游打爆。

7.2 监控指标:四个必须埋的点

短信链路的监控我一般埋四组指标,缺一不可。

第一组是发送量与成功率,按场景、按模板、按小时聚合。成功率突然下跌是最直接的问题信号。

第二组是错误码分布,尤其要关注几个高危错误码的出现次数:余额不足、签名非法、模板非法。这三个指标应该配置成零容忍告警,出现一次就通知到人。

第三组是调用耗时分布,重点看 P95 和 P99。耗时的突然拉长往往预示着上游网关承压,是故障的早期信号。

第四组是被限流拦截的请求数,包括平台侧返回的流控错误和本地限流拦截的数量。本地拦截数量突增通常意味着有人在刷验证码,需要联动风控。

这几个指标用 Micrometer 埋点,接到 Prometheus 上看板就行,成本很低但收益很大。我的经验是,有了这四组指标,绝大多数短信问题都能在用户投诉之前被发现。

7.3 成本控制:长短信和无效号码是最大的两个坑

短信成本有两块很容易失控。第一块是长短信。一条普通短信的计费长度是 70 字(含签名),超过 70 字会按长短信拆分,每 67 字计一条费。如果你的模板参数里塞了 URL 或者长段文字,很容易把一条变成三条,成本直接翻三倍。控制方法是在模板设计阶段就把内容控制在 70 字以内,参数只放必要信息,URL 用短链。

第二块是无效号码。空号、停机、错号发出去照样计费,所以号码有效性校验很关键。除了本地格式校验,还可以接号码检测能力,把明显无效的号码在入库阶段就过滤掉。另外,对发送返回"号码非法"的记录做定期统计,如果某个业务线的非法率异常高,说明上游数据源有问题,需要从源头治理。

第三块是重发。用户频繁点"重新发送"会显著增加成本,前端应该做按键倒计时,后端要做频控兜底。这两道防线都必须在,只靠前端是不够的,接口可以被直接调用。

提示:成本监控建议做成日报,把每日发送量、失败量、拆分率、活跃号码数做成趋势图。成本异常往往不是一天涨上去的,而是慢慢爬坡,日报能让你在一周内发现异常,而不是一个月后发现账单翻倍。

8. 我在这条链路上交过的两次学费

第一次是把发送放在了事务里。当时的实现是注册接口里先插用户表,再发验证码,两个操作在一个@Transactional方法里。短信发送失败抛异常,事务回滚,用户注册失败,这看起来合理,实际上非常糟糕:短信已经发出去了,但数据库回滚了,用户收到验证码却无法完成注册,重新注册又会收到第二条。这个设计错误的本质是把外部副作用和本地事务混在一起。正确做法是把发短信挪到事务提交之后,用TransactionSynchronizationManager的 afterCommit 回调或者事务事件监听。

第二次是被短信回执坑了。当时以为发送返回 OK 就万事大吉,没有接回执,结果在一次通道故障中,大量短信显示"发送成功"但用户普遍反映没收到。事后追溯才发现,那段时间该通道的下行投递大面积失败,控制台里有一堆"已提交未送达"的记录,但我们没有任何告警。接了回执之后,这种情况就能通过"提交成功但回执失败率上升"及时发现。

如果现在让我重新接一遍,我会按这样的顺序做:先理清场景和模板,把签名和模板提前审核;然后搭好服务层,把校验、错误分类、结果对象、幂等和限流一次性做进去;接着配置监控和告警,确保余额、签名、模板三类问题能第一时间知道;最后才是异步化和多通道容灾这些优化项。顺序很重要,因为前两步是地基,后面所有优化都建立在它们之上。至于代码本身有多简单,其实不重要——难的一直都不是那几行调用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进