消息中心架构设计实战:三层治理与四段链路拆解
发布时间:2026/10/1 1:07:58来源:尧图网络
接手消息中心这个项目的时候我的第一反应是这活儿看着简单做起来能要人命。业务方给的需求永远是同一句话——“我们想给用户发个通知”可真落到架构层面你要面对的是模板散落在十几处代码里、短信通道被某个业务方的循环调用打爆、用户投诉一天收到八条重复推送、财务月底拿着几百万条短信账单找不到归属部门。消息中心的架构设计本质上不是解决“怎么把一条消息发出去”而是解决“怎么让几十个业务方在同一个管道里有序地发消息”。这篇文章我按自己实际做过的项目节奏来写把模板、路由、策略这三层治理怎么收口把受理、渲染、路由、发送四段链路怎么拆把分库分表、幂等、限流降级这些绕不过去的地方一条条摊开讲。如果你正在做类似的系统或者正在准备一场关于消息中间件的架构评审这篇笔记里的踩坑记录应该能帮你少走几个月弯路。1. 消息中心不是发消息的接口而是把三件事收口的治理层很多团队对消息中心的第一版理解是提供一个 HTTP 接口传手机号、标题、内容内部调一下短信服务商 SDK返回成功。这个版本能跑但只要业务线超过三条它必然崩。因为问题从来不在发送本身而在发送之前的那些决策——用哪条模板、走哪个通道、给谁发、发几次、发失败了怎么办。这些决策如果留给业务方自己拍消息中心就退化成了一个纯粹的 SDK 包装器没有任何治理价值。1.1 四条业务线各自发消息时的真实混乱我在项目启动前做过一轮现状盘点把当时的混乱归成了四类这四类基本上也是所有中型公司都会踩的模板黑盒化。营销线把文案硬编码在 Java 类里做字符串拼接风控线把文案放在数据库表里但字段名叫content客服线的文案在配置中心。改一个错别字要发版加一个变量要动三个人。更麻烦的是合规审查——法务要看所有对外文案结果没人能一次给出完整清单。通道无隔离。短信通道只有一个账号所有业务共用带宽。大促时营销线批量群发占满了通道配额导致风控线的验证码延迟两分钟才到用户登录不了。这个问题的根因不是通道不够用而是没有按业务优先级做通道隔离和配额切分。触达无统计。运营想知道某次活动的到达率只能去问短信服务商的账单。站内信有多少未读、Push 有多少被系统拦截、邮件有多少进了垃圾箱全靠猜。没有统一回执就没有任何优化依据。用户无感知。用户想关掉营销推送但保留订单通知做不到。因为“订阅偏好”这个概念在系统里根本不存在每条业务线自己判断判断逻辑还各不相同。这四类问题的共同点它们都不是“发送”环节的问题而是发送之前和之后的治理缺位。所以消息中心的第一版设计目标必须是治理层不是通道层。1.2 收口的边界模板、路由、策略仅此三样确定要收口之后下一个问题是要收多少。我的建议是只收三样东西其他一律放给业务方否则消息中心会变成第二个业务中台谁都不敢动。模板收口。所有对外文案以模板为唯一单位管理模板包含渠道类型、标题、正文、变量声明、审核状态、生效时间。业务方只能引用模板 ID 加变量不能传裸文案。这一条能解决合规审查和文案统一代价是业务方要改一次代码。路由收口。业务方只声明“这是验证码类消息”“这是营销类消息”“这是订单状态变更”具体走短信还是 Push、走哪个通道商、失败了降级到哪全部由消息中心决定。路由规则集中在配置里业务方无感。策略收口。去重、频控、限流、重试、静默期全部在消息中心统一实现。业务方可以申请调整策略参数但不能绕过。这是最容易被业务方抵触的一条也是最有价值的一条。我特意没有收口的是“消息内容本身”和“发送时机”。内容属于业务语义消息中心不该懂发送时机属于业务逻辑消息中心只接受“现在发”或“某个时间点发”的指令。守住这条边界消息中心才能保持足够薄够薄才不会被业务需求拽着变形。1.3 为什么先做治理再做通道有个常见的项目节奏错误先花两个月把短信、Push、邮件、站内信四个通道全部对接完做成一个漂亮的多通道适配层再回头做治理。这么做的问题在于通道对接是纯体力活做完之后你对业务的理解仍然是零而治理设计需要大量的现状数据支撑——哪些模板重复、哪些通道配额紧张、哪些业务方调用量最大。我当时的做法是反过来先花三周把现有所有发送点位扫出来统计出模板清单和调用频次把最高频的二十个模板先迁进来通道只对接短信和站内信两个。跑一个月拿到真实的量级分布和失败率分布再设计路由规则和配额方案这时候每一个参数都有数据支撑而不是拍脑袋。剩下一堆通道对接反而是最后收尾的活。2. 领域模型怎么切一条消息从产生到送达要挨几刀模型设计决定了后面所有代码的形态。我见过最糟糕的一种做法是把“消息”做成一张大宽表业务方塞进来、发送状态改一改、结束。这种模型在量小的时候毫无问题量一大就全是坑——因为你没有办法表达“一条业务消息发给一万个人其中三个人发送失败需要重试”这种现实。2.1 模板与变量分离把文案从代码里赶出去模板表我只保留了必要的字段结构大致这样CREATE TABLE msg_template ( id BIGINT PRIMARY KEY AUTO_INCREMENT, template_code VARCHAR(64) NOT NULL COMMENT 业务方引用的唯一编码, channel VARCHAR(16) NOT NULL COMMENT SMS/PUSH/INBOX/EMAIL, title VARCHAR(128) COMMENT Push 与邮件的标题, content TEXT NOT NULL COMMENT 带占位符的正文, var_spec JSON NOT NULL COMMENT 变量声明名称/类型/必填/长度, audit_status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审 1通过 2驳回, effective_at DATETIME COMMENT 生效时间, expire_at DATETIME COMMENT 失效时间, UNIQUE KEY uk_code_channel (template_code, channel) );这里有两个设计细节值得展开。第一个是template_code channel做唯一键而不是只用template_code。原因是同一条业务消息在不同渠道上的文案往往不一样短信有 70 字限制要写短版站内信可以带链接和长说明。把它们做成同一编码下的不同渠道版本业务方调用时只需要传一个编码换渠道不用改代码。第二个是var_spec用 JSON 声明变量而不是在代码里约定。这个字段的价值在渲染环节才体现——渲染时如果业务方漏传变量或者传了个超长字符串系统能在渲染前就拦掉而不是把尊敬的${name}这种半成品发给用户。我在生产环境见过一次真实事故某业务方传 userName 时传了空字符串短信直接发出去变成“尊敬的您的订单已发货”几千条一起发客服电话被打爆。变量声明里我强烈建议加一个maxLength并且对文本类变量做敏感词和换行符过滤。换行符在短信里会计费异常一条变三条的情况我遇到过。2.2 消息任务与消息明细一对多必须提前定死这是整个模型里最容易被做错的一处。业务方调用一次发送接口可能对应一万个接收人系统内部必须拆成两层消息任务业务方的一次调用记录模板编码、变量、接收人范围、期望发送时间、业务方标识、幂等键。消息明细拆分后的单条记录一个接收人一条记录最终的渲染结果、通道、发送状态、回执、重试次数。为什么必须拆因为状态机不在同一个粒度上。任务的终态是“已受理”明细的终态才是“已送达”或“已失败”。如果把两者合成一张表你会遇到一个无解问题一万条明细里九千九百条成功、一条失败这张表的状态该是什么改成“部分成功”之后查询、重试、统计全部要做特殊处理代码复杂度会失控。拆开之后还有个附带好处任务的幂等键和明细的幂等键可以分别设计。任务的幂等键用来防重复提交比如业务方网络超时重试明细的幂等键用来防重复发送比如 MQ 重复消费。两层防护各管一段逻辑清晰。2.3 用户偏好与退订名单晚做一天就多还一天债这个模块我在第一版里是“预留”状态结果第二个月就被投诉逼着补上了。经验是偏好中心必须和消息中心同期上线哪怕它只是个最简陋的版本。最小可用版本包含三样东西维度作用实现要点用户级渠道开关允许用户关闭某渠道的营销类消息只对营销类生效验证码等强触达消息不受影响分类订阅按业务分类订单、物流、活动、安全订阅分类由消息中心统一定义不接受业务方自定义全局退订名单明确的拒绝触达用户所有通道发送前强制校验且要有本地的布隆过滤器缓存全局退订名单那一行特别关键。这个校验如果每次都查数据库高峰期会成为瓶颈如果加缓存又怕数据不一致导致误发。我的做法是 Redis 中存集合同时本地用布隆过滤器做一层短路——布隆过滤器判断“肯定不在退订名单里”的直接放行判断“可能存在”的再回查 Redis 确认。误判率控制住之后数据库压力能降两个数量级。3. 核心链路拆解一次发送请求在系统里走了多远链路设计我按四段来切受理、渲染、路由、发送。这四段的划分依据是“失败原因归属”——受理层失败是业务方参数问题渲染层失败是模板问题路由层失败是配置问题发送层失败是通道问题。职责一清楚排查时看错误码就能定位到段不用满系统翻日志。3.1 受理层参数校验、幂等键与业务去重受理层是同步接口要求响应时间控制在 50ms 以内因为它直接被业务方的用户请求链路调用。这意味着受理层不能做任何重活——不查数据库、不渲染、不调通道。它只做四件事参数合法性校验模板编码存在、变量齐全、接收人数量在上限内。幂等键检查。幂等键由业务方提供格式建议是业务标识:业务单据号:动作比如order:2024080112345:shipped。频控预检。这一步只需要读本地缓存里的计数器不落库。写任务记录并投递 MQ返回“已受理”。幂等键的存储我用的是 RedisSETNX加过期时间过期时间取业务上合理的重复窗口一般 24 小时。这里有个细节SETNX成功之后如果后续写库失败这个键就变成脏数据了导致业务方正常重试被拒。解决办法是把写库和投递 MQ 放在一个本地事务里事务失败时显式删除幂等键。或者更稳一点用状态机——键的值先写PROCESSING写库成功改成DONE如果读到的值是PROCESSING且超过 30 秒视为上次处理失败允许覆盖。3.2 渲染层模板引擎选型与变量缺失兜底渲染层是 MQ 消费端的第一站。模板引擎的选型上我推荐用轻量的字符串替换比如自己写占位符解析或者用 FreeMarker 的简单模式而不是引入完整模板语言。原因是消息文案的场景非常固定就是变量替换加少量条件分支引入强大模板引擎的代价是渲染性能下降、模板里能写的逻辑太多导致业务方把它当代码用、安全风险增加模板注入。变量缺失的处理策略必须提前定死我采用的是三档必填变量缺失直接判失败不发送记录错误码MISSING_VAR并告警给业务方。选填变量缺失用配置的默认值填充比如昵称默认填“用户”。变量值超长按渠道规则截断短信按 67 个字含签名和链接截断加省略号站内信不截断。第三档要特别注意。短信的计费单位是 70 个字符长短信按 67 计一条超长短信会拆成多条计费。所以渲染层必须做长度预检超过阈值的直接告警并拒绝而不是老老实实拆开发出去——我见过一次因为一个变量没控制长度单条短信拆成 7 条一个月多出十几万成本。3.3 路由层渠道优先级与降级链路路由层的输入是“消息分类 用户属性 当前通道健康度”输出是具体的通道和通道商。规则用配置表达大致长这样route: - scene: VERIFY_CODE # 验证码 channels: - { name: SMS, priority: 1, fallback: false } # 不降级短信失败就失败 - scene: ORDER_STATUS channels: - { name: INBOX, priority: 1, fallback: true } - { name: PUSH, priority: 2, fallback: true } - { name: SMS, priority: 3, fallback: false, condition: amount 500 } - scene: MARKETING channels: - { name: PUSH, priority: 1, fallback: true } - { name: INBOX, priority: 2, fallback: false }这张配置里有三个设计决策值得说。第一个是验证码类不做降级——验证码只有短信这一条路降级到 Push 用户看不到反而浪费时间窗口。第二个是订单状态类的三段降级先站内信成本为零、未读则 Push、金额大于 500 才补短信。这个规则是我们和运营一起算过账的站内信到达率大概七成Push 大概五成两者叠加之后补发短信的比例降到 8% 左右成本省下九成。第三个是营销类永远不降级到短信这是一条硬规矩写死在代码里而不是配置里防止有人在配置里改歪。3.4 发送层限流、批量聚合与通道隔离发送层是唯一真正和外部通道商通信的一层也是最脆弱的一层。这里要做三件事。批量聚合。短信和 Push 的通道商一般支持批量接口一次提交 100 到 500 个接收人。我的做法是在发送层做一个 200ms 的窗口聚合把同一模板、同一渠道、同一批变量的明细聚成一批提交。这么做能把 QPS 降低一到两个数量级但要注意聚合窗口会引入 200ms 的额外延迟验证码类消息必须绕过聚合直接单发。通道隔离。前面提到的营销挤爆验证码的问题解法是给每个通道按业务分类分配配额用独立的线程池和连接池。营销类走的是sms-marketing线程池配额用完直接排队或丢弃验证码走sms-critical线程池独立配额且优先级最高。两个池子是物理隔离的营销池子堵死了也不会影响验证码。限流。限流做在三个位置发送层入口按通道商的配额做全局限流按业务方做配额限流按接收人做频控比如同一用户 5 分钟内最多 3 条营销消息。限流的实现用令牌桶加本地缓存热点数据在本地避免每次都要访问 Redis。4. 存储设计消息明细表怎么扛住每天几千万条明细表是整个系统里数据量最大的表也是唯一一张必须从第一天就按分库分表设计的表。按每天两千万条算一年就是七十亿条单表根本撑不住。我的方案是按接收人 ID 做 HASH 分片分 64 个库或 64 张表。4.1 冷热分离与生命周期管理明细数据的访问特征非常集中90% 的查询发生在发送后 7 天内30 天后的查询基本只有客服工单和合规审查。据此我把数据分成三档数据年龄存储位置查询方式保留策略0 - 7 天在线库分片表主键或联合索引全量保留7 - 90 天在线库 归档标识走归档索引保留精简字段90 天以上对象存储 / 离线仓库异步导出按合规要求保留归档这一步我用的是定时任务每天凌晨跑把 7 天前的明细里的“渲染后内容”“回执明细”这些大字段清空只保留状态、通道、时间戳这些统计必需字段。这一步能把存储成本砍掉六成以上而且不影响任何统计报表——因为报表本来也不需要看具体文案。这里有个容易被忽略的点归档任务必须按分片逐个跑且每批限制条数中间要主动 sleep。我第一版没做限速凌晨归档直接把在线库的 IO 打满早上业务查询全部超时被投诉了一轮。4.2 分片键为什么选接收者 ID 而不是消息 ID这个选择取决于最核心的查询场景。明细表被查得最多的两个场景是C 端用户查询自己的消息列表按接收人查客服按用户查历史记录还是按接收人查。按接收人 ID 分片这两个场景都能精准命中单个分片查询效率最高。如果按消息 ID 分片那么“查某个用户的所有消息”就要扫全部 64 个分片这是不可接受的。反过来如果有一个场景是“查某个任务下所有明细”按消息 ID 分片更优——但这个场景我们可以用另一个办法解决在任务表里冗余记录聚合后的发送统计总数、成功数、失败数不查明细。分片的算法我用的是hash(receiverId) % 64而不是一致性哈希。原因是消息明细是只增不改的追加型数据分片数量在可预见的将来不会调整不需要一致性哈希的弹性扩容能力简单取模性能更好、定位更直接。如果确实需要扩容提前设计成 1024 个逻辑分片映射到 64 个物理库扩容时搬逻辑分片即可。4.3 索引与查询场景对齐明细表的索引只建三个多了会拖慢写入-- 主键分片内自增 PRIMARY KEY (id), -- 用户维度查询用户的消息列表 KEY idx_receiver_time (receiver_id, created_at), -- 任务维度补偿查某个任务下失败的明细 KEY idx_task_status (task_id, send_status)第三个索引是给重试和补偿用的。当某个任务的失败明细需要批量重发时走这个索引能快速捞出待重试的记录。注意索引顺序——task_id在前因为一个任务的明细天然聚簇在同一个分片同一个任务的接收人可能散落在多个分片所以这个查询要广播但每个分片内走索引很快。查询接口必须强制带上时间范围或者分页游标不接受无限制的全量拉取。我在网关层做了硬限制单次查询最多返回 50 条超过直接拒绝。这一条拦住过好几次因为业务方写错循环导致的慢查询。5. 可靠性不丢、不重、不乱三个抓手消息系统最怕的不是失败而是失败得不明不白——用户说没收到你查不到记录或者用户收到三条你查出来只发了一条。可靠性的目标就是把每一个不确定都变成确定。5.1 本地消息表 MQ 的最终一致性受理层写任务记录和投递 MQ 这两步天然存在不一致窗口写库成功、投递失败消息就永远不发出去了。解决方案是本地消息表模式——任务记录本身就充当本地消息表投递 MQ 的内容就是任务 ID。具体的消费侧逻辑是这样的// 消费端伪代码重点是幂等与状态推进 public void onMessage(TaskMessage msg) { Long taskId msg.getTaskId(); // 1. 状态推进CAS 从 PENDING 改成 PROCESSING boolean ok taskMapper.casStatus(taskId, PENDING, PROCESSING); if (!ok) { // 已经被处理过或者正在处理直接丢弃 return; } try { // 2. 拆分明细、渲染、路由、发送 dispatchService.dispatch(taskId); taskMapper.updateStatus(taskId, DONE); } catch (Exception e) { // 3. 不要吞异常让 MQ 重投同时记录重试次数 retryCounter.incr(taskId); if (retryCounter.get(taskId) MAX_RETRY) { taskMapper.updateStatus(taskId, DEAD); alarmService.alert(taskId, e); return; // 不再抛让它进死信 } throw e; } }这段代码的关键是第一步的 CAS。用状态机做幂等比在业务逻辑里到处判断“是不是已经发过”要可靠得多因为状态推进本身就是原子的。MQ 重复投递时CAS 失败直接返回什么都不会发生。5.2 幂等设计从业务键到内容指纹幂等分三层粒度从粗到细任务级幂等用业务方提供的幂等键防止一次业务动作产生多个任务。明细级幂等用taskId receiverId做唯一约束防止一次分发产生重复明细。内容级指纹对渲染后的内容做哈希配合时间窗口防止短时间内相同内容重复触达。第三层最容易被忽略但它对用户体验影响最大。场景是这样的业务方因为代码 bug在一秒内调了两次发送接口两次幂等键不同比如用了随机数但内容和接收人都一样。前两层幂等拦不住用户就会收到两条一模一样的推送。内容指纹的做法是把receiverId templateCode contentHash放 Redis设置 5 分钟过期命中就直接丢弃并打点。内容指纹会误伤一种合法场景用户确实需要收到两条相同文案的消息比如“您的订单已发货”对两个不同订单。所以指纹里必须带业务单据号不能只带内容。5.3 重试分层与死信处理重试策略必须按失败原因分层一刀切的重试会把问题放大。我分成三类可重试的通道错误网络超时、通道商限流、服务端 5xx。这类失败用指数退避重试间隔 1s、5s、30s、5min、30min最多 5 次。通道商限流时要注意重试的抖动加随机因子否则所有重试请求会在同一时刻再次撞墙。不可重试的永久错误接收人号码格式错误、用户已退订、模板审核未通过。这类直接标终态不重试但记录原因供业务方查询。需要人工介入的错误重试耗尽后仍失败、通道商返回未知错误码。这类进死信表触发告警由值班同学按预案处理——通常手段是换通道商补发或者通知业务方查问题。死信处理必须有一个“重放”入口。我的做法是在管理后台提供一个按任务 ID 重放的功能重放时重置状态为 PENDING 并重新投递 MQ。这个功能在大促期间救过场某个通道商临时故障半小时故障恢复后一键重放两万条消息十分钟内补齐。6. 大促前的容量与稳定性限流、熔断、降级怎么落地平时跑得再稳的系统到了大促都可能翻车因为大促的量级和调用模式都变了——平时是均匀分布大促是瞬间脉冲而且脉冲高度不可预测。消息中心在大促中的角色很特殊它是所有业务的“下游”几乎每个核心链路都会在关键节点调用它所以它必须比其他系统更保守。6.1 三级限流全局、业务方、用户限流我做了三层每层的目标和实现都不同。全局限流按通道商给的总配额切分目标是保护通道商接口不被打爆。实现用 Redis 的滑动窗口因为需要多实例共享计数。配额取通道商承诺容量的 80%留 20% 缓冲应对突发。超限的请求不是直接拒绝而是进入排队队列队列长度设上限比如 10 万条排队也满了才拒绝。业务方限流按业务方维度切配额目标是防止单个业务方挤占其他方的资源。配额在配置中心维护大促前一周和业务方逐个对齐并预分配。这里有个实践细节配额要区分“保障配额”和“弹性配额”保障配额内不拒绝超出部分按优先级竞争弹性池。用户级频控按接收人维度限制目标是保护用户体验和降低成本。规则是营销类同一用户 24 小时内最多 3 条5 分钟内最多 1 条通知类同一用户 1 小时内最多 10 条验证码类只做 1 分钟 60 秒的重复提交限制同一场景同一手机号 60 秒内不重复发。频控的实现在本地缓存做用 Guava Cache 之类的带过期时间的结构避免每次访问 Redis。代价是多个实例之间的计数不共享实际频控会比配置宽松一点。我觉得这个代价可以接受——频控的目标是挡住异常流量不是为了精确计数宽松一点反而避免误伤。6.2 通道故障时的自动降级与补偿通道商故障是必然会发生的问题在于你多久发现、发现后多久切换。我的方案是“自动降级 异步补偿”两步走。自动降级靠两个信号触发连续失败率超过阈值比如 1 分钟内失败率超过 30%或者接口响应时间 P99 超过阈值比如 3 秒。触发后立即切换流量到备用通道商同时在配置中心打标让所有实例同步状态。这里要注意切换的粒度——按业务分类切换而不是全量切换因为验证码类可能容忍更高的失败率宁可等一等也不要切换后发错而营销类可以立即停发。异步补偿针对的是降级期间失败的明细。故障恢复后从死信表里捞出这些记录批量重放。补发的时间点要选好——不要选在故障刚恢复的时刻因为这时通道商可能还在恢复中也不要拖太久用户对通知的时效性有预期。我的经验是故障恢复后等 2 分钟观察失败率稳定在低位再开始补发且补发要限速比如每秒 200 条避免补发流量本身把通道再打挂。6.3 灰度与开关新通道上线前必做新通道、新模板、新路由规则上线必须经过灰度。我的灰度做法是按业务方维度切流量先切一个调用量小、业务不敏感的业务方观察 24 小时看失败率、耗时、回执延迟三个指标没问题再逐步放大。开关设计上我要求每一个“可能出问题”的环节都有一个可以一键切换的开关且开关必须放在配置中心支持秒级生效。开关清单大致包括新通道启用开关、路由规则版本开关、批量聚合开关、内容指纹开关、降级策略开关。这些开关平时全部打开出问题时可以逐个关闭定位问题。大促期间我会打印一份开关清单贴在值班群置顶出问题时按清单快速操作不用现查。这里有个反向经验开关太多也是一种风险。我见过有人误关了内容指纹开关导致重复发送排查了两小时才想到是开关。所以开关的命名要极其明确且在管理后台记录操作日志谁在什么时候改了哪个开关必须可追溯。7. 上线半年踩过的坑与几条经验前面六节讲的是设计这一节讲的是实际跑起来之后暴露的问题。这些问题在设计阶段我全都没想到写下来的价值比设计文档更大。7.1 模板变量注入一次差点出事的透传第一个坑发生在模板的变量透传上。我们的站内信支持 HTML 内容业务方传的变量直接拼进模板。有一天风控同学发现某个用户把昵称改成了带标签的字符串站内信页面在渲染这条消息时执行了那段内容。虽然影响范围很小但如果那段内容里有恶意代码后果会很严重。修复方案分两层渲染层对所有变量做 HTML 转义只允许白名单的标签实际上站内信场景根本不需要用户输入的标签模板本身的内容也做审核禁止在模板里写脚本相关内容。另外补了一条站内信的展示端也要做一层 XSS 防护不能完全信任后端。这个坑的教训是只要用户输入能进入输出就必须转义消息系统也不例外。7.2 定时任务整点触发引发的连锁拥塞第二个坑是定时消息。我们支持业务方指定“明天上午 10 点发送”结果发现大量业务方都选了整点导致每天 10:00:00 这一秒要处理十几万条定时消息MQ 消费端瞬间堆积延迟几分钟。解法有三个层次时间散列在业务方指定时间的基础上加一个随机抖动比如 10 点整的消息实际在 9:58 到 10:05 之间随机分布。这个抖动对业务完全无感但对系统削峰效果显著。提前预加载定时扫描器提前 5 分钟把即将到期的任务加载到内存时间轮避免在触发时刻查库。分批投递把同一时刻的大批量任务打散成多个批次投递到 MQ 的不同队列让消费端可以并行处理而不是挤在一个队列里排队。抖动这一条是我最推荐的实现成本最低效果最直接。抖动范围建议控制在业务可接受的时间精度内比如小时级任务抖到前后 5 分钟天级任务抖到前后 30 分钟。7.3 状态流转设计里最容易被忽略的回执第三个坑是回执。我们最初的状态只有“已提交”和“发送失败”认为提交成功就等于送达成功。上线后发现通道商返回的“提交成功”只是接收了请求真正的送达结果要通过异步回执回调获取。这就导致一个问题用户投诉没收到短信我们查系统显示“成功”双方各执一词。补上回执之后状态机变成了这样状态含义进入条件INIT已受理任务创建成功DISPATCHED已分发明细拆分完成SUBMITTED已提交通道商接收请求成功DELIVERED已送达收到通道商成功回执FAILED发送失败通道商返回失败UNKNOWN状态未知提交后超过回执超时时间未收到回执UNKNOWN这个状态很关键。长短信和 Push 的回执延迟可能到几分钟甚至更久如果一直等明细状态会长时间悬空。我的做法是设置回执超时短信 10 分钟Push 30 分钟超时未收到回执的标UNKNOWN并计入对账任务。对账任务每天跑一次主动去通道商拉取状态把UNKNOWN收敛成确定状态。回执接入之后还有一个附带收益到达率终于可以算了。DELIVERED / SUBMITTED就是通道到达率这个指标后来成了我们评估通道商的核心依据——同一个通道商换了接口版本之后到达率掉了 3 个百分点靠这个指标及时发现并换回去了。最后分享一个我在维护期总结的小习惯每周把失败明细按错误码做一次聚合看 Top 5 错误码的变化趋势。很多系统性的问题都是从这个趋势里提前发现的——某个错误码连续两周上涨往往意味着某个业务方的参数在悄悄变坏或者某个通道商的质量在下降。这个动作每周花不到半小时但比任何监控大盘都灵敏。
网站建设高端定制企业官网