通知渠道抽象:站内信、短信、邮件与Push统一模型设计
“通知渠道抽象”这个词听起来像是架构评审会上才会出现的词可它离日常开发一点都不远。你只要做过一个稍微有点年头的业务系统就一定见过这样的场面订单模块自己注入了一套短信 SDK营销模块另外拼了一套邮件 HTMLApp 端的推送又躺在第三个小服务里而站内信干脆由业务同学直接往消息表里插数据。四种通知四套代码四种失败处理方式最后谁也不知道上个月到底有多少条通知真正送到了用户手里。这篇文章聊的就是怎么把站内信、短信、邮件、Push 这四种形态差异极大的东西收进一个统一模型并且在将来接入第五种渠道时只需要新增一个实现类加一段配置而不是翻遍十个业务模块去改代码。这篇文章适合三类人做消息中心、增长系统、用户触达链路的同学正在被“短信发重了”“邮件进垃圾箱”“推送送不到”折磨的同学以及准备接手一块历史包袱、打算做重构的架构同学。文章里有表结构、有接口定义、有调度代码也有我这些年踩过的坑代码以 Java 为例但设计思路跟语言无关换成 Go、Python 一样能照着搭。1. 先把痛点摊在桌面上1.1 四处散落的发送代码长什么样我见过的典型形态是这样的一个OrderService里藏着三行调用短信客户端的代码短信模板 ID 硬编码在业务方法里一个MarketingJob自己用字符串拼 HTML 邮件正文样式靠内联 style 硬写推送逻辑在一个定时任务里设备令牌从一张临时表读站内信最省事直接insert into user_message连个统一的消息 ID 都没有。每套代码都有自己的重试策略短信失败重试三次邮件失败就记个日志算了推送压根不管结果。这种结构最要命的地方不是代码丑而是任何一次变更都会变成一场考古。某团队遇到短信服务商调价要换供应商一个 grep 搜出四十多处调用点涉及七个模块、三个定时任务其中两处还是半夜跑的对账任务改完之后测试花了两周。换模板、改签名、加频控、做发送量统计每一次都等于把上面这套痛苦再走一遍。更麻烦的是数据口径短信发送量在 A 表邮件发送量在 B 日志推送在 C 平台的报表里运营想问“这个月触达了多少用户”你得同时打开四个后台手工对。1.2 抽象要解决的五个真问题很多人以为渠道抽象就是“定义一个接口让短信和邮件都实现它”这只是表面功夫真做起来会发现值钱的部分全在接口之外。我把这层抽象要解决的问题归成五类按重要性排调用方式的统一业务侧只说“给这个用户发一条订单发货通知”不关心走哪个渠道、用什么模板、失败怎么办。业务代码里不应该出现任何渠道商 SDK 的类名。内容与渠道的解耦一份业务事件比如“付款成功”对应一份结构化数据站内信渲染成图文卡片短信渲染成几十个字的短文本邮件渲染成 HTML推送渲染成标题加摘要。同一个事件四种表达。状态与回执的闭环发出去不等于送达。短信有运营商回执邮件有退信和投诉推送有厂商的送达回调站内信有已读状态。这些异构的反馈必须收敛成一套统一状态否则运营看到的永远是“已提交”而不是“已送达”。失败重试与降级短信网关抖一下、邮件服务商限流、推送通道超时这些是常态。重试要有退避、要有上限、要有幂等降级要有顺序、要有兜底。配额、频控与审计谁在什么时候给谁发了什么花了多少钱有没有人被短信轰炸这些既关系到用户体验也关系到真金白银。注意这五件事里只有第一条是“接口设计”问题后面四条全是工程问题。所以我一直认为通知渠道抽象的重点不在接口长什么样而在于围绕接口建立的那套运行时。2. 统一模型的骨架一条通知从产生到闭环2.1 消息实体把“内容”和“渠道”彻底拆开我在设计这套东西时第一刀切的是实体分层。很多人上来就定义一个Message里面塞了渠道类型、模板 ID、接收人、标题、正文、状态结果发现同一条业务通知要发三个渠道时只能复制三份实体状态互相打架。正确的做法是分三层第一层是通知事件NotificationEvent描述业务上发生了什么比如“订单 12345 已发货”携带一组结构化变量不含任何渠道信息。第二层是发送任务NotificationTask由事件根据路由规则扇出而来一个事件可以对应 N 个任务每个任务绑定一个渠道、一个模板、一个接收地址。第三层是发送尝试NotificationAttempt每次真正调用渠道商就是一条记录一个任务可以有多次尝试。这三层的好处是责任清晰事件层只关心业务语义任务层负责编排和状态机尝试层负责记录每一次网络调用。排查问题时价值巨大——用户投诉没收到短信你打开任务表看到状态是“已提交”再打开尝试表看到第一次调用超时、第二次返回“号码在黑名单”答案一目了然。如果只有一层实体你只能看到一条记录翻来覆去被改历史全丢了。2.2 渠道接口五个方法定生死接口我倾向于做得极简方法多了实现方会偷懒。核心就五个public interface NotificationChannel { ChannelType type(); ChannelCapability capability(); SendResult send(ChannelRequest request); DeliveryStatus query(String channelMessageId); ChannelHealth health(); }逐个说明为什么是这五个。type()返回渠道枚举用于路由和统计没什么好讲的。capability()是这个接口里最容易被忽略但最有用的一个它返回一份能力描述最大内容长度、是否支持富文本、是否支持图片、是否支持送达回执、是否需要预先审核模板、单条预估成本。上层调度器拿到这份描述就能在渲染前做校验——比如发现短信渠道最大长度 70 字而模板渲染出来 130 字直接在任务层标失败并告警而不是等渠道商返回一个语焉不详的错误码。send()是唯一的写操作入参ChannelRequest里只放渠道无关的字段接收地址手机号、邮箱、设备令牌、用户 ID 四选一、渲染好的内容、模板标识、幂等键、扩展参数 Map。返回值SendResult必须包含三样东西本次调用是否被渠道商接受、渠道侧返回的消息 ID用于后续查回执、失败时的错误分类可重试、不可重试、参数错误、配额耗尽。query()用来主动查回执不是所有渠道都支持能力描述里会标明。health()返回最近一段时间的成功率、平均耗时、熔断状态供调度器决定要不要降级。提示ChannelRequest里那个扩展参数 Map 是必要的妥协。不同渠道总有一些私有参数比如推送要不要响铃、邮件要不要带附件、短信要不要走营销通道。与其为了统一而砍掉这些能力不如留一个受控的口子但约定只能放渠道私有的、非必需的参数不能把核心字段塞进去。2.3 状态机与回执发送不是一锤子买卖任务的状态机我定成六态PENDING待发送、SENDING发送中、SUBMITTED渠道已接受、DELIVERED确认送达、FAILED终态失败、EXPIRED超时未确认。前三个态由我们控制后三个态要么来自渠道回执要么来自超时判定。这里有个反直觉的点SUBMITTED不等于成功。短信网关返回“提交成功”只代表它收下了最终能不能到用户手机上要看运营商回执邮件的“已发送”只代表交给了投递服务进不进收件箱另说推送的“成功”很多时候只是厂商服务器接受了请求。如果产品经理看到的报表把SUBMITTED算成送达那这个数字的水分会非常大。我的做法是报表上分两列展示提交量和送达量中间差额单独标一个“待确认”运营看久了自然就懂。回执的处理用一条独立的异步链路渠道侧的回执通过回调接口或者定时轮询进来落到attempt表然后触发任务状态推进。这里必须做幂等因为回执可能重复推送也可能乱序到达先收到“送达”再收到“提交成功”。我的处理方式是在 attempt 表上记录状态版本号只允许单向推进低版本的回执直接丢弃。3. 四类渠道的差异化细节怎么塞进同一个模型3.1 站内信本质是一次带索引的写库站内信是所有渠道里最“没有技术含量”的但也最容易做拧巴。它的本质就是一次写库加一次查询可一旦涉及未读数、已读状态、消息分类、批量已读、过期清理复杂度就上来了。我在模型里对站内信做了两个特殊约定。第一站内信是必定成功的渠道。它不依赖外部服务只要数据库可用就能落库。所以我把站内信放在降级链的最后一环任何外部渠道失败都可以退到站内信兜底。这意味着站内信的发送不应该走异步队列的强依赖路径或者说即使队列积压站内信也应该有一条同步旁路保证消息不丢。第二站内信的“回执”是已读状态而不是送达状态。用户打开 App 拉取消息列表那一刻就算送达点开详情才算已读。这两个状态在表结构上要分开字段delivered_at和read_at各存一份未读数用read_at is null来算配合(user_id, read_at, created_at)的联合索引十万级消息量下分页查询依然很快。注意站内信表不要跟通知任务表混在一起。业务侧的站内信查询是高频读通知任务表是高频写加低频查混在一张表里会影响读写性能而且业务查询会被通知系统的字段污染。我的做法是任务表里存一份biz_id站内信落地时写入独立的user_inbox表两边通过biz_id关联。3.2 短信模板、签名、频控三座大山短信是四种渠道里外部约束最多的。内容不能随便写必须用报备过的模板模板里的变量有格式要求签名必须提前申请不同业务线用不同签名长度按计费规则算中文单条 70 字超过就按 67 字一条拆分拼接拼接失败会导致乱码或者直接丢字。这三条决定了短信渠道的实现必须做严格的前置校验。我踩过最典型的坑是变量拼接导致模板不匹配。业务侧传进来一个商品名里面带了个空格或者特殊符号渲染出来的内容和报备模板的格式对不上渠道商直接拒绝。后来我在渲染层加了一道正则校验模板定义里每个变量都声明类型和长度上限渲染前先校验不合格直接判为参数错误不浪费一次调用。频控是另一个重灾区。同一个用户短时间内收到五条营销短信轻则卸载 App重则投诉到监管渠道。我的方案是三层频控单用户单场景每日上限、单用户全局每日上限、单渠道全局每分钟上限。前两层用缓存计数器实现key 设计成freq:{userId}:{scene}:{yyyyMMdd}第三层用令牌桶限流。频控命中后任务状态标为FAILED错误分类记为“频控拦截”这个分类很重要因为它不算真正的失败统计报表里要单独剔除否则你会看到成功率莫名其妙掉下去。3.3 邮件富文本与投递质量邮件的技术难点不在发送在“进不进收件箱”。发送本身很简单一份 HTML 加一个收件地址就完事但用户看不到邮件的原因千奇百怪进了垃圾箱、被企业邮箱拦截、域名信誉不足、图片被默认屏蔽、样式在某个客户端里全乱。这些问题的根子大多在发信域名的配置上跟代码关系不大但代码层面能做的优化也不少。第一个优化是模板必须用表格布局加内联样式。很多邮件客户端会剥离style标签和外部 CSS你写的 flex 布局在那些客户端里会塌成一团。我的做法是模板引擎渲染完之后再过一道内联化处理把 CSS 规则塞进元素的 style 属性里。第二个优化是纯文本兜底每封邮件都附带一个纯文本版本部分客户端或者用户的阅读偏好会走这个版本。第三个优化是退信处理硬退信地址不存在要立即把地址标记为无效并从后续发送中剔除软退信临时故障走重试投诉则直接进全局黑名单。这里有个容易被忽略的细节邮件渠道的query()方法基本查不到实时状态因为投递结果要么通过退信回调异步回来要么根本拿不到。所以能力描述里我把邮件的“支持回执”标成 false状态机最多推进到SUBMITTED再往后就只能靠退信事件来修正。这一点必须提前跟产品说清楚不然他们会在报表上追问为什么邮件送达率永远是零。3.4 Push设备令牌和厂商通道的坑推送是四种渠道里状态最不可控的。移动端的推送要经过厂商通道通道本身有自己的限制单设备每天有推送条数上限、消息体有大小限制、部分通道不支持富媒体、折叠规则各不相同。再加上设备令牌会失效、用户会关闭通知权限、App 卸载后令牌还挂在服务端实际送达率能做到七成就已经不错了。我的处理思路是把设备令牌当成一份需要持续维护的资产。每次 App 启动上报令牌时记录设备型号、系统版本、通知权限状态、最后活跃时间令牌失效的回执回来时立刻在令牌表上打失效标记而不是物理删除保留三十天用于分析超过六十天没有活跃的设备令牌降级为“低优先级”不再参与营销推送只发关键的账号类消息。另一个细节是消息的时效性。推送任务里要带一个过期时间超过这个时间还没送达就自动作废。促销类推送设置两小时验证码类设置五分钟系统通知可以设一天。如果没有这个字段你会遇到用户半夜收到白天活动推送的尴尬场面而这类问题在事后复盘时几乎无法归因。4. 路由、模板与降级抽象层之上的策略设计4.1 路由策略谁来决定走哪个渠道渠道抽象解决了“怎么发”路由解决“发给谁、用哪个渠道发”。我把路由拆成两层场景配置层和运行时决策层。场景配置是一份声明式的规则写在配置中心里形如“订单发货场景首选短信短信失败降级站内信不降级邮件”。运行时决策则在配置基础上叠加用户偏好、免打扰时段、渠道健康状态三个动态因子。举个例子同一条营销通知用户 A 在 App 内活跃、开启了推送权限那就走推送用户 B 关闭了推送但绑定过邮箱走邮件用户 C 什么都没有只能走短信但他昨天刚收过三条营销短信频控命中那就整个场景放弃发送落一条“因频控未触达”的记录。这套逻辑如果写死在代码里每加一个场景就要改一次写成配置之后运营自己就能调。配置的结构我大致这么设计scene: order_shipped channels: - type: SMS priority: 1 template: tpl_sms_ship_v3 fallback: true - type: INBOX priority: 2 template: tpl_inbox_ship_v3 fallback: false - type: PUSH priority: 3 template: tpl_push_ship_v3 fallback: true quiet_hours: 22:00-08:00 quiet_hours_policy: DELAY max_cost_per_send: 0.05priority决定扇出顺序fallback决定失败后是否继续往下一档走quiet_hours管免打扰max_cost_per_send是一道成本阀超过这个单条成本的渠道直接跳过——这条规则救过我一次某次配置误操作把所有通知都路由到了国际短信通道单条成本涨了十几倍靠这个阈值当天就拦住了。4.2 模板渲染变量、占位符与多语言模板这块我的原则是“模板即配置代码零改动”。模板存两张表模板主表和模板版本表。主表存模板标识和当前生效版本版本表存每个版本的内容、变量定义、审核状态、适用渠道。为什么要版本因为模板改错了是要出事故的有版本就能一键回滚而且历史任务重放时能拿到当时那个版本的内容。变量定义是模板里最值得花心思的部分。每个模板显式声明它需要哪些变量、什么类型、长度上限、是否必填。渲染前先做一遍校验缺变量、类型不对、超长统统在渲染阶段就拦下不让脏数据流到渠道商那里。我们有过一次事故模板里少传了一个收货人姓名渲染出来的短信是“您好您的包裹已到达自提点”读起来不算太怪但用户完全不知道是哪个包裹客服电话被打爆。加了必填校验之后这类问题再没出现过。多语言的处理方式是模板标识里带语言后缀比如tpl_sms_ship_v3_zh、tpl_sms_ship_v3_en用户的语言偏好存在画像里路由时带上。这里不建议在渲染阶段做翻译因为翻译质量的不可控性太高尤其涉及专业术语和品牌词人工审核过的模板永远比机器翻译靠谱。4.3 降级与兜底短信挂了怎么办降级链条必须有明确的终点。我的设计是一条三级链主渠道失败 → 备用渠道 → 站内信兜底。站内信之所以能兜底是因为它不依赖任何外部服务落库即成功。哪怕用户在免打扰时段站内信也应该照常落库只是不触发提醒红点。降级的触发条件要谨慎。不是所有失败都值得降级参数错误降级没意义因为换个渠道一样错频控拦截降级到别的渠道可能会让用户体验更差本来就不该发只有网关超时、渠道熔断、配额耗尽这三类才触发降级。我给每个错误分类打了标记只有RETRYABLE和CHANNEL_UNAVAILABLE两个分类会走降级逻辑。熔断用最朴素的方式实现就够了滑动窗口统计最近一分钟的成功率和平均耗时成功率低于 80% 或者平均耗时超过两秒直接打开熔断开关五分钟后半开试探一次。这套逻辑不需要引入复杂的组件一个定时任务加一个内存计数器就能搞定别为了熔断专门搭一套基础设施。5. 扩展方式新渠道接进来要改几行代码5.1 SPI 插件化与自动注册新渠道接入的成本是衡量这套抽象是否合格的唯一标准。我的目标是新增一个渠道只在渠道模块内新增代码不动调度器、不动业务模块、不动表结构。实现方式是 Java 的 SPI 加 Spring 的自动装配。每个渠道实现打成独立的 jarjar 内部声明META-INF/spring.factories或者新版本的AutoConfiguration.imports把渠道实现类注册成 Bean。调度器启动时把所有NotificationChannel实现收集到一个 Map 里key 是type()的返回值。业务代码永远面向接口新增渠道时只要改配置中心里的路由规则把新渠道的 type 加进去就能生效。这里有个小细节值得说渠道实现类必须是无状态的所有状态都放在请求对象或者外部存储里。因为调度器可能用线程池并发调用有状态的实现类会互相干扰。我一开始就定了这条规矩后面加渠道的人照做没出过问题。5.2 配置驱动的渠道能力描述capability()返回的那份描述我建议做成配置化的而不是硬编码在实现类里。原因是同一个渠道不同通道的能力可能不一样短信的验证码通道支持更长的内容营销通道不支持变量邮件的事务通道保证投递批量通道不管退信。把这些差异写成配置运营调整通道时不用发版。能力描述大致长这样能力项短信邮件站内信推送最大内容长度70 字200KB无限制4KB支持富文本否是是有限支持回执是否是已读部分需要预审模板是否否否免打扰生效是是否是单条预估成本0.04 元0.001 元00这张表不光是给调度器看的也是给新人看的。一个刚入职的同学不需要读实现代码看这张表就知道每个渠道的边界在哪。5.3 灰度接入与双写校验新渠道上线尤其是替换老渠道时最保险的做法是双写。老链路照常发新链路并行发一份到一个不触达用户的测试地址池对比两边的渲染结果和调用耗时。跑够一周、样本量足够之后再按用户 ID 分桶逐步切流10%、30%、100%。双写期间最容易发现的问题不是功能错误而是性能差异。某次切换推送通道功能完全正常但新通道的 P99 耗时是老通道的三倍导致调度线程池被打满连带影响了短信的正常发送。这种问题只有在真实流量下才暴露得出来测试环境的那点 QPS 根本测不出来。6. 落地实现一条可复现的最小可用链路6.1 表结构设计三张核心表尽量精简CREATE TABLE notification_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL COMMENT 业务事件ID用于幂等, scene VARCHAR(64) NOT NULL COMMENT 场景标识, channel_type VARCHAR(16) NOT NULL, receiver VARCHAR(128) NOT NULL COMMENT 手机号/邮箱/设备令牌/用户ID, template_code VARCHAR(64) NOT NULL, template_version INT NOT NULL, payload JSON NOT NULL COMMENT 渲染变量, status TINYINT NOT NULL DEFAULT 0, attempts INT NOT NULL DEFAULT 0, next_retry_at DATETIME DEFAULT NULL, expire_at DATETIME DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_event_channel (event_id, channel_type), KEY idx_status_retry (status, next_retry_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE notification_attempt ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id BIGINT NOT NULL, attempt_no INT NOT NULL, channel_message_id VARCHAR(128) DEFAULT NULL, result TINYINT NOT NULL COMMENT 0成功 1可重试 2不可重试 3频控, error_code VARCHAR(64) DEFAULT NULL, error_msg VARCHAR(512) DEFAULT NULL, cost_millis INT DEFAULT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_task_attempt (task_id, attempt_no), KEY idx_channel_msg (channel_message_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE notification_template ( id BIGINT PRIMARY KEY AUTO_INCREMENT, template_code VARCHAR(64) NOT NULL, version INT NOT NULL, channel_type VARCHAR(16) NOT NULL, content TEXT NOT NULL, variables JSON NOT NULL COMMENT 变量定义名称、类型、长度、必填, audit_status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, UNIQUE KEY uk_code_version (template_code, version) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_event_channel这个唯一索引是幂等的第一道防线。业务侧重复投递同一个事件时第二笔插入直接失败从源头杜绝重复发送。idx_status_retry支撑重试扫描扫描条件是状态为待发送且next_retry_at小于当前时间走这个索引避免全表扫描。6.2 核心代码骨架调度器的主循环我写成这样去掉业务细节后大概三十行Component public class NotificationDispatcher { private final MapChannelType, NotificationChannel channels; private final TaskRepository taskRepo; private final TemplateRenderer renderer; public void dispatch(NotificationTask task) { NotificationChannel channel channels.get(task.getChannelType()); if (channel null) { taskRepo.markFailed(task.getId(), CHANNEL_NOT_FOUND); return; } if (!channel.health().isAvailable()) { taskRepo.scheduleRetry(task.getId(), nextBackoff(task.getAttempts())); return; } RenderResult content renderer.render(task); if (!content.isValid()) { taskRepo.markFailed(task.getId(), RENDER_INVALID); return; } ChannelRequest request ChannelRequest.builder() .receiver(task.getReceiver()) .content(content.getText()) .templateCode(task.getTemplateCode()) .idempotentKey(task.getEventId() : task.getChannelType()) .build(); SendResult result channel.send(request); taskRepo.recordAttempt(task.getId(), result); } private Duration nextBackoff(int attempts) { long[] seconds {5, 30, 120, 600, 1800}; return Duration.ofSeconds(seconds[Math.min(attempts, seconds.length - 1)]); } }退避序列定成 5 秒、30 秒、2 分钟、10 分钟、30 分钟一共五次尝试总跨度约 45 分钟。这个参数不是拍脑袋来的5 秒覆盖网络瞬时抖动30 秒覆盖渠道侧短暂限流后面三档覆盖较长时间的服务不可用。超过 45 分钟还没发出去的通知时效性基本已经没了继续重试只会增加无效成本。6.3 异步化与重试队列同步调用渠道商是绝对不行的一个 HTTP 调用动辄几百毫秒赶上渠道抖动能拖到几秒直接把业务线程占满。我的方案是业务侧只做事件落库落库成功立即返回真正发送交给独立的消费者线程池。这里有一个取舍用消息队列还是用数据库轮询。队列的实时性好但引入了额外的组件依赖和运维成本数据库轮询实现简单靠idx_status_retry索引也能做到秒级延迟缺点是扫描频率和数据库压力要平衡。我的选择是数据库轮询加多实例抢占扫描间隔两秒每批取 200 条用update ... where status 0 limit n的乐观抢占方式避免多实例重复处理。中小规模下这套方案足够用日均百万级以下完全扛得住。提示多实例抢占时一定要加实例标识和超时保护。我用locked_by和locked_at两个字段抢占时写入实例 ID处理超时比如超过五分钟的任务允许被其他实例重新接管。没有这层保护一次进程崩溃会留下一批永远卡在“发送中”的僵尸任务。7. 常见问题与排查实录7.1 重复发送与幂等重复发送的根因通常有三种业务侧重试、调度侧重复扫描、渠道侧超时但其实已成功。第一种靠唯一索引兜住第二种靠抢占锁和状态机兜住第三种最难缠——渠道商收到了请求但响应超时你重试一次就变成两条。解法是强制给每个渠道的调用带幂等键。短信和邮件的服务商大多支持业务侧传一个唯一标识用于去重推送的部分通道也支持折叠键。对于不支持的渠道只能在本地做去重窗口同一个event_id channel_type在十分钟内只允许调用一次重试时先查 attempt 表确认上次调用的结果如果上次是超时状态宁可不重试也不冒险重发。这个取舍听起来保守但短信重复发送的用户投诉成本远高于一条通知没发出去。7.2 发送成功但用户没收到这是排查工单里占比最高的一类。我的排查顺序固定成一条链路基本十分钟内能定位排查环节检查内容常见结论任务表状态是否为 SUBMITTED 及以上未到 SUBMITTED 说明卡在本地尝试表每次调用的返回码和耗时超时/限流/黑名单渠道后台用自己的单号反查渠道侧记录渠道侧未收到请求接收地址号码/邮箱/令牌是否有效地址错误或已失效用户侧是否退订、是否开权限、是否进垃圾箱用户主动屏蔽实践中大部分问题出在第四和第五环。手机号少一位、邮箱域名写错、设备令牌换了手机之后失效、用户点了退订、邮件被归类到推广文件夹这些都是纯业务侧的确认工作跟系统设计关系不大。真正需要系统背锅的是第二环比如某个渠道的错误码被误判为可重试导致反复重试却永远失败。7.3 频控误伤与配额管理频控规则写得太宽或者太窄都会出问题。太宽了用户被轰炸太窄了正常通知发不出去。我遇到过一次典型事故全局频控配成了“单用户每小时最多三条”本意是限制营销结果把验证码也限进去了用户连续登录失败三次之后第四条验证码直接被拦客服当天收到一堆投诉。修正的办法是给频控加维度营销类走严格频控事务类验证码、订单、支付、安全提醒走宽松频控甚至豁免。配置上给每个场景打一个category标签频控规则按标签匹配而不是按渠道匹配。这个改动很小但把误伤率直接降到了接近零。配额管理是另一回事主要针对有成本的外部渠道。做法是按天统计每个场景的消耗设置日预算和月预算超预算时把非关键场景自动降级到免费渠道同时给运营发告警。预算数据就放在 attempt 表里按天聚合不需要额外的计费系统。8. 我踩过的坑与实操心得8.1 几个反直觉的经验第一个经验不要把站内信当成“顺便发一下”的渠道。它在降级链的末端承担的兜底责任最重反而最需要保证可用性。站内信的落库路径要独立、要快、要不依赖任何第三方甚至要考虑数据库主从切换时的自动重试。第二个经验模板管理比渠道管理更难。渠道加一个就一个模板会随着业务迭代无限膨胀几百个模板散落在配置中心里命名混乱、版本随意、没人敢删。我的做法是强制模板命名规范场景_渠道_版本加一个每月自动扫描把超过六个月零调用的模板标为待清理由场景负责人确认后归档。第三个经验成功率这个指标会骗人。按任务算的成功率可能很好看因为大部分通知都走了免费的站内信按渠道算、按场景算、按用户分群算才看得出真实问题。报表至少要有三个维度渠道维度、场景维度、错误分类维度。我曾经盯着一个 99.2% 的总成功率很久直到按渠道拆开才发现短信渠道的成功率其实只有 71%被站内信的巨大基数掩盖了。8.2 演进节奏建议这套东西不要想着一次做全。我的建议是分三步走第一步先把四个渠道的调用收敛到一个接口里哪怕内部还是很粗糙先把“统一入口”这件事做成业务侧不再直接依赖渠道 SDK第二步补上模板管理、状态机和重试这时候系统开始具备可观测性第三步再做路由、降级、频控和配额进入精细化运营阶段。每一步之间隔两三个月是合理的因为每一步上线后都需要真实流量跑一段时间才能发现问题。我见过有团队想一次性把三步全做完结果上线时四个渠道同时切换出了问题完全无法定位是哪个环节的锅最后回滚重来浪费了更多时间。现在回头看通知渠道抽象这件事最难的部分从来不是设计接口而是说服所有人“我们必须走同一个入口”。技术上三个类就能解决组织上要花半年。接口定下来之后剩下的都是耐心活一个个渠道接进去一个个场景迁过来一条条错误码归好类报表一个个维度补齐。等哪天运营问“这个月触达了多少人”你不需要打开四个后台这套东西就算成了。