资讯详情

资讯详情

事件驱动架构下短信接口触发机制的设计与实现

做短信接口这活儿搞过的人都知道最烦的不是接口本身而是“什么时候发”这件事没想清楚。业务方今天说下单要发通知明天说注册要发验证码后天又冒出来一个“密码找回也得发”。如果你一个个去改业务代码在每一个需要发短信的地方同步调一次接口后期维护起来绝对是一地鸡毛。所以“触发短信接口”这个概念核心价值就是四个字解耦。让发短信这件事从业务动作里彻底剥离开变成被事件驱动的独立环节。这篇文章我会从事件触发短信的架构思路、接口设计、实操落地方案以及常见的坑这几个层面展开。无论你是Java后端、Node.js开发者还是像有些在LabVIEW这类工控环境里折腾上位机的人只要你需要在某个条件满足后快速把短信发出去这套思路都能直接用而且能让你少踩不少我踩过的坑。1. 事件触发短信为什么这件事没有想象中那么简单短信接口本身并不复杂把手机号、模板ID、参数拼好调一下HTTP接口几十行代码就能跑通。真正麻烦的是“触发”这个词。“什么时候触发”比“怎么发出去”难十倍。1.1 直接从业务代码里调短信接口问题出在哪最直觉的做法就是在业务逻辑里嵌发送逻辑。比如用户注册成功后直接调smsClient.send()。早期系统这么干没问题但业务量一大几个隐患就会冒出来业务链路被拖慢。短信接口是外部依赖响应时间不可控。遇到短信服务商抖动一次调用可能阻塞2~5秒你的注册接口也跟着变慢甚至超时。用户等不起前端直接报错。重复发送失控。用户连续点了两次注册按钮或者你的重试机制起了作用业务方法被执行了两次短信也跟着发了两条。这种问题用同步逻辑很难彻底规避。扩展性差。今天发短信明天可能要同时发APP Push、邮件、站内信。如果每个业务方法里都在手动调一堆通知渠道代码会越来越乱。1.2 事件机制到底解决什么问题事件触发的核心思想业务方法只负责产生一个“事件”比如“用户注册成功”然后立刻返回至于这个事件之后要触发什么动作发送方完全不关心。由事件总线或消息队列去通知所有对此事件感兴趣的监听器短信模块只是其中一个监听器。用生活化的比喻解释你点了一个外卖你只需要在APP上按下“下单”这个按钮然后就可以继续干别的事。至于订单怎么推送给商家、骑手怎么接单、配送进度怎么同步都是系统后台通过事件流去处理的你不需要站在餐厅门口等着商家现做。事件驱动带来几个肉眼可见的好处主链路秒级响应。业务操作完成后直接发布事件返回不等待短信发送结果。天然削峰填谷。短信服务商通常有QPS限制大量事件涌入时队列会自动排队避免一瞬间把短信通道打爆。逻辑隔离故障隔离。短信服务商挂了不影响用户注册主流程。用户该注册成功还是注册成功只是收不到短信而已后面的补偿机制再补发就行。这里我用的方案是基于Spring Boot环境推荐的如果你的技术栈是Python Flask/Django、Go思路完全一样只是事件总线的具体选型不同。提示事件触发并不意味着你一定要引入MQ消息队列。小项目或者单机应用用Spring的事件监听器就够了。先看场景复杂度再决定要不要上重型中间件这是很多新手容易犯的过度设计错误。2. 核心拆解一个完整的事件触发短信系统需要哪些零件一个能跑在生产环境的事件触发短信模块绝不止“接口文档上那几个参数”那么简单。仔细拆解的话至少包含五个零件事件源、事件总线、监听器、短信发送器、补偿与监控。2.1 事件源谁产生事件事件源就是业务代码本身。用户注册成功、订单支付成功、退款到账、白名单添加成功……这些都是事件源。在实际设计时我强烈建议你为每个事件定义一个独立的领域事件类不要搞一个万能事件类包罗万象。比如UserRegisteredEvent用户注册成功OrderPaidEvent订单支付成功VerificationCodeEvent需要发送验证码每个事件类携带自己的业务必要参数。比如订单支付事件至少要带orderId、userId、payAmount、phone至于短信模板要怎么渲染、要不要发营销短信由监听器根据事件类型自己决定。这里有个容易忽略的点事件里该传什么参数是有讲究的。原则是“事件只携带业务ID和必要上下文不要携带需要实时查询的完整数据快照”。比如订单支付事件里传orderId就够监听器需要订单详情时再去查。否则在分布式环境下事件经过消息队列延迟消费时你携带的快照数据可能已经过期了。2.2 事件总线事件怎么流转事件总线是连接事件源和监听器的“快递干线”。在小项目里Spring的ApplicationEventPublisher就能当总线用发布事件后由同步监听器处理。但它的局限在于同步。意味着监听器如果执行慢了发布者还是要等。举一个我自己踩过的例子。有一次我把短信发送做成同步监听器结果短信服务商接口超时监听器抛异常直接把用户注册事务给回滚了。用户注册失败短信没发出去两边都没落着好。要解决这个问题生产环境建议引入异步事件总线。异步方案市面上选择很多RabbitMQ Dead Letter Queue适合中小规模可靠性和灵活性平衡。RocketMQ阿里生态和阿里云短信配合方便支持事务消息和定时消息国内使用很广。Redis Pub/Sub轻量级但不持久化适合能容忍少量丢失的场景。Kafka高吞吐适合大规模日志型、流量型事件。对短信这种量级来说有点大炮打蚊子。我自己的推荐是如果你已经在用Spring Cloud体系那直接用Spring Cloud Stream封装RabbitMQ代码侵入小切换实现也方便。如果你整个云服务都建在阿里云上直接用它的事务消息能力配合短信服务链路最简单。2.3 监听器怎么知道要发短信监听器负责订阅特定事件收到事件后从事件对象里取出业务参数拼接短信模板调用发送器。监听器的设计原则是“单一事件单一监听器”或者说一个监听器专注一类事件的短信提醒。不要写一个巨大的监听器里面塞满if (event instanceof UserRegisteredEvent)这样的分支判断后期维护起来特别反人类。此外监听器要幂等。一个事件可能因为消息队列重投被消费两次如果监听器不做幂等校验用户就会收到两条一模一样的短信。幂等的常见做法用业务ID比如订单号加短信类型在Redis里加锁或去重标记设置几分钟的过期时间一旦发现已经发送过就跳过。2.4 短信发送器真正连服务商的那一段短信发送器是模块里最底层的那一环直接对接服务商SDK。它的职责是组装请求参数、调用服务商接口、解析结果、记录日志、在失败时抛出特定异常。这里有一个实践上的重要心得发送器一定要做通道抽象。不要把你的代码写死在某一家服务商上。因为短信服务商是高危依赖随时可能因为资质、价格、故障被替换。抽象一个SmsSendPort接口默认实现走阿里云将来要切换腾讯云只需新增一个实现类配置中心切换路由即可。发送器的另一个职责是动态模板匹配。业务方只负责说“我要发验证码”但具体用哪个短信模板、模板里有哪些变量由发送器自己根据业务类型去配置中心拉取。这样做的好处是运营要调整话术时不需要业务方发版修改配置中心里的模板就能生效。2.5 补偿与监控事件丢失了怎么办事件驱动最大的风险在于消息丢失。队列满了、消费者宕机、服务商回调失败都可能导致“用户付了钱却收不到短信”。所以生产级别的短信系统必须有补偿机制。规范的补偿方案分两层。第一层是消息队列自带的重试机制消费失败自动重投重投仍失败进入死信队列。第二层是定时任务兜底每天跑一遍“补发扫描”检查所有应该收到短信但没成功发送给用户的单子进行人工或自动补发。监控方面我习惯在短信发送器里埋点统计短信发送成功率、平均耗时、服务商错误码分布。另外强烈建议做一份“短信发送日终报告”按业务类型统计各短信的发送量、失败量。这样出了问题你第一反应能有数据支撑而不是猜。3. 具体怎么落地一个用户注册验证码的完整事件触发实现光讲理论不够我来带你走一遍完整实现。从我做过的项目里抽一个最典型的场景用户注册时验证码短信通过事件触发发送。这个场景麻雀虽小五脏俱全可以覆盖上面所说的所有知识点。3.1 环境与技术选型我们在确认方案时用的是如下这套JDK 11 Spring Boot 2.7Spring Cloud Stream RabbitMQ异步事件总线Redis幂等标记 验证码存储阿里云短信服务短信通道Nacos配置中心管理模板ID和签名为什么选RabbitMQ而不是缓存里的线程池因为RabbitMQ支持消息持久化消费者宕机重启后消息还能捞回来生产环境可靠性有保障。而线程池一旦进程重启队列里的任务全部丢失。3.2 定义事件对象事件对象的定义我通常会单独放到一个common模块里方便多个服务共用。public class VerificationCodeEvent { private String mobile; private String scene; // REGISTER, LOGIN, RESET_PWD private String code; private Long eventId; // 唯一事件ID用于幂等 // 构造函数、getter/setter省略 }这个事件只携带发送短信所需的最小信息集。code由验证码服务生成在发布事件之前已经存入Redis事件里带上code是为了防止监听器再去查一次Redis。如果你对安全性要求更高也可以只携带mobile和scene监听器收到后再生成或查询验证码。这里我个人的习惯是带code但给code设置一个很短的过期时间监听器消费事件时如果发现事件在队列里积压超过了业务可容忍的时间比如5分钟直接放弃发送因为验证码已经失效了。3.3 业务侧发布事件业务侧代码非常简洁注册逻辑只管创建用户创建成功后发布一个事件Service public class UserRegisterService { Autowired private ApplicationEventPublisher eventPublisher; public void register(RegisterRequest request) { // 1. 校验参数 // 2. 检查手机号是否已注册 // 3. 创建用户 userRepository.save(user); // 4. 发布事件注意这里发布的是异步事件 VerificationCodeEvent event new VerificationCodeEvent( request.getMobile(), REGISTER, code, UUID.randomUUID().toString() ); eventPublisher.publishEvent(event); } }配合Spring Cloud Stream后publishEvent会通过Binder将事件发送到RabbitMQ的指定exchange。整个注册方法执行到这里就返回了用户感知到的注册接口响应时间非常快。3.4 监听器消费并发送短信监听器侧核心逻辑Component public class SmsNotifyListener { Autowired private SmsSender smsSender; Autowired private StringRedisTemplate redisTemplate; StreamListener(SmsSink.INPUT) public void onEvent(VerificationCodeEvent event) { // 幂等处理同一个eventId只允许发送一次 Boolean firstSend redisTemplate.opsForValue() .setIfAbsent(sms:dedup: event.getEventId(), 1, Duration.ofMinutes(5)); if (Boolean.FALSE.equals(firstSend)) { log.warn(重复事件跳过发送: {}, event.getEventId()); return; } // 发送 try { smsSender.send(event.getMobile(), buildSmsRequest(event)); } catch (SmsException ex) { log.error(短信发送失败: mobile{}, scene{}, event.getMobile(), event.getScene(), ex); // 进入本地补偿表等待定时任务重发 saveToSupplementTable(event, ex.getErrorCode()); throw ex; // 抛出异常触发MQ重投 } } }这里有两个细节值得留意第一个是幂等标记的过期时间。验证码5分钟有效这个标记的过期时间5分钟够用。如果是订单支付成功通知这种没有时效性的短信幂等标记建议放长一点比如24小时防止银行回调重投造成重复安抚用户。第二个是“抛异常触发MQ重投”的行为。很多人不敢抛异常觉得重投会重复消费。其实只要有幂等标记兜底重投是安全的你只需要弄清楚服务商返回的错误码中哪些是永久性错误比如签名错误哪些是暂时性错误比如限流。永久性错误不要重投记录日志走人工处理暂时性错误才重投。3.5 短信发送器对接服务商发送器的实现我建议在类内部做一次“责任链式”处理。先把所有传入参数标准化然后统一走模板解析、签名校验、调用、结果解析。以阿里云短信为例核心代码大致如下Component SmsChannel(aliyun) public class AliyunSmsSender implements SmsSender { Value(${sms.aliyun.accessKeyId}) private String accessKeyId; Value(${sms.aliyun.accessKeySecret}) private String accessKeySecret; Value(${sms.aliyun.signName}) private String signName; Override public SmsSendResult send(String mobile, SmsRequest request) { DefaultProfile profile DefaultProfile.getProfile(cn-hangzhou, accessKeyId, accessKeySecret); IAcsClient client new DefaultAcsClient(profile); CommonRequest commonRequest new CommonRequest(); commonRequest.setSysMethod(MethodType.POST); commonRequest.setSysDomain(dysmsapi.aliyuncs.com); commonRequest.setSysVersion(2017-05-25); commonRequest.setSysAction(SendSms); // 手机号、签名、模板CODE、模板参数 commonRequest.putQueryParameter(PhoneNumbers, mobile); commonRequest.putQueryParameter(SignName, signName); commonRequest.putQueryParameter(TemplateCode, request.getTemplateCode()); commonRequest.putQueryParameter(TemplateParam, JSON.toJSONString(request.getTemplateParams())); CommonResponse response client.getCommonResponse(commonRequest); // 解析响应Code为OK代表成功 // 非OK时根据Code值决定是否可重试 } }注意这里模板参数里的值都需要转换成字符串。之前有同事直接传了一个Long类型的验证码被服务商侧拒绝报“模板变量类型不合法”排查了半天。这种小细节对接第三方接口时最容易中招。提示服务商的AccessKey属于高敏感信息一定要放到配置中心或者环境变量里不要写死在代码库。一旦泄露你可能会被拿去刷短信那损失不是一句“抱歉”能解决的。4. 事件触发场景里的几个关键参数事件触发短信不只是写好代码就完事还有几个参数和配置需要根据你的业务规模仔细设定。我把它们列出来你可以对照着自己的场景来评估。4.1 并发与流控的参数设定短信服务商一般会对单账户的每日发送量、单条发送频率、营销短信与通知短信的占比做限制。在事件总线这一层你需要对RabbitMQ的消费端做限流。Spring Cloud Stream里做限流很方便spring: cloud: stream: bindings: smsInput: destination: smsEventExchange group: sms-consumer-group consumer: max-attempts: 3 back-off-initial-interval: 1000 back-off-multiplier: 2.0 concurrency: 2concurrency建议不要设太高短信发送是IO密集型但服务商QPS有限你并发开10个消费者可能直接触发服务商限流被返回“isv.BUSINESS_LIMIT_CONTROL”。我一般在刚上线时设置并发2~4观察服务商返回的限流错误再慢慢调。又在发送器内部我自己还会做一个令牌桶限流器用Guava的RateLimiter每秒最多发出N个请求。比如服务商说账号支持每秒10次调用我就设置RateLimiter.create(8.0)预留20%的余量避免瞬时峰值把通道打满。4.2 超时与重试的参数设定短信发送是一个外部HTTP调用超时时间是必须设置的。默认的HTTP客户端超时可能长达30秒这会严重影响消费端的吞吐量。我的建议是连接超时3秒读超时5秒。如果5秒内服务商没有返回直接判定失败进入重试逻辑。注意重试次数不要太多MQ重投一般最多3次再多就容易造成消息堆积影响后续事件消费。不同错误码下的重试策略我也做了一个简单的分类错误码含义是否重试OK成功否isv.BUSINESS_LIMIT_CONTROL触发流控是延迟重试isv.SMS_SEND_OVERTIME发送超时是isv.MOBILE_NUMBER_ILLEGAL手机号非法否人工处理isv.SMS_TEMPLATE_ILLEGAL模板不合法否检查配置isv.SMS_SIGNATURE_ILLEGAL签名不合法否检查配置好多团队在对接时没有认真看错误码文档一遇到失败就无脑重试。结果签名错误这种配置问题重试10次都是同样的错还白白消耗服务商配额。正确姿势是事先把错误码分类配置型错误直接定位修复业务型错误直接丢弃或进人工队列。4.3 模板参数长度的微妙边界短信模板的变量是有长度限制的。阿里云单条短信正文内容最多500字但如果你为了“内容丰富”塞了一堆变量最终拼接后的短信被拆成多条计费还会触发“长短信”逻辑。长短信的到达速度有时会慢于普通短信。因此在模板设计阶段就严格控制变量个数和长度。我遇到过一个客服系统模板里塞了订单号、商品名称、快递单号、折后价、原价、优惠券名称一共6个变量结果拼接出来200多字每次都被拆分用户收到的还是乱序反映很差。5. 常见问题与排查技巧我踩过的那些坑这部分我原原本本把实践里遇到最多的问题写出来希望能帮你节省排查时间。5.1 事件发布了但短信没发出去这是出现频率最高的问题。排查路径我建议按下面顺序来先看事件是否真的发布成功。在消费端入口打日志确认有没有收到消息。如果消费端连日志都没有检查RabbitMQ的exchange与queue绑定是否正常、binding key是否匹配。如果消费端有日志但报错重点看服务商返回错误码以及幂等标记是否误拦截。如果啥日志都没有大概率是事件根本没有发到队列里检查发布端有没有加EnableBinding注解。有一次线上出了这个问题排查半天最后发现是服务器时间漂移导致定时任务没跑补偿流程全部失效。所以日志监控里顺带把服务器时间也报出来是个好习惯。5.2 验证码短信收不到但通知短信正常这种“部分场景失败”的问题十有八九是模板审核或签名的问题。有些短信模板在服务商侧用了不同签名比如验证码短信要求用“XX科技”签名通知短信用“XX订单”签名而你代码里误用了同一个签名。我的排查建议是在发送器里把mobile、templateCode、signName、templateParam全部打印成结构化日志。出问题时直接去日志平台搜索一眼就能看出来是哪个字段配错了。5.3 用户一分钟内收到好几条相同短信出现这个基本是幂等逻辑失效。常见场景是消费者宕机重启后RabbitMQ把未确认的消息重新投递而你的Redis里的幂等标记因为过期时间设得太短已经失效了。我的解决方式是把幂等标记分成两层。第一层是用Redis setnx做短时去重防瞬时重复。第二层是往MySQL的短信发送流水表里插一条唯一索引记录eventId mobile如果插入时唯一键冲突就说明已经发过直接跳过。这样做能兜住极端情况下的重复投递。5.4 服务商返回成功但用户就是收不到这是最棘手的一种。服务商API返回OK但短信石沉大海。可能原因包括用户手机号进了运营商黑名单、手机信号问题、手机本身拦截了陌生号码短信、历史投诉过多被运营商屏蔽。说实话这种事情你的代码再健壮也没用。你能做的只有两件事一是记录好发送流水包含请求ID、发送时间、服务商返回码用户投诉时方便和运营商申诉二是提供“短信重发”的运营入口让客服人员可以手动触发一次重发。5.5 LabVIEW等上位机环境里的“事件触发”补充说明最后说说热词里提到的LabVIEW场景。我看最近总有人搜“labview 字符输完按回车触发事件的设置视频”其实原理和Web后端的事件驱动是一致的。LabVIEW里的事件结构Event Structure可以监听字符串输入控件的“回车键”事件用户在输入框输完字符按下回车事件结构触发接着再调用短信接口发送数据给远端。如果你的业务是工控设备报警短信通知那大致的实现链路是设备数据采集 → LabVIEW判断报警条件 → 调用短信HTTP接口 → 发送报警短信给值班人员。这里注意LabVIEW调用的是HTTP接口你需要用POST方式把手机号和消息内容传过去。跨语言平台之间事件的基础载体通常还是消息队列或HTTP回调没有本质区别。在LabVIEW里处理字符串输入时有一个细节极易踩坑字符串控件的“按下回车键”事件和“值改变”事件触发时机不同。值改变事件是控件内容变化就触发回车事件是按下回车才触发。如果你要做“输完按回车才发短信”的效果监听事件源一定要选“回车键”而不是“值改变”否则用户还在编辑短信内容呢代码已经把半截话发出去了。6. 一点关于事件驱动架构的肺腑之言做了几年事件驱动的短信模块我的体会是触发短信接口这件事真正的复杂度根本不在短信服务商那几个API上而是在“事件体系如何设计得足够顺滑”。事件的定义是否清晰、事件之间是否会互相干扰、消息堆积后怎么快速消费、重复消息怎么拦截这些才是决定系统稳定性的关键。个人的建议是如果你所在的公司还没有统一的事件中心从短信这个场景切入做事件驱动是个非常合适的试点。它不像订单状态机那样牵扯大量业务一致性却能让你快速体会到事件驱动的收益。把短信模块做成事件驱动之后后续接邮件通知、站内信、Webhook推送都只是多挂一个监听器的事。再分享一个小技巧。如果你刚接手一个老项目里面全是同步调短信的代码一次性全部改造成事件驱动的风险很高。我的做法是“太阳底下走两步”先选一个低频业务比如后台管理员手动触发通知改造成事件驱动跑通一两个礼拜验证队列、幂等、补偿都OK了再逐步把高频的注册、订单场景迁过来。步子太大容易扯着消息队列。这套方案我用了这么久最放心的一点就是短信故障再也不会拖垮核心业务补偿机制会悄悄把漏网之鱼捞回来。你尽管睡大觉剩下的事交给事件总线去处理。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →