
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 groupIdcom.example.sms/groupId artifactIdsms-sdk-java/artifactId versionx.y.z/version exclusions !-- 如果项目已统一使用某 HTTP 客户端可以排除 SDK 自带的以减小包体 -- exclusion groupIdcom.example.http/groupId artifactIdhttp-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 就万事大吉没有接回执结果在一次通道故障中大量短信显示发送成功但用户普遍反映没收到。事后追溯才发现那段时间该通道的下行投递大面积失败控制台里有一堆已提交未送达的记录但我们没有任何告警。接了回执之后这种情况就能通过提交成功但回执失败率上升及时发现。如果现在让我重新接一遍我会按这样的顺序做先理清场景和模板把签名和模板提前审核然后搭好服务层把校验、错误分类、结果对象、幂等和限流一次性做进去接着配置监控和告警确保余额、签名、模板三类问题能第一时间知道最后才是异步化和多通道容灾这些优化项。顺序很重要因为前两步是地基后面所有优化都建立在它们之上。至于代码本身有多简单其实不重要——难的一直都不是那几行调用。