基于Redis Stream的异步短信队列设计与实践

📅 发布时间:2026/10/16 7:00:29
基于Redis Stream的异步短信队列设计与实践
短信这东西平时没人注意一到业务高峰期就给你脸色看。我接手过一个电商项目大促的时候用户下单要发通知、支付要发通知、退款要发通知同一个手机号可能同时排着好几条消息。最开始图省事直接在业务服务里同步调用短信接口结果上游渠道商一抖动整个下单流程就卡死了用户那边的体验就是钱付了短信却迟迟不来。后来我花了两周时间把这块重写成一个独立的短信发送队列模块核心就一句话把短信发送从同步业务链路里剥出来放进一个可控的队列系统里慢慢消化。模块上线后短信发送成功率从92%提到了99.2%通道切换基本零人工干预加两台机器就能扛住翻倍流量。这篇不是讲PPT架构的是我把一个短信队列模块从设计、落地到排障的完整过程参考的是消息中间件和分布式系统里比较成熟的做法。适合正在设计通知推送类系统或者正被短信发送链路搞到焦头烂额的后端开发。文章会覆盖核心设计思路、具体数据结构、实操代码、扩容方案以及我踩过的坑。1. 先想清楚再动手这个模块到底要解决什么问题很多团队做短信发送上来就急着写代码等到线上出问题了才回头想方案。我建议先花半天时间把问题域理清楚要理解你要做的不是一个发短信的工具类而是一个扛得住流量、管得住失败、扩得开规模的发送系统。1.1 短信发不出去的三种典型场景做架构设计之前先盘一下短信在真实业务里常见的故障现场。我遇到过三类问题基本覆盖了90%的线上事故。第一类是上游通道抖动。短信服务商也是一个接口是接口就会间歇性变慢、报错、甚至直接拒收。最早我们同步调用时通道商一个5秒超时用户的下单接口就被拖慢了5秒。这种抖动如果发生在晚上8点的业务高峰连锁反应就是用户投诉和订单流失。第二类是流量尖峰。比如电商大促、营销活动开奖、优惠券批量发放瞬间可能有几十万条短信涌进来。如果一次性直连通道商人家限流你发出去的短信大量失败还影响正常业务短信的发送。做过大促活动的同学应该深有体会凌晨0点那一下短信通道说挂就挂。第三类是通道不可用时的降级问题。很多公司会接入多个短信服务商做备份但如果这些通道的切换靠人去登录控制台操作夜里出事就是灾难。等你找到值班的人、登录后台、切换通道可能已经半小时过去了早该发出的短信全堵在内存里了。1.2 可扩展不是堆机器这么简单说到可扩展很多人第一反应是多部署几个消费者实例不就行了。这句话只说对了一半。短信发送系统的扩展性我认为至少有四层含义。第一层是吞吐扩展。队列中的消息消费速度要能跟得上生产速度比如日常每秒100条大促时每秒2000条加机器就能扛不能因为某个单点组件卡死在500条/秒。第二层是通道扩展。今天接了两家短信服务商下个月要再接第三家或者某一家的接口协议从HTTP改成私有TCP系统不能推倒重写。通道管理要做成可插拔的插件化结构。第三层是场景扩展。短信的类型远不止验证码和通知两种还有营销短信、语音通知、国际短信。不同场景对时效性、优先级、内容模板的要求完全不同一个写死的发送器根本没法支撑业务增长。第四层是容错扩展。队列积压、消费者宕机、数据库故障都是常态系统要有自愈能力。这一步的设计好坏直接决定你能睡多少个踏实觉。2. 架构设计关键决策为什么是队列异步而不是同步接口理解的起点是短信发送的本质是什么它是一个对外部系统短信服务商的远程调用而外部系统的稳定性我们完全不可控。所以系统的核心目标不是追求单次调用多快而是保证整个链路在外部系统波动时不崩、不丢、可恢复。2.1 同步发送的死穴如果你曾经把短信发送直接写在业务逻辑里我列几个场景你感受一下。第一个场景用户注册需要发验证码。如果短信通道超时3秒注册接口就慢了3秒而这个3秒是用户感知的。第二个场景用户支付成功需要发通知这时候短信通道挂了你是不是要把支付事务回滚显然不合理。第三个场景财务系统群发1000条短信业务线程要同步等1000次HTTP调用前端一直转圈后台连接数被打满。同步调用把业务成功和短信送达强绑定在了一起而短信送达这件事本质上是一个可延迟、可补偿的异步过程。用户下单成功了那条通知短信就算晚两分钟发影响也有限。把它从主链路拆出去订单流程就不容易被短信通道拖垮。2.2 队列中间件选型对比Redis Stream、RabbitMQ、Kafka做异步队列最核心的决策是中间件选型。我评估过三种主流方案直接给你一张对比表。特性Redis StreamRabbitMQKafka部署复杂度低复用Redis中需要独立集群高依赖ZooKeeper或KRaft消息确认机制支持XACK支持ACK支持offset提交重试与死信手动实现原生支持DLX需自己构建重试topic消费组机制原生支持原生支持原生支持吞吐能力较高单实例约几万QPS中等极高百万级运维成本低中高数据持久化AOF/RDB磁盘持久化磁盘持久化我最终选的方案是Redis Stream。原因很实际项目里本来就有Redis集群不需要再引入一套新的中间件运维成本最低。短信业务的峰值吞吐在每秒几千条Redis Stream完全扛得住。而且Stream的消费组模型天然支持多消费者分摊XACK确认机制能保证消息不丢失。提示如果你的公司已经有成熟的RabbitMQ或Kafka集群用现成的中间件也完全可以。选型没有绝对的对错关键看你的团队运维能力和业务规模。Kafka适合超大规模的消息流转但对多数短信场景来说有点重。2.3 模块边界划分确定技术选型后我先把系统划分成了五个互不依赖的子模块。Producer消息生产者接收业务服务发来的发送请求做基础校验、组装消息体投递到队列。它不关心短信怎么发出去、哪个通道发。Queue队列存储用Redis Stream的独立Stream存储待发送消息支持多个队列分区按业务场景或优先级拆多个Stream。Consumer消费执行器从Stream消费消息执行真正的短信发送。它从队列中拉取消息后先进行通道路由、限流再调用通道商接口。Channel Manager通道管理器管理所有已接入的短信服务商负责通道的健康检查、权重分配、熔断降级。Monitor Alert监控告警汇总发送成功量、失败量、队列堆积量、通道成功率等指标异常时触发告警。我用一个不太恰当的比喻这五个模块的关系就像外卖平台——用户下单Producer只管把订单放到台面上Queue骑手Consumer按规则取单配送平台调度中心Channel Manager决定这一单分给哪个街道的骑手后台监控Monitor看哪些骑手超时了、哪些片区爆单了。每一层解耦任一层出问题都不至于让整个系统瘫痪。3. 核心数据结构与接口契约设计很多新手设计这类模块上来就写代码消息体里就一个手机号和一句文案。等到要扩展场景、扩展通道的时候就发现什么信息都没有只能一直改表结构、改接口。消息结构设计是系统扩展性的地基值得多花时间。3.1 消息体设计每个字段都是为扩展铺路我设计的消息体大致如下。字段类型说明messageIdString全局唯一消息ID用于幂等和追踪bizTypeString业务类型如 REGISTER_CODE、ORDER_NOTIFY、MARKETINGtemplateIdString模板ID对应内容管理系统中配置的模板paramsMap模板渲染参数如姓名、订单号、金额recipientsList接收手机号列表支持批量发送channelString指定通道名为空则由路由策略自动选择priorityInteger优先级数字越小越先发送scheduleTimeLong定时发送时间戳0表示立即发送retryCountInteger已重试次数由消费端维护maxRetryInteger最大重试次数默认5traceIdString全链路追踪ID方便日志排查expireTimeLong消息过期时间过了这个时间就不再发送其中我特别想强调两个容易被忽略的字段。第一个是bizType。别看它只是一个简单的枚举值它对路由和限流都有影响。验证码类短信有强时效性必须走最快的通道、享受最高优先级营销类短信允许延迟甚至可以安排在流量低谷时段批量发送。系统里可以针对不同bizType配置不同的通道策略和QPS上限。第二个是expireTime。一部分短信场景本身就是有实效的比如登录验证码有效期可能只有10分钟。如果消息在队列里积压了半小时再发出去对用户来说毫无意义甚至是一条垃圾消息。所以消费端每次取出消息先判断expireTime过期就直接丢弃并记录原因不浪费通道资源。3.2 通道管理器的抽象设计通道设计的关键是把变化隔离在一个接口后面。我定义的通道接口如下。public interface SmsChannel { String channelName(); SmsResult send(SmsMessage message, String content); boolean isHealthy(); }任何一个短信服务商无论它的协议是HTTP还是TCP无论参数格式是什么都实现这个接口即可。例如某云服务商A的接入类内部负责把业务消息转换成A服务商要求的签名格式、请求参数然后发起HTTP调用。通道的动态配置我放在了配置中心里结构类似下面这样。sms: channels: - name: channel_a enabled: true weight: 50 maxQps: 120 timeoutMs: 3000 strategy: weight_random - name: channel_b enabled: true weight: 30 maxQps: 60 timeoutMs: 5000 strategy: weight_random - name: channel_c enabled: false weight: 20 maxQps: 80 timeoutMs: 3000 strategy: weight_random这套配置是热更新的运营的同学在控制台调整权重不需要重启服务。比如通道A最近成功率跌到60%以下可以先把它enabled设为false或者把weight调低系统自动将流量切换到其它通道。3.3 优先级与定时发送的实现思路需求里经常出现这个短信要尽快发那条短信明天早上9点发。亢奋的时候可能想直接用延迟队列但短信场景其实有更简单的解法。优先级我用了Redis Stream的一个特性拆Stream。把队列拆成priority_high和priority_normal两个Stream消费端优先消费高优先级队列高优先级积压低于阈值时才处理普通队列。虽然高优先级消息不能抢跑已经在半路PEL中的普通消息但短信场景的秒级延迟要求不高这个简单方案足够用了。定时发送更简单。消息体里的scheduleTime字段就是为它准备的。生产者投递时并不需要真的延迟投递而是投递到stream_scheduled这个独立Stream由定时器扫描每30秒扫一次把scheduleTime小于当前时间的消息重新投递到正常的发送队列。实现简单而且定时任务挂了也不影响主流程。4. 实操落地基于Redis Stream从零实现现在进入核心部分。我拿一个基础版本讲清楚代码怎么组织重点在逻辑完整性和关键参数上有依据直接抄是可以跑的。4.1 生产者业务侧接入代码生产者封装成独立的Client业务方只需要一行调用。Service public class SmsSender { Autowired private StringRedisTemplate redisTemplate; private static final String STREAM_KEY stream:sms:normal; public void send(SmsSendRequest request) { SmsMessage msg SmsMessage.builder() .messageId(UUID.randomUUID().toString().replace(-, )) .bizType(request.getBizType()) .templateId(request.getTemplateId()) .params(request.getParams()) .recipients(request.getRecipients()) .priority(Optional.ofNullable(request.getPriority()).orElse(5)) .scheduleTime(Optional.ofNullable(request.getScheduleTime()).orElse(0L)) .retryCount(0) .maxRetry(5) .expireTime(System.currentTimeMillis() 10 * 60 * 1000) .traceId(request.getTraceId()) .build(); String json JSON.toJSONString(msg); // XADD 消息 redisTemplate.opsForStream().add( StreamRecords.newRecord() .ofObject(json) .withStreamKey(STREAM_KEY) ); } }注意一个细节消息体里我计算了默认的expireTime10分钟。如果业务方传了expireTime就以其为准。这个设计可以防止消息在队列里躺太久导致迟到消息造成负面影响。4.2 选择Redis Stream而不是Redis List的原因我早期用Redis List做过一版但后来踩了很多坑。List的BRPOP虽然能实现队列但是有两个致命问题。一是没有消息确认机制。消费者把消息LPOP出去还没来得及处理进程就崩溃了这条消息就永远丢了。二是实现消费组很别扭。多个消费者从同一个List取消息通过LPOP或RPOP自行竞争但无法精准控制这条消息被谁消费了、消费到哪一步了。Stream从设计上就解决了这些问题。Redis Stream的核心概念我用大白话解释一下。消息ID每条消息自动生成全局唯一的ID形如1683000000000-0包含时间戳和序列号。消费组通过记录last-delivered-id来定位消费进度即使消费者重启也不担心重复或丢失。消费组Consumer Group一个Stream可以创建多个消费组每个消费组独立维护自己的消费位移。就像多组工人各自领任务互不干扰。待确认列表Pending Entries ListPEL消费者读走消息后消息会进入PEL直到消费者发送XACK才算确认完成。如果消费者宕机PEL里未被确认的消息可以交给其他消费者处理。我用一个生活化的类比Stream就像一个快递驿站消息是包裹每个包裹有唯一的运单号。消费者是快递员取走包裹后需要在系统里点已签收驿站才知道这单完成了。如果快递员取走包裹后失联驿站会在第二天把包裹重新分配给其他快递员。4.3 消费者核心消费逻辑消费者的核心逻辑我建议不要用Spring的Scheduled去轮询而是用while循环配合阻塞读取。Spring的定时任务在负载高时可能出现线程饥饿不好控制并发。我写了一个独立的消费线程池。Component public class SmsConsumer { Autowired private StringRedisTemplate redisTemplate; Autowired private ChannelRouter channelRouter; private static final String STREAM_KEY stream:sms:normal; private static final String CONSUMER_GROUP group:sms:consumer; private static final String CONSUMER_NAME consumer: UUID.randomUUID().toString().substring(0, 6); PostConstruct public void start() { // 初始化消费组如果已存在则忽略异常 try { redisTemplate.opsForStream().createGroup(STREAM_KEY, CONSUMER_GROUP); } catch (Exception e) { // 消费组已存在则忽略 } // 启动消费线程 new Thread(this::loop).start(); } SuppressWarnings(unchecked) private void loop() { while (true) { try { ListMapRecordString, Object, Object records redisTemplate.opsForStream() .read(Consumer.from(CONSUMER_GROUP, CONSUMER_NAME), StreamReadOptions.empty().count(20).block(Duration.ofSeconds(5)), StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed())); if (records null || records.isEmpty()) { continue; } for (MapRecordString, Object, Object record : records) { process(record); } } catch (Exception e) { log.error(消费队列异常, e); sleepQuietly(1000); } } } private void process(MapRecordString, Object, Object record) { SmsMessage msg JSON.parseObject((String) record.getValue().get(message), SmsMessage.class); // 过期判断 if (msg.getExpireTime() ! null msg.getExpireTime() System.currentTimeMillis()) { log.warn(消息过期直接丢弃 messageId{}, msg.getMessageId()); redisTemplate.opsForStream().acknowledge(STREAM_KEY, CONSUMER_GROUP, record.getId()); return; } // 限流等待 RateLimiter rateLimiter channelRouter.getRateLimiter(msg); if (!rateLimiter.tryAcquire()) { // 获取不到令牌重新放回队列稍后重试 requeue(msg); redisTemplate.opsForStream().acknowledge(STREAM_KEY, CONSUMER_GROUP, record.getId()); return; } // 路由并发送 boolean success channelRouter.routeAndSend(msg); if (success) { redisTemplate.opsForStream().acknowledge(STREAM_KEY, CONSUMER_GROUP, record.getId()); } else { // 执行重试逻辑 handleRetry(msg, record); } } }这段代码有几个关键点。第一个是read方法的block参数。阻塞时间设为5秒这样当队列为空时消费线程不会空转疯狂请求Redis减少无谓的资源消耗。count设为20每次最多拉20条避免一次拉太多导致内存压力。第二个是限流等待的处理方式。我的做法是如果当前拿不到令牌就把消息重新投递回队列尾部然后ACK掉原消息。虽然可能会延迟但这是最简单的实现。你也可以用Stream的消费者组内部重试机制但不好控制等待时间还是重新入队更直观。第三个是ACK的时机。务必在消息处理成功后再ACK。如果AC就崩溃了消息还在PEL里重启后可以重新消费。如果忘记ACK消息会一直堆积在PEL里积少成多会造成内存浪费。注意上面代码为了体现核心逻辑省略了PEL队列的兜底处理。建议在所有消费者启动前先执行一个转移PEL中未ACK消息的过程避免因为某个消费者宕机时间太长导致消息一直卡在它的PEL里。4.4 重试机制与死信队列短信发的失败是常态通道商返回欠费号码不存在关键字拦截等各种原因所以必须有一个健壮的重试机制。重试策略我用的是指数退避第1次失败后等1分钟重试第2次失败等5分钟第3次等15分钟第4次等1小时第5次等2小时之后不再重试。重试次数和间隔都拆成配置不同bizType可以配不同策略。具体实现上我维护了一个独立的重试队列Streamstream:sms:retry。当正常消费发失败时如果retryCount小于maxRetry就把消息塞进重试队列同时ACK掉原来的消息。重试队列由单独的消费者定期扫描扫描频率可以比正常消费低一些比如每30秒一次。private void handleRetry(SmsMessage msg, MapRecordString, Object, Object record) { int retried msg.getRetryCount() null ? 0 : msg.getRetryCount(); if (retried msg.getMaxRetry()) { // 计算下一次执行时间放入延迟重试队列 long nextRetryTime System.currentTimeMillis() retryIntervalSeconds(retried) * 1000; msg.setRetryCount(retried 1); msg.setNextRetryTime(nextRetryTime); redisTemplate.opsForStream().add( StreamRecords.newRecord() .ofObject(JSON.toJSONString(msg)) .withStreamKey(stream:sms:retry) ); redisTemplate.opsForStream().acknowledge(STREAM_KEY, CONSUMER_GROUP, record.getId()); } else { // 超过最大重试次数进入死信队列 redisTemplate.opsForStream().add( StreamRecords.newRecord() .ofObject(JSON.toJSONString(msg)) .withStreamKey(stream:sms:deadletter) ); redisTemplate.opsForStream().acknowledge(STREAM_KEY, CONSUMER_GROUP, record.getId()); } }把重试和主队列分开是一个很重要的设计决策。假设通道商A从下午3点开始故障如果重试消息全部还留在主队列队列中的消息永远消费不掉后面的新消息被堵住头部阻塞问题会迅速击垮系统。独立重试队列保证主队列永远是当前待发送的消息重试队列的压力则由专门的消费者承担两边互不拖累。死信队列就一个用途让人工介入。每天早上一看死信队列里的数据基本能判断出是通道问题、号码问题还是内容问题。如果有必要甚至可以做一个后台管理页面支持手工重放死信队列的消息。5. 扩展性深入多通道路由与水平扩容一个短信模块的扩展性最终要落地到两个具体的动作一是有新通道能低成本接入二是流量上来后能水平加机器。这一章我重点讲怎么做。5.1 多通道路由规则从固定通道到动态路由路由模块的设计目标是让系统在通道故障时自动切换不需要人工干预。路由决策的依据我总结了三个维度。权重分配。每个通道配置weight和maxQps。系统按权重比例随机选择一个通道但必须同时考虑通道的最大QPS限制。比如通道A权重50但上限只有100QPS通道B权重30但上限200QPS当总量超过一定阈值时就不能死板地按权重走。健康状态。每个通道要有一个健康分根据最近5分钟的成功率、平均耗时、超时次数计算。成功率低于70%时路由模块直接跳过该通道高于90%时恢复参与路由。健康分的计算可以做成一个滑动窗口每10秒更新一次。业务类型约束。某些场景你可能只愿意走某一家通道比如国际短信只能走通道C。路由时先按业务类型过滤可用通道再做权重分配。路由接口我定义成下面这样。public interface ChannelRouter { SmsChannel route(SmsMessage message); }接口背后的实现内部维护了一个可用通道列表并且缓存了每个通道的令牌桶限流器RateLimiter。发送前先尝试从对应限流器获取令牌拿不到就换下一个通道全部拿不到则进重试队列。5.2 分布式限流本地限流器怎么升级单机版的限流器用Guava的RateLimiter就行但一旦部署多个消费者实例每个实例都有自己的本地限流器就会出现一个尴尬的情况总QPS等于单机QPS乘以实例数通道商直接给你限流封号。解决方案是做一个Redis分布式限流器。考虑到短信发送的QPS并不高用简单的Lua脚本实现令牌桶或滑动窗口都行。我这里用了最简单的固定窗口计数器。-- Lua脚本每秒钟最多允许 key 通过 cnt 个请求 local key KEYS[1] local limit tonumber(ARGV[1]) local current tonumber(redis.call(GET, key) or 0) if current 0 then redis.call(SET, key, 1, PX, 1000) return 1 elseif current limit then redis.call(INCR, key) return 1 else return 0 end注意固定窗口有个小缺陷周期切换的瞬间可能突破2倍QPS。对短信场景来说通道商一般不会卡得那么精准这个方案性价比最高。如果你的通道商检测非常严格可以升级成Redis Lua实现的滑动窗口网上有成熟脚本改动成本也不大。5.3 消费者水平扩容的两种姿势扩容真的是加个实例这么简单吗对于Redis Stream模型答案是加一个实例运行同样的Consumer Group代码确实就能分摊流量。所有实例都注册在同一个消费组group:sms:consumer下面Redis Stream会自动把消息分发给组内的不同消费者大家互不重复。但这里有个性能隐忧所有消息都在一个Redis的Stream里当单Stream的消息量达到几百万条时Redis的存储和读取压力会大消费延迟也会上升。此时需要分区。分区方式我实践中推荐按业务类型分多个Stream。比如stream:verify_code、stream:marketing、stream:notification。这么拆的好处有三点一是不同类型消息互相隔离营销类消息再猛也不会堵验证码队列二是可以为不同业务配置不同的消费组和消费者数量三是Redis集群部署时不同Stream可以分布在不同的节点上。如果你的业务量更高比如每秒几万条营销短信那可以考虑按用户手机号哈希分片比如100个Stream分片。但说实话对多数公司来说这是过度设计。按业务类型拆3到5个Stream已经能支撑非常可观的量了。5.4 关于加机器就有效的一个误区很多团队把系统做成消费者越多越好结果发现再加机器吞吐上不去了。原因往往出在通道瓶颈上。短信通道商给每个客户都有QPS上限比如某通道商上限就是200QPS你加再多的消费者也没用因为所有流量最终都要从这一个通道商的200QPS窗口挤过去。所以扩容是不是有效取决于你的瓶颈在哪里。如果瓶颈在通道商你要做的是多接几家通道把流量分散出去而不是加消费者。如果瓶颈在Redis Stream的读取性能加消费者确实能分摊。如果瓶颈在短信内容渲染的CPU消耗则要优化模板渲染逻辑或者做内容缓存。做性能优化之前先确认瓶颈在哪一层。6. 常见问题与排查实录这个模块上线一年多我处理过不少线上问题。整理几个高频排查场景有些可能是你迟早要遇到的。6.1 消息积压怎么排查当时大促期间运营批量发50万条营销短信结果我一看监控积压了20万条积压量还在涨。第一反应是看消费端有没有报错。查日志发现某个消费者实例挂了但消费组里的其他实例并没有接管它的PEL消息。这就是我之前一直强调要处理的PEL兜底问题。Redis Stream的消息归属是有绑定关系的消费者A取走的未确认消息不会自动转移给消费者B需要手动转移或等待消息超时机制触发。后来我写了一个定时任务每5分钟检查一次PEL中是否有超过3分钟未ACK的消息有的话用XAUTOCLAIM命令把它们转移给当前活跃消费者再处理。第二反应是看通道的QPS。查了监控发现某通道商的QPS达到了我们配置的上限后面的消息全部在限流等待。这种积压属于通道能力不足不是消费端的问题需要等低谷期自然消化或者临时把部分流量切到别的通道。排查思路总结先看消费端健康再看通道QPS最后看Redis节点负载。顺序错了容易被假象误导。6.2 重复发送的根因与幂等有用户投诉说同一个验证码短信收到了两遍。查了日志发现是消费端处理消息时第一次调用通道商接口接口超时未返回。我的代码认为发送失败就把消息放进了重试队列。第二次重试时通道商其实之前已经成功发出去了。这是一个典型的超时二义性问题HTTP调用超时了你不知道服务端到底成功没有。解决方向有两种。第一是让重试间隔大于通道商接口的超时时间比如超时5秒重试至少等10秒后再发降低前一个请求仍在途、后一个请求已发出的概率。第二是向通道商要幂等支持在请求参数中带上bizId业务主键通道商侧对相同bizId的消息去重。不是所有通道商都支持幂等所以接入时一定要问清楚。如果通道商不支持幂等只能尽量用业务规则兜底。比如用户每分钟验证码请求最多发3条系统侧做Redis计数超限就丢弃。这个方案不能100%防重但能把重复消息对用户的干扰降到可控范围。6.3 通道被限流甚至封号怎么办某个通道商突然从某天开始频繁返回trigger flow limit错误。联系商务才知道我们在他们那边的日发送量达到了阈值触发了限制。这类问题技术是解决不了根本的但有一些缓解手段。一是做发送速率的平滑控制。不要等到QPS触顶才开始降速而是配置通道的目标QPS为商家属性的90%留出10%的缓冲。二是在路由时加入通道日发送量这个维度。如果通道A今天已经发了80%的量就自动把部分流量切到通道B。三是监控通道返回的错误码把限流、拦截、欠费等错误码归类触发对应告警必要时候自动熔断该通道。6.4 监控指标体系最后一定要有监控不然等于盲飞。我总结四类指标你照着配就行。第一类生产侧每秒钟生产消息量、队列积压数、积压时间趋势。这反映的是业务侧的发量波动。第二类消费侧每秒消费消息数、消费耗时分布P99、待确认消息数。消费耗时增加了说明通道在变慢或者路由逻辑有问题。第三类通道侧每个通道的成功率、平均耗时、QPS、当前健康分。最好做成看板一眼看出哪个通道需要处理。第四类业务侧按bizType统计的发送成功率、延迟送达时间。这能反映特定业务短信的真实体验。告警阈值我给一个初始建议你可以按自己的量级调积压数超过5000或积压时间超过10分钟通道成功率低于90%持续3分钟死信队列每小时新增超过200。这些指标建议接入现有的监控平台钉钉或企微推送告警即可不用重复造轮子。这个模块的代码版本迭代过三轮从最早的Redis List到现在的Redis Stream中间走过的弯路不少。我个人在实际项目里的体会是短信发送系统70%的价值在于失败处理和降级策略而不是发送本身。把通道管理、重试策略、限流熔断做到位系统在外部通道频繁故障的背景下依然能稳定运转这才是设计这套东西真正的意义。最后再分享一个小技巧如果你还没有一套链路追踪系统至少要在短信消息里带上业务侧的traceId打日志的时候把它和messageId一起打出来。线上排查问题的时候没有这两个ID你会非常痛苦。我就是因为这个后来专门把整个消息体的日志输出格式做了一次统一。这个小小的改造省下来的排查时间远比写代码的时间多。