ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

设计拉黑也拉不掉的可靠消息通知系统:送达、重试与推拉结合

2026/9/8 4:43:26 拓冰建站 浏览量
设计拉黑也拉不掉的可靠消息通知系统:送达、重试与推拉结合 “苏冉这一周我给你打了125个电话整整125个”——这本是一个小说故事里让人心碎的场景丈夫拼命联系妻子却因为被拉黑一次次错过最终留下了永远无法弥补的遗憾。但如果把这个情节翻译成技术语言你会发现一个非常硬核的工程命题一个系统发了125次通知接收方却一次都没有收到问题到底出在哪在小说里这事叫“沟通悲剧”。在软件工程里这事叫“消息可达性失败”。我们日常开发的很多系统其实每天都在经历类似的“苏冉困境”用户关闭了 App 通知权限你发了 50 条 Push一条都到不了第三方短信通道限流验证码发不出去用户急得骂客服告警系统持续重试同一个失败的 Webhook既没送达还把链路打爆用户明明在线却因为长连接断开了没人发现消息永远停在高水位。很多人一提到“消息没送达”第一反应就是加重试、加大重复发送次数。但真正等到“发了125个电话”这种极端强度时你会发现不是发得不够多而是从链路设计上就已经注定了会错过。这篇文章想带你解决一个问题如何设计一个“拉黑也拉不掉”的可靠通知系统我会从真实工程链路出发讲清楚通知消息从产生到送达的完整路径拆解重试策略、推拉结合、频控防打扰和渠道降级。最后给出完整的代码实现和排查清单。不管你是写 App 推送、短信验证码、站内信还是监控告警这套思路都适用。1. 你发出去的不是消息而是一次“未被确认的请求”先看一个容易被忽略的事实。绝大部分开发者在设计通知功能时只考虑了一件事把消息发出去。发完即忘没有回执、没有确认、没有兜底。这就像故事里打了125个电话但每一次都在等待接听——你无法确认对方是否听见更无法知道对方是不是已经把手机扔进了抽屉。在工程上一次完整的通知必须由四段组成阶段作用失败表现创建消息产生一条带唯一 ID 的通知记录记录丢失无法追踪选择渠道根据场景和用户状态选 Push、短信、站内信等选错渠道送达率低投递与确认调第三方渠道等待回执渠道超时、回执丢失用户交互用户点击、查看、处理消息用户未感知消息形同虚设很多团队的失败是因为只把“投递”当作全部而漏掉了“确认”和“兜底”。更严重的问题是很多系统把“调用成功”等同于“送达成功”。打个比方你把电话拨出去系统只记录“已拨出”但对方有没有接、有没有听到、有没有挂断你完全不知道。短信渠道返回 200不代表用户已经看到Push 返回 accepted不代表手机通知栏已经出现。从“发出去”到“被看到”中间隔着至少三层不可控渠道商的投递策略网关限流、关键字拦截、用户退订设备的展示条件App 进程被杀、通知权限关闭、省电模式用户的注意资源消息太多被折叠或者被当成骚扰消息批量删除。所以在设计一个通知系统前建议先建立核心认知通知系统的第一指标不是发送量而是确认送达率。125次发送失败往往不是数量问题而是系统对“未确认状态”缺乏处理机制。2. 通知系统的三种基础架构模式想要设计可靠的通知系统先得理解消息从服务端到用户端到底有哪几种基本路径。技术圈通常把消息通知架构分为三类推送Push、拉取Pull、推拉结合Push Pull。先看它们各自的能力边界。2.1 纯推送模式服务端主动把消息推给客户端。常见于APNs / FCM 等厂商推送WebSocket 长连接下行短信、邮件、Webhook。推送模式时效性好服务端可以精确控制发送时机。但它的致命弱点是客户端不在线消息就丢了。虽然很多推送服务提供了离线存储但那只是“厂商服务器帮你存一会儿”并不保证用户一定看到。如果只依赖推送用户一旦关闭了通知权限或者 App 被系统杀死你发多少条都等于打给一个已经拉黑你的号码。2.2 纯拉取模式客户端主动向服务端询问“有没有新消息”。典型应用是HTTP 定时轮询IM 中的离线消息恢复用户在设置中心手动刷新未读数。拉取模式的好处是可控只要用户打开 App就一定和服务端有一次交互离线消息有机会被补充拉取。缺点是及时性差。轮询间隔太长消息延迟间隔太短服务端压力大客户端耗电。2.3 推拉结合模式这是当前业界最主流的方案。第一次用推送做即时触达同时把消息持久化到服务端。客户端收到推送后再主动向服务端发起一次“拉取”请求把该用户的所有消息从服务端同步下来。这么做的好处是双保险推送负责“提醒用户有消息”拉取负责“确保消息不丢”。即使推送完全失败只要用户在某一刻打开 App未读消息仍然可以补充同步。从这个角度看“拉黑”不可怕可怕的是你把所有通路都堵死了只留了一条推送。推拉结合的价值就在于哪怕主通道被“拉黑”还有备用的链路能兜底。3. 从“打电话”到“发消息”一条通知消息的完整链路先把“通知系统”放进一条真实的业务链路来看。假设你开发的是一个 To B 的工单系统当客户提交了紧急工单系统要通知到负责该客户的服务工程师。一条通知消息从产生到用户点击会经历下面这些环节。3.1 应用层生成通知服务端在工单状态变更的事件里创建一条通知记录。这条记录必须包含通知 ID幂等 ID用户 ID通知类型标题和正文跳转链接或业务参数优先级P0/P1/P2。这里最重要的一点是先入库再发送。很多团队习惯先调第三方接口成功了才写数据库。一旦第三方接口超时消息既没发出也没记录变成“幽灵事件”。正确姿势是先把通知落库标记为待发送再异步派发。3.2 异步写入队列通知的生成和发送不应该在同一个线程里完成。原因很简单工单是高频业务操作但第三方推送服务可能很慢如果同步等待一个故障的推送服务会把整个工单接口拖垮。正确做法是把“待发送通知”投递到消息队列由独立的发送 Worker 处理。队列在这里做的是削峰和解耦。3.3 发送 Worker 选择渠道发送 Worker 根据用户配置、通知优先级、渠道可用性决定这次通知走哪个通道高优通知多渠道并行发送Push 短信 站内信普通通知只发 Push用户已关闭 Push只发站内信。渠道选择本质上是动态路由。你要维护每个渠道的健康状态不能写死在代码里。3.4 第三方渠道投递与回执接下来是调用渠道。这里的关键已经不是“调用”而是“等待回执”。通知发送成功后第三方会返回一个消息 ID。这个 ID 必须被保存。之后通过回调或主动查询确认消息最终处于什么状态送达、已读、退订、还是投递失败。3.5 离线和未读处理消息同时被持久化到“消息表”。用户下一次打开 App客户端先调用拉取接口把未读消息同步下来。最终用户看到消息点击跳转返回已读回执。整个链路才算真正闭环。把这些环节画成一张链路图业务事件触发 ↓ 生成通知记录入库 ↓ 写入消息队列 ↓ 发送 Worker 消费 ↓ 渠道路由Push / 短信 / 站内信 ↓ 第三方投递 回执确认 ↓ 持久化 离线存储 ↓ 客户端推拉结合 已读上报很多人忽略的往往是最后两步。离线存储和已读上报才是“125个电话必须被看到”的最终保障。4. 重试不等于送达指数退避和幂等设计的关键逻辑我们回到文章开头说的“125个电话”。如果从代码角度理解这就是一个“对同一个接收方连续重试了125次”的案例。问题来了为什么连续重试125次仍然失败最直接的原因可能是接收方处于不可达状态也就是“拉黑”。但在工程里更常见的原因有三个重试策略太激进被渠道方限流或封禁每次重试都是同一份请求没有幂等保护导致同一条消息被重复发送多次执行重试的线程占满了连接池其他正常消息也发不出去。所以重试不是不能做而是要讲策略。4.1 指数退避 抖动正确的重试间隔应当逐次增大而不是固定时间重试。第一次失败等 1 秒第二次失败等 2 秒第三次等 4 秒第四次等 8 秒。这就是指数退避。加上随机抖动是为了避免多个任务同时醒来对渠道造成高峰压力。下面是一段演示用的指数退避重试策略代码可以直接复用思路import java.util.concurrent.ThreadLocalRandom; /** * 指数退避重试策略 * 每次重试间隔 基础间隔 * 2^attempt 随机抖动 */ public class RetryPolicy { private static final int MAX_RETRY 5; private static final long BASE_INTERVAL_MS 1000L; // 返回 -1 表示达到最大重试次数不再继续 public static long nextDelayMs(int attempt) { if (attempt MAX_RETRY) { return -1; } double exp Math.pow(2, attempt); long base (long) (BASE_INTERVAL_MS * exp); long jitter ThreadLocalRandom.current().nextLong(0, 1000); return base jitter; } public static void main(String[] args) throws InterruptedException { int attempt 0; long delay; while ((delay nextDelayMs(attempt)) ! -1) { System.out.println(第 (attempt 1) 次重试等待 delay ms); Thread.sleep(delay); // 这里执行实际发送逻辑 attempt; } System.out.println(已达最大重试次数转人工或降级处理); } }你可能会问为什么不是固定时间重试因为第三方渠道通常有频率风控。固定间隔的重试在高并发下等于把所有失败请求集中在同一秒砸向渠道网关结果就是渠道直接拒绝与你通信。这就像你连续不停地拨同一个号码最后运营商判定你是在恶意骚扰直接限制主叫。4.2 重试幂等同一个业务通知只能发一次重试的目的是让“发送成功”这件事有最终结果而不是让同一个通知被多次发送。假设你调用短信接口超时了实际上短信已经发到了用户手机上但你没有收到响应。于是你启动重试同一验证码又发了一遍。用户收到的可能是两条一模一样的短信。解决办法是在通知创建时生成全局唯一 ID。每一次调用第三方渠道都带上这个 ID。第三方渠道根据 ID 做去重。如果第三方不支持自定义 ID就需要在本地维护“通知 ID → 渠道消息 ID”的映射通过查询渠道状态决定是否要补发。4.3 重试必须限制次数预留降级口文章里的故事之所以可怕是因为作者反复拨打对方手机却没有换一种方式联系。工程里的重试也一样重试救不了不可达的渠道只能白白消耗系统资源。所以建议同一渠道最多重试 5 次重试全部失败后自动切换到备用渠道备用渠道也失败标记消息状态为“待人工处理”而不是无限循环。重试不是目的最终可达才是目的。5. 拉取兜底当推送被“拉黑”如何保证消息最终可见前面说得很清楚了纯推送模式下只要设备离线或者用户关闭通知权限消息就一定会丢。要保证消息最终可见必须引入“拉取”机制。这就是整个方案的主干无论推送成不成功所有消息先落库用户每次进入应用拉取一遍自己的未读消息列表。下面用 Python Flask 做一个最简单的拉取接口示例演示离线消息补偿的核心逻辑。from flask import Flask, request, jsonify import sqlite3 import time app Flask(__name__) DATABASE notifications.db def init_db(): conn sqlite3.connect(DATABASE) conn.execute( CREATE TABLE IF NOT EXISTS notifications ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, status TEXT DEFAULT UNREAD, create_time INTEGER NOT NULL ) ) conn.commit() conn.close() app.route(/api/notifications/pull, methods[GET]) def pull_notifications(): 客户端拉取未读消息 参数 user_id: 用户ID last_msg_id: 客户端已拉取的最新消息ID初次传 0 limit: 本次拉取条数上限 返回 当前用户从 last_msg_id 之后的新消息 init_db() user_id request.args.get(user_id, ) last_msg_id request.args.get(last_msg_id, 0) limit request.args.get(limit, 50) if not user_id: return jsonify({code: 400, msg: 缺少 user_id}), 400 try: last_id int(last_msg_id) limit int(limit) except ValueError: return jsonify({code: 400, msg: 参数格式错误}), 400 conn sqlite3.connect(DATABASE) cursor conn.execute( SELECT id, title, content, status, create_time FROM notifications WHERE user_id ? AND id ? ORDER BY id ASC LIMIT ? , (user_id, last_id, limit), ) rows cursor.fetchall() conn.close() messages [] max_id last_id for row in rows: msg_id, title, content, status, create_time row messages.append({ id: msg_id, title: title, content: content, status: status, create_time: create_time, }) if msg_id max_id: max_id msg_id return jsonify({ code: 0, data: { messages: messages, has_more: len(rows) limit, last_msg_id: max_id, } }) if __name__ __main__: init_db() app.run(host0.0.0.0, port8000)这个接口的核心逻辑非常简单却有很好的实战价值客户端每次启动、或者从后台切换到前台都调用这个接口由于消息按 ID 升序返回客户端只要记住最后一条消息 ID就能增量补齐所有消息即使推送通道完全被关闭消息仍然能从服务端拉到用户面前。很多 IM 系统的离线消息同步用的就是类似方案。区别只是把 SQLite 换成了更牛的存储把 HTTP 换成了长连接或增量同步协议。把这个逻辑引入通知系统后你就能从架构上做到“消息不丢”业务事件触发时通知先入库推送通道负责即时提醒用户打开 App拉取接口负责兜底。这就是推拉结合的含义无限重试解决不了的问题一次真正的“拉取”就解决了。6. 防打扰频控别让系统成为那个“打125个电话的人”前面讲了大量“如何可靠送达”现在必须提醒另一个极端一个正常人不应该连续收到 125 条相同通知。如果系统出现这种情况问题不是送达率不够而是业务和频控都失控了。从工程角度看高频重复通知会带来三重危害渠道被封禁短期内大量相同内容会被第三方判定为营销骚扰用户主动卸载用户会像故事里一样把 App 通知权限彻底“拉黑”系统资源浪费无效消息挤占队列容量和渠道配额。所以通知系统必须围绕“频控”加入严谨策略。下面用一个 Redis 计数器示例演示如何限制同一用户在一段时间内的通知接收频率import redis.clients.jedis.Jedis; public class NotificationRateLimiter { private static final int MAX_COUNT 10; private static final long WINDOW_SECONDS 3600L; private Jedis jedis; public NotificationRateLimiter(Jedis jedis) { this.jedis jedis; } public boolean allow(String userId, String notifyType) { // 同一个用户、同一类通知在时间窗口内最多允许发 MAX_COUNT 条 String key notify:limit: userId : notifyType; long current jedis.incr(key); if (current 1) { jedis.expire(key, WINDOW_SECONDS); } return current MAX_COUNT; } }在实践中频控策略不应该只有这一层。比较完整的做法是同一用户、同一业务类型在窗口内限流同一内容、不同用户在窗口内限流防止内容异常导致全局轰炸失败消息进入重试队列前也要重新过频控避免重试变成骚扰。你会发现频控和送达其实是同时存在的两个目标。送达追求“不能漏”频控追求“不能滥”。一个合格的通知系统应该像手机上理性的联系人一样平时不轻易打扰重要的事情出现时才用最可靠的方式通知到位。7. 完整示例多渠道降级通知系统的核心实现现在把前面章节整合起来做一个综合示例。场景当用户有多条可用通知渠道时系统可以先尝试最高优先级渠道失败后自动降级到备用渠道。渠道优先级定义如下优先级渠道适用条件1App Push用户打开推送权限、设备在线2短信Push 发送失败且该通知优先级为 P03站内信所有外部通道都失败消息落库等待用户主动拉取下面这段 Java 代码表示渠道路由和降级策略的骨架import java.util.Arrays; import java.util.List; public class NotificationRouter { public interface Channel { boolean send(String userId, String title, String content); } static class PushChannel implements Channel { Override public boolean send(String userId, String title, String content) { // 假设内部调用了厂商推送接口 // 返回 true 表示渠道确认接收false 表示失败 System.out.println([Push] 尝试推送给 userId); return false; // 模拟失败 } } static class SmsChannel implements Channel { Override public boolean send(String userId, String title, String content) { System.out.println([SMS] 尝试短信通知 userId); return false; // 模拟失败 } } static class InboxChannel implements Channel { Override public boolean send(String userId, String title, String content) { System.out.println([Inbox] 写入站内信 userId); return true; // 站内信落库永不失败 } } public static void main(String[] args) { ListChannel channels Arrays.asList( new PushChannel(), new SmsChannel(), new InboxChannel() ); String userId user_10001; String title 紧急工单提醒; String content 客户 A 的工单已超期 2 小时请尽快处理; boolean sent false; int attempt 0; while (!sent attempt channels.size()) { Channel channel channels.get(attempt); try { sent channel.send(userId, title, content); if (sent) { System.out.println(最终通过渠道 channel.getClass().getSimpleName() 送达); } else { System.out.println(渠道 channel.getClass().getSimpleName() 发送失败开始降级); attempt; } } catch (Exception e) { // 捕获渠道异常防止第三方 SDK 抛出异常阻断主流程 System.err.println(渠道 channel.getClass().getSimpleName() 异常 e.getMessage()); attempt; } } if (!sent) { System.out.println(所有渠道均失败消息已持久化等待客户端下次拉取兜底); } } }这段代码演示了最核心的降级思想渠道之间是主备关系不是并联关系。你可以在此基础上继续扩展把渠道列表做成配置中心下发便于动态调整优先级对每个渠道加健康探测失败的渠道自动摘除Push 和短信可以并行发送但需要保证消息幂等降级日志要完整记录每一步状态方便问题追踪。理想情况下一个通知从产生到最终被用户看到可能会有多条记录。工程上必须定义清楚通知业务 ID业务侧唯一每条渠道发送记录 ID渠道侧唯一消息全局主键 ID系统中唯一。这三套 ID 对应不同环节组合使用才能完成全链路追踪。8. 高优先级场景短信验证码和监控告警的硬性要求普通通知可以“尽力送达”但有两类通知不允许“尽力”短信验证码监控告警。它们的共同点是错过了就会造成直接损失。验证码错过用户登录失败告警错过系统故障无法及时处理。这两类场景的工程方案需要额外加强。8.1 短信验证码短信验证码的正确设计通常包含这几个核心点验证码有效期短5 分钟内有效过期必须重发不能拿旧码一直重试发送前先落库用户申请验证码先写验证码表再调短信同一手机号限频60 秒内不能重复发送防止刷接口失败降级处理短信通道失败可以记录日志提供语音验证码或其他备用通道验证码不记日志日志中不能打印完整验证码这是安全红线。如果你不做频控一个恶意用户完全可以拿一个手机号刷爆你的短信账户。短信接口的超时重试和频控必须放到同一套策略里去考虑。8.2 监控告警监控告警场景的问题是另一种“125个电话”某服务挂了监控系统每分钟告警一次三小时内连续发了上百条告警。运维人员不堪其扰关掉告警群真正的故障最终被忽略。解决方案是“告警聚合”相同告警内容在一个时间窗口内合并为一条告警通知附带上连续发生次数而不是逐条发送如果该告警持续超过一定时间升级到更高优先级渠道由短信/电话通知。监控告警系统最需要的不是“发得多”而是“发得准”。9. 常见问题与排查方向通知系统出问题往往现象相似、原因不同。这里整理一份常见排查清单按频率从高到低排列。问题现象可能原因排查方式解决方案用户收不到 Push用户关闭了 App 通知权限查看系统推送报告中的“拒绝数”提示用户重新开启权限配合站内信兜底短信验证码收不到短信通道被运营商限流查看短信服务商的发送记录和状态报告换备用签名/通道减少重试频率消息重复发送重试时未做幂等控制检查数据库中的通知 ID 和渠道消息 ID发送前加全局唯一 ID 去重某渠道全部失败第三方渠道异常或密钥过期查看渠道服务健康状态和响应码开启自动降级到备用渠道消息延迟严重队列积压或 Worker 数量不足查看队列消费速度与积压量增加消费者实例或拆分队列用户收到大量重复通知业务侧通知触发条件重复查看消息去重窗口配置增加内容去重和聚合策略从实践来看排查通知问题的第一步永远是“先定位消息当前状态”而不是直接看第三方渠道。建议为每条重要通知维护一张状态表CREATED已创建 SENDING发送中 SENT渠道已接收 DELIVERED已送达设备 READ用户已查看 FAILED最终失败每次状态变更都要有时间戳。这样一旦出现问题你可以像看快递物流一样精确知道消息卡在了哪个环节。10. 工程最佳实践做到能送达、不打扰、可追踪最后把通知系统的工程经验沉淀成一套可执行的最佳实践方便你在新项目中直接落地。10.1 先落库再发送永远不要先发送再落库消息是重要业务数据它应该先被安全保存再进行派发。哪怕第三方渠道故障消息也还在库里你可以随时重新投递。10.2 所有通知记录必须带唯一 ID全局 ID 用于本地去重渠道消息 ID 用于和第三方对账。没有 ID 的通知系统一旦重复根本无法定位。10.3 推送负责提醒拉取负责兜底不要测试“推送成功率”要测试“最终可见率”。后者意味着你必须把离线消息拉取接口也一起上线、一起压测。10.4 频控是通知系统的第一道保护对于同一用户、同一业务类型必须设置时间窗口内的发送上限。这是防止系统自我伤害的关键防线。10.5 失败的路径要自动降级而不是无限重试建议为每个渠道设置健康状态。渠道连续失败达到阈值后自动摘除把流量切换到备用渠道。人工确认恢复后再重新摘回来。10.6 小流量灰度先内部用户后全量通知文案、渠道策略、频控规则任何一个字段改动都可能影响全部用户。上线前建议先用测试用户灰度观察送达率后再放大。10.7 全链路日志和监控重点监控三个指标消息创建量各渠道发送成功率最终已读率。第三个指标最容易被忽视但它才是用户真正感受到的体验。如果发送成功率很高、已读率却很低多半是内容或者触达策略出了问题。11. 回到“125个电话”的设计隐喻现在回到最开始那个故事。小说里的“125个电话”之所以成立是因为那个世界没有多样化的联系渠道一个人被拉黑了就只能反复打同一个号码。但在真实的工程系统里我们不应该允许这种情况发生。可靠通知系统的本质不是“把消息发出去”而是“在正确的时间用多条互补的路径把高价值信息触达用户并且让这个过程可控、可追踪、可治理”。所以当你以后再看到某个系统通知没送达不要只想“重试几次”而要问五个问题消息有没有先落库第一次投递失败后有没有自动切换到备用渠道用户离线或者关闭权限后有没有拉取兜底系统有没有频控避免重复打扰全链路有没有日志和状态追踪把这五个问题都回答清楚了你设计的系统就不会再成为那个“打了125个电话仍然错过重要事情”的悲剧角色。如果你正在开发或重构通知模块建议从最小闭环开始先做“落库 站内信 拉取接口”的兜底方案再逐步接入 Push 和短信用灰度数据驱动渠道策略调整。这样既能快速上线又不会在第三方通道出问题时手足无措。