news 2026/10/1 1:07:51

消息中心架构设计实战:三层治理与四段链路拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
消息中心架构设计实战:三层治理与四段链路拆解

接手消息中心这个项目的时候,我的第一反应是:这活儿看着简单,做起来能要人命。业务方给的需求永远是同一句话——“我们想给用户发个通知”,可真落到架构层面,你要面对的是模板散落在十几处代码里、短信通道被某个业务方的循环调用打爆、用户投诉一天收到八条重复推送、财务月底拿着几百万条短信账单找不到归属部门。消息中心的架构设计,本质上不是解决“怎么把一条消息发出去”,而是解决“怎么让几十个业务方在同一个管道里有序地发消息”。这篇文章我按自己实际做过的项目节奏来写,把模板、路由、策略这三层治理怎么收口,把受理、渲染、路由、发送四段链路怎么拆,把分库分表、幂等、限流降级这些绕不过去的地方一条条摊开讲。如果你正在做类似的系统,或者正在准备一场关于消息中间件的架构评审,这篇笔记里的踩坑记录应该能帮你少走几个月弯路。

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 以内,因为它直接被业务方的用户请求链路调用。这意味着受理层不能做任何重活——不查数据库、不渲染、不调通道。

它只做四件事:

  1. 参数合法性校验(模板编码存在、变量齐全、接收人数量在上限内)。
  2. 幂等键检查。幂等键由业务方提供,格式建议是业务标识:业务单据号:动作,比如order:2024080112345:shipped。
  3. 频控预检。这一步只需要读本地缓存里的计数器,不落库。
  4. 写任务记录并投递 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 消费端瞬间堆积,延迟几分钟。

解法有三个层次:

  1. 时间散列:在业务方指定时间的基础上加一个随机抖动,比如 10 点整的消息实际在 9:58 到 10:05 之间随机分布。这个抖动对业务完全无感,但对系统削峰效果显著。
  2. 提前预加载:定时扫描器提前 5 分钟把即将到期的任务加载到内存时间轮,避免在触发时刻查库。
  3. 分批投递:把同一时刻的大批量任务打散成多个批次投递到 MQ 的不同队列,让消费端可以并行处理,而不是挤在一个队列里排队。

抖动这一条是我最推荐的,实现成本最低,效果最直接。抖动范围建议控制在业务可接受的时间精度内,比如小时级任务抖到前后 5 分钟,天级任务抖到前后 30 分钟。

7.3 状态流转设计里最容易被忽略的回执

第三个坑是回执。我们最初的状态只有“已提交”和“发送失败”,认为提交成功就等于送达成功。上线后发现通道商返回的“提交成功”只是接收了请求,真正的送达结果要通过异步回执回调获取。这就导致一个问题:用户投诉没收到短信,我们查系统显示“成功”,双方各执一词。

补上回执之后,状态机变成了这样:

状态含义进入条件
INIT已受理任务创建成功
DISPATCHED已分发明细拆分完成
SUBMITTED已提交通道商接收请求成功
DELIVERED已送达收到通道商成功回执
FAILED发送失败通道商返回失败
UNKNOWN状态未知提交后超过回执超时时间未收到回执

UNKNOWN这个状态很关键。长短信和 Push 的回执延迟可能到几分钟甚至更久,如果一直等,明细状态会长时间悬空。我的做法是设置回执超时(短信 10 分钟,Push 30 分钟),超时未收到回执的标UNKNOWN,并计入对账任务。对账任务每天跑一次,主动去通道商拉取状态,把UNKNOWN收敛成确定状态。

回执接入之后还有一个附带收益:到达率终于可以算了。DELIVERED / SUBMITTED就是通道到达率,这个指标后来成了我们评估通道商的核心依据——同一个通道商换了接口版本之后到达率掉了 3 个百分点,靠这个指标及时发现并换回去了。

最后分享一个我在维护期总结的小习惯:每周把失败明细按错误码做一次聚合,看 Top 5 错误码的变化趋势。很多系统性的问题都是从这个趋势里提前发现的——某个错误码连续两周上涨,往往意味着某个业务方的参数在悄悄变坏,或者某个通道商的质量在下降。这个动作每周花不到半小时,但比任何监控大盘都灵敏。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 1:06:16

Vue prop类型校验失败警告:从排查到修复的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:06:15

WASM不是ESP32应用:硬件绑定与实时性本质辨析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:06:08

AI工程化从零搭建:数据版本控制、实验追踪与模型部署实战

1. 从零搭建AI工程能力:这个项目到底在解决什么问题第一次看到ai-engineering-from-scratch这个标题,我脑子里蹦出来的第一个念头是:又一个“从入门到放弃”的教程仓库?但翻了一圈之后发现,它想做的事情其实比“教你调…

作者头像 李华
网站建设 2026/10/1 1:05:04

Keil调试实战指南:从SWD连接、断点观察到FreeRTOS多任务排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:05:01

Android 10 Perfetto 命令行抓 trace 与 SQL 分析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华