消息通知场景的高并发推送成本优化实践
作者:4042026.11.07 02:55浏览量:0简介:消息通知看似简单,但规模化发送时成本与时效会迅速恶化。本文从一次大促短信账单复盘出发,拆解通知渠道选择、模板压缩、异步批量发送、失败重试与状态追踪等关键环节,给出一套可落地的降本提效方案。读者可了解如何将单条通知成本降低一半以上,并将发送吞吐从秒级推进到数百条每秒。
一、从一个真实的账单问题说起
在一次大促活动结束后,团队复盘时发现一个令人意外的情况:系统为了提醒用户参与活动,累计发送了十万条短信,最终账单金额达到数千元。运营负责人第一反应是:通知成本为什么这么高?后续活动还能不能继续按这个方式发送?
排查之后,问题并不是出在短信单价异常,而是出在通知方案本身。主要表现集中在三个方面:
- 短信模板冗余:为了保证信息完整,模板里加入了活动名称、时间、参与方式、温馨提示等多段文字,实际有效信息占比不高,但每条短信都按标准计费。
- 通知渠道单一:所有消息都走短信,即使其中相当一部分用户已经安装了应用、开启了推送权限,也没有被优先使用免费渠道。
- 高并发下同步发送效率低:短信发送逻辑与业务主流程耦合在一起,逐条同步调用,活动开始前消息集中产生时,大量请求排队,发送延迟从毫秒级拉长到秒级,甚至出现超时。
这三个问题分别对应成本、渠道策略和系统吞吐。解决它们不能只靠“换一个更便宜的短信服务”,而是需要从消息通知的完整链路入手。
二、先把通知链路拆开看
一个典型的通知发送流程可以拆成五个阶段:
flowchart LRA[业务事件] --> B[通知任务生成]B --> C[渠道选择]C --> D[内容渲染]D --> E[消息发送]E --> F[状态确认与追踪]
这个链路看似简单,但在高并发场景下,每一步都可能成为瓶颈:
- 通知任务生成:如果直接在业务方法里同步生成通知,会增加业务接口耗时。
- 渠道选择:如果没有统一策略,不同业务各自判断,容易出现逻辑分散和重复开发。
- 内容渲染:模板渲染过程可能涉及用户信息查询、文案拼接等操作。
- 消息发送:第三方通道的响应时间不可控,同步等待会拖垮上游服务。
- 状态确认:发送成功并不等于用户真正收到,还需要回执确认和异常追踪。
因此,优化不能只盯着“短信太贵”这一点,而要把通知系统当成一个完整的消息分发平台来设计。
三、渠道选择:让合适的消息走合适的路
3.1 渠道特性不是平均的
常用的通知渠道包括短信、邮件和应用内推送。它们的能力差异非常明显:
| 渠道 | 到达率参考 | 成本 | 实时性 | 适合场景 |
|---|---|---|---|---|
| 短信 | 通常较高 | 单条计费 | 高 | 验证码、安全提醒、紧急通知 |
| 邮件 | 中等偏上 | 几乎忽略不计 | 低 | 周报、详细说明、营销内容 |
| 应用推送 | 受授权率影响 | 免费 | 高 | 活动提醒、状态变更、站内互动 |
从表格可以看出,短信并不是唯一选择,甚至在很多场景下不是最优选择。一个合理的通知系统,首先要根据消息类型、用户状态和内容特征动态选择渠道。
3.2 渠道选择的决策逻辑
渠道选择不能靠业务方各写一套。可以封装一个统一的渠道选择器,输入是通知类型、用户偏好、内容长度和紧急程度,输出是渠道组合。
一个可参考的决策流程如下:
public NotificationChannel selectChannel(Notification notification, User user) {// 安全类、验证类、资金变动类必须走短信if (notification.isUrgent() || notification.isSecurityRelated()) {return NotificationChannel.SMS_WITH_PUSH;}// 长内容优先邮件if (notification.getContentLength() > 200) {return NotificationChannel.EMAIL;}// 用户已开启推送且当前在线,优先应用推送if (user.isPushEnabled() && user.isActive()) {return NotificationChannel.PUSH;}// 推送不可达但事情不算紧急,邮件兜底if (notification.getContentLength() > 50) {return NotificationChannel.EMAIL;}// 最后再选择短信return NotificationChannel.SMS;}
这个策略的核心思想是:短信从“默认渠道”变成“兜底渠道”。免费渠道能覆盖用户时,优先使用;只有紧急、安全或用户不可达时才启动短信。
3.3 渠道组合与降级
单渠道通知存在失效风险。例如应用推送可能因为用户关闭权限、设备离线、厂商通道限流而没有到达。因此,关键通知需要渠道组合。
常见组合策略包括:
- 短信 + 推送:紧急且需要快速触达,例如支付异常提醒。
- 邮件 + 推送:非紧急但内容较多,例如活动总结、周报告。
- 先推送、后短信:活动提醒先尝试推送,一段时间未确认到达后再降级发短信。
- 先短信、后邮件:验证码类先短信保证时效,详细说明再通过邮件补充。
降级机制需要基于触达状态判断。例如推送发出后一段时间内没有回执或用户未点击,再触发短信发送。这样可以在保证触达率的同时显著降低成本。
四、模板压缩:把每一条消息字数降下来
4.1 流量与成本直接相关
在短信场景中,单条计费通常与内容长度和拆分条数相关。一个包含大量冗余信息的模板,可能因为超过标准长度被拆成两条甚至三条计费。即使不计拆分,文案本身也需要被压缩到必要范围内。
模板优化前和优化后的对比可以非常直观:
优化前:
尊敬的用户您好!您关注的商品已经参加本次限时优惠活动,活动时间为今晚8点到12点,数量有限先到先得,请您尽快登录应用查看并参与,如已购买请忽略本短信。如有疑问请联系客服。退订回T。
优化后:
您关注的商品今晚8点开抢,数量有限,先到先得。已购忽略。退订回T。
两者信息传达几乎等价,但字数从七十余字降到三十字左右,单条成本可下降一半以上。
4.2 模板压缩规则
模板压缩可以遵循几个原则:
- 去除敬语和冗余开场:通知场景不需要“尊敬的”“您好”等词语。
- 保留核心动作和时间:用户只关心“发生了什么”“什么时间”“要做什么”。
- 合并重复说明:同一信息不要出现两次。
- 使用短句:短信以较短的句子为单位,便于快速阅读。
- 可配置退订说明:营销类短信需要保留退订方式,但可以精简表达。
模板压缩需要和法务、运营达成一致,避免因为过度精简导致合规风险。
五、异步批量发送:高并发下的吞吐优化
5.1 同步发送的问题
如果通知发送直接嵌入业务接口,业务请求需要等待网关响应。主要体现在:
- 单次发送耗时可能在几十毫秒到几秒之间,大量请求同步等待会快速耗尽线程资源。
- 短信网关通常有接口频控,瞬时流量过大时可能触发限流,导致请求失败。
解决思路是将发送过程从业务主流程中剥离出来。
5.2 引入消息队列解耦
业务事件产生后,只向消息队列投递一个通知任务,由独立的通知服务负责消费和处理。业务接口可以立即返回,不再等待消息真正发出。
// 业务侧:只投递任务public void onOrderPaid(Order order) {NotificationTask task = NotificationTask.builder().userId(order.getUserId()).type(NotificationType.ORDER_PAID).payload(order.toNotificationPayload()).build();messageQueue.send("notification-topic", task);}// 通知服务:消费任务并发送@Consumer(topic = "notification-topic")public void handleTask(NotificationTask task) {NotificationChannel channel = selectChannel(task);String content = renderTemplate(task);send(channel, content);}
这样业务系统和通知系统解耦,通知任务的积压不会直接拖慢下单、支付等核心流程。
5.3 批量合并与聚合窗口
单条通知单独调用通道接口,性能仍然有限。可以引入批量合并机制:通知服务在短时间内收集同一渠道的任务,合并为一个批次请求发送。例如在200毫秒窗口内收集所有等待发送的短信,一次请求提交给网关。
public void batchSend() {List<NotificationTask> tasks = queue.drain(200, TimeUnit.MILLISECONDS);if (tasks.isEmpty()) return;Map<NotificationChannel, List<NotificationTask>> grouped =tasks.stream().collect(Collectors.groupingBy(this::selectChannel));grouped.forEach((channel, taskList) -> {channelClient.batchSend(taskList);});}
批量合并的效果体现在两方面:一是降低通道接口调用次数,减轻网关压力;二是减少连接建立、鉴权等固定开销,提高整体吞吐。
5.4 限流与削峰
通知发送还要考虑对下游通道的保护。消费端可以配置限流阈值,避免积压任务恢复时瞬间打满下游。限流可以和批量机制配合:每批消费数量固定,批间间隔根据通道限流值动态调整。
例如短信渠道限流为每秒500条,则每批最多取出500条发送,并以1秒为间隔执行下一批。
六、失败重试与状态追踪
6.1 通知不能“发了不管”
异步化之后,系统性能提升,但失败场景也变得更加隐蔽。如果发送后没有监控,用户反馈“没收到通知”时,开发很难定位问题。
因此,完整的通知链路至少需要记录以下状态:
- 待发送:任务已创建,尚未进入发送。
- 发送中:已提交给通道接口,等待回执。
- 成功:通道返回成功确认。
- 失败:参数错误、余额不足、通道限流等。
- 未知:请求超时,无法准确判断最终状态。
6.2 失败重试策略
失败重试用固定间隔重试容易导致消息堆积。推荐指数退避,例如:
int maxRetry = 5;long baseDelay = 1000L;public void handleFailure(NotificationTask task, int retryCount) {if (retryCount >= maxRetry) {markDead(task);return;}long delay = baseDelay * (1L << retryCount); // 1s, 2s, 4s, 8s...retryQueue.schedule(task, delay, TimeUnit.MILLISECONDS);}
对于超时结果,需要采用查询确认机制。短信和推送通道通常提供回执查询能力,通知服务可以在超时后定时查询最终结果,而不是盲目重发,避免用户重复收到相同通知。
6.3 状态追踪与链路标识
每条通知在生成时分配全局唯一标识。标识贯穿任务创建、队列投递、渠道发送、回执更新等环节。日志系统可以使用该标识串联完整链路,方便对账和问题排查。
同时可以记录以下字段用于统计分析:
- 渠道类型
- 模板标识
- 发送时间
- 回执时间
- 最终状态
- 重试次数
有了这些数据,后续可以定期统计各渠道到达率、平均耗时和失败原因。
七、完整架构与配置化能力
7.1 简化架构图
flowchart TDA[业务应用] -->|投递任务| B[分布式消息队列]B --> C[通知消费服务]C --> D{渠道选择}D -->|推送| E[推送通道]D -->|短信| F[短信通道]D -->|邮件| G[邮件通道]E --> H[回执查询]F --> HG --> HH --> I[状态存储]I --> J[监控告警]
7.2 配置化管理
通知模板、渠道权重、重试次数、批量窗口等参数都应该支持动态配置。这样运营可以调整模板文案,开发可以调整限流阈值,而不需要每次修改代码发布新版本。
一个简单的配置模型可以包含:
template: 模板内容与变量定义channel: 渠道标识与优先级batch: 批量窗口大小retry: 最大重试次数与退避参数limit: 每秒最大发送量
7.3 监控指标
通知系统上线后应重点观察以下指标:
- 任务入队速率
- 消费速率
- 各渠道发送成功率
- 平均端到端延迟
- 重试队列长度
- 单用户接收消息数量
八、优化结果与经验总结
通过多渠道策略、模板压缩、异步队列和批量发送四项改造,整体通知成本得到明显下降。以十万条短信量级为例,综合成本可以下降约两到三成,若进一步扩大免费渠道的覆盖比例,降幅可能更高。发送吞吐从同步单条处理提升到批量处理,系统可稳定支撑每秒数百条消息的下发。
本次优化最重要的并不是技术难度,而是改变了三个认知:
- 通知是分发系统,不只是调用一个接口。
- 最贵的渠道不一定是必选项,关键是触达效果。
- 性能优化和成本优化可以同向进行,而不是互相牺牲。
后续消息通知系统还可以继续演进,例如增加用户通知偏好设置、按用户行为动态调整触达策略、通过历史数据预判渠道到达概率等。只要链路设计合理,通知系统既能保证到达率和时效,也能把成本控制在可接受范围内。

登录后可评论,请前往 登录 或 注册