设计通知系统:多通道推送与频率控制
通知系统设计:多通道推送与频率控制
在现代应用系统中,通知是连接用户与信息的核心纽带。从社交互动到系统告警,高效的通知系统需要可靠送达、合理触达,并避免信息轰炸。本教程从零开始,带你设计一个支持多通道推送与智能频率控制的通知系统。
为什么需要专门设计通知系统
通知不仅仅是“发一条消息”这么简单。随着业务发展,你会面临以下挑战:
- 通道多样化:站内信、短信、邮件、App Push、微信模板消息等,每个通道有独立的调用方式、限流和成本。
- 触达策略复杂:同一用户可能希望不同消息在不同时间、不同通道上接收。
- 防打扰:无控制的推送会造成用户流失,甚至被封禁通道权限。
- 高可用与一致性:系统故障时消息不能丢失,且应尽量避免重复发送。
核心需求梳理
功能性需求:
- 支持多通道:App Push、短信、邮件、站内信、Webhook 等。
- 优先级管理:验证码类高优,营销类低优。
- 模板化消息:内容与通道解耦,支持变量替换。
- 用户偏好设置:让用户选择接收通道和免打扰时段。
- 发送记录与追踪:送达率、点击率、退订记录。
非功能性需求:
- 高可伸缩:大促期间可平滑支撑10倍量级。
- 低延迟:验证码须在5秒内到达。
- 高一致性:同一条通知不会重复发送给用户。
- 容错隔离:单一通道故障不影响其他通道。
系统整体架构
我们采用典型的分层架构,将消息生产、路由、加工、投递与反馈处理解耦。
[业务服务] -> 消息队列(Kafka/RabbitMQ) -> 推送引擎
|
+-------------------+-------------------+
| | |
路由网关 频控 / 去重 通道适配器
| |
用户偏好服务 [短信/邮件/Push...]
消息队列:所有通知先入队,削峰填谷,保证业务接口快速响应。
推送引擎:核心消费组件,负责校验、频控、路由、渲染并投递。
通道适配器:用适配器模式封装第三方SDK,对上层屏蔽具体细节。
多通道推送设计
统一消息模型
所有通知进入系统后,使用一个标准化的消息体,避免每个通道各写一套逻辑。
{
"messageId": "uuid",
"userId": "用户ID",
"bizType": "ORDER_SHIPPED",
"priority": "HIGH",
"channels": ["PUSH", "SMS"],
"templateCode": "order_shipped_tpl",
"params": {
"order_no": "123456",
"tracking_url": "https://..."
},
"callbackUrl": "https://callback.example.com",
"createTime": 1717401600000
}
字段说明:
bizType:业务类型,决定后续的频控规则和模板。channels:期望送达的通道列表,由业务方指定,系统可能根据用户偏好裁剪。templateCode:对应各通道的模板,如短信模板 ID、邮件模板名称。
通道路由与用户偏好
路由流程:
- 解析消息的
channels字段。 - 调用用户偏好服务,查询该用户对各通道的订阅状态、免打扰时段、手机号/邮箱的有效性。
- 交叉过滤:
最终通道 = 请求通道 ∩ 用户已启用通道 ∩ 非免打扰通道。 - 若结果为空,可降级到站内信或丢弃,并记录告警。
用户偏好服务 存储结构示例(Redis + DB):
Key: user:pref:10086
Value: {
"push": {"enabled": true, "quietHours": ["22:00-07:00"]},
"sms": {"enabled": true, "quietHours": []},
"email": {"enabled": false}
}
通道适配器实现
采用策略模式为每个通道构建独立适配器,实现统一接口:
interface ChannelAdapter {
SendResult send(Message msg, UserProfile user);
boolean healthCheck();
}
// SMSSender, EmailSender, PushSender 各自实现
适配器负责:
- 查询该用户的 channel 特有凭据(如设备Token、邮箱地址)。
- 调用第三方 API(带上模板变量渲染后的内容)。
- 处理返回结果,将状态回写至消息日志,失败时触发重试机制。
频率控制与去重
这是平衡用户体验与信息触达的关键模块,分单用户频控、全局频控和去重三部分。
单用户维度频控
防止向同一用户短时倾泻通知。设计两级滑动窗口:
| 频控类型 | 窗口 | 限制示例 | 用途 |
|---|---|---|---|
| 危急通知(验证码) | 60秒 | 1次 | 防止通道费用浪费和用户骚扰 |
| 营销通知 | 1小时/天 | 3次/小时, 5次/天 | 保障营销效果又不致反感 |
| 系统通知 | 无强制限制 | 按用户维度动态调节 | 让用户自行设置上限 |
实现方式:使用 Redis 的 ZSET 滑动窗口记录发送时间戳。
伪代码逻辑:
def check_user_rate_limit(user_id, biz_type):
key = f"notify:freq:{user_id}:{biz_type}"
now = time.time()
# 取窗口规则
window_seconds, max_count = get_limit_config(biz_type)
# 移除窗口外记录
redis.zremrangebyscore(key, 0, now - window_seconds)
# 计算当前计数
current_count = redis.zcard(key)
if current_count >= max_count:
return False
# 加入本次发送时间戳
redis.zadd(key, {str(now): now})
redis.expire(key, window_seconds + 10)
return True
全局通道限流
每个通道有第三方限制(如短信每秒200条),在适配器层用令牌桶或信号量控制。
令牌桶示例(基于 Guava RateLimiter):
RateLimiter smsLimiter = RateLimiter.create(200.0); // 每秒200个令牌
sendResult = smsLimiter.acquire() ? smsAdapter.send(msg) : rejectWithRetry();
消息去重(Exactly-Once 语义)
网络重试、业务幂等缺失可能导致用户收到重复通知。我们通过发送记录表 + Redis 互斥锁保障。
- 发送记录表(MySQL):
- 字段:
message_id,user_id,channel,status,send_time - 联合唯一索引:
(message_id, user_id, channel)
- 字段:
- Redis 缓存最近消息 ID:
- Key:
sent:msg:<userId>:<channel> - Value:
message_id,设置合理的过期时间(如7天)
- Key:
- 处理流程:
- 消费到消息后,先
SET NX尝试占位,成功则继续发送,失败则查询发送记录确认是否已发。 - 若发送中第三方超时,记录
PENDING状态,由定时任务补偿查询最终结果,防止双发。
- 消费到消息后,先
模板化与内容渲染
各通道内容格式差异大,但核心业务参数不变。维护一套模板表:
| 模板编码 | 通道 | 内容模板 | 参数说明 |
|---|---|---|---|
order_shipped |
SMS | 您的订单${order_no}已发货,点击查看:${short_url} | order_no, short_url |
order_shipped |
PUSH | 标题:订单已发货 内容:您的订单${order_no}已由${carrier}发出 | order_no, carrier |
渲染时使用模板引擎(如 Thymeleaf、Freemarker)统一替换变量,并针对通道做内容截断、URL 短链转换。
可靠性保障
- 消息持久化与重试:
- 消息队列开启持久化,消费失败重新入队或进入死信队列。
- 发送失败指数退避重试(最多3次),最终失败记录到异常表,必要时人工介入。
- 监控与告警:
- 各通道成功率、P99延迟、频控拦截率。
- 死信队列积压、模板缺失等异常打点。
- 灰度发布:新模板或新通道先对内部用户测试,稳定后全量放开。
实践总结
设计一个企业级通知系统,本质是让“正确的消息,通过合适的通道,在恰当的时间,触达愿意接收的用户”。核心在于抽象统一的消息模型、解耦通道适配、精细化的频控和优雅的容错机制。希望本教程为你构建自己的通知中心提供了清晰的路径。
如果想深入了解具体实现,可继续关注后续针对各厂 Push 通道适配、或基于 Flink 的实时频控方案专题。