ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

从渠道割裂到统一推送:企业级消息中心架构设计与实践复盘

从渠道割裂到统一推送:企业级消息中心架构设计与实践复盘

这年头做企业应用的,基本都绕不开“给用户发消息”这件事。我们最初并不是没有消息能力,恰恰相反,是消息能力太“丰富”了:订单系统接了一家短信服务商,账号系统自己搭了邮件服务,运营团队买了另一家的push通道,客服工单通知还要走企业微信应用消息……每个业务线各搞一套,渠道SDK版本混乱、签名和模板各管各的、发送数据完全不通。等业务量起来之后,问题集中爆发,领导拍板让我牵头搭一套企业级消息中心,把所有触达渠道收口到同一个多渠道统一推送平台上。本文就把这个项目的架构设计思路、核心模块实现和实战踩坑过程完整记录下来,给正在规划消息平台的同行提供一个可落地的参考。

1. 项目背景与核心诉求解析

1.1 渠道各自为战:真实业务里的一地鸡毛

接手之前,我先盘点了一圈现状,越盘越头大。每个业务系统接入渠道的方式都不一样,有的直接用供应商官方SDK,有的通过封装好的内部工具类,还有的是别的团队写的一个“能用就行”的HTTP接口。结果就是同一家短信服务商的API,代码里出现了三个不同的签名版本。新同事入职第一周,有一半时间在搞明白“到底应该用哪个方法发短信”。

更折磨人的是模板管理。发短信要提交签名和模板给服务商审核,但因为没有统一平台,运营同学改一句营销话术,需要开发排期改代码、重新提交审核、再发版上线。有一次大促活动文案里有个词涉及敏感词,短信模板被运营商打回,活动前一天晚上我们才发现,硬是连夜找服务商加了白名单才救回来。这种经历一次就够,多了真的会让人对“发消息”这三字产生心理阴影。

没有统一的路由和频控,问题更直接。用户在同一分钟里收到订单通知、优惠券提醒、活动邀请三样东西,短信、push、邮件全渠道轰炸。投诉进来之后,客服都要在几个系统之间来回切换,才能拼凑出这个用户到底被“打扰”了多少次。属于典型的用户体感问题,技术侧却一直没有抓手。

数据层面也是一本烂账。各系统有各系统的统计口径,某个渠道的到达率到底是多少,完全靠供应商后台自己显示,有没有偷发、漏发、错发,平台侧一概不知。没有全链路轨迹,事后审计无从谈起,更不要说对渠道质量做横向对比了。我们最后决定做统一消息中心,本质上就是两个诉求:让业务方一条API触达所有用户,让平台方能掌控每一次发送的全生命周期。

1.2 从“能发消息”到“发好消息”:明确设计目标

项目启动时,我对团队和自己提了几个硬指标,后来事实证明这些目标在整个设计过程中都在帮我做取舍:

第一,统一接入。业务方只对接一套API,不感知底层渠道变化。今天砍掉某家供应商,换另一家,业务方代码一行不动。第二,统一模板。模板的创建、审核、发布、下线全生命周期管理,文案改动走平台流程,不碰代码。第三,智能路由。支持指定渠道,也支持按优先级、成本、到达率动态选择渠道,并有兜底降级能力。第四,可靠投递。不丢消息、不重复投递、失败有重试,重试有兜底。第五,可观测。每一次发送从接入到渠道回执,全程有迹可循,有监控告警,有事后分析。

技术选型上,我们用的是Java生态:Spring Boot做接入层和处理层的基础框架,RocketMQ做消息异步缓冲和削峰,Redis做频控、幂等和数据临时存储,MySQL存消息记录、模板配置和渠道配置。没有上一堆微服务组件,架构始终保持着“按需演进”的状态,部门里的大部分同事都能快速上手。实际做下来我的感受是,消息中心这种基础平台,先用好一套简单清晰的技术栈,比一开始就追求各种炫酷组件重要得多。

2. 整体架构设计与核心模块拆解

2.1 四层架构:消息流转的主线

整个消息中心从逻辑上分为四层,按数据流方向依次是接入层、缓冲层、处理层、存储层。各层各司其职,边界比较清晰。

接入层是对外提供的HTTP API,业务方调用时做三件事:鉴权校验、参数合法性校验、生成全局唯一的messageId和traceId。同时接入层还会做前置频控,比如同一手机号在短时间内重复请求,直接在这里拦截,避免后面的链路被无效请求浪费。

缓冲层就是RocketMQ。消息通过API进入MQ后,按业务优先级和渠道维度进了不同topic。这么做有几个直接好处:业务方调用API后立刻返回,不用等待下游渠道响应;大促流量瞬间从几万压下来,由MQ削峰后按渠道消费能力慢慢放行;消费端还能根据积压情况动态扩容。

处理层是核心,消费MQ消息之后依次完成流水线:加载并渲染模板、执行路由决策、检查频控和幂等、选定渠道适配器执行发送、解析渠道回执并更新状态。如果失败,按重试策略重新投递;超过重试上限,进入死信人工处理队列。

存储层除了MySQL主库之外,还有一张状态流转表和一张审计表。状态流转表记录每条消息在哪个时间点处于什么状态,审计表则保存发送原始请求和渠道返回的完整报文。监控系统会实时消费状态流转数据,计算成功率、到达率、耗时等指标。整条链路下来,任何一个环节出了故障,都能在后台搜到具体消息的完整轨迹。

2.2 为什么坚持异步缓冲,而不是同步调用

如果你第一次设计消息系统,最大的诱惑就是让业务方同步等待发送结果:请求进来,调用短信接口,等渠道返回,再给业务方一个结果。看起来简单直接,但生产环境里根本跑不动。短信接口的正常响应是几百毫秒到几秒,遇到运营商网关抖动,十几秒才返回的情况也常见。如果业务方同步等着,一次请求就把线程占住了,接口吞吐量上不去,上游调用方也跟着超时,故障像多米诺骨牌一样传导。

用异步缓冲,相当于把“发消息”从一个同步动作拆成了两步:业务方先把发送意图提交给我们,我们马上确认收到;真正的投递动作由后台任务异步执行。这样就彻底解耦了业务方和渠道方的节奏。不过异步化是有代价的,最大的代价是消息可能丢失,比如服务重启、MQ宕机、网络分区等。所以在接入层我们用了生产者确认机制,消息发到MQ没有确认返回就落本地表,由定时任务扫表补偿重投。消费端则必须做幂等处理,同样的消息无论收到几次,对业务的影响只能有一次。

我见过不少团队把MQ用得像“RBQ”,消息进去就不管了,出问题全靠人工翻日志。消息中心这种场景,MQ的可靠性设计一点都省不了。宁可多写一些补偿代码,也不要侥幸觉得“生产环境不会出问题”。

3. 统一消息模型与模板服务的关键实现

3.1 消息模型设计:一个字段都不能少

统一消息中心能不能真正“统一”,很大程度上取决于消息模型设计得好不好。我们最后敲定的核心DTO大概是这样的结构:

public class UnifiedMessage { // 全局唯一ID,去重和幂等的基础 private String messageId; // 全链路追踪ID,贯穿业务方 private String traceId; // 业务类型,如 REGISTER_CODE、ORDER_NOTIFY private String bizType; // 模板编码,对应平台上配置的模板 private String templateCode; // 模板渲染参数,Map结构 private Map<String, Object> params; // 接收方信息,如手机号、邮箱、token private Receiver receiver; // 渠道列表,支持指定/候选 private List<String> channels; // 路由策略:SPECIFIED_ONLY / PRIORITY / DYNAMIC private String routeStrategy; // 优先级,HIGH / NORMAL / LOW private Integer priority; // 定时发送时间 private Long scheduleTime; // 过期时间,超过则不再投递 private Integer expireSeconds; }

messageId是全局唯一的,由接入层生成,通常用雪花算法或UUID变体,兼顾有序性和唯一性。traceId则从业务方传入,也可以在接入层生成,方便在消息平台里关联到某一笔具体业务请求。params是个Map,因为模板需要把运行时变量注入进去,比如验证码值、用户昵称、订单号等。

scheduleTime和expireSeconds是两个容易被忽视但很重要的字段。定时发送用于营销活动场景,允许业务方指定某时某刻触达;过期时间则保证一条消息如果因为渠道故障一直发不出去,超过有效期就直接标记为EXPIRED,不再进行无意义的重试。实际运营中,“一条已经过期的营销短信在三天后突然发出去了”是灾难级的负面体验,这种坑一定要靠字段提前挡住。

存储层对应的有三张核心表:message保存消息主数据,message_state记录从接入到最终结果的状态机流转,channel_config保存各渠道的启停状态和权重配置。状态表是增量追加的,每次变更插一条记录,这样就能完整还原一条消息的生命周期,后面做轨迹查询、对账、问题排查全靠它。

3.2 模板渲染:别小看变量解析和格式校验

模板管理在项目里的重要程度被我们严重低估过。最开始觉得服务商已经提供了模板能力,我们只要把参数丢过去就行,后来发现完全不是那么回事。

短信服务商的模板变量有严格的位置和格式限制,一个“尊敬的{1}先生”里的占位符是服务商自定义的,跟我们的字段对不上。如果业务方直接调用服务商API,运营改一句话可能就要重新审核模板,审核周期动不动就要一两天。统一平台把模板包在自己的体系里,业务方只传一个templateCode和参数Map,内部渲染成最终文案,再交给渠道。

public String render(String content, Map<String, Object> params) { // 占位符示例:{name}、{code},统一用大括号 Pattern pattern = Pattern.compile("\\{(\\w+)\\}"); Matcher matcher = pattern.matcher(content); StringBuffer sb = new StringBuffer(); while (matcher.find()) { String key = matcher.group(1); Object value = params.get(key); if (value == null) { throw new TemplateParamMissingException(key); } matcher.appendReplacement(sb, Matcher.quoteReplacement(String.valueOf(value))); } matcher.appendTail(sb); return sb.toString(); }

渲染之前必须做参数校验,校验项包括:必填参数是否存在、手机号格式是否正确、邮件地址是否合法、长度是否超过渠道上限,以及金额类参数是否按照指定精度格式化。校验失败直接返回业务方具体错误码,比如“params.missing”或“param.invalid”,这样业务方半小时内就能定位问题,不用再来回查日志。

这里有个容易踩的坑:同一套文案在不同的渠道上可能需要不同版本。短信有字数限制,一条70个字,长短信按67字拆分计费;push通知通常限制在30~100个字,过长会被系统截断;邮件则没有太多限制,但可能需要带HTML样式。所以我们的模板表里存的是channelContentMap,同一个templateCode可以为不同渠道维护不同内容。比如“验证码通知”的短信版是企业签名加6位码,push版是简洁的一句话,邮件版则是完整的HTML模板。这种设计看着笨,用起来是真省心。

4. 路由策略、频控与幂等设计

4.1 动态路由:成本、到达率与用户体验的平衡

消息路由是整个平台的“大脑”,也是最需要业务规则参与的部分。路由决策的核心问题是:一条消息应该走哪个渠道,才能既让用户及时看到,又把成本控制住,还不出故障。

最朴素的方案是业务方调用API时指定渠道,比如验证码必须走短信。我们保留了这种“指定渠道”模式,因为它对时效性要求高,这时动态路由价值不大。但对于营销通知类消息,我们就用动态路由。路由引擎的输入包括:用户最近活跃渠道偏好、用户历史上各渠道的到达率、各渠道的实时成本、各渠道当前的健康状态,以及业务方设置的优先级策略。

举个例子,一项“订单发货通知”,默认策略是APP Push优先,站内信兜底,只有用户长期不在App活跃时才走短信。这样既保证触达,又节省短信成本。我们会在路由策略里配置一个“沉默用户判定规则”,比如近7天没有打开过App的用户,push到达率其实很低,那么订单通知就自动降级为短信+站内信。

渠道单条成本平均到达率实时性主要风险
短信高高秒级成本高,可能被运营商拦截
APP Push低中分钟级用户关闭通知则无法触达
站内信极低高(登录可见)实时用户不登录则看不到
邮件低中分钟级进垃圾箱

路由决策里还包含一个硬性逻辑:任何消息发出前,都要检查渠道健康状态。如果主力渠道成功率已经连续一段时间低于阈值,就自动切到备用渠道。这个开关不能只依赖人工判断,必须在系统层面自动化。我们的渠道配置表里存了每个渠道的“最低成功率阈值”和“备用渠道列表”,一旦触发熔断,路由引擎会把后续流量切走。

动态路由的代码不算复杂,本质就是一堆规则条件的组合判断。难的是规则们互相打架,比如运营想追成本选push,而用户近7天活跃度非常高,push的到达率其实很不错,这时路由结果应该倾向于push而不是短信。所以路由规则需要支持权重,不同行业、不同业务的权重完全不同,做配置时一定要让运营深度参与,而不是技术团队拍脑袋。

4.2 频控、去重与限流:别让消息变成骚扰

频控是消息中心最容易“翻车”的模块,因为它直接涉及用户体感。我在业内见过的最常见事故就是:系统bug导致同一验证码在短时间内重复发送几十条,用户被运营商限频甚至封号,投诉电话都被打爆。

频控设计用的是Redis计数器。核心单元是“用户+策略维度+时间窗口”,比如“同一手机号每分钟最多5条消息”“同一手机号每天最多20条营销类短信”。具体实现上,我们用Redis的INCR和EXPIRE,key格式类似user:msg:limit:{userId}:{scene},在接入层和发送处理层各做一道检查。接入层拦截一部分,处理层再做最终校验,防止并发情况下超过阈值。

去重和幂等则是“重试安全”的基石。消息中心必然要重试,如果重试导致重复投递,业务方就无法信任平台。我们用了双重幂等方案:一是Redis的SETNX加过期时间,以messageId为key,查到存在就直接丢弃;二是MySQL的唯一索引,以message_id和receiver做唯一约束,防止Redis数据丢失后并发重复插入。双重机制下来,我后面做过压测,重复投递率降到了零。

限流主要针对的是渠道供应商的接出侧。有些供应商的QPS上限很低,消息中心一个活动瞬间过去几千QPS,直接就把对方打挂了。我们给每个渠道都配了一个令牌桶,用简单的限流算法控制对外的并发和速率。这么做也反向保护了上游供应商系统,合作方反而觉得我们很专业。

5. 渠道适配器设计与供应商接入实践

5.1 适配器统一接口:屏蔽所有外网差异

消息中心接的渠道类型五花八门,短信、邮件、APP Push、站内信、Webhook、企业微信应用消息。如果没有一个统一抽象,处理层代码会被渠道差异撕得支离破碎。所以我们在处理层定义了统一的渠道适配器接口,所有渠道都实现同一套语义:

public interface ChannelAdapter { // 渠道编码,如 sms、email、push String channel(); // 执行发送 SendResult send(ChannelMessage message); // 主动查询状态 StatusResult queryState(String messageId); // 接收渠道回调 void handleCallback(CallbackData data); }

短信适配器是最复杂的。国内短信服务商的API虽然大同小异,但每家对签名、模板、通道号的校验都不一样,而且发送和回执是分离的——你发出去之后经销商还要异步回调报告送达结果。我们接入了一家主一家备,主渠道异常时自动切换。长短信拆分也必须在适配器里处理,超过70字按67字一条拆分,分别计费。还有短信模板的审核状态,适配器要定时同步更新到平台,文案被服务商打回时运营才能及时知道。

邮件适配器相对简单些,核心要处理的是发件域名预热、SPF和DKIM配置、退信解析、以及对“进垃圾箱率”的监控。新域名刚开始发邮件时,直接发大量营销邮件非常容易被邮箱服务商判垃圾,所以要先做低量预热,逐步提升日发送量。邮件到达率没有实时回执,只能靠用户点击和打开来推测,所以查询状态的逻辑是低频拉取加统计模型。

Push适配器的坑主要在设备Token上。用户卸载App或长时间不打开,厂商Token就会失效。我们维护了一个Token库,发送前先过滤掉近期失效的Token,能省不少无效请求。厂商的送达回执也不完整,有的厂商只上报“已发送到厂商服务器”而不报“已到达设备”,所以Push到达率的统计口径必须在文档里写清楚,否则运营会拿错误数据来质疑你。

Webhook适配器则可以复用已有的HTTP客户端能力,核心是验签和超时重试。回调方会用自己的私钥对请求体签名,我们配好公钥在适配器里验签,验签失败直接丢弃,防止伪造回调。

5.2 供应商调用的超时、熔断与配额保护

供应商是外部系统,稳定性完全不在我们控制范围内。因此,消息中心对待每一个供应商调用,都要默认它“随时可能挂”。具体到工程实现就是三件套:超时、熔断、配额保护。

超时是基础。短信接口我们设的是3秒,邮件接口给5秒,push厂商通道给2秒。超过就置为失败,进入重试逻辑,不占用线程死等。重试不能无脑重试,退避策略我们用指数退避加随机抖动,序列是30秒、1分钟、2分钟、5分钟,最多四次。重试和熔断要联动:如果同一个渠道连续失败超过50次,或成功率降到50%以下,熔断器打开,后续请求不再进入这个渠道,直接走备用渠道,进入半开状态后放少量流量测试,恢复了才关闭熔断。

配额保护是很多团队忽略的细节。每家供应商给你的套餐都有QPS和每日条数上限,超限了不是报错就是被停服。我们在渠道配置里维护了实时用量计数,发送前先判断今天的用量余量,余量不足就切换渠道。这样能有效避免“月底套餐突然用超,财务收到巨额账单”的尴尬局面。

做这块时必须做好数据补偿:如果发送到渠道超时,但渠道实际已经发出去了,这时候消息状态会显示失败并触发重试,同一个用户可能收到两条一模一样的系统通知。要规避这个问题,只能尽力而为地做“查询确认发生后再重试”,也就是超时后先调用一次queryState,确认是否已投递给渠道,再决定是否需要重试。这也是我之前说幂等很重要的原因。

6. 可观测性建设:消息轨迹与监控告警

6.1 全链路Trace:用户说“没收到”时的破案工具

消息中心上线后,日常最多的工单就是“我收不到验证码”。客服盲猜解决不了问题,必须靠数据说话。所以消息轨迹查询能力就是消息中心自己的“数据库日志分析系统”。

我们的状态机设计为:INIT(已接入) -> PROCESSING(处理中) -> SENT(已投递给渠道) -> DELIVERED(渠道确认送达) -> FAILED(发送失败) -> EXPIRED(超过有效期未送达) -> DEAD(重试次数耗尽进入死信)。每一次状态变迁都会写入一张state_change表,记录变更时间、变更前后状态、操作人/任务ID、关联的渠道返回报文。为了控制表膨胀,状态变更记录只保留近90天,90天前的迁移到归档表。

查询后台支持按messageId、traceId、手机号、业务单号去搜索。用户投诉“没收到验证码”时,客服输入手机号,一分钟之内就能看到这条消息最终是失败、被拦截、还是根本没进平台。这一步实操下来节省了大量解释成本,也让业务方对平台有了信任感。

轨迹数据还承担审计职责。发送内容涉及用户个人信息,平台必须留痕,什么时间、什么系统、用什么模板、发给谁、结果如何,全部可查。我们在消息记录表里做了加密存储和脱敏展示,查询权限严格区分到人。

6.2 监控指标与告警规则:把故障扼杀在三分钟内

消息中心是高流水、高依赖外部系统的平台,没有监控告警就是裸奔。我们的监控体系分三层。

第一层是核心业务指标:总发送量、成功量、失败量、重试量、过期量、到达率。按业务类型、渠道、模板三个维度分别统计。第二层是渠道健康指标:每个供应商的实时成功率、平均耗时、P99耗时、告警触发次数、熔断状态。第三层是系统资源指标:MQ积压量、消费者线程池活跃度、Redis内存占用、Worker Pod的CPU和内存。

告警规则要少量但敏锐。我踩过的坑是告警太多、值班人员麻木,真出问题时反而被淹没。所以规则最终沉淀为六条:整体成功率低于95%告警、单个渠道成功率低于90%告警、MQ积压超过5万条告警、某渠道连续失败超过50次告警、单条消息重试超过三次告警、状态机出现非法跳转告警。每条告警都配上处理手册,值班人员照着执行即可,不依赖“老师傅”经验。

有了这些数据之后,我们的月度复盘就轻松了。按模板统计的“失败率Top10”直接推动优化文案和发送策略,按渠道统计的“到达率和成本”则成为和供应商谈判的筹码。数据会让平台的价值变得可量化,这是项目长期能运营下去的关键。

7. 常见问题与排查实践记录

7.1 案例一:某短信渠道大面积失败

那天下午告警突然弹出,某短信渠道成功率降到30%。第一反应不是去翻日志,而是看监控面板确认是普遍故障还是个别模板问题。进一步核实发现,整个渠道的发送请求超时占比明显升高,供应商状态页显示正在紧急维护。我们立刻打开了熔断开关,把流量切到备用渠道。同时让值班同事联系供应商确认恢复时间,营销类低优先级短信直接暂停,只保留验证码类时效消息走备用通道。

整个过程大约15分钟。核心经验是不要等供应商主动通知,也不要自己拿代码慢慢调,先把流量切走恢复业务,再排查根因。事后看,那次是供应商扩容导致网关不稳定。我们把这次的处置过程沉淀成了一条应急预案:任何渠道触发熔断,默认切备用、发公告、降级非核心消息,然后再调查。

7.2 案例二:验证码显示已到达,用户却死活收不到

这种工单最烦人:平台轨迹里渠道回执已经显示DELIVERED,但用户就是收不到。排查时先确认手机号和目标接口是否匹配,携号转网或手机号状态异常会导致实际未送达但回执误报。再查短信签名和模板是否被运营商拦截,有些号段对高频发送或含营销关键词的模板有额外策略。

我们遇到过最典型的场景:同一号码短时间内收到多条同模板验证码,被运营商按“高频骚扰”直接拦截,而渠道回执不会上报“被拦截”。应对策略是发送前做频率检查,发现同一手机号1分钟内多次请求同类模板,直接触发智能验证码策略,改为图片验证或延长有效期。这类运营策略需要和业务方达成一致,但确实能大幅降低“到达但未收到”的投诉量。

7.3 案例三:消息积压导致的连锁反应

大促凌晨的定时营销任务把MQ积压顶到了几十万条,消费者严重跟不上。一开始我们只是无脑扩消费者,结果发现供应商的配额不够,反而把渠道接口搞限流了,成功率和响应双降,成了恶性循环。后来调整了策略:先暂停低优先级任务,按业务优先级调整消费顺序,把过期消息快速判死不投递,再把高优先级消息的消费者数扩上去,最后才恢复低优先级业务。经过这次,我们把定时任务的调度策略改成了“散列发放”,把整点集中任务拆成按用户分桶、错峰执行,从源头上避免对MQ和渠道造成瞬时冲击。

这个案例教育了我:消息中心所有环节都是联动的,消费者扩容不是万能的,系统设计时要留出降级开关。平时就要想清楚“哪些消息可以放、哪些消息必须保”,并把这些决策固化成配置,而不是每次都在故障现场拍脑袋。

做这个项目最大的感悟是:消息平台最难的不是把某种渠道对接上,而是把不可靠的外部渠道、不可预期的业务流量、不可妥协的成本约束,揉进一个统一的系统里,还要让它可控、可观测、可干预。那些在文档里看起来轻飘飘的状态机、幂等、路由策略,落地时全是细节。如果让我从头再做一次,我会先把消息状态机和各环节的降级预案定义得更细再动代码,因为消息中心一旦跑起来,每一次重构都是在给线上用户表演“如何优雅地踩坑”。希望这篇复盘能帮你少走一些弯路。

RELATED READING

延伸阅读

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