设计一个通知系统(推送/短信/邮件)
提出问题
通知系统几乎是每个互联网应用的标配——用户注册要发验证码、下单成功要推送通知、营销活动要群发短信。但真正生产级的通知系统远不止调个 SDK 发出去这么简单:多通道怎么抽象、频率怎么控制、重复通知怎么去重合并、第三方通道挂了怎么办、发了错误内容怎么撤回?这些才是面试官想听到的深度。实际生产环境中,一个通知系统每天要处理百万级甚至亿级通知,任何通道故障都可能导致核心业务受阻。
分析问题
通道抽象与路由策略
通知系统的第一层设计是通道抽象。定义统一的 Notification 接口,各通道(Push / SMS / Email / 站内信)实现各自的适配器。业务方只调用 notifyService.send(userId, content),底层路由层根据用户偏好、通道优先级、通知类型自动选择通道。
// 统一的通道抽象接口
public interface NotificationChannel {
SendResult send(NotificationRequest request);
ChannelType type();
boolean isAvailable();
}
// Push 通道实现
@Component
public class PushChannel implements NotificationChannel {
@Override
public SendResult send(NotificationRequest request) {
// 调用 APNs / FCM / 自研长连接
PushRequest push = PushRequest.builder()
.deviceToken(request.getDeviceToken())
.title(request.getTitle())
.body(request.getContent())
.build();
return pushService.send(push);
}
}
// 路由层:根据优先级和用户偏好选择通道
@Component
public class NotificationRouter {
public ChannelRoute route(NotificationRequest request) {
// P0 通知(验证码、支付):Push + SMS 双通道并行
if (request.getPriority() == Priority.P0) {
return ChannelRoute.parallel(PushChannel.class, SmsChannel.class);
}
// P1 通知(订单状态变更):优先 Push,失败降级 SMS
if (request.getPriority() == Priority.P1) {
return ChannelRoute.failover(PushChannel.class, SmsChannel.class);
}
// P2(营销推送):仅 Push,可延迟
return ChannelRoute.single(PushChannel.class);
}
}频率控制与去重合并
高频通知是用户体验的杀手。双 11 期间一个用户可能同时收到 10 条"限时优惠"推送——这就是频率控制存在的意义。用 Redis 滑动窗口实现用户+通道维度的限流:
// 滑动窗口频率控制
public class RateLimiter {
private static final String KEY_PREFIX = "rate:user:";
public boolean allow(String userId, ChannelType channel, int limit, int windowSeconds) {
String key = KEY_PREFIX + userId + ":" + channel.name();
long now = System.currentTimeMillis() / 1000;
long windowStart = now - windowSeconds;
// 移除窗口外的记录
redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart);
long count = redisTemplate.opsForZSet().zCard(key);
if (count < limit) {
redisTemplate.opsForZSet().add(key, String.valueOf(now), now);
redisTemplate.expire(key, Duration.ofSeconds(windowSeconds * 2));
return true;
}
return false;
}
}去重策略更重要:相同内容 3 秒内重复触发时合并发送;同一个订单的多次状态变更(已支付→已发货→配送中)合并为一条"订单状态已更新"通知。合并逻辑用 MQ 延迟消息实现——先放入延迟队列,等待 3 秒去重窗口,再发送最终版本。
高可用与幂等设计
第三方通道不可用时,不能阻塞主流程。设计通道降级链:Push 不可用 → 自动降级到 SMS → SMS 不可用 → 降级到站内信。P0 通知采用多通道并行发送(主通道+备用通道同时发,先到为主),用 MQ 隔离不同优先级的 Topic 队列。
幂等设计同样关键:业务方因分布式重试可能重复调用。以 biz_id + channel 为唯一键去重,重复请求直接返回成功:
-- 通知幂等表
CREATE TABLE notification_idempotent (
biz_id VARCHAR(64) NOT NULL,
channel VARCHAR(16) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
PRIMARY KEY (biz_id, channel)
) ENGINE=InnoDB;回执处理与撤回
Push 通道的回执(送达/点击/已读)通过回调 Webhook 接收,写入 MySQL 后异步归档到 ClickHouse 做分析。回执数据量大,直接写 MySQL 会影响性能,所以用 MQ 缓冲:
// 回执处理:MQ 异步消费
@Component
public class ReceiptConsumer {
@RabbitListener(queues = "notification.receipt")
public void handleReceipt(ReceiptMessage msg) {
// 先写入 MySQL 做实时查询
receiptRepository.save(msg);
// 异步归档到 ClickHouse 做分析
kafkaTemplate.send("receipt_archive", msg);
}
}撤回方面:Push 推送支持撤回(iOS 的 mutable-content + Android 的 revoke),但 SMS 和 Email 已投递后无法撤回,只能靠审核机制前置拦截。生产上建议所有通知发送前经过内容审核队列,P0 通知自动审核通过,P2 营销通知需要人工审核。
总结
通知系统的五个关键设计要点:
| 设计维度 | 方案 | 投产要点 |
|---|---|---|
| 通道抽象 | 统一接口 + 适配器模式 | 路由层支持 failover 和 parallel 两种策略 |
| 频率控制 | Redis 滑动窗口 | 按用户+通道维度,P0 通道放宽限制 |
| 去重合并 | MQ 延迟消息 + 3s 窗口 | 用 biz_id 做幂等键 |
| 高可用 | 通道降级链 + 多通道并行 | P0 走双通道,P2 可延迟 |
| 撤回 | Push 协议撤回 + 前置审核 | SMS/Email 不可撤回,审核前置是唯一防线 |
参考:阿里云推送 SDK 设计、Apple APNs 回执机制、通知系统架构实践